import Foundationimport Testingimport 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 { // MARK: - Data Store Identity Tests @Test("WKWebsiteDataStore should return same instance for same email") func dataStoreReturnsSameInstance() { 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() } // MARK: - WebView Reuse Tests @Test("WebView should be cached after release, not destroyed") func webViewCachedAfterRelease() async throws { 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("Different data stores should have separate cached WebViews") func separateCachesPerDataStore() async throws { 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("WebView should be pruned after idle timeout") func webViewPrunedAfterIdleTimeout() async throws { let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) // Acquire and release let lease = try await cache.acquire( websiteDataStore: store, usageURL: url, logger: nil) lease.release() #expect(cache.hasCachedEntry(for: store), "Should be cached immediately after release") // Simulate time passing beyond idle timeout (10 minutes + buffer) let futureTime = Date().addingTimeInterval(11 * 60) 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("Recently used WebView should not be pruned") func recentlyUsedWebViewNotPruned() async throws { let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) // Acquire and release let lease = try await cache.acquire( websiteDataStore: store, usageURL: url, logger: nil) lease.release() // Simulate time passing within idle timeout (5 minutes) let nearFutureTime = Date().addingTimeInterval(5 * 60) cache.pruneForTesting(now: nearFutureTime) #expect(cache.hasCachedEntry(for: store), "Should still be cached within idle timeout") } // MARK: - Eviction Tests @Test("Evict should remove specific WebView from cache") func evictRemovesSpecificWebView() async throws { 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() } // MARK: - Busy WebView Tests @Test("Busy WebView should create temporary WebView for concurrent access") func busyWebViewCreatesTemporary() async throws { 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("Multiple sequential fetches should reuse same WebView (network optimization)") func sequentialFetchesReuseWebView() async throws { 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("Sequential fetches with OpenAIDashboardWebsiteDataStore should reuse WebView") func sequentialFetchesWithRealDataStoreFactory() async throws { 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() }}
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 {
// MARK: - Data Store Identity Tests
@Test("WKWebsiteDataStore should return same instance for same email")
func dataStoreReturnsSameInstance() {
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()
}
// MARK: - WebView Reuse Tests
@Test("WebView should be cached after release, not destroyed")
func webViewCachedAfterRelease() async throws {
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("Different data stores should have separate cached WebViews")
func separateCachesPerDataStore() async throws {
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("WebView should be pruned after idle timeout")
func webViewPrunedAfterIdleTimeout() async throws {
let cache = OpenAIDashboardWebViewCache()
let store = WKWebsiteDataStore.nonPersistent()
let url = try #require(URL(string: "about:blank"))
// Acquire and release
let lease = try await cache.acquire(
websiteDataStore: store,
usageURL: url,
logger: nil)
lease.release()
#expect(cache.hasCachedEntry(for: store), "Should be cached immediately after release")
// Simulate time passing beyond idle timeout (10 minutes + buffer)
let futureTime = Date().addingTimeInterval(11 * 60)
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("Recently used WebView should not be pruned")
func recentlyUsedWebViewNotPruned() async throws {
let cache = OpenAIDashboardWebViewCache()
let store = WKWebsiteDataStore.nonPersistent()
let url = try #require(URL(string: "about:blank"))
// Acquire and release
let lease = try await cache.acquire(
websiteDataStore: store,
usageURL: url,
logger: nil)
lease.release()
// Simulate time passing within idle timeout (5 minutes)
let nearFutureTime = Date().addingTimeInterval(5 * 60)
cache.pruneForTesting(now: nearFutureTime)
#expect(cache.hasCachedEntry(for: store), "Should still be cached within idle timeout")
}
// MARK: - Eviction Tests
@Test("Evict should remove specific WebView from cache")
func evictRemovesSpecificWebView() async throws {
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()
}
// MARK: - Busy WebView Tests
@Test("Busy WebView should create temporary WebView for concurrent access")
func busyWebViewCreatesTemporary() async throws {
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("Multiple sequential fetches should reuse same WebView (network optimization)")
func sequentialFetchesReuseWebView() async throws {
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("Sequential fetches with OpenAIDashboardWebsiteDataStore should reuse WebView")
func sequentialFetchesWithRealDataStoreFactory() async throws {
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()
}
}