mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 14:12:34 +00:00
* test(mac): clean up scratch UserDefaults suites, guard against new leaks * test(mac): fixed suite names so late cfprefsd write-backs reuse one file per test * test(mac): start every scratch suite clean
875 lines
36 KiB
Swift
875 lines
36 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodeBurnMenubar
|
|
|
|
/// Everything here runs against an injected transport, an injected defaults
|
|
/// suite, an injected clock and an injected desktop state file in a temporary
|
|
/// directory. Nothing in this suite reads the real desktop app's state, the
|
|
/// app's own defaults domain, or the network.
|
|
@MainActor
|
|
@Suite("Telemetry")
|
|
struct TelemetryTests {
|
|
|
|
// MARK: - Harness
|
|
|
|
/// Records what would have gone out. `outcome` decides what the client is
|
|
/// told happened; `.pending` never answers at all, which is what the quit
|
|
/// flush's timeout is measured against.
|
|
final class RecordingTransport: TelemetryTransport, @unchecked Sendable {
|
|
enum Answer: Sendable { case sent, rejected, retry, pending }
|
|
|
|
private let lock = NSLock()
|
|
private var _bodies: [Data] = []
|
|
private var _pending: [@Sendable (TelemetryPostOutcome) -> Void] = []
|
|
private let answer: Answer
|
|
|
|
init(_ answer: Answer = .sent) { self.answer = answer }
|
|
|
|
var bodies: [Data] {
|
|
lock.lock(); defer { lock.unlock() }
|
|
return _bodies
|
|
}
|
|
|
|
/// Answers every request still waiting, which is how a test decides
|
|
/// what happens *after* it has changed the consent underneath one.
|
|
func answerPending(_ outcome: TelemetryPostOutcome) {
|
|
lock.lock()
|
|
let waiting = _pending
|
|
_pending = []
|
|
lock.unlock()
|
|
for completion in waiting { completion(outcome) }
|
|
}
|
|
|
|
func post(
|
|
_ body: Data,
|
|
to endpoint: URL,
|
|
timeout: TimeInterval,
|
|
completion: @escaping @Sendable (TelemetryPostOutcome) -> Void
|
|
) {
|
|
#expect(endpoint.host == "telemetry.invalid", "a test must never reach a real endpoint")
|
|
lock.lock()
|
|
_bodies.append(body)
|
|
lock.unlock()
|
|
switch answer {
|
|
case .sent: completion(.sent)
|
|
case .rejected: completion(.rejected)
|
|
case .retry: completion(.retry)
|
|
case .pending:
|
|
lock.lock()
|
|
_pending.append(completion)
|
|
lock.unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A defaults suite and a directory that exist only for one test.
|
|
final class Scratch {
|
|
let suiteName: String
|
|
let directory = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codeburn-telemetry-tests-\(UUID().uuidString)")
|
|
let defaults: UserDefaults
|
|
|
|
/// `label` distinguishes several `Scratch`es created by one test
|
|
/// (a loop, or a few named cases); it must be unique per call site.
|
|
init(_ label: String = "", testName: String = #function) {
|
|
let name = label.isEmpty ? testName : "\(testName).\(label)"
|
|
(defaults, suiteName) = TestDefaults.make("CodeBurnMenubarTests.Telemetry.\(name)")
|
|
try? FileManager.default.createDirectory(
|
|
at: directory, withIntermediateDirectories: true)
|
|
}
|
|
|
|
deinit {
|
|
TestDefaults.forget(suiteName)
|
|
try? FileManager.default.removeItem(at: directory)
|
|
}
|
|
|
|
/// A desktop `telemetry.v1.json` in this test's own directory.
|
|
func writeDesktopState(_ json: String) -> URL {
|
|
let url = directory.appendingPathComponent("telemetry.v1.json")
|
|
try? json.data(using: .utf8)!.write(to: url)
|
|
return url
|
|
}
|
|
|
|
var missingDesktopState: URL {
|
|
directory.appendingPathComponent("absent.json")
|
|
}
|
|
}
|
|
|
|
static let endpoint = URL(string: "https://telemetry.invalid/v1/telemetry")!
|
|
|
|
static func client(
|
|
_ scratch: Scratch,
|
|
desktopStateURL: URL? = nil,
|
|
region: String? = "US",
|
|
transport: TelemetryTransport = RecordingTransport(),
|
|
maySend: Bool = true,
|
|
now: @escaping @Sendable () -> Date = { Date(timeIntervalSince1970: 1_788_393_600) },
|
|
requiresExplicitDecision: Bool = false
|
|
) -> Telemetry {
|
|
Telemetry(
|
|
defaults: scratch.defaults,
|
|
desktopStateURL: desktopStateURL ?? scratch.missingDesktopState,
|
|
region: region,
|
|
appVersion: "0.9.25",
|
|
endpoint: endpoint,
|
|
transport: transport,
|
|
maySend: maySend,
|
|
now: now,
|
|
requiresExplicitDecision: requiresExplicitDecision
|
|
)
|
|
}
|
|
|
|
/// The transport answers from whatever queue it likes, so the client settles
|
|
/// a batch on a hop back to the main actor. Let that hop run.
|
|
static func settled() async {
|
|
for _ in 0..<3 { await Task.yield() }
|
|
}
|
|
|
|
static func envelope(_ transport: RecordingTransport) throws -> [String: Any] {
|
|
let body = try #require(transport.bodies.first)
|
|
return try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
|
}
|
|
|
|
// MARK: - Consent
|
|
|
|
@Test("the desktop app's decision wins and carries its install id")
|
|
func desktopDecisionWins() throws {
|
|
let scratch = Scratch()
|
|
let state = scratch.writeDesktopState(
|
|
#"{"version":1,"installId":"desk-1","enabled":true,"onboardedAt":"2026-01-01T00:00:00Z"}"#)
|
|
scratch.defaults.set(false, forKey: Telemetry.enabledKey)
|
|
let transport = RecordingTransport()
|
|
let telemetry = Self.client(scratch, desktopStateURL: state, transport: transport)
|
|
|
|
telemetry.track("app_open")
|
|
telemetry.flush()
|
|
|
|
let envelope = try Self.envelope(transport)
|
|
#expect(envelope["installId"] as? String == "desk-1")
|
|
#expect(telemetry.status().source == .desktop)
|
|
#expect(telemetry.status().enabled)
|
|
}
|
|
|
|
@Test("a desktop file without a completed consent screen is not a decision")
|
|
func desktopWithoutOnboarding() throws {
|
|
let scratch = Scratch()
|
|
let state = scratch.writeDesktopState(
|
|
#"{"version":1,"installId":"desk-1","enabled":true}"#)
|
|
let transport = RecordingTransport()
|
|
let telemetry = Self.client(scratch, desktopStateURL: state, transport: transport)
|
|
|
|
telemetry.track("app_open")
|
|
telemetry.flush()
|
|
|
|
#expect(telemetry.queuedEvents.isEmpty)
|
|
#expect(transport.bodies.isEmpty)
|
|
}
|
|
|
|
@Test("a desktop toggle that is off wins over this app's own state")
|
|
func desktopOffWins() throws {
|
|
let scratch = Scratch()
|
|
let state = scratch.writeDesktopState(
|
|
#"{"version":1,"installId":"desk-1","enabled":false,"onboardedAt":"2026-01-01T00:00:00Z"}"#)
|
|
scratch.defaults.set(true, forKey: Telemetry.enabledKey)
|
|
let transport = RecordingTransport()
|
|
let telemetry = Self.client(scratch, desktopStateURL: state, transport: transport)
|
|
|
|
telemetry.track("app_open")
|
|
telemetry.flush()
|
|
|
|
#expect(telemetry.queuedEvents.isEmpty)
|
|
#expect(transport.bodies.isEmpty)
|
|
}
|
|
|
|
@Test("a desktop file the toggle turns off mid-run stops the next flush")
|
|
func desktopTurnedOffMidRun() throws {
|
|
let scratch = Scratch()
|
|
let state = scratch.writeDesktopState(
|
|
#"{"version":1,"installId":"desk-1","enabled":true,"onboardedAt":"2026-01-01T00:00:00Z"}"#)
|
|
let transport = RecordingTransport(.retry)
|
|
let telemetry = Self.client(scratch, desktopStateURL: state, transport: transport)
|
|
telemetry.track("app_open")
|
|
telemetry.flush()
|
|
#expect(transport.bodies.count == 1)
|
|
|
|
_ = scratch.writeDesktopState(
|
|
#"{"version":1,"installId":"desk-1","enabled":false,"onboardedAt":"2026-01-01T00:00:00Z"}"#)
|
|
telemetry.flush()
|
|
|
|
#expect(transport.bodies.count == 1, "the desktop's new answer is read at every flush")
|
|
}
|
|
|
|
@Test("a malformed or unknown-version desktop file falls through to this app's own state")
|
|
func desktopFileFallsThrough() {
|
|
#expect(Telemetry.parseDesktopState(Data("not json".utf8)) == nil)
|
|
#expect(Telemetry.parseDesktopState(Data(#"{"version":2,"installId":"x","enabled":true}"#.utf8)) == nil)
|
|
#expect(Telemetry.parseDesktopState(Data(#"{"version":1,"installId":"","enabled":true}"#.utf8)) == nil)
|
|
#expect(Telemetry.parseDesktopState(Data(#"{"version":1,"installId":"x"}"#.utf8)) == nil)
|
|
}
|
|
|
|
@Test("standalone defaults on outside the default-off region, off inside it and off when unknown")
|
|
func standaloneRegionDefaults() {
|
|
let us = Scratch("us")
|
|
#expect(Self.client(us, region: "US").status().enabled)
|
|
|
|
let de = Scratch("de")
|
|
#expect(Self.client(de, region: "DE").status().enabled == false)
|
|
|
|
let unknown = Scratch("unknown")
|
|
#expect(Self.client(unknown, region: nil).status().enabled == false)
|
|
|
|
// A UN M.49 region is nobody's country code, so it reads as unknown.
|
|
let m49 = Scratch("m49")
|
|
#expect(Self.client(m49, region: "419").status().enabled == false)
|
|
}
|
|
|
|
@Test("standalone mints and keeps one install id")
|
|
func standaloneInstallID() {
|
|
let scratch = Scratch()
|
|
let telemetry = Self.client(scratch)
|
|
let stored = scratch.defaults.string(forKey: Telemetry.installIDKey)
|
|
|
|
#expect(stored != nil)
|
|
#expect(UUID(uuidString: stored ?? "") != nil)
|
|
#expect(telemetry.status().source == .app)
|
|
#expect(Self.client(scratch).status().source == .app)
|
|
#expect(scratch.defaults.string(forKey: Telemetry.installIDKey) == stored)
|
|
}
|
|
|
|
@Test("turning the toggle off queues nothing, drops the queue and mints a fresh install id")
|
|
func standaloneOptOut() {
|
|
let scratch = Scratch()
|
|
let transport = RecordingTransport()
|
|
let telemetry = Self.client(scratch, transport: transport)
|
|
telemetry.track("app_open")
|
|
let before = scratch.defaults.string(forKey: Telemetry.installIDKey)
|
|
|
|
telemetry.setEnabled(false)
|
|
telemetry.track("popover_open")
|
|
telemetry.flush()
|
|
|
|
#expect(telemetry.queuedEvents.isEmpty)
|
|
#expect(transport.bodies.isEmpty)
|
|
#expect(scratch.defaults.string(forKey: Telemetry.installIDKey) != before)
|
|
}
|
|
|
|
@Test("the local toggle can veto the desktop app's yes without rotating its id")
|
|
func localVetoOverDesktopYes() throws {
|
|
let scratch = Scratch()
|
|
let state = scratch.writeDesktopState(
|
|
#"{"version":1,"installId":"desk-1","enabled":true,"onboardedAt":"2026-01-01T00:00:00Z"}"#)
|
|
let before = try Data(contentsOf: state)
|
|
let transport = RecordingTransport()
|
|
let telemetry = Self.client(scratch, desktopStateURL: state, transport: transport)
|
|
#expect(telemetry.status().isLocked == false, "a desktop yes leaves the toggle usable")
|
|
|
|
telemetry.setEnabled(false)
|
|
telemetry.track("app_open")
|
|
telemetry.flush()
|
|
|
|
#expect(telemetry.status().enabled == false)
|
|
#expect(telemetry.queuedEvents.isEmpty)
|
|
#expect(transport.bodies.isEmpty)
|
|
#expect(scratch.defaults.string(forKey: Telemetry.installIDKey) == nil,
|
|
"the id belongs to the desktop app, so a local veto does not rotate one")
|
|
#expect(try Data(contentsOf: state) == before, "the desktop app's file is that app's to write")
|
|
|
|
telemetry.setEnabled(true)
|
|
#expect(telemetry.status().enabled)
|
|
}
|
|
|
|
@Test("the local toggle can never opt in against the desktop app's no")
|
|
func localVetoCannotOverturnDesktopNo() {
|
|
let scratch = Scratch()
|
|
let state = scratch.writeDesktopState(
|
|
#"{"version":1,"installId":"desk-1","enabled":false,"onboardedAt":"2026-01-01T00:00:00Z"}"#)
|
|
let telemetry = Self.client(scratch, desktopStateURL: state)
|
|
#expect(telemetry.status().isLocked, "a desktop no makes the toggle a readout")
|
|
|
|
telemetry.setEnabled(true)
|
|
|
|
#expect(telemetry.status().enabled == false)
|
|
#expect(scratch.defaults.bool(forKey: Telemetry.localOptOutKey) == false)
|
|
}
|
|
|
|
/// Exactly what the Privacy section reads, for every combination it can be
|
|
/// shown in: under a desktop yes the toggle is a veto, under a desktop no it
|
|
/// is a disabled readout, and standalone it is the decision itself.
|
|
@Test("the Privacy toggle's state for every desktop and local combination")
|
|
func toggleStateForEveryCombination() {
|
|
for desktopEnabled in [true, false] {
|
|
for vetoed in [true, false] {
|
|
let scratch = Scratch("desktop\(desktopEnabled)-veto\(vetoed)")
|
|
scratch.defaults.set(vetoed, forKey: Telemetry.localOptOutKey)
|
|
let state = scratch.writeDesktopState(
|
|
"""
|
|
{"version":1,"installId":"desk-1","enabled":\(desktopEnabled),\
|
|
"onboardedAt":"2026-01-01T00:00:00Z"}
|
|
""")
|
|
let status = Self.client(scratch, desktopStateURL: state).status()
|
|
#expect(status.source == .desktop)
|
|
#expect(status.isLocked == !desktopEnabled,
|
|
"desktop \(desktopEnabled), veto \(vetoed)")
|
|
#expect(status.enabled == (desktopEnabled && !vetoed),
|
|
"desktop \(desktopEnabled), veto \(vetoed)")
|
|
}
|
|
}
|
|
|
|
let scratch = Scratch("standalone")
|
|
let standalone = Self.client(scratch, region: "US")
|
|
#expect(standalone.status().source == .app)
|
|
#expect(standalone.status().isLocked == false, "standalone the toggle is never a readout")
|
|
#expect(standalone.status().enabled)
|
|
standalone.setEnabled(false)
|
|
#expect(standalone.status().enabled == false)
|
|
}
|
|
|
|
@Test("a local opt-out sticks across a restart under the desktop app's yes")
|
|
func localVetoSurvivesARestart() {
|
|
let scratch = Scratch()
|
|
let state = scratch.writeDesktopState(
|
|
#"{"version":1,"installId":"desk-1","enabled":true,"onboardedAt":"2026-01-01T00:00:00Z"}"#)
|
|
Self.client(scratch, desktopStateURL: state).setEnabled(false)
|
|
#expect(scratch.defaults.bool(forKey: Telemetry.localOptOutKey))
|
|
|
|
let restarted = Self.client(scratch, desktopStateURL: state)
|
|
#expect(restarted.status().enabled == false)
|
|
#expect(restarted.status().isLocked == false, "it can still be switched back on")
|
|
}
|
|
|
|
@Test("a desktop decision is inherited, so deleting that app cannot reverse its no")
|
|
func desktopDecisionSurvivesTheFileDisappearing() {
|
|
let scratch = Scratch()
|
|
let state = scratch.writeDesktopState(
|
|
#"{"version":1,"installId":"desk-1","enabled":false,"onboardedAt":"2026-01-01T00:00:00Z"}"#)
|
|
// Region US, whose default is ON — exactly the reversal being guarded.
|
|
let telemetry = Self.client(scratch, desktopStateURL: state, region: "US")
|
|
#expect(telemetry.status().source == .desktop)
|
|
|
|
try? FileManager.default.removeItem(at: state)
|
|
let after = telemetry.status()
|
|
|
|
#expect(after.source == .app)
|
|
#expect(after.enabled == false, "an explicit no must outlive the app that made it")
|
|
#expect(scratch.defaults.object(forKey: Telemetry.enabledKey) as? Bool == false)
|
|
}
|
|
|
|
@Test("a desktop yes is inherited too, under one stable install id of this app's own")
|
|
func desktopYesFallsBackToAStableLocalID() {
|
|
let scratch = Scratch()
|
|
let state = scratch.writeDesktopState(
|
|
#"{"version":1,"installId":"desk-1","enabled":true,"onboardedAt":"2026-01-01T00:00:00Z"}"#)
|
|
let telemetry = Self.client(scratch, desktopStateURL: state, region: "DE")
|
|
#expect(telemetry.status().source == .desktop)
|
|
|
|
try? FileManager.default.removeItem(at: state)
|
|
let first = telemetry.status()
|
|
let id = scratch.defaults.string(forKey: Telemetry.installIDKey)
|
|
|
|
#expect(first.source == .app)
|
|
#expect(first.enabled, "a desktop yes is a decision too, even in a default-off region")
|
|
#expect(id != nil && id != "desk-1")
|
|
_ = telemetry.status()
|
|
_ = telemetry.status()
|
|
#expect(scratch.defaults.string(forKey: Telemetry.installIDKey) == id,
|
|
"one install, one id: resolving again must not mint another")
|
|
}
|
|
|
|
@Test("a standalone install can be made to wait for an explicit decision")
|
|
func standaloneExplicitDecisionSwitch() {
|
|
// Both settings of Telemetry.standaloneRequiresExplicitDecision, so the
|
|
// product call is a one-line change with tests already standing.
|
|
let regionDecides = Scratch("regionDecides")
|
|
let deciding = Self.client(regionDecides, region: "US", requiresExplicitDecision: false)
|
|
deciding.track("app_open")
|
|
#expect(deciding.queuedEvents.count == 1, "the region default is itself the answer")
|
|
|
|
let mustBeAsked = Scratch("mustBeAsked")
|
|
let waiting = Self.client(mustBeAsked, region: "US", requiresExplicitDecision: true)
|
|
waiting.track("app_open")
|
|
#expect(waiting.queuedEvents.isEmpty, "nothing before the question has been answered")
|
|
|
|
waiting.setEnabled(true)
|
|
waiting.track("app_open")
|
|
#expect(waiting.queuedEvents.count == 1)
|
|
#expect(mustBeAsked.defaults.bool(forKey: Telemetry.decidedKey))
|
|
}
|
|
|
|
@Test("the default-off region list is still the desktop app's list")
|
|
func regionListMatchesDesktop() {
|
|
// Copied from app/electron/telemetry.ts DEFAULT_OFF_COUNTRIES: EU-27 +
|
|
// EEA (IS, LI, NO) + UK + CH. Drift here fails rather than silently
|
|
// opting a region in or out on one platform only.
|
|
let desktop: Set<String> = [
|
|
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR",
|
|
"HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK",
|
|
"SI", "ES", "SE", "IS", "LI", "NO", "GB", "CH",
|
|
]
|
|
#expect(desktop.count == 32)
|
|
#expect(Telemetry.defaultOffCountries == desktop)
|
|
}
|
|
|
|
// MARK: - Events
|
|
|
|
@Test("an unknown event name never reaches the queue")
|
|
func unknownEventNames() {
|
|
let scratch = Scratch()
|
|
let telemetry = Self.client(scratch)
|
|
|
|
telemetry.track("app_open")
|
|
telemetry.track("rm_rf")
|
|
telemetry.track("")
|
|
|
|
#expect(telemetry.queuedEvents.map(\.name) == ["app_open"])
|
|
}
|
|
|
|
@Test("usage_snapshot is forwarded verbatim, once per day")
|
|
func usageSnapshotOncePerDay() {
|
|
let scratch = Scratch()
|
|
nonisolated(unsafe) var clock = Date(timeIntervalSince1970: 1_788_393_600)
|
|
let telemetry = Self.client(scratch, now: { clock })
|
|
let snapshot = JSONValue.object([
|
|
"costBucket": .string("1-10"),
|
|
"providers": .int(3),
|
|
])
|
|
|
|
telemetry.trackUsageSnapshot(snapshot)
|
|
telemetry.trackUsageSnapshot(snapshot)
|
|
|
|
#expect(telemetry.queuedEvents.filter { $0.name == "usage_snapshot" }.count == 1)
|
|
#expect(telemetry.queuedEvents.first?.props == [
|
|
"costBucket": .string("1-10"),
|
|
"providers": .int(3),
|
|
])
|
|
|
|
clock = clock.addingTimeInterval(48 * 3600)
|
|
telemetry.trackUsageSnapshot(snapshot)
|
|
#expect(telemetry.queuedEvents.filter { $0.name == "usage_snapshot" }.count == 2)
|
|
}
|
|
|
|
@Test("a missing or non-object snapshot is nothing to send")
|
|
func usageSnapshotAbsent() {
|
|
let scratch = Scratch()
|
|
let telemetry = Self.client(scratch)
|
|
|
|
telemetry.trackUsageSnapshot(nil)
|
|
telemetry.trackUsageSnapshot(.null)
|
|
telemetry.trackUsageSnapshot(.int(7))
|
|
|
|
#expect(telemetry.queuedEvents.isEmpty)
|
|
}
|
|
|
|
@Test("the desktop app sends its own snapshot, so this app does not send a second one")
|
|
func usageSnapshotSkippedUnderDesktop() {
|
|
let scratch = Scratch()
|
|
let state = scratch.writeDesktopState(
|
|
#"{"version":1,"installId":"desk-1","enabled":true,"onboardedAt":"2026-01-01T00:00:00Z"}"#)
|
|
let telemetry = Self.client(scratch, desktopStateURL: state)
|
|
|
|
telemetry.trackUsageSnapshot(.object(["costBucket": .string("1-10")]))
|
|
|
|
#expect(telemetry.queuedEvents.isEmpty)
|
|
}
|
|
|
|
@Test("the CLI's snapshot rides through the payload decoder untouched")
|
|
func snapshotSurvivesPayloadDecoding() throws {
|
|
let json = """
|
|
{"generated":"2026-09-19T00:00:00Z",
|
|
"current":{"providers":[]},
|
|
"optimize":{},
|
|
"history":{},
|
|
"telemetrySnapshot":{"schema":2,"models":[{"name":"sonnet","costBucket":"1-10"}]}}
|
|
"""
|
|
let payload = try? JSONDecoder().decode(MenubarPayload.self, from: Data(json.utf8))
|
|
// The other blocks have required fields this fixture cannot fake; what
|
|
// matters here is only that the field decodes as an opaque object.
|
|
if let snapshot = payload?.telemetrySnapshot {
|
|
#expect(snapshot == .object([
|
|
"schema": .int(2),
|
|
"models": .array([.object([
|
|
"name": .string("sonnet"),
|
|
"costBucket": .string("1-10"),
|
|
])]),
|
|
]))
|
|
}
|
|
let standalone = try JSONDecoder().decode(
|
|
JSONValue.self,
|
|
from: Data(#"{"schema":2,"rate":0.5,"ok":true,"none":null}"#.utf8))
|
|
#expect(standalone == .object([
|
|
"schema": .int(2),
|
|
"rate": .double(0.5),
|
|
"ok": .bool(true),
|
|
"none": .null,
|
|
]))
|
|
}
|
|
|
|
@Test("app_close carries the session length in whole minutes")
|
|
func closeCarriesSessionMinutes() {
|
|
let scratch = Scratch()
|
|
nonisolated(unsafe) var clock = Date(timeIntervalSince1970: 1_788_393_600)
|
|
let telemetry = Self.client(scratch, now: { clock })
|
|
|
|
clock = clock.addingTimeInterval(9 * 60 + 40)
|
|
telemetry.trackClose()
|
|
|
|
#expect(telemetry.queuedEvents.last?.name == "app_close")
|
|
#expect(telemetry.queuedEvents.last?.props == ["sessionMinutes": .int(10)])
|
|
}
|
|
|
|
// MARK: - Sanitizer
|
|
|
|
@Test("long strings are truncated and long keys with them")
|
|
func sanitizerTruncates() {
|
|
let long = String(repeating: "a", count: 200)
|
|
let clean = Telemetry.sanitizeProps(.object([long: .string(long)]))
|
|
|
|
#expect(clean.count == 1)
|
|
#expect(clean.keys.first?.count == Telemetry.maxString)
|
|
#expect(clean.values.first == .string(String(repeating: "a", count: Telemetry.maxString)))
|
|
}
|
|
|
|
@Test("non-finite numbers, nulls and empty containers are dropped")
|
|
func sanitizerDropsJunk() {
|
|
let clean = Telemetry.sanitizeProps(.object([
|
|
"nan": .double(.nan),
|
|
"inf": .double(.infinity),
|
|
"nil": .null,
|
|
"emptyArray": .array([]),
|
|
"emptyObject": .object([:]),
|
|
"kept": .double(0.5),
|
|
]))
|
|
|
|
#expect(clean == ["kept": .double(0.5)])
|
|
}
|
|
|
|
@Test("nesting deeper than the snapshot's own shape is dropped whole")
|
|
func sanitizerCapsDepth() {
|
|
// props -> a -> b -> c -> d is the deepest the snapshot goes.
|
|
var value = JSONValue.object(["leaf": .int(1)])
|
|
for _ in 0..<3 { value = .object(["down": value]) }
|
|
#expect(Telemetry.sanitizeProps(.object(["top": value])) != [:])
|
|
|
|
var tooDeep = JSONValue.object(["leaf": .int(1)])
|
|
for _ in 0..<5 { tooDeep = .object(["down": tooDeep]) }
|
|
#expect(Telemetry.sanitizeProps(.object(["top": tooDeep])) == [:])
|
|
}
|
|
|
|
@Test("key, array and leaf counts are all capped")
|
|
func sanitizerCapsWidth() {
|
|
var wide: [String: JSONValue] = [:]
|
|
for index in 0..<40 { wide[String(format: "k%02d", index)] = .int(index) }
|
|
#expect(Telemetry.sanitizeProps(.object(wide)).count == Telemetry.maxKeys)
|
|
|
|
let long = JSONValue.array((0..<40).map { .int($0) })
|
|
if case .array(let trimmed)? = Telemetry.sanitizeProps(.object(["a": long]))["a"] {
|
|
#expect(trimmed.count == Telemetry.maxArray)
|
|
} else {
|
|
Issue.record("array was dropped entirely")
|
|
}
|
|
|
|
let flood = JSONValue.array((0..<Telemetry.maxLeaves + 50).map { .int($0) })
|
|
var budgeted: [String: JSONValue] = [:]
|
|
for index in 0..<10 { budgeted["a\(index)"] = flood }
|
|
var leaves = 0
|
|
for case .array(let entries) in Telemetry.sanitizeProps(.object(budgeted)).values {
|
|
leaves += entries.count
|
|
}
|
|
#expect(leaves <= Telemetry.maxLeaves)
|
|
}
|
|
|
|
@Test("a props value that is not an object carries nothing")
|
|
func sanitizerRejectsNonObjects() {
|
|
#expect(Telemetry.sanitizeProps(.array([.int(1)])) == [:])
|
|
#expect(Telemetry.sanitizeProps(.string("x")) == [:])
|
|
#expect(Telemetry.sanitizeProps(.null) == [:])
|
|
}
|
|
|
|
// MARK: - The wire
|
|
|
|
@Test("the envelope is the shape the other clients post")
|
|
func envelopeShape() throws {
|
|
let scratch = Scratch()
|
|
let transport = RecordingTransport()
|
|
let telemetry = Self.client(scratch, transport: transport)
|
|
|
|
telemetry.track("popover_open")
|
|
telemetry.flush()
|
|
|
|
let envelope = try Self.envelope(transport)
|
|
#expect(envelope["schema"] as? Int == 1)
|
|
let app = try #require(envelope["app"] as? [String: Any])
|
|
#expect(app["name"] as? String == "codeburn-menubar")
|
|
#expect(app["version"] as? String == "0.9.25")
|
|
#expect(app["platform"] as? String == "darwin")
|
|
#expect(app["country"] as? String == "US")
|
|
#expect(app["arch"] as? String != nil)
|
|
let events = try #require(envelope["events"] as? [[String: Any]])
|
|
#expect(events.count == 1)
|
|
#expect(events[0]["name"] as? String == "popover_open")
|
|
#expect(events[0]["day"] as? String == Telemetry.dayKey(Date(timeIntervalSince1970: 1_788_393_600)))
|
|
#expect(events[0]["props"] as? [String: Any] != nil)
|
|
}
|
|
|
|
@Test("the day key is a plain calendar date, so nothing finer than a day is sent")
|
|
func dayKeyGranularity() {
|
|
let day = Telemetry.dayKey(Date(timeIntervalSince1970: 1_788_393_600))
|
|
#expect(day.count == 10)
|
|
#expect(day.wholeMatch(of: /\d{4}-\d{2}-\d{2}/) != nil)
|
|
}
|
|
|
|
@Test("a debug or unpackaged build queues but never sends")
|
|
func debugBuildNeverSends() {
|
|
#expect(Telemetry.defaultMaySend(environment: [:]) == false)
|
|
#expect(Telemetry.defaultMaySend(environment: ["CODEBURN_TELEMETRY_DEV": "1"]))
|
|
|
|
let scratch = Scratch()
|
|
let transport = RecordingTransport()
|
|
let telemetry = Self.client(scratch, transport: transport, maySend: false)
|
|
telemetry.track("app_open")
|
|
telemetry.flush()
|
|
|
|
#expect(telemetry.queuedEvents.count == 1)
|
|
#expect(transport.bodies.isEmpty)
|
|
}
|
|
|
|
@Test("a failed send keeps the batch, never throws, and backs the beat off")
|
|
func failedSendRetries() async {
|
|
let scratch = Scratch()
|
|
let transport = RecordingTransport(.retry)
|
|
let telemetry = Self.client(scratch, transport: transport)
|
|
|
|
telemetry.track("app_open")
|
|
telemetry.flush()
|
|
await Self.settled()
|
|
|
|
#expect(telemetry.queuedEvents.map(\.name) == ["app_open"])
|
|
#expect(Telemetry.beatsToSkip(failures: 0) == 0)
|
|
#expect(Telemetry.beatsToSkip(failures: 1) == 0)
|
|
#expect(Telemetry.beatsToSkip(failures: 2) == 1)
|
|
#expect(Telemetry.beatsToSkip(failures: 9) == 5, "the backoff stops at half an hour")
|
|
|
|
// The beat sits out the delay it just earned, then tries again.
|
|
telemetry.runFlushBeat()
|
|
await Self.settled()
|
|
#expect(transport.bodies.count == 2)
|
|
}
|
|
|
|
@Test("a refused batch is dropped rather than retried forever")
|
|
func refusedBatchIsDropped() async {
|
|
let scratch = Scratch()
|
|
let transport = RecordingTransport(.rejected)
|
|
let telemetry = Self.client(scratch, transport: transport)
|
|
|
|
telemetry.track("app_open")
|
|
telemetry.flush()
|
|
await Self.settled()
|
|
|
|
#expect(telemetry.queuedEvents.isEmpty)
|
|
#expect(transport.bodies.count == 1)
|
|
}
|
|
|
|
@Test("the queue never grows past its cap, whatever the network does")
|
|
func queueCap() async {
|
|
let scratch = Scratch()
|
|
let transport = RecordingTransport(.retry)
|
|
let telemetry = Self.client(scratch, transport: transport)
|
|
|
|
for _ in 0..<(Telemetry.maxQueue + 50) { telemetry.track("popover_open") }
|
|
#expect(telemetry.queuedEvents.count == Telemetry.maxQueue)
|
|
|
|
telemetry.flush()
|
|
for _ in 0..<50 { telemetry.track("popover_open") }
|
|
await Self.settled()
|
|
#expect(telemetry.queuedEvents.count == Telemetry.maxQueue)
|
|
}
|
|
|
|
@Test("the quit flush is bounded even when the endpoint never answers")
|
|
func quitFlushIsBounded() {
|
|
let scratch = Scratch()
|
|
let transport = RecordingTransport(.pending)
|
|
let telemetry = Self.client(scratch, transport: transport)
|
|
telemetry.track("app_open")
|
|
|
|
let started = Date()
|
|
telemetry.flushOnQuit(timeout: 0.2)
|
|
let elapsed = Date().timeIntervalSince(started)
|
|
|
|
#expect(elapsed < 2, "quit waited \(elapsed)s on a dead endpoint")
|
|
#expect(transport.bodies.count == 1)
|
|
let posted = try? JSONSerialization.jsonObject(with: transport.bodies[0]) as? [String: Any]
|
|
let events = (posted?["events"] as? [[String: Any]]) ?? []
|
|
#expect(events.map { $0["name"] as? String } == ["app_open", "app_close"])
|
|
}
|
|
|
|
@Test("a quit with the toggle off posts nothing at all")
|
|
func quitFlushRespectsConsent() {
|
|
let scratch = Scratch()
|
|
let transport = RecordingTransport(.pending)
|
|
let telemetry = Self.client(scratch, region: "DE", transport: transport)
|
|
|
|
telemetry.track("app_open")
|
|
telemetry.flushOnQuit(timeout: 0.2)
|
|
|
|
#expect(transport.bodies.isEmpty)
|
|
}
|
|
|
|
// MARK: - In flight
|
|
|
|
@Test("opting out while a batch is in flight drops it instead of retrying it")
|
|
func optOutAbandonsTheBatchInFlight() async {
|
|
let scratch = Scratch()
|
|
let transport = RecordingTransport(.pending)
|
|
let telemetry = Self.client(scratch, transport: transport)
|
|
telemetry.track("app_open")
|
|
telemetry.flush()
|
|
#expect(transport.bodies.count == 1)
|
|
let firstID = scratch.defaults.string(forKey: Telemetry.installIDKey)
|
|
|
|
telemetry.setEnabled(false)
|
|
transport.answerPending(.retry)
|
|
await Self.settled()
|
|
|
|
#expect(telemetry.queuedEvents.isEmpty,
|
|
"events recorded under the retired id must never come back")
|
|
#expect(scratch.defaults.string(forKey: Telemetry.installIDKey) != firstID)
|
|
|
|
telemetry.setEnabled(true)
|
|
telemetry.flush()
|
|
await Self.settled()
|
|
#expect(transport.bodies.count == 1, "nothing from before the opt-out is ever posted")
|
|
}
|
|
|
|
@Test("a veto under desktop consent abandons the batch in flight as well")
|
|
func vetoAbandonsTheBatchInFlight() async {
|
|
let scratch = Scratch()
|
|
let state = scratch.writeDesktopState(
|
|
#"{"version":1,"installId":"desk-1","enabled":true,"onboardedAt":"2026-01-01T00:00:00Z"}"#)
|
|
let transport = RecordingTransport(.pending)
|
|
let telemetry = Self.client(scratch, desktopStateURL: state, transport: transport)
|
|
telemetry.track("app_open")
|
|
telemetry.flush()
|
|
|
|
telemetry.setEnabled(false)
|
|
transport.answerPending(.retry)
|
|
await Self.settled()
|
|
|
|
#expect(telemetry.queuedEvents.isEmpty)
|
|
telemetry.flush()
|
|
await Self.settled()
|
|
#expect(transport.bodies.count == 1)
|
|
}
|
|
|
|
@Test("a quit that lands on top of a live flush still returns, bounded")
|
|
func quitRacesALiveFlush() {
|
|
let scratch = Scratch()
|
|
let transport = RecordingTransport(.pending)
|
|
let telemetry = Self.client(scratch, transport: transport)
|
|
telemetry.track("popover_open")
|
|
telemetry.flush()
|
|
#expect(transport.bodies.count == 1)
|
|
|
|
let started = Date()
|
|
telemetry.flushOnQuit(timeout: 0.2)
|
|
let elapsed = Date().timeIntervalSince(started)
|
|
|
|
#expect(elapsed < 2, "quit waited \(elapsed)s behind an unanswered flush")
|
|
#expect(transport.bodies.count == 2, "app_close still gets its own attempt")
|
|
}
|
|
|
|
// MARK: - Transport
|
|
|
|
@Test("the telemetry session keeps no cookies, no cache and no identifying agent")
|
|
func transportSessionIsPrivate() {
|
|
let configuration = URLSessionTelemetryTransport.makeConfiguration()
|
|
|
|
#expect(configuration.httpCookieStorage == nil, "a cookie would outlive an opt-out")
|
|
#expect(configuration.httpShouldSetCookies == false)
|
|
#expect(configuration.httpCookieAcceptPolicy == .never)
|
|
#expect(configuration.urlCache == nil)
|
|
#expect(configuration.requestCachePolicy == .reloadIgnoringLocalAndRemoteCacheData)
|
|
#expect(configuration.tlsMinimumSupportedProtocolVersion == .TLSv12)
|
|
let agent = configuration.httpAdditionalHeaders?["User-Agent"] as? String
|
|
#expect(agent == "codeburn-menubar", "the default agent spells out the kernel build")
|
|
#expect(configuration.httpAdditionalHeaders?["Content-Type"] as? String == "application/json")
|
|
// Ephemeral, so nothing it does touch reaches disk.
|
|
#expect(configuration.identifier == nil)
|
|
}
|
|
|
|
@Test("a redirect is refused rather than followed")
|
|
func transportRefusesRedirects() async {
|
|
let transport = URLSessionTelemetryTransport()
|
|
let redirect = HTTPURLResponse(
|
|
url: Telemetry.endpoint, statusCode: 302,
|
|
httpVersion: nil, headerFields: nil)!
|
|
var followed: URLRequest? = URLRequest(url: URL(string: "http://elsewhere.invalid")!)
|
|
|
|
await withCheckedContinuation { continuation in
|
|
transport.urlSession(
|
|
URLSession(configuration: .ephemeral),
|
|
task: URLSession(configuration: .ephemeral)
|
|
.dataTask(with: URLRequest(url: Telemetry.endpoint)),
|
|
willPerformHTTPRedirection: redirect,
|
|
newRequest: URLRequest(url: URL(string: "http://elsewhere.invalid")!)
|
|
) { request in
|
|
followed = request
|
|
continuation.resume()
|
|
}
|
|
}
|
|
|
|
#expect(followed == nil)
|
|
}
|
|
|
|
// MARK: - Cross-implementation sanitizer fixture
|
|
|
|
@Test("the sanitizer answers exactly what the desktop app's sanitizeProps would")
|
|
func sanitizerMatchesTheDesktopImplementation() {
|
|
// Fixture and expectation both derived from `sanitizeProps` /
|
|
// `sanitizeValue` / `sanitizeObject` in app/electron/telemetry.ts:
|
|
// MAX_STRING 64, MAX_ARRAY 12, MAX_KEYS 16, MAX_DEPTH 5, MAX_LEAVES 1000.
|
|
// Strings and keys truncate, non-finite numbers and nulls are dropped,
|
|
// a container that empties out is dropped with them, and a container
|
|
// sitting at MAX_DEPTH is dropped whole rather than flattened.
|
|
let longString = String(repeating: "x", count: 100)
|
|
let longKey = String(repeating: "k", count: 100)
|
|
|
|
var wide: [String: JSONValue] = [:]
|
|
for index in 0..<20 { wide[String(format: "w%02d", index)] = .int(index) }
|
|
|
|
var tooDeep = JSONValue.object(["leaf": .int(1)])
|
|
for _ in 0..<5 { tooDeep = .object(["down": tooDeep]) }
|
|
|
|
let clean = Telemetry.sanitizeProps(.object([
|
|
"str": .string(longString),
|
|
longKey: .string("short"),
|
|
"nan": .double(.nan),
|
|
"inf": .double(-.infinity),
|
|
"nil": .null,
|
|
"bool": .bool(false),
|
|
"int": .int(-3),
|
|
"rate": .double(0.5),
|
|
"arr": .array((0..<20).map { .int($0) }),
|
|
"mixed": .array([.string("a"), .null, .double(.nan), .int(2)]),
|
|
"wide": .object(wide),
|
|
// props -> deep -> b -> c -> d -> leaf: the snapshot's own depth.
|
|
"deep": .object(["b": .object(["c": .object(["d": .int(4)])])]),
|
|
"tooDeep": tooDeep,
|
|
"emptyAfterCleaning": .object(["only": .null]),
|
|
]))
|
|
|
|
let truncated = String(repeating: "x", count: 64)
|
|
var expectedWide: [String: JSONValue] = [:]
|
|
for index in 0..<16 { expectedWide[String(format: "w%02d", index)] = .int(index) }
|
|
|
|
#expect(clean == [
|
|
"str": .string(truncated),
|
|
String(repeating: "k", count: 64): .string("short"),
|
|
"bool": .bool(false),
|
|
"int": .int(-3),
|
|
"rate": .double(0.5),
|
|
"arr": .array((0..<12).map { .int($0) }),
|
|
"mixed": .array([.string("a"), .int(2)]),
|
|
"wide": .object(expectedWide),
|
|
"deep": .object(["b": .object(["c": .object(["d": .int(4)])])]),
|
|
])
|
|
}
|
|
}
|