diff --git a/Sources/PokeTokenBar/Core/AppEnv.swift b/Sources/PokeTokenBar/Core/AppEnv.swift index 40083ddd5..8277fa5e4 100644 --- a/Sources/PokeTokenBar/Core/AppEnv.swift +++ b/Sources/PokeTokenBar/Core/AppEnv.swift @@ -28,4 +28,13 @@ enum AppEnv { static var allowsLiveLimitsFetch: Bool { isBundledApp || isParityRun || allowLiveFetchForTesting } + + /// Whether a store may read and write its file. Always true for an injected path, which is how + /// tests check persistence against a temporary file. A store that fell back to its default + /// (user) path persists only in the app bundle, so `swift test` never reads or rewrites the + /// user's real files there. Keep this rule here instead of copying it into each store. + static func persistsToUserLocation(injectedFileURL: URL?, + isBundledApp: Bool = AppEnv.isBundledApp) -> Bool { + injectedFileURL != nil || isBundledApp + } } diff --git a/Sources/PokeTokenBar/Core/LocalUsageCache.swift b/Sources/PokeTokenBar/Core/LocalUsageCache.swift index 210fc9e49..aa52294b1 100644 --- a/Sources/PokeTokenBar/Core/LocalUsageCache.swift +++ b/Sources/PokeTokenBar/Core/LocalUsageCache.swift @@ -122,6 +122,9 @@ actor LocalUsageCache { private let piRoots: [URL]? private let ompRoots: [URL]? private let fileURL: URL + /// Whether this cache reads and writes `fileURL` (`AppEnv.persistsToUserLocation`). Not private so + /// tests can check the gate without doing any IO. + nonisolated let persistsToDisk: Bool private let now: @Sendable () -> Date /// throwing probe 를 쓴다 — 읽기 실패(throw)와 "metadata 없음"(`nil`)은 인덱스에 남길지가 다르다. private let codexProbe: @Sendable (URL) throws -> String? @@ -153,6 +156,9 @@ actor LocalUsageCache { self.piRoots = piRoots self.ompRoots = ompRoots self.fileURL = fileURL ?? Self.defaultFileURL + // `LocalUsageProvider` uses `.shared`, so without this a single `refresh()` during `swift test` + // reads and rewrites the user's real usage-cache.json. + self.persistsToDisk = AppEnv.persistsToUserLocation(injectedFileURL: fileURL) self.now = now self.codexProbe = codexProbe } @@ -414,10 +420,20 @@ actor LocalUsageCache { // MARK: 영속화 + /// Number of loaded blobs, used to observe the read gate: if a default-path cache read the user's + /// real file, hundreds of their blobs would show up next to a single fixture. + var cachedBlobCount: Int { + ensureLoaded() + return claudeCache.count + codexCache.count + geminiCache.count + + grokCache.count + piCache.count + ompCache.count + } + private func ensureLoaded() { guard !loaded else { return } loaded = true - guard let raw = try? Data(contentsOf: fileURL) else { return } + // Reads are gated too: a test that read the user's real cache would assert on fixtures mixed + // with live data. + guard persistsToDisk, 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 } @@ -471,6 +487,7 @@ actor LocalUsageCache { /// 변경이 있으면 디스크에 저장(최소 60초 간격으로 throttle — 잦은 쓰기 방지). private func saveIfNeeded() { guard dirty else { return } + guard persistsToDisk else { dirty = false; return } if let last = lastSave, now().timeIntervalSince(last) < 60 { return } prune() let snap = Snapshot( diff --git a/Tests/PokeTokenBarTests/LocalUsageCacheTests.swift b/Tests/PokeTokenBarTests/LocalUsageCacheTests.swift index 4352eba41..c3e4bf42e 100644 --- a/Tests/PokeTokenBarTests/LocalUsageCacheTests.swift +++ b/Tests/PokeTokenBarTests/LocalUsageCacheTests.swift @@ -127,6 +127,49 @@ final class LocalUsageCacheTests: XCTestCase { return url } + // MARK: User file isolation (regression) + + /// A cache on the default path must neither read nor write the user's real usage-cache.json. + /// + /// Every test that touches `LocalUsageCache` injects a `fileURL`, but that only covers that test: + /// `LocalUsageProvider` uses `.shared`, so any `refresh()` that runs the real providers during + /// `swift test` (a store built with default providers, a detached Task outliving its test) reaches + /// the default path. Measured: one full suite run rewrote the real file. So the guard is the store's + /// own gate, not "tests inject a path". + func testDefaultPathCacheNeitherReadsNorWritesUserFile() async throws { + _ = try writeFile("a.jsonl", lines: [claudeLine(id: "1", output: 42)]) + let cache = LocalUsageCache(claudeRoot: root) // no fileURL: the default (user) path + + XCTAssertFalse(cache.persistsToDisk, "swift test is not an app bundle, so the default path stays inert") + + // Sample the mtime before the *first* scan: a second call returns early (dirty == false) before + // the save throttle, so sampling after it would pass even with the write gate removed. + let userFile = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("PokeTokenBar/usage-cache.json") + func userFileMtime() -> Date? { + (try? FileManager.default.attributesOfItem(atPath: userFile.path))?[.modificationDate] as? Date + } + let before = userFileMtime() + + // The gate only stops IO: parsing and totals still work. + let entries = await cache.claudeEntries(modifiedSince: Date(timeIntervalSince1970: 0)) + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries.first?.output, 42, "only the fixture root counts, not the user's real cache") + + let blobs = await cache.cachedBlobCount + XCTAssertEqual(blobs, 1, "with the read gate open, the user's real blobs would show up here") + XCTAssertEqual(before, userFileMtime(), "the user's usage-cache.json was rewritten") + } + + /// The opposite direction: an injected path must still persist, or every persistence test is void. + func testInjectedPathCacheStillPersists() async throws { + _ = try writeFile("a.jsonl", lines: [claudeLine(id: "1", output: 7)]) + let cache = LocalUsageCache(claudeRoot: root, fileURL: cacheFile) + XCTAssertTrue(cache.persistsToDisk) + _ = await cache.claudeEntries(modifiedSince: Date(timeIntervalSince1970: 0)) + XCTAssertTrue(FileManager.default.fileExists(atPath: cacheFile.path), "the injected path stays live") + } + private func makeCache(now: @escaping @Sendable () -> Date = Date.init, probes: ProbeCounter? = nil, probe: (@Sendable (URL) throws -> String?)? = nil, diff --git a/Tests/PokeTokenBarTests/UsageEnvironmentTests.swift b/Tests/PokeTokenBarTests/UsageEnvironmentTests.swift index 9c88bb54f..c04d8bcec 100644 --- a/Tests/PokeTokenBarTests/UsageEnvironmentTests.swift +++ b/Tests/PokeTokenBarTests/UsageEnvironmentTests.swift @@ -202,3 +202,22 @@ final class UsageEnvironmentTests: XCTestCase { XCTAssertTrue(missing.isEmpty, "names 에 없는 조회: \(missing.joined(separator: ", "))") } } + +/// The single rule for whether a store may touch its default (user) file. +final class UserLocationGateTests: XCTestCase { + func testInjectedPathIsAlwaysLive() { + let tmp = URL(fileURLWithPath: "/tmp/whatever.json") + XCTAssertTrue(AppEnv.persistsToUserLocation(injectedFileURL: tmp, isBundledApp: false)) + XCTAssertTrue(AppEnv.persistsToUserLocation(injectedFileURL: tmp, isBundledApp: true)) + } + + func testDefaultPathIsInertUnlessBundled() { + XCTAssertFalse(AppEnv.persistsToUserLocation(injectedFileURL: nil, isBundledApp: false)) + XCTAssertTrue(AppEnv.persistsToUserLocation(injectedFileURL: nil, isBundledApp: true)) + } + + /// `swift test` is not an app bundle; if that ever changes, the gate above protects nothing. + func testTestBinaryIsNotABundledApp() { + XCTAssertFalse(AppEnv.isBundledApp) + } +}