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("alive", 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 } }