Files
steipete--codexbar/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift
2026-07-13 12:22:33 +08:00

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
}
}