629 lines
24 KiB
Swift
629 lines
24 KiB
Swift
import Foundation
|
|
import Testing
|
|
import WebKit
|
|
@testable import CodexBar
|
|
@testable import CodexBarCore
|
|
|
|
/// Tests for OpenAIDashboardWebViewCache to verify WebView reuse behavior.
|
|
///
|
|
/// Background: The cache should keep WebViews alive after use to avoid re-downloading
|
|
/// the ChatGPT SPA bundle on every refresh. Previously, WebViews were destroyed after
|
|
/// each fetch, causing 15+ GB of network traffic over time. See GitHub issues #269, #251.
|
|
@MainActor
|
|
@Suite(.serialized)
|
|
struct OpenAIDashboardWebViewCacheTests {
|
|
private func shouldSkipOnCI() -> Bool {
|
|
let env = ProcessInfo.processInfo.environment
|
|
return env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true"
|
|
}
|
|
|
|
// MARK: - Data Store Identity Tests
|
|
|
|
@Test
|
|
func `navigation retry uses only remaining shared deadline`() throws {
|
|
let start = Date(timeIntervalSinceReferenceDate: 1000)
|
|
let deadline = start.addingTimeInterval(10)
|
|
|
|
let remaining = try OpenAIDashboardWebViewCache.remainingNavigationTimeout(
|
|
until: deadline,
|
|
now: start.addingTimeInterval(9.75))
|
|
|
|
#expect(remaining == 0.25)
|
|
}
|
|
|
|
@Test
|
|
func `navigation retry refuses expired shared deadline`() {
|
|
let deadline = Date(timeIntervalSinceReferenceDate: 1000)
|
|
|
|
do {
|
|
_ = try OpenAIDashboardWebViewCache.remainingNavigationTimeout(
|
|
until: deadline,
|
|
now: deadline)
|
|
Issue.record("Expected deadline timeout")
|
|
} catch let error as URLError {
|
|
#expect(error.code == .timedOut)
|
|
} catch {
|
|
Issue.record("Unexpected error: \(error)")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `WKWebsiteDataStore should return same instance for same email`() {
|
|
if self.shouldSkipOnCI() { return }
|
|
OpenAIDashboardWebsiteDataStore.clearCacheForTesting()
|
|
|
|
let store1 = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: "test@example.com")
|
|
let store2 = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: "test@example.com")
|
|
let store3 = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: "TEST@EXAMPLE.COM") // Case insensitive
|
|
|
|
#expect(store1 === store2, "Same email should return same instance")
|
|
#expect(store1 === store3, "Email comparison should be case-insensitive")
|
|
|
|
// Different email should return different instance
|
|
let store4 = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: "other@example.com")
|
|
#expect(store1 !== store4, "Different emails should return different instances")
|
|
|
|
OpenAIDashboardWebsiteDataStore.clearCacheForTesting()
|
|
}
|
|
|
|
@Test
|
|
func `same email profile homes use distinct website data stores`() {
|
|
OpenAIDashboardWebsiteDataStore.clearCacheForTesting()
|
|
defer { OpenAIDashboardWebsiteDataStore.clearCacheForTesting() }
|
|
|
|
let profileA = CookieHeaderCache.Scope.profileHome("/tmp/codex-profile-a")
|
|
let profileB = CookieHeaderCache.Scope.profileHome("/tmp/codex-profile-b")
|
|
let storeA = OpenAIDashboardWebsiteDataStore.store(
|
|
forAccountEmail: "shared@example.com",
|
|
scope: profileA)
|
|
let storeAAgain = OpenAIDashboardWebsiteDataStore.store(
|
|
forAccountEmail: "SHARED@example.com",
|
|
scope: profileA)
|
|
let storeB = OpenAIDashboardWebsiteDataStore.store(
|
|
forAccountEmail: "shared@example.com",
|
|
scope: profileB)
|
|
let liveStore = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: "shared@example.com")
|
|
|
|
#expect(storeA === storeAAgain)
|
|
#expect(storeA !== storeB)
|
|
#expect(storeA !== liveStore)
|
|
#expect(storeB !== liveStore)
|
|
#expect(storeA.identifier != storeB.identifier)
|
|
#expect(storeA.identifier != liveStore.identifier)
|
|
#expect(storeB.identifier != liveStore.identifier)
|
|
}
|
|
|
|
@Test
|
|
func `live website data store preserves legacy email identifier`() {
|
|
OpenAIDashboardWebsiteDataStore.clearCacheForTesting()
|
|
defer { OpenAIDashboardWebsiteDataStore.clearCacheForTesting() }
|
|
|
|
let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: " SHARED@EXAMPLE.COM ")
|
|
|
|
#expect(store.identifier?.uuidString == "CC61BD27-6855-439F-9D11-F470B7977B90")
|
|
}
|
|
|
|
// MARK: - WebView Reuse Tests
|
|
|
|
@Test
|
|
func `WebView should be cached after release, not destroyed`() async throws {
|
|
if self.shouldSkipOnCI() { return }
|
|
let cache = OpenAIDashboardWebViewCache()
|
|
let store = WKWebsiteDataStore.nonPersistent()
|
|
let url = try #require(URL(string: "about:blank"))
|
|
|
|
// First acquire
|
|
let lease1 = try await cache.acquire(
|
|
websiteDataStore: store,
|
|
usageURL: url,
|
|
logger: nil)
|
|
let webView1 = lease1.webView
|
|
|
|
// Release - should hide, not destroy
|
|
lease1.release()
|
|
|
|
// Entry should still be in cache
|
|
#expect(cache.hasCachedEntry(for: store), "WebView should remain cached after release")
|
|
#expect(cache.entryCount == 1, "Should have exactly one cached entry")
|
|
|
|
// Second acquire should reuse the same WebView
|
|
let lease2 = try await cache.acquire(
|
|
websiteDataStore: store,
|
|
usageURL: url,
|
|
logger: nil)
|
|
let webView2 = lease2.webView
|
|
|
|
#expect(webView1 === webView2, "Should reuse the same WebView instance")
|
|
|
|
lease2.release()
|
|
cache.clearAllForTesting()
|
|
}
|
|
|
|
@Test
|
|
func `Different data stores should have separate cached WebViews`() async throws {
|
|
if self.shouldSkipOnCI() { return }
|
|
let cache = OpenAIDashboardWebViewCache()
|
|
let store1 = WKWebsiteDataStore.nonPersistent()
|
|
let store2 = WKWebsiteDataStore.nonPersistent()
|
|
let url = try #require(URL(string: "about:blank"))
|
|
|
|
// Acquire for first store
|
|
let lease1 = try await cache.acquire(
|
|
websiteDataStore: store1,
|
|
usageURL: url,
|
|
logger: nil)
|
|
let webView1 = lease1.webView
|
|
lease1.release()
|
|
|
|
// Acquire for second store
|
|
let lease2 = try await cache.acquire(
|
|
websiteDataStore: store2,
|
|
usageURL: url,
|
|
logger: nil)
|
|
let webView2 = lease2.webView
|
|
lease2.release()
|
|
|
|
#expect(webView1 !== webView2, "Different data stores should have different WebViews")
|
|
#expect(cache.entryCount == 2, "Should have two cached entries")
|
|
|
|
cache.clearAllForTesting()
|
|
}
|
|
|
|
// MARK: - Idle Timeout / Pruning Tests
|
|
|
|
@Test
|
|
func `WebView should be pruned after idle timeout`() {
|
|
if self.shouldSkipOnCI() { return }
|
|
let cache = OpenAIDashboardWebViewCache()
|
|
let store = WKWebsiteDataStore.nonPersistent()
|
|
cache.cacheEntryForTesting(websiteDataStore: store)
|
|
|
|
#expect(cache.hasCachedEntry(for: store), "Should be cached immediately after release")
|
|
|
|
// Simulate time passing beyond the configured idle timeout.
|
|
let futureTime = Date().addingTimeInterval(cache.idleTimeoutForTesting + 5)
|
|
cache.pruneForTesting(now: futureTime)
|
|
|
|
#expect(!cache.hasCachedEntry(for: store), "Should be pruned after idle timeout")
|
|
#expect(cache.entryCount == 0, "Should have no cached entries after prune")
|
|
}
|
|
|
|
@Test
|
|
func `Recently used WebView should not be pruned`() {
|
|
if self.shouldSkipOnCI() { return }
|
|
let cache = OpenAIDashboardWebViewCache()
|
|
let store = WKWebsiteDataStore.nonPersistent()
|
|
cache.cacheEntryForTesting(websiteDataStore: store)
|
|
|
|
// Simulate time passing comfortably within the configured idle timeout.
|
|
let nearFutureTime = Date().addingTimeInterval(max(1, cache.idleTimeoutForTesting / 2))
|
|
cache.pruneForTesting(now: nearFutureTime)
|
|
|
|
#expect(cache.hasCachedEntry(for: store), "Should still be cached within idle timeout")
|
|
cache.clearAllForTesting()
|
|
}
|
|
|
|
@Test
|
|
func `Preserved page handoff is consumed only once`() {
|
|
if self.shouldSkipOnCI() { return }
|
|
let cache = OpenAIDashboardWebViewCache()
|
|
let store = WKWebsiteDataStore.nonPersistent()
|
|
cache.cacheEntryForTesting(websiteDataStore: store)
|
|
cache.markPreservedPageForTesting(
|
|
websiteDataStore: store,
|
|
expiresAt: Date().addingTimeInterval(cache.preservedPageHandoffTimeoutForTesting))
|
|
|
|
#expect(cache.hasPreservedPageForTesting(for: store), "Expected preserved page handoff to be armed")
|
|
#expect(cache.consumePreservedPageForTesting(websiteDataStore: store), "First acquire should reuse handoff")
|
|
#expect(
|
|
!cache.consumePreservedPageForTesting(websiteDataStore: store),
|
|
"Second acquire should not keep reusing preserved page")
|
|
|
|
cache.clearAllForTesting()
|
|
}
|
|
|
|
@Test
|
|
func `Expired preserved page is cleared before idle eviction`() {
|
|
if self.shouldSkipOnCI() { return }
|
|
let cache = OpenAIDashboardWebViewCache()
|
|
let store = WKWebsiteDataStore.nonPersistent()
|
|
cache.cacheEntryForTesting(websiteDataStore: store)
|
|
cache.markPreservedPageForTesting(
|
|
websiteDataStore: store,
|
|
expiresAt: Date().addingTimeInterval(1))
|
|
|
|
let afterExpiry = Date().addingTimeInterval(cache.preservedPageHandoffTimeoutForTesting + 1)
|
|
cache.pruneForTesting(now: afterExpiry)
|
|
|
|
#expect(!cache.hasPreservedPageForTesting(for: store), "Expired preserved page should be cleared")
|
|
#expect(cache.hasCachedEntry(for: store), "Entry should remain cached after page handoff expires")
|
|
|
|
cache.clearAllForTesting()
|
|
}
|
|
|
|
@Test
|
|
func `Preserved page expiry is scheduled without future cache activity`() async throws {
|
|
if self.shouldSkipOnCI() { return }
|
|
let cache = OpenAIDashboardWebViewCache()
|
|
let store = WKWebsiteDataStore.nonPersistent()
|
|
let webView = cache.cacheEntryForTesting(websiteDataStore: store)
|
|
|
|
_ = webView.loadHTMLString("<html><body>alive</body></html>", baseURL: nil)
|
|
try? await Task.sleep(for: .milliseconds(150))
|
|
|
|
cache.markPreservedPageForTesting(
|
|
websiteDataStore: store,
|
|
expiresAt: Date().addingTimeInterval(0.2))
|
|
|
|
#expect(cache.hasPreservedPageForTesting(for: store), "Expected preserved page handoff to be armed")
|
|
|
|
var bodyText: String?
|
|
let deadline = Date().addingTimeInterval(2)
|
|
repeat {
|
|
try? await Task.sleep(for: .milliseconds(100))
|
|
bodyText = try await webView.evaluateJavaScript(
|
|
"document.body ? String(document.body.innerText || '') : ''") as? String
|
|
} while (cache.hasPreservedPageForTesting(for: store) || bodyText?.isEmpty != true) && Date() < deadline
|
|
|
|
#expect(!cache.hasPreservedPageForTesting(for: store), "Expected scheduled expiry to clear preserved page")
|
|
#expect(bodyText?.isEmpty == true, "Expected scheduled expiry to detach the preserved page to about:blank")
|
|
|
|
cache.clearAllForTesting()
|
|
}
|
|
|
|
@Test
|
|
func `Idle prune is scheduled without future cache activity`() async throws {
|
|
if self.shouldSkipOnCI() { return }
|
|
let cache = OpenAIDashboardWebViewCache(idleTimeout: 0.2)
|
|
let store = WKWebsiteDataStore.nonPersistent()
|
|
let url = try #require(URL(string: "about:blank"))
|
|
|
|
var lease: OpenAIDashboardWebViewLease? = try await cache.acquire(
|
|
websiteDataStore: store,
|
|
usageURL: url,
|
|
logger: nil)
|
|
lease?.release()
|
|
lease = nil
|
|
|
|
#expect(cache.hasCachedEntry(for: store), "WebView should remain cached right after release")
|
|
|
|
let deadline = Date().addingTimeInterval(5)
|
|
while cache.hasCachedEntry(for: store), Date() < deadline {
|
|
try? await Task.sleep(for: .milliseconds(100))
|
|
}
|
|
|
|
#expect(
|
|
!cache.hasCachedEntry(for: store),
|
|
"Expected the scheduled idle prune to evict the WebView without any further cache activity")
|
|
|
|
cache.clearAllForTesting()
|
|
}
|
|
|
|
@Test
|
|
func `Later release does not postpone an older idle entry`() async throws {
|
|
if self.shouldSkipOnCI() { return }
|
|
let cache = OpenAIDashboardWebViewCache(idleTimeout: 5)
|
|
let firstStore = WKWebsiteDataStore.nonPersistent()
|
|
let secondStore = WKWebsiteDataStore.nonPersistent()
|
|
let url = try #require(URL(string: "about:blank"))
|
|
|
|
let firstLease = try await cache.acquire(
|
|
websiteDataStore: firstStore,
|
|
usageURL: url,
|
|
logger: nil)
|
|
firstLease.release()
|
|
let firstDeadline = try #require(cache.idlePruneDeadlineForTesting)
|
|
|
|
try await Task.sleep(for: .milliseconds(50))
|
|
|
|
let secondLease = try await cache.acquire(
|
|
websiteDataStore: secondStore,
|
|
usageURL: url,
|
|
logger: nil)
|
|
secondLease.release()
|
|
let rescheduledDeadline = try #require(cache.idlePruneDeadlineForTesting)
|
|
|
|
#expect(
|
|
abs(rescheduledDeadline.timeIntervalSince(firstDeadline)) < 0.001,
|
|
"A later release should keep the prune scheduled for the oldest idle entry")
|
|
#expect(cache.hasCachedEntry(for: firstStore))
|
|
#expect(cache.hasCachedEntry(for: secondStore), "A later release should keep its own idle window")
|
|
cache.clearAllForTesting()
|
|
}
|
|
|
|
@Test
|
|
func `Reused page reset clears one shot scraper globals`() async throws {
|
|
if self.shouldSkipOnCI() { return }
|
|
let cache = OpenAIDashboardWebViewCache()
|
|
let store = WKWebsiteDataStore.nonPersistent()
|
|
let url = try #require(URL(string: "about:blank"))
|
|
|
|
let lease = try await cache.acquire(
|
|
websiteDataStore: store,
|
|
usageURL: url,
|
|
logger: nil)
|
|
|
|
_ = try await lease.webView.evaluateJavaScript(
|
|
"""
|
|
window.__codexbarDidScrollToCredits = true;
|
|
window.__codexbarUsageBreakdownJSON = '[{"day":"2026-04-19"}]';
|
|
window.__codexbarUsageBreakdownDebug = 'debug';
|
|
true;
|
|
""")
|
|
|
|
#expect(await cache.resetReusablePageStateForTesting(lease.webView))
|
|
|
|
let reset = try await lease.webView.evaluateJavaScript(
|
|
"""
|
|
typeof window.__codexbarDidScrollToCredits === 'undefined' &&
|
|
typeof window.__codexbarUsageBreakdownJSON === 'undefined' &&
|
|
typeof window.__codexbarUsageBreakdownDebug === 'undefined'
|
|
""") as? Bool
|
|
|
|
#expect(reset == true, "Expected one-shot scraper globals to be cleared before reuse")
|
|
|
|
lease.release()
|
|
cache.clearAllForTesting()
|
|
}
|
|
|
|
// MARK: - Eviction Tests
|
|
|
|
@Test
|
|
func `Evict should remove specific WebView from cache`() async throws {
|
|
if self.shouldSkipOnCI() { return }
|
|
let cache = OpenAIDashboardWebViewCache()
|
|
let store1 = WKWebsiteDataStore.nonPersistent()
|
|
let store2 = WKWebsiteDataStore.nonPersistent()
|
|
let url = try #require(URL(string: "about:blank"))
|
|
|
|
// Cache two WebViews
|
|
let lease1 = try await cache.acquire(websiteDataStore: store1, usageURL: url, logger: nil)
|
|
lease1.release()
|
|
let lease2 = try await cache.acquire(websiteDataStore: store2, usageURL: url, logger: nil)
|
|
lease2.release()
|
|
|
|
#expect(cache.entryCount == 2, "Should have two cached entries")
|
|
|
|
// Evict only the first one
|
|
cache.evict(websiteDataStore: store1)
|
|
|
|
#expect(!cache.hasCachedEntry(for: store1), "First store should be evicted")
|
|
#expect(cache.hasCachedEntry(for: store2), "Second store should still be cached")
|
|
#expect(cache.entryCount == 1, "Should have one cached entry remaining")
|
|
|
|
cache.clearAllForTesting()
|
|
}
|
|
|
|
@Test
|
|
func `Evicted WebView should not be reused on next acquire`() async throws {
|
|
if self.shouldSkipOnCI() { return }
|
|
let cache = OpenAIDashboardWebViewCache()
|
|
let store = WKWebsiteDataStore.nonPersistent()
|
|
let url = try #require(URL(string: "about:blank"))
|
|
|
|
let lease1 = try await cache.acquire(websiteDataStore: store, usageURL: url, logger: nil)
|
|
let webView1 = lease1.webView
|
|
lease1.release()
|
|
|
|
cache.evict(websiteDataStore: store)
|
|
|
|
let lease2 = try await cache.acquire(websiteDataStore: store, usageURL: url, logger: nil)
|
|
let webView2 = lease2.webView
|
|
|
|
#expect(webView1 !== webView2, "Acquire after eviction should create a fresh WebView")
|
|
|
|
lease2.release()
|
|
cache.clearAllForTesting()
|
|
}
|
|
|
|
@Test
|
|
func `Evict all should remove every cached WebView`() async throws {
|
|
if self.shouldSkipOnCI() { return }
|
|
let cache = OpenAIDashboardWebViewCache()
|
|
let store1 = WKWebsiteDataStore.nonPersistent()
|
|
let store2 = WKWebsiteDataStore.nonPersistent()
|
|
let url = try #require(URL(string: "about:blank"))
|
|
|
|
let lease1 = try await cache.acquire(websiteDataStore: store1, usageURL: url, logger: nil)
|
|
lease1.release()
|
|
let lease2 = try await cache.acquire(websiteDataStore: store2, usageURL: url, logger: nil)
|
|
lease2.release()
|
|
|
|
#expect(cache.entryCount == 2, "Should have two cached entries")
|
|
|
|
cache.evictAll()
|
|
|
|
#expect(cache.entryCount == 0, "Evict all should remove every cached entry")
|
|
#expect(!cache.hasCachedEntry(for: store1), "First store should be evicted")
|
|
#expect(!cache.hasCachedEntry(for: store2), "Second store should be evicted")
|
|
}
|
|
|
|
@Test
|
|
func `Evict idle removes idle WebViews without interrupting busy WebViews`() {
|
|
if self.shouldSkipOnCI() { return }
|
|
let cache = OpenAIDashboardWebViewCache()
|
|
let idleStore = WKWebsiteDataStore.nonPersistent()
|
|
let busyStore = WKWebsiteDataStore.nonPersistent()
|
|
|
|
cache.cacheEntryForTesting(websiteDataStore: idleStore)
|
|
cache.cacheEntryForTesting(websiteDataStore: busyStore, isBusy: true)
|
|
|
|
cache.evictIdle()
|
|
|
|
#expect(!cache.hasCachedEntry(for: idleStore), "Idle WebView should be evicted")
|
|
#expect(cache.hasCachedEntry(for: busyStore), "Busy WebView should remain cached")
|
|
#expect(cache.entryCount == 1, "Only the busy entry should remain")
|
|
|
|
cache.clearAllForTesting()
|
|
}
|
|
|
|
@Test
|
|
func `Memory pressure monitor evicts idle shared WebViews without interrupting busy WebViews`() {
|
|
if self.shouldSkipOnCI() { return }
|
|
let cache = OpenAIDashboardWebViewCache.shared
|
|
cache.clearAllForTesting()
|
|
defer { cache.clearAllForTesting() }
|
|
|
|
let idleStore = WKWebsiteDataStore.nonPersistent()
|
|
let busyStore = WKWebsiteDataStore.nonPersistent()
|
|
|
|
cache.cacheEntryForTesting(websiteDataStore: idleStore)
|
|
cache.cacheEntryForTesting(websiteDataStore: busyStore, isBusy: true)
|
|
|
|
#expect(cache.entryCount == 2, "Should have one idle entry and one busy entry before pressure")
|
|
|
|
let monitor = MemoryPressureMonitor()
|
|
monitor.handleMemoryPressureForTesting(isWarning: true, isCritical: false)
|
|
|
|
#expect(!cache.hasCachedEntry(for: idleStore), "Memory pressure should evict the idle shared WebView")
|
|
#expect(cache.hasCachedEntry(for: busyStore), "Memory pressure should not interrupt a busy shared WebView")
|
|
#expect(cache.entryCount == 1, "Only the busy shared entry should remain")
|
|
}
|
|
|
|
@Test
|
|
func `Memory pressure malloc relief runs off the main thread`() async {
|
|
let probe = MemoryPressureThreadProbe()
|
|
let monitor = MemoryPressureMonitor(releaseFreeMallocPages: {
|
|
probe.recordCurrentThread()
|
|
})
|
|
|
|
monitor.handleMemoryPressureForTesting(isWarning: true, isCritical: false)
|
|
|
|
let completed = await Task.detached {
|
|
probe.wait(timeout: .now() + 2)
|
|
}.value
|
|
#expect(completed)
|
|
#expect(probe.wasMainThread == false)
|
|
}
|
|
|
|
// MARK: - Busy WebView Tests
|
|
|
|
@Test
|
|
func `Busy WebView should create temporary WebView for concurrent access`() async throws {
|
|
if self.shouldSkipOnCI() { return }
|
|
let cache = OpenAIDashboardWebViewCache()
|
|
let store = WKWebsiteDataStore.nonPersistent()
|
|
let url = try #require(URL(string: "about:blank"))
|
|
|
|
var logMessages: [String] = []
|
|
let logger: (String) -> Void = { logMessages.append($0) }
|
|
|
|
// Acquire first (don't release yet - keeps it busy)
|
|
let lease1 = try await cache.acquire(
|
|
websiteDataStore: store,
|
|
usageURL: url,
|
|
logger: logger)
|
|
let webView1 = lease1.webView
|
|
|
|
// Try to acquire again while first is busy
|
|
let lease2 = try await cache.acquire(
|
|
websiteDataStore: store,
|
|
usageURL: url,
|
|
logger: logger)
|
|
let webView2 = lease2.webView
|
|
|
|
#expect(webView1 !== webView2, "Should create temporary WebView when cached one is busy")
|
|
#expect(
|
|
logMessages.contains { $0.contains("Cached WebView busy") },
|
|
"Should log that cached WebView is busy")
|
|
|
|
lease1.release()
|
|
lease2.release()
|
|
cache.clearAllForTesting()
|
|
}
|
|
|
|
// MARK: - Network Traffic Regression Prevention
|
|
|
|
@Test
|
|
func `Multiple sequential fetches should reuse same WebView (network optimization)`() async throws {
|
|
if self.shouldSkipOnCI() { return }
|
|
let cache = OpenAIDashboardWebViewCache()
|
|
let store = WKWebsiteDataStore.nonPersistent()
|
|
let url = try #require(URL(string: "about:blank"))
|
|
|
|
var webViews: [WKWebView] = []
|
|
|
|
// Simulate 5 sequential fetches (like 5 refresh cycles)
|
|
for _ in 0..<5 {
|
|
let lease = try await cache.acquire(
|
|
websiteDataStore: store,
|
|
usageURL: url,
|
|
logger: nil)
|
|
webViews.append(lease.webView)
|
|
lease.release()
|
|
}
|
|
|
|
// All should be the same WebView instance
|
|
let firstWebView = webViews[0]
|
|
for (index, webView) in webViews.enumerated() {
|
|
#expect(
|
|
webView === firstWebView,
|
|
"Fetch \(index + 1) should reuse the same WebView instance")
|
|
}
|
|
|
|
// Only one entry should exist in cache
|
|
#expect(cache.entryCount == 1, "Should maintain single cached entry across all fetches")
|
|
|
|
cache.clearAllForTesting()
|
|
}
|
|
|
|
// MARK: - Integration Test with Real Data Store Factory
|
|
|
|
@Test
|
|
func `Sequential fetches with OpenAIDashboardWebsiteDataStore should reuse WebView`() async throws {
|
|
if self.shouldSkipOnCI() { return }
|
|
OpenAIDashboardWebsiteDataStore.clearCacheForTesting()
|
|
let cache = OpenAIDashboardWebViewCache()
|
|
let url = try #require(URL(string: "about:blank"))
|
|
let email = "integration-test@example.com"
|
|
|
|
var webViews: [WKWebView] = []
|
|
|
|
// Simulate 3 sequential fetches using the real data store factory
|
|
// This tests that OpenAIDashboardWebsiteDataStore returns stable instances
|
|
for _ in 0..<3 {
|
|
let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: email)
|
|
let lease = try await cache.acquire(
|
|
websiteDataStore: store,
|
|
usageURL: url,
|
|
logger: nil)
|
|
webViews.append(lease.webView)
|
|
lease.release()
|
|
}
|
|
|
|
// All should be the same WebView instance
|
|
let firstWebView = webViews[0]
|
|
for (index, webView) in webViews.enumerated() {
|
|
#expect(
|
|
webView === firstWebView,
|
|
"Fetch \(index + 1) with real data store factory should reuse same WebView")
|
|
}
|
|
|
|
#expect(cache.entryCount == 1, "Should have single cached entry")
|
|
|
|
cache.clearAllForTesting()
|
|
OpenAIDashboardWebsiteDataStore.clearCacheForTesting()
|
|
}
|
|
}
|
|
|
|
private final class MemoryPressureThreadProbe: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private let semaphore = DispatchSemaphore(value: 0)
|
|
private var recordedMainThread: Bool?
|
|
|
|
var wasMainThread: Bool? {
|
|
self.lock.withLock { self.recordedMainThread }
|
|
}
|
|
|
|
func recordCurrentThread() {
|
|
self.lock.withLock {
|
|
self.recordedMainThread = Thread.isMainThread
|
|
}
|
|
self.semaphore.signal()
|
|
}
|
|
|
|
func wait(timeout: DispatchTime) -> Bool {
|
|
self.semaphore.wait(timeout: timeout) == .success
|
|
}
|
|
}
|