From bcf5e84bfe556dc4add625cebe1a6f71b2004f3c Mon Sep 17 00:00:00 2001 From: "Patrick.Park" Date: Sat, 19 Sep 2026 01:21:46 +0900 Subject: [PATCH 1/6] fix: hide providers with zero-token synthetic activity (#333) --- .../PokeTokenBar/Core/LocalUsageReader.swift | 6 ++++- Sources/PokeTokenBar/Core/UsageStore.swift | 3 ++- .../LocalUsageReaderTests.swift | 22 +++++++++++++++++++ Tests/PokeTokenBarTests/UsageStoreTests.swift | 21 ++++++++++++++++++ docs/reference/defect-log.md | 6 +++++ 5 files changed, 56 insertions(+), 2 deletions(-) diff --git a/Sources/PokeTokenBar/Core/LocalUsageReader.swift b/Sources/PokeTokenBar/Core/LocalUsageReader.swift index 5c113028f..efcccef9e 100644 --- a/Sources/PokeTokenBar/Core/LocalUsageReader.swift +++ b/Sources/PokeTokenBar/Core/LocalUsageReader.swift @@ -1430,7 +1430,11 @@ enum LocalUsageReader { /// 최근 5시간 롤링 윈도우 기반 활성 블록(번 레이트 추정용). static func activeBlock(entries: [Entry], now: Date) -> BlockUsage? { let windowStart = now.addingTimeInterval(-blockWindow) - let recent = entries.filter { $0.date >= windowStart }.sorted { $0.date < $1.date } + // Claude Code can write `` assistant records whose usage fields are all zero + // even when no Claude request ran (for example, a local wrapper/session bootstrap). Those + // records are parser-valid metadata, not usage: exclude them from both existence and the + // block start time so they cannot create or stretch a carrier snapshot/tab. + let recent = entries.filter { $0.date >= windowStart && $0.total > 0 }.sorted { $0.date < $1.date } guard let first = recent.first else { return nil } var b = Bucket() for e in recent { b.add(e) } diff --git a/Sources/PokeTokenBar/Core/UsageStore.swift b/Sources/PokeTokenBar/Core/UsageStore.swift index 2ee22c8f0..971b4d5c5 100644 --- a/Sources/PokeTokenBar/Core/UsageStore.swift +++ b/Sources/PokeTokenBar/Core/UsageStore.swift @@ -742,7 +742,8 @@ final class UsageStore { // 누적만으로 만들면, weekTotal 이 옵셔널이 아니라(토큰 0이어도 non-nil) 오늘·최근 // 미사용 프로바이더까지 탭이 떠서 "안 썼는데 왜 뜨지" 회귀가 난다. 블록이 있을 때만 // 그 시점의 주/월도 함께 보존한다. - let hasActiveBlock = enrichment.blocksOK && enrichment.activeBlock != nil + let hasActiveBlock = enrichment.blocksOK + && (enrichment.activeBlock?.totalTokens ?? 0) > 0 if hasActiveBlock, let provider = providers.first(where: { $0.id == id }) { snapshots.append(ProviderSnapshot( providerID: id, displayName: provider.displayName, today: nil, diff --git a/Tests/PokeTokenBarTests/LocalUsageReaderTests.swift b/Tests/PokeTokenBarTests/LocalUsageReaderTests.swift index 19c64f6af..79f590cb9 100644 --- a/Tests/PokeTokenBarTests/LocalUsageReaderTests.swift +++ b/Tests/PokeTokenBarTests/LocalUsageReaderTests.swift @@ -1010,6 +1010,28 @@ final class LocalUsageReaderTests: XCTestCase { XCTAssertGreaterThanOrEqual(p.totalTokens, 600_000) } + /// Claude writes parser-valid `` assistant rows with every usage field set to zero. + /// They are metadata, not evidence that the provider is active, so they must not create the + /// active-block carrier snapshot that makes a zero-usage Claude Code tab appear. + func testActiveBlockIgnoresZeroTokenSyntheticEntries() { + let now = Date() + let synthetic = LocalUsageReader.Entry( + id: "synthetic", date: now.addingTimeInterval(-120), + localDay: LocalUsageReader.localDayFormatter().string(from: now), + model: "", input: 0, output: 0, cacheWrite: 0, cacheRead: 0) + + XCTAssertNil(LocalUsageReader.activeBlock(entries: [synthetic], now: now)) + + let actual = LocalUsageReader.Entry( + id: "actual", date: now.addingTimeInterval(-60), + localDay: LocalUsageReader.localDayFormatter().string(from: now), + model: "claude-opus", input: 10, output: 5, cacheWrite: 0, cacheRead: 0) + let block = LocalUsageReader.activeBlock(entries: [synthetic, actual], now: now) + XCTAssertEqual(block?.totalTokens, 15) + XCTAssertEqual(block?.id, "block-\(Int(actual.date.timeIntervalSince1970))", + "zero-token metadata must not move the active block start earlier") + } + // MARK: enrichment 스캔 하한 (월초 경계 흡수) /// 스캔 하한은 블록(now-5h)·이번 주(weekStart)·이번 달(monthStart) 세 윈도우 시작을 모두 덮어야 diff --git a/Tests/PokeTokenBarTests/UsageStoreTests.swift b/Tests/PokeTokenBarTests/UsageStoreTests.swift index 316349fb3..0b261796b 100644 --- a/Tests/PokeTokenBarTests/UsageStoreTests.swift +++ b/Tests/PokeTokenBarTests/UsageStoreTests.swift @@ -836,6 +836,27 @@ final class UsageStoreTests: XCTestCase { "오늘·최근 미사용 프로바이더는 탭이 뜨면 안 됨") } + /// Provider enrichment must not turn a zero-token parser artifact into a visible tab. + /// LocalUsageReader normally rejects this at the source; the store keeps the UI boundary strict + /// for every current and future provider implementation. + func testNoCarrierForZeroTokenActiveBlock() async { + let claude = FakeUsageProvider(id: "claude_code", displayName: "Claude Code", daily: nil) + claude.enrichment = ProviderEnrichment( + activeBlock: BlockUsage( + id: "synthetic", startTime: "2026-09-18T15:54:00Z", + endTime: "2026-09-18T20:54:00Z", isActive: true, + totalTokens: 0, costUSD: 0, tokensPerMinute: 0), + blocksOK: true, + weekTotal: PeriodUsage(period: "w", totalTokens: 0, totalCost: 0), + monthTotal: PeriodUsage(period: "m", totalTokens: 0, totalCost: 0), + periodsOK: true) + let store = makeStore(providers: [claude]) + + await store.refresh(scheduleEmptyRetry: false) + + XCTAssertFalse(store.snapshots.contains { $0.providerID == "claude_code" }) + } + /// 여러 프로바이더의 burn 은 합산된다 (60k + 60k = 120k → fast). func testBurnTierCombinesProviders() async { let claude = FakeUsageProvider(id: "claude_code", displayName: "Claude Code", daily: todayDaily(10_000_000)) diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index bf65aa654..1b6215b76 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -18,6 +18,12 @@ read_when: - **옵셔널 tautology.** 옵셔널 필드라도 *생산자가 항상 채우면* `x != nil` 은 항상 참이다. "값이 있나"는 의미값으로 검사한다(예: `totalTokens > 0`, 또는 진짜 nil 가능한 필드 `activeBlock`). — weekTotal 회귀(#56). +- **사용량 엔트리의 존재는 실제 사용의 증거가 아니다.** Claude 는 로컬 세션 시작 과정에서 토큰 필드가 + 전부 0인 `` assistant 엔트리를 기록할 수 있다. `activeBlock` 이 최근 엔트리 존재만 검사하고, + store 가 non-nil 블록만 검사하면 오늘 사용량 0인 Claude Code 탭이 생긴다. 블록 집계와 캐리어 스냅샷 + 경계 모두 `totalTokens > 0`을 요구한다. 회귀는 비어 있는 배열이 아니라 **non-empty zero-token 입력**으로 + `LocalUsageReaderTests.testActiveBlockIgnoresZeroTokenSyntheticEntries`와 + `UsageStoreTests.testNoCarrierForZeroTokenActiveBlock`에서 고정한다. - **JSON `null` 은 "값 있음"이 아니다.** `obj["x"] != nil` 은 `NSNull` 에도 참이라 `intValue` 가 0 을 돌려주고, 그 0 으로 캐시분을 빼면 토큰이 통째로 사라진다. 숫자 필드는 `NSNull`·문자열·부재를 모두 nil 로 만드는 추출기(`intOrNil`/`doubleOrNil`)로 읽고, 대체 스펠링 폴백은 그 nil 로 판단한다. From 7cb2d2048f44a2a076879bfdc444a353d23a3738 Mon Sep 17 00:00:00 2001 From: CallumAcorn Date: Tue, 29 Sep 2026 10:34:32 +1000 Subject: [PATCH 2/6] Stop the test suite touching the user's real files and credentials Two defects, both measured on the maintainer's machine. Upstream fixed the same two (d6d24a04, 671c1ece); these are ports against this fork. The usage cache defaults to the real Application Support usage-cache.json and had no gate, so any test that ran real providers read and rewrote it. With the app quit, one full suite run changed the real file from 726,929 to 763,881 bytes. Every test run in this fork had been modifying user data. Path injection cannot catch this: it covers the test that injects, not the process. AppEnv.persistsToUserLocation now owns the rule (an injected path always persists, the default path only inside the app) and the cache obeys it. Measured again after the gate: no state file changes across a full run. The companion save was never at risk, since its tests all inject a path. UsageStore defaults its limits providers to the real ones, and about ten tests build a store without a stub. Whenever the Keychain grant happened to be live, a refresh in one of those called api.anthropic.com with the user's real token and spent their rate limit. AppEnv.allowsLiveLimitsFetch now gates the real Claude and Antigravity fetches before any Keychain read, allowing them only in the app or with PTB_PARITY=1. The gate made the two Keychain-discipline tests hollow: blocking before the provider runs makes 'the automatic path never reads the Keychain' true for the wrong reason. Both now open the gate with KeychainReader stubbed, so the provider's real logic runs against a fake Keychain. The manual-path test had been querying the real Keychain with prompts allowed during swift test. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Core/AntigravityRateLimitsProvider.swift | 3 ++ Sources/PokeTokenBar/Core/AppEnv.swift | 34 +++++++++++++++++++ .../PokeTokenBar/Core/KeychainAccess.swift | 6 ++++ .../PokeTokenBar/Core/LocalUsageCache.swift | 7 ++-- .../Core/OAuthLimitsProvider.swift | 3 ++ .../KeychainAutoPathTests.swift | 8 +++++ .../SecurityHardeningTests.swift | 29 ++++++++++++++++ .../UsageEnvironmentTests.swift | 2 ++ 8 files changed, 90 insertions(+), 2 deletions(-) diff --git a/Sources/PokeTokenBar/Core/AntigravityRateLimitsProvider.swift b/Sources/PokeTokenBar/Core/AntigravityRateLimitsProvider.swift index 711d6df4a..d6e06e4b0 100644 --- a/Sources/PokeTokenBar/Core/AntigravityRateLimitsProvider.swift +++ b/Sources/PokeTokenBar/Core/AntigravityRateLimitsProvider.swift @@ -17,6 +17,9 @@ public struct AntigravityRateLimitsProvider: AntigravityLimitsProviding, Sendabl public init() {} public func fetch(allowKeychainPrompt: Bool = false) async throws -> AntigravityRateLimitStatus { + // 테스트가 사용자의 실제 토큰으로 원격을 부르지 않게 — `AppEnv.allowsLiveLimitsFetch` 참조. + // Keychain 읽기보다 **먼저** 막는다: 막아야 할 것은 네트워크만이 아니라 자격증명 접근 자체다. + guard AppEnv.allowsLiveLimitsFetch else { throw LimitsError.keychainAccessDisabled } let token = try await tokenCache.accessToken(allowKeychainPrompt: allowKeychainPrompt) do { return try await fetchStatus(accessToken: token) diff --git a/Sources/PokeTokenBar/Core/AppEnv.swift b/Sources/PokeTokenBar/Core/AppEnv.swift index a18e6b7f5..23e0c6ba3 100644 --- a/Sources/PokeTokenBar/Core/AppEnv.swift +++ b/Sources/PokeTokenBar/Core/AppEnv.swift @@ -8,4 +8,38 @@ enum AppEnv { static var isBundledApp: Bool { Bundle.main.bundleIdentifier != nil && Bundle.main.bundlePath.hasSuffix(".app") } + + /// 저장소가 자기 파일을 읽고 써도 되는가. + /// + /// 경로를 **주입했으면** 언제나 허용한다 — 테스트가 임시 파일로 영속화를 검증하는 방식이다. 주입하지 + /// 않아 **기본(사용자) 경로**로 떨어진 저장소는 실앱에서만 영속화한다. 그래야 `swift test` 가 사용자의 + /// 실제 파일을 건드리지 않는다. + /// + /// 왜 저장소 쪽에 두는가: 테스트가 경로를 주입해서는 못 막는다. 주입은 그 테스트만 덮고 **프로세스**는 + /// 못 덮으므로, 기본 경로로 가는 분기는 테스트에서 보이지 않는다. 실측(2026-09-29, 앱 종료 상태): + /// 전체 테스트 한 번에 사용자 실제 `usage-cache.json` 이 726,929 → 763,881 바이트로 바뀌었다 — + /// 이 세션 내내 테스트를 돌릴 때마다 사용자 데이터를 고쳐 쓰고 있었다(상류 d6d24a04 와 같은 결함). + /// 규칙은 여기 한 곳에만 둔다 — 저장소마다 복사하면 다음 복사본에서 빠뜨린다. + /// 한도 프로바이더가 **실제** 자격증명을 읽고 원격 엔드포인트를 불러도 되는가. + /// + /// `UsageStore` 는 한도 프로바이더를 기본값으로 **진짜** 것(`OAuthLimitsProvider()` 등)을 쓰고, 스텁을 + /// 주입하지 않은 테스트가 열 개쯤 있다(실측). 그 테스트가 refresh 를 돌리면, 마침 Keychain 승인이 살아 + /// 있는 순간에는 무UI 읽기가 성공해 **사용자의 실제 토큰으로 api.anthropic.com 을 부른다** — 사용자의 + /// 한도 요청량을 소모하고(실앱이 그 뒤 429 백오프를 맞는다), Antigravity 면 refresh 토큰까지 쓴다. + /// + /// 실앱에서만 허용하고, 개발자가 **일부러** 실측하려면 `PTB_PARITY=1` 로 켠다(로컬 패리티 테스트와 같은 + /// 스위치). 스텁을 주입한 테스트는 이 게이트를 지나지 않으므로 영향이 없다. + static var allowsLiveLimitsFetch: Bool { + allowLiveLimitsFetchForTesting || isBundledApp || ProcessInfo.processInfo.environment["PTB_PARITY"] == "1" + } + + /// 키체인 규율 테스트 전용 opt-in. 반드시 `KeychainReader.stubStatusForTesting` 과 **같이** 켠다 — + /// 게이트만 열면 프로바이더가 실제 키체인을 읽는다. 게이트를 닫아 둔 채면 프로바이더 로직이 아예 안 + /// 돌아서, "자동 경로는 키체인을 안 읽는다"가 프로바이더의 규율이 아니라 게이트 때문에 참이 된다. + nonisolated(unsafe) static var allowLiveLimitsFetchForTesting = false + + static func persistsToUserLocation(injectedFileURL: URL?, + isBundledApp: Bool = AppEnv.isBundledApp) -> Bool { + injectedFileURL != nil || isBundledApp + } } diff --git a/Sources/PokeTokenBar/Core/KeychainAccess.swift b/Sources/PokeTokenBar/Core/KeychainAccess.swift index f420cb602..f9ee8c611 100644 --- a/Sources/PokeTokenBar/Core/KeychainAccess.swift +++ b/Sources/PokeTokenBar/Core/KeychainAccess.swift @@ -28,8 +28,14 @@ enum KeychainReader { static func resetQueryCountForTesting() { queryCount = 0 } + /// 테스트용 대체 백엔드. 설정하면 `SecItemCopyMatching` 대신 이 값을 돌려준다 — 조회 **횟수**는 + /// 그대로 세면서 실제 키체인에는 닿지 않는다. 키체인 규율 테스트가 사용자의 실제 키체인을 읽고 + /// (`allowKeychainPrompt: true` 면 프롬프트까지 띄우며) 돌던 것을 막는다. + nonisolated(unsafe) static var stubStatusForTesting: OSStatus? + static func copyMatching(_ query: [String: Any], _ result: inout CFTypeRef?) -> OSStatus { queryCount += 1 + if let stub = stubStatusForTesting { return stub } return SecItemCopyMatching(query as CFDictionary, &result) } } diff --git a/Sources/PokeTokenBar/Core/LocalUsageCache.swift b/Sources/PokeTokenBar/Core/LocalUsageCache.swift index cded00776..fd4f366a5 100644 --- a/Sources/PokeTokenBar/Core/LocalUsageCache.swift +++ b/Sources/PokeTokenBar/Core/LocalUsageCache.swift @@ -102,6 +102,8 @@ actor LocalUsageCache { private let grokRoots: [URL]? private let piRoots: [URL]? private let fileURL: URL + /// false 면 디스크를 읽지도 쓰지도 않는다 — `AppEnv.persistsToUserLocation` 참조. + private let persists: Bool private let now: @Sendable () -> Date /// throwing probe 를 쓴다 — 읽기 실패(throw)와 "metadata 없음"(`nil`)은 인덱스에 남길지가 다르다. private let codexProbe: @Sendable (URL) throws -> String? @@ -132,6 +134,7 @@ actor LocalUsageCache { self.grokRoots = grokRoots self.piRoots = piRoots self.fileURL = fileURL ?? Self.defaultFileURL + self.persists = AppEnv.persistsToUserLocation(injectedFileURL: fileURL) self.now = now self.codexProbe = codexProbe } @@ -364,7 +367,7 @@ actor LocalUsageCache { private func ensureLoaded() { guard !loaded else { return } loaded = true - guard let raw = try? Data(contentsOf: fileURL) else { return } + guard persists, let raw = try? Data(contentsOf: fileURL) else { return } // zlib 압축 스냅샷(현행) → 실패 시 평문 JSON(구버전 캐시) 폴백 let data = (try? (raw as NSData).decompressed(using: .zlib) as Data) ?? raw guard let snap = try? JSONDecoder().decode(Snapshot.self, from: data) else { return } @@ -407,7 +410,7 @@ actor LocalUsageCache { /// 변경이 있으면 디스크에 저장(최소 60초 간격으로 throttle — 잦은 쓰기 방지). private func saveIfNeeded() { - guard dirty else { return } + guard persists, dirty else { return } if let last = lastSave, now().timeIntervalSince(last) < 60 { return } prune() let snap = Snapshot( diff --git a/Sources/PokeTokenBar/Core/OAuthLimitsProvider.swift b/Sources/PokeTokenBar/Core/OAuthLimitsProvider.swift index 6385996bd..e075019f2 100644 --- a/Sources/PokeTokenBar/Core/OAuthLimitsProvider.swift +++ b/Sources/PokeTokenBar/Core/OAuthLimitsProvider.swift @@ -40,6 +40,9 @@ struct OAuthLimitsProvider: ClaudeLimitsProviding, Sendable { }() func fetch(allowKeychainPrompt: Bool = false) async throws -> LimitStatus { + // 테스트가 사용자의 실제 토큰으로 원격을 부르지 않게 — `AppEnv.allowsLiveLimitsFetch` 참조. + // Keychain 읽기보다 **먼저** 막는다: 막아야 할 것은 네트워크만이 아니라 자격증명 접근 자체다. + guard AppEnv.allowsLiveLimitsFetch else { throw LimitsError.keychainAccessDisabled } let token = try await accessTokenCache.accessToken(allowKeychainPrompt: allowKeychainPrompt) var status: LimitStatus do { diff --git a/Tests/PokeTokenBarTests/KeychainAutoPathTests.swift b/Tests/PokeTokenBarTests/KeychainAutoPathTests.swift index 0106815e9..6ccd7aa3f 100644 --- a/Tests/PokeTokenBarTests/KeychainAutoPathTests.swift +++ b/Tests/PokeTokenBarTests/KeychainAutoPathTests.swift @@ -20,6 +20,10 @@ final class KeychainAutoPathTests: XCTestCase { let savedGate = KeychainAccessGate.isDisabled KeychainAccessGate.isDisabled = false defer { KeychainAccessGate.isDisabled = savedGate } + // 게이트를 열되 키체인은 가짜로 — 프로바이더의 실제 규율을 돌리면서 사용자 키체인은 안 건드린다. + AppEnv.allowLiveLimitsFetchForTesting = true + KeychainReader.stubStatusForTesting = errSecItemNotFound + defer { AppEnv.allowLiveLimitsFetchForTesting = false; KeychainReader.stubStatusForTesting = nil } for probe in Self.automaticProbes { KeychainReader.resetQueryCountForTesting() @@ -40,6 +44,10 @@ final class KeychainAutoPathTests: XCTestCase { let savedGate = KeychainAccessGate.isDisabled KeychainAccessGate.isDisabled = false defer { KeychainAccessGate.isDisabled = savedGate } + // 게이트를 열되 키체인은 가짜로 — 프로바이더의 실제 규율을 돌리면서 사용자 키체인은 안 건드린다. + AppEnv.allowLiveLimitsFetchForTesting = true + KeychainReader.stubStatusForTesting = errSecItemNotFound + defer { AppEnv.allowLiveLimitsFetchForTesting = false; KeychainReader.stubStatusForTesting = nil } KeychainReader.resetQueryCountForTesting() _ = try? await AntigravityRateLimitsProvider().fetch(allowKeychainPrompt: true) diff --git a/Tests/PokeTokenBarTests/SecurityHardeningTests.swift b/Tests/PokeTokenBarTests/SecurityHardeningTests.swift index ad42ef34d..533540bd0 100644 --- a/Tests/PokeTokenBarTests/SecurityHardeningTests.swift +++ b/Tests/PokeTokenBarTests/SecurityHardeningTests.swift @@ -606,4 +606,33 @@ final class CredentialGateCoverageTests: XCTestCase { "prompt=\(allowPrompt): 매칭 0건이어야 한다 (status=\(status))") } } + + /// 테스트가 사용자의 실제 파일을 고쳐 쓰면 안 된다. 실측: 게이트 전에는 전체 테스트 한 번이 사용자 + /// `usage-cache.json` 을 726,929 → 763,881 바이트로 바꿨다. 주입 경로는 언제나 허용(테스트가 임시 + /// 파일로 영속화를 검증하는 방식), 기본 경로는 실앱에서만. + func testStoresOnlyTouchTheUserLocationInsideTheApp() { + let tmp = URL(fileURLWithPath: "/tmp/x.json") + XCTAssertTrue(AppEnv.persistsToUserLocation(injectedFileURL: tmp, isBundledApp: false), + "주입한 경로는 테스트에서도 영속화돼야 한다") + XCTAssertFalse(AppEnv.persistsToUserLocation(injectedFileURL: nil, isBundledApp: false), + "기본(사용자) 경로를 테스트가 건드렸다") + XCTAssertTrue(AppEnv.persistsToUserLocation(injectedFileURL: nil, isBundledApp: true), + "실앱에서는 기본 경로로 영속화해야 한다") + } + + /// 커서 로컬 DB 의 손상된 토큰 수가 `parseCursorBubble` 의 `input + output` 합산에서 트랩을 내면 안 된다. + /// 예전 `intValue` 사본은 상한이 없어 `NSNumber.intValue` 가 `Int.max` 로 포화했고, 둘을 더하는 순간 죽었다. + /// 수정 전 실측: SIGTRAP(signal 5). + func testCorruptCursorTokenCountsCannotTrap() { + let huge = NSNumber(value: Double.greatestFiniteMagnitude) + let bubble: [String: Any] = [ + "tokenCount": ["inputTokens": huge, "outputTokens": huge], + "createdAt": "2026-09-29T00:00:00Z", + "modelType": "gpt-5", + ] + let entry = LocalAdditionalUsageReader.parseCursorBubble(bubble, key: "k", modifiedSince: .distantPast) + let e = try? XCTUnwrap(entry) + XCTAssertNotNil(e, "파싱 자체는 돼야 한다") + XCTAssertLessThanOrEqual(e?.total ?? 0, 2 * LocalUsageReader.maxParsedTokenValue) + } } diff --git a/Tests/PokeTokenBarTests/UsageEnvironmentTests.swift b/Tests/PokeTokenBarTests/UsageEnvironmentTests.swift index e5d63559b..cca620959 100644 --- a/Tests/PokeTokenBarTests/UsageEnvironmentTests.swift +++ b/Tests/PokeTokenBarTests/UsageEnvironmentTests.swift @@ -122,6 +122,7 @@ final class UsageEnvironmentTests: XCTestCase { /// 허용 목록은 "사용자가 셸에 export 해 두는 값이 아닌 것"만 담는다: /// - `UsageEnvironment` 자신(프로세스 환경을 읽는 유일한 정당한 위치) /// - `BinaryLocator`: 자식 프로세스 환경 구성과 `SHELL` — 앱이 쓰는 값이지 사용자 override 가 아니다 + /// - `AppEnv`: `PTB_PARITY` is a developer opt-in for live limits calls, not a usage-location override /// - `CompanionStore`/`TradeStore`: `PTB_STATE_DIR` is for dev/QA isolation (both decide their /// state file location this way — same justification, same exception) /// - `OAuthLimitsProvider`: 의도적으로 프로세스 환경만 본다(자동 폴링 경로에서 셸 spawn 금지 — @@ -130,6 +131,7 @@ final class UsageEnvironmentTests: XCTestCase { let allowed: Set = [ "UsageEnvironment.swift", "BinaryLocator.swift", + "AppEnv.swift", "CompanionStore.swift", "TradeStore.swift", "OAuthLimitsProvider.swift", From 69bb5b3dd1dc06f64032ce6e7f984f196e393cf3 Mon Sep 17 00:00:00 2001 From: CallumAcorn Date: Tue, 29 Sep 2026 10:34:32 +1000 Subject: [PATCH 3/6] Clamp token counts in the extra-provider parser (upstream #307) LocalAdditionalUsageProvider has its own copy of intValue, separate from the LocalUsageReader one that got an upper bound after an overflow crash. This copy had a floor but no ceiling, and NSNumber.intValue saturates at Int.max, so parseCursorBubble's 'input + output' trapped on a corrupt Cursor record. Verified: SIGTRAP with the old helper, clean parse with the clamp. Uses LocalUsageReader.maxParsedTokenValue rather than a second limit. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Core/LocalAdditionalUsageProvider.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Sources/PokeTokenBar/Core/LocalAdditionalUsageProvider.swift b/Sources/PokeTokenBar/Core/LocalAdditionalUsageProvider.swift index e4f68d6d5..9364385ac 100644 --- a/Sources/PokeTokenBar/Core/LocalAdditionalUsageProvider.swift +++ b/Sources/PokeTokenBar/Core/LocalAdditionalUsageProvider.swift @@ -1115,10 +1115,22 @@ enum LocalAdditionalUsageReader { return trimmed.isEmpty ? nil : trimmed } + /// 외부 파일(커서 DB·Kiro·OpenCode 로그)의 토큰 수 — 음수는 0, 상한은 `LocalUsageReader.maxParsedTokenValue`. + /// + /// 이 함수는 `LocalUsageReader.intValue` 의 **별도 사본**이다. 그쪽은 합산 오버플로 크래시 뒤에 상한을 + /// 얻었지만(defect-log '외부에서 오는 모든 수치'), 이 사본은 하한만 있고 상한이 없었다. `NSNumber.intValue` + /// 는 `Int.max` 로 포화하므로, 손상된 값 하나가 합산에서 `+` 오버플로 트랩을 낸다 — 커서 로컬 DB 의 + /// `tokenCount` 가 정확히 이 경로를 탄다(`parseCursorBubble`). 상류 #307 과 같은 수정. + /// 사본이 둘이면 수정도 두 번이다. 상한은 새로 만들지 않고 그쪽 값을 그대로 쓴다. private static func intValue(_ value: Any?) -> Int { - if let number = value as? NSNumber { return max(0, number.intValue) } + let cap = LocalUsageReader.maxParsedTokenValue + if let number = value as? NSNumber { + let d = number.doubleValue + guard d.isFinite, d > 0 else { return 0 } + return d >= Double(cap) ? cap : Int(d) + } if let string = value as? String, let number = Int(string.trimmingCharacters(in: .whitespaces)) { - return max(0, number) + return min(max(0, number), cap) } return 0 } From 1903df938b73b590f4d0e2a4bf2e774457b10d49 Mon Sep 17 00:00:00 2001 From: CallumAcorn Date: Tue, 29 Sep 2026 10:34:32 +1000 Subject: [PATCH 4/6] Price Opus 5.5, Opus 5 and Sonnet 5 explicitly (upstream #304, #309) This fork kept the model-family price fallback, so nothing showed as Unavailable, but the fallback was wrong for two models. claude-opus-5-5 was billed at Opus 5's rate (5, 25, 6.25, 0.5) instead of (4, 20, 5, 0.2): 25% high on input, output and cache writes and 2.5x high on cache reads, which dominate real usage. claude-sonnet-5 fell back to Sonnet 4's rate, 1.5x high. Opus 5 happened to match the fallback and is now explicit so it cannot drift. Also adds gemini-2.5-flash-lite. Verified by injection: without the Opus 5.5 row a million cache reads cost 0.5 instead of 0.2. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PokeTokenBar/Core/ModelPricing.swift | 9 +++++++++ Tests/PokeTokenBarTests/GeminiUsageTests.swift | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/Sources/PokeTokenBar/Core/ModelPricing.swift b/Sources/PokeTokenBar/Core/ModelPricing.swift index 9b412762b..454ac7285 100644 --- a/Sources/PokeTokenBar/Core/ModelPricing.swift +++ b/Sources/PokeTokenBar/Core/ModelPricing.swift @@ -24,12 +24,21 @@ enum ModelPricing { "claude-opus-4-7": .perMillion(5, 25, 6.25, 0.5), "claude-sonnet-4-6": .perMillion(3, 15, 3.75, 0.3), "claude-haiku-4-5-20251001": .perMillion(1, 5, 1.25, 0.1), + // Claude 5 계열 — 명시 행(상류 #304·Opus 5.5 행). 패밀리 폴백이 이 포크엔 남아 있어 "Unavailable"은 + // 안 됐지만, 폴백 단가가 **틀린** 모델이 있었다: `claude-opus-5-5` 는 폴백이 Opus 5 단가 + // (5, 25, 6.25, 0.5)를 매겼는데 실제는 (4, 20, 5, 0.2) — 입력·출력·캐시쓰기 25%, **캐시 읽기 2.5배** + // 과대계상. 캐시 읽기가 실사용의 대부분이라 총액이 크게 부풀었다. `claude-sonnet-5` 도 폴백이 + // Sonnet 4 단가로 1.5배 과대. `claude-opus-5` 는 폴백과 같은 값이지만 명시해 둔다(폴백 변경에 안 흔들리게). + "claude-opus-5": .perMillion(5, 25, 6.25, 0.5), + "claude-opus-5-5": .perMillion(4, 20, 5, 0.2), + "claude-sonnet-5": .perMillion(2, 10, 2.5, 0.2), "claude-fable-5": .perMillion(10, 50, 12.5, 1.0), // LiteLLM 스냅샷 가격 등재됨(2026-08) — 기존 미가격 $0 플레이스홀더 대체 // Fable 5.1: Fable 5 와 기본 단가는 같지만 cache read 만 $0.25/MTok(입력의 0.025배)로 내려갔다. // 이 줄이 없으면 `fable` 패밀리 폴백이 Fable 5 의 $1.00 을 적용해 **비용을 4배 부풀린다**(상류 #277). "claude-fable-5-1": .perMillion(10, 50, 12.5, 0.25), "gpt-5.5": .perMillion(5, 30, 0, 0.5), // Gemini — 공식 API 단가(기본 티어, ≤200K 프롬프트). 캐시는 read 단가만(스토리지 시간요금 제외). + "gemini-2.5-flash-lite": .perMillion(0.10, 0.40, 0, 0.01), // 상류 #309 "gemini-2.5-pro": .perMillion(1.25, 10, 0, 0.3125), "gemini-2.5-flash": .perMillion(0.30, 2.5, 0, 0.075), "gemini-2.0-flash": .perMillion(0.10, 0.4, 0, 0.025), diff --git a/Tests/PokeTokenBarTests/GeminiUsageTests.swift b/Tests/PokeTokenBarTests/GeminiUsageTests.swift index 6cc86f1e6..0433cec62 100644 --- a/Tests/PokeTokenBarTests/GeminiUsageTests.swift +++ b/Tests/PokeTokenBarTests/GeminiUsageTests.swift @@ -97,6 +97,14 @@ final class GeminiUsageTests: XCTestCase { // 통과하지만, cache read 만 $1.00 → $0.25 로 내려가 있다. 명시 행이 없으면 캐시 비용을 // **4배** 부풀린다 — 캐시 읽기가 대부분인 실사용에서 총액이 눈에 띄게 틀어진다(상류 #277). XCTAssertEqual(ModelPricing.rate(for: "claude-fable-5-1"), .perMillion(10, 50, 12.5, 0.25)) + // Opus 5.5 는 폴백(`contains("opus")` → Opus 5 단가)이 **틀린** 값을 매기던 모델이다. 명시 행이 + // 없으면 캐시 읽기가 0.5 로 새어 2.5배 과대가 된다 — 캐시 읽기가 대부분인 실사용에서 총액이 부푼다. + XCTAssertEqual(ModelPricing.rate(for: "claude-opus-5-5"), .perMillion(4, 20, 5, 0.2)) + XCTAssertEqual(ModelPricing.cost(model: "claude-opus-5-5", input: 0, output: 0, + cacheWrite: 0, cacheRead: 1_000_000), 0.2, + accuracy: 0.0001, "폴백으로 새면 0.5 가 된다") + XCTAssertEqual(ModelPricing.rate(for: "claude-sonnet-5"), .perMillion(2, 10, 2.5, 0.2), + "폴백은 Sonnet 4 단가(3,15)로 1.5배 과대") XCTAssertEqual(ModelPricing.cost(model: "claude-fable-5-1", input: 0, output: 0, cacheWrite: 0, cacheRead: 1_000_000), 0.25, accuracy: 0.0001, "패밀리 폴백으로 새면 1.00 이 된다") From b4acb01d71d2a1ffaad7a8c64b78c8cbbdb5aecb Mon Sep 17 00:00:00 2001 From: CallumAcorn Date: Tue, 29 Sep 2026 10:35:45 +1000 Subject: [PATCH 5/6] ledger: record all 61 upstream commits from this sync Seven taken or ported (the Cursor token clamp, two test-isolation fixes, pricing rows, hiding zero-token providers). Two declined on security grounds, both built on the session key declined as #241. The rest are skipped (tool not installed, or already present), declined (release process, or changing existing behaviour by design), or deferred. Adds a 'deferred' disposition: wanted in principle but colliding with how far the fork has diverged, so it needs a deliberate port rather than a cherry-pick. Forty of the attempted commits conflicted, concentrated in Localization, CompanionStore, PopoverView and CompanionView, which is where porting would risk existing features. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reference/upstream-ledger.md | 62 +++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/docs/reference/upstream-ledger.md b/docs/reference/upstream-ledger.md index 2076041c6..d21cf699c 100644 --- a/docs/reference/upstream-ledger.md +++ b/docs/reference/upstream-ledger.md @@ -31,6 +31,7 @@ Rows are permanent: a declined commit stays declined until someone deliberately | `skipped` | Not applicable here — the code it touches does not exist in this fork | | `declined` | Applicable, and deliberately not wanted | | `pending` | Decided to take, not yet applied. Carries the reason it is waiting, so it stays out of UNREVIEWED without being forgotten | +| `deferred` | Wanted in principle, but it conflicts with how far this fork has diverged, so taking it means a deliberate port that could disturb existing behaviour. Not a decline: revisit by porting, not cherry-picking | ## Ledger @@ -105,3 +106,64 @@ declined for the same reason, not re-litigated. | `232108cf` | #279 | **ported** | Taken as the `claude-fable-5-1` price row only. Without it the `fable` family fallback applies Fable 5's $1.00 cache read instead of $0.25 and **overstates cost 4x**; verified by injection. **The other half of this commit is NOT taken and still needs a decision**: `LocalUsageReader` +77 changes how Codex total-only turns are counted (#278), which is usage attribution rather than pricing. Codex is present on this machine, so it may be wanted | | `b6bf676f` | #264 | **declined** | Persistent individual values plus a Pokédex detail page, 1651 insertions across 16 files. **This fork already has IVs** (`MonState.ivs`/`evs`, `StatCalc`, clamped in `sanitizedMon`) built independently, so taking this would run a second parallel implementation into a model that already has one. Same structural fork as #158 and #211 at roughly ten times the size. If the Pokédex detail page is ever wanted, port that piece alone against our model | | `78980651` | #275 | **declined** | Ambient Keychain help popover. Its explanation is correct and independently confirms our own root-cause diagnosis, but the remedy it offers is the session key declined as #241: the help body's payoff is "register a session key", and the button is gated on `store.sessionKeyConfigured`, which does not exist here. Taking the meaningful part would steer users toward a full account cookie stored in plaintext. The safe half (explaining why the grant dies) already shipped in our own revocation notice, which offers re-authorising instead | +| `a9107676` | #333 | taken | Hide providers with zero-token synthetic activity. Applied cleanly | +| `878a0be2` | #307 | **ported** | Clamp extra-provider token counts. Ported as the `LocalAdditionalUsageProvider.intValue` ceiling only: that separate copy had no upper bound, and `parseCursorBubble`'s `input + output` trapped on a corrupt Cursor record (verified SIGTRAP). The Cursor dashboard half targets `CursorUsageAPI.swift`, which this fork does not have (#197 pending) | +| `1ef19797` | #304 | **ported** | Claude 5 price rows, without #289's fallback removal. Sonnet 5 had been billed at Sonnet 4's fallback rate, 1.5x high | +| `832154af` | — | **ported** | Opus 5.5 price row. The fallback billed it at Opus 5's rate: 25% high on input, output and cache writes, 2.5x high on cache reads | +| `29a4e2d2` | #309 | **ported** | gemini-2.5-flash-lite price row. Pricing only | +| `d6d24a04` | — | **ported** | Stop tests rewriting the user's files. **Measured on this machine**: one suite run changed the real `usage-cache.json` 726,929 to 763,881 bytes. Ported as `AppEnv.persistsToUserLocation` gating the usage cache; no state file changes after | +| `671c1ece` | — | **ported** | Stop tests calling live limits endpoints. About ten tests built a `UsageStore` with the real providers, so with a live Keychain grant they called Anthropic with the user's token. Ported as `AppEnv.allowsLiveLimitsFetch` on the Claude and Antigravity fetches. The two Keychain-discipline tests now run against a stubbed Keychain instead of the real one | +| `e5b48004` | #327 | **declined** | Track several Claude accounts, 3,419 lines. Built on the session key declined as #241 (25 references). Would reintroduce a full account cookie stored in plaintext | +| `eed5b735` | — | **declined** | Per-account session key. Same reason as #241 | +| `c9c016d0` | #280 | skipped | Prioritise user account over MCP placeholders. Built on the multi-account Keychain enumeration declined as #232; this fork's single-entry lookup is unaffected | +| `e76a8e01` | #344 | skipped | Keep forked usage with its original account. Depends on multi-account (#327, declined) | +| `5bd8d435` | #283 | skipped | Aside usage provider. Not installed, and a new provider adds parsing of untrusted files for no benefit | +| `aaef5db0` | #274 | skipped | Antigravity nested token file and OAuth refresh. Antigravity not installed | +| `87c67927` | #325 | skipped | Antigravity CLI/IDE token auto-detect. Not installed | +| `641e3e80` | #323 | skipped | Localise Antigravity group names. Not installed | +| `ab6c768d` | #276 | skipped | oh-my-pi per-model usage. Not installed | +| `8c08e013` | #369 | skipped | Discover the codex bundle inside ChatGPT.app. ChatGPT.app not installed | +| `3ea40afc` | #363 | skipped | OpenCode v2 sessions. Not installed | +| `777ee274` | — | skipped | Cursor remaining included usage from the dashboard. Depends on the Cursor dashboard API (#197, still pending) | +| `e94da861` | #334 | skipped | Re-arm Rare Candy when a limit window resets. **This fork already has it**, independently, as PR #29: the same design, keeping `resets_at` out of the key and re-arming when the recorded value changes. Upstream's also covers Codex windows; Codex limits do not load on this machine, so no loss | +| `32725093` | #302 | skipped | Re-read the Keychain on limit refresh. This fork already bypasses the token cache on manual refresh. The remainder is the floating-pet footer toggle, tied to the difficulty chain | +| `09fd6003` | — | declined | Upstream 2.5.4 version bump. Meaningless under this fork's versioning | +| `42df4e61` | — | declined | Upstream 2.5.5 version bump. Same | +| `065b2e18` | #297 | declined | Upstream release tour and screenshots. READMEs have diverged | +| `b63fce25` | — | declined | Upstream screenshots and feature guides. Same | +| `5e08e6fe` | #296 | declined | Require release notes and contributor attribution. Rewrites `release.sh`, which this fork has heavily reworked (source-only releases, asset waivers, `-hardened` versioning) | +| `dec297a5` | #254 | declined | 2x growth on repeat hatches. Changes the balance of an existing feature | +| `69ff39bb` | #289 | declined | Distinguish estimated from unavailable cost by removing the model-family price fallback. Would turn every unpriced model to 'Unavailable'; this fork instead adds explicit rows. 626 lines across 28 files | +| `b0c6074f` | #244 | deferred | Difficulty multipliers (growth and shop-price slider, default unchanged). Changes shop and growth code paths; awaiting the maintainer's call | +| `5963293b` | #287 | deferred | Fix for #244: difficulty changes advancing earned progress. Take with #244 | +| `d4d34e7d` | #284 | deferred | Removes the floating-pet toggle #244 added. Take with #244 | +| `014fcb28` | #352 | deferred | First-run egg hint follows growth difficulty. Depends on #244 | +| `2fab082c` | #290 | deferred | Complete selected-language localisation, 835 lines. Heavy churn in the most-diverged file | +| `10d1a07c` | #288 | deferred | Collect all Unown letter forms, 1,587 lines. Normalises its new state at the save boundary, but builds on the representative pin this fork reimplemented | +| `4bfae978` | — | deferred | Select collected appearances separately. Builds on #288 | +| `0912c68c` | #270 | deferred | Monthly day-by-day usage trend, 1,022 lines | +| `78595ed8` | #332 | deferred | Usage recap for any week, month or year, 1,136 lines | +| `2eec9afa` | #298 | deferred | Pace marker on quota bars | +| `68deb094` | #349 | deferred | Colour quota bars by pace. Builds on #298 | +| `b32717fb` | — | deferred | Colour menu-bar limit percentages like the gauges | +| `2ecf7d2f` | #246 | deferred | Explain delayed egg hatches and harden retry state | +| `2e8efddf` | #328 | deferred | Bulk Rare Candy use with growth previews. Touches the candy code fixed in #29 | +| `13e3bf2a` | — | deferred | Local save auto-backup and corruption recovery, 776 lines. Changes the load path; worth doing as its own reviewed change | +| `ae7bd9d3` | — | deferred | Search, sort and filters for the Pokédex and catch log, 775 lines | +| `f0ef5ff3` | — | deferred | Colour dex numbers by rarity | +| `279e112d` | #370 | deferred | Larger text and sprites in a 4x4 collection grid | +| `d44329bb` | — | deferred | Fixed-height Home tab with a reserved scroll area | +| `746d8384` | #356 | deferred | Korean particles matched to the name | +| `c9514926` | #291 | deferred | Tell shoppers what a released Pokémon keeps. Build fails here: depends on the release feature this fork lacks | +| `b4ff5de9` | — | deferred | **Protect legendary companions from accidental egg discard.** The most valuable deferral: it prevents losing a legendary. Changes the egg-purchase flow, so it wants a deliberate port | +| `fe51577e` | #351 | deferred | Show real shiny odds in hatch notifications | +| `184c5f41` | #282 | deferred | Avoid UI stalls opening large catch logs | +| `087fd0f7` | #285 | deferred | Dex sprite animation consistency | +| `fd008e53` | #286 | deferred | Quota graph fill matches remaining display mode | +| `bb92bd2a` | #292 | deferred | Align quota percentages | +| `a8b3ba82` | #293 | deferred | Display costs as plain dollars | +| `1a8a7252` | #295 | deferred | Avoid English flashes loading Pokémon names | +| `1c695c7d` | #311 | deferred | Show a skipped release in Settings. Safe for update pinning, but conflicts across four forked UI files | +| `a94b3dae` | — | deferred | Stop unchanged status lines filling the log | +| `5f6a4f0a` | — | deferred | Log each unpriced model once | +| `8b2dd955` | — | deferred | Prefer reported Claude cost-state over the price-table estimate | From 2da457134e1d333f7cb5a32f3706abb965d811cf Mon Sep 17 00:00:00 2001 From: CallumAcorn Date: Tue, 29 Sep 2026 10:44:15 +1000 Subject: [PATCH 6/6] Fix the flaky spectator test failing CI on main testStartTransitionsThroughConnectingToWatching failed intermittently on CI ('expected watching, got connecting'), including on main at fac7ea0, a commit that only bumps a version string. It passed locally. The stub serves responses from a shared static queue, and each store polls every 10ms. Only one of the four spectator tests stopped its store, so stores from earlier tests kept polling and removeFirst() took the next test's queued response first. That test's own store then got an empty body and never left connecting. Timing-dependent, hence CI-only. Every store created by these helpers is now stopped at teardown, and the queue is reset per test. BattleStoreTests copied the same pattern and stopped none of its stores; not yet triggered, but the same mechanism, so fixed there too. 25 consecutive runs of both classes: 0 failures. Co-Authored-By: Claude Opus 5.5 (1M context) --- Tests/PokeTokenBarTests/BattleStoreTests.swift | 11 ++++++++++- Tests/PokeTokenBarTests/SpectatorStoreTests.swift | 12 +++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Tests/PokeTokenBarTests/BattleStoreTests.swift b/Tests/PokeTokenBarTests/BattleStoreTests.swift index b398a3bd7..c24d0217e 100644 --- a/Tests/PokeTokenBarTests/BattleStoreTests.swift +++ b/Tests/PokeTokenBarTests/BattleStoreTests.swift @@ -151,7 +151,16 @@ final class BattleStoreNetworkTests: XCTestCase { let session = URLSession(configuration: config) let online = OnlineStore(defaults: UserDefaults(suiteName: "BattleStoreNetworkTests.\(UUID().uuidString)")!, session: session) online.serverURL = "https://mock.test" - return BattleStore(companion: companion, online: online, session: session, pollIntervalNanoseconds: pollIntervalNanoseconds) + let store = BattleStore(companion: companion, online: online, session: session, pollIntervalNanoseconds: pollIntervalNanoseconds) + // SpectatorStoreTests 와 같은 누수: 멈추지 않은 스토어가 다음 테스트가 정적 공유 큐에 넣은 응답을 + // 먼저 가져간다. 거기서 CI 간헐 실패로 드러났고, 같은 패턴을 복사한 이 파일은 스토어를 하나도 안 + // 멈추고 있었다(아직 터지지 않았을 뿐). 부류째 고친다. + addTeardownBlock { await store.cancel() } + return store + } + + override func setUp() async throws { + QueuedStubURLProtocol.responses = [] } /// [Mirrors TradeStoreFailureTests] A 401 must not be a dead end — self-heals back to idle. diff --git a/Tests/PokeTokenBarTests/SpectatorStoreTests.swift b/Tests/PokeTokenBarTests/SpectatorStoreTests.swift index 233096791..50f4e3b49 100644 --- a/Tests/PokeTokenBarTests/SpectatorStoreTests.swift +++ b/Tests/PokeTokenBarTests/SpectatorStoreTests.swift @@ -31,10 +31,20 @@ final class SpectatorStoreTests: XCTestCase { return condition() } + /// 스텁 응답 큐는 **정적 공유 상태**다. 이전 테스트가 남긴 스토어가 10ms 마다 계속 폴하면서 다음 테스트가 + /// 넣은 응답을 먼저 `removeFirst()` 해 가고, 그 테스트의 스토어는 빈 본문을 받아 `.connecting` 에 멈춘다 — + /// CI 에서만 간헐적으로 실패한 원인(main fac7ea0 포함, 로컬은 통과). 네 테스트 중 스토어를 멈추는 건 + /// 하나뿐이었다. 그래서 여기서 만든 스토어는 **전부** 테스트 끝에 멈추고, 큐는 테스트마다 비운다. + override func setUp() async throws { + SpectatorQueuedStubURLProtocol.responses = [] + } + private func makeStore(pollIntervalNanoseconds: UInt64 = 10_000_000) -> SpectatorStore { let config = URLSessionConfiguration.ephemeral config.protocolClasses = [SpectatorQueuedStubURLProtocol.self] - return SpectatorStore(session: URLSession(configuration: config), pollIntervalNanoseconds: pollIntervalNanoseconds) + let store = SpectatorStore(session: URLSession(configuration: config), pollIntervalNanoseconds: pollIntervalNanoseconds) + addTeardownBlock { @MainActor in store.stop() } + return store } func testStartTransitionsThroughConnectingToWatching() async {