codeburn/mac/Tests/CodeBurnMenubarTests/CursorSyncStatusTests.swift
Resham Joshi 04d0b65327
feat(cursor): show the Cursor sync status and an on/off switch in the apps (#1638)
* feat(cursor): show the cursor.com sync in the apps, with an off switch

status --format menubar-json gains an additive cursorSync block (enabled,
state, lastSuccessAt, and a fixed errorCode/error on failure), read from
the sync sidecar, the config and the Cursor app's login. It never syncs,
never makes a request and never carries the token, the account hash or
server text. It is absent when Cursor is not signed in and has never
synced, and it is attached after the snapshot and per-device payloads.

With the Cursor provider selected, the desktop overview, the macOS menu
bar and the Windows tray show one line under the total: "Synced from
cursor.com 12 min ago", or the error in a muted warning colour. A
"Sync Cursor usage from cursor.com" switch writes config cursorSync:
Settings > Providers > Cursor on the desktop, the Cursor settings pane
on macOS and Windows. Desktop and mac strings are in all six locales;
the tray stays English until its localization lands.

* fix(cursor): guard config writes, show the env override in the switches

The tray's shared config writer now refuses to write over a config.json
that does not parse or is not an object (it used to replace it with an
empty map plus the one key), which also protects the currency, budget
and Claude config directory writers. The desktop writer rejects null
and array configs the same way.

With CODEBURN_CURSOR_SYNC=0 set, the desktop, mac and tray switches show
"Turned off by CODEBURN_CURSOR_SYNC=0" and are disabled. A failed mac
save now says so under the switch. The tray hero note uses the warning
ink and maps errorCode to its own messages.
2026-10-05 17:11:41 +02:00

90 lines
5.7 KiB
Swift

import Foundation
import Testing
@testable import CodeBurnMenubar
@Suite("Cursor sync status")
struct CursorSyncStatusTests {
private static func payload(_ cursorSync: String?) -> Data {
Data("""
{
"generated": "2026-10-05T12:00:00Z",
"current": { "label": "Today", "cost": 1.25, "calls": 2, "sessions": 1, "inputTokens": 100, "outputTokens": 50 },
"optimize": { "findingCount": 0, "savingsUSD": 0, "topFindings": [] },
"history": { "daily": [] }\(cursorSync.map { ", \"cursorSync\": \($0)" } ?? "")
}
""".utf8)
}
private static let now = LiveSession.parseISO8601("2026-10-05T12:00:00.000Z")!
private static func status(_ json: String) throws -> CursorSyncStatus {
try JSONDecoder().decode(CursorSyncStatus.self, from: Data(json.utf8))
}
@Test("absent on an older CLI, and a malformed block never costs the payload")
func optionalDecoding() throws {
#expect(try JSONDecoder().decode(MenubarPayload.self, from: Self.payload(nil)).cursorSync == nil)
#expect(try JSONDecoder().decode(MenubarPayload.self, from: Self.payload("{\"state\": 3}")).cursorSync == nil)
}
@Test("decodes the block and survives the status cache round trip")
func decodesAndEncodes() throws {
let decoded = try JSONDecoder().decode(MenubarPayload.self, from: Self.payload(
#"{"enabled": true, "state": "ok", "lastSuccessAt": "2026-10-05T11:48:00.000Z"}"#
))
#expect(decoded.cursorSync == CursorSyncStatus(enabled: true, state: "ok", lastSuccessAt: "2026-10-05T11:48:00.000Z", errorCode: nil, error: nil))
let again = try JSONDecoder().decode(MenubarPayload.self, from: JSONEncoder().encode(decoded))
#expect(again.cursorSync == decoded.cursorSync)
}
@Test("words each state from fixed copy, hiding the line when off")
func lines() throws {
let ok = try Self.status(#"{"enabled": true, "state": "ok", "lastSuccessAt": "2026-10-05T11:48:00.000Z"}"#).line(now: Self.now)
#expect(ok?.text == "Synced from cursor.com 12m ago")
#expect(ok?.warn == false)
#expect(try Self.status(#"{"enabled": true, "state": "syncing-never", "lastSuccessAt": null}"#).line(now: Self.now)?.text == "Not synced from cursor.com yet")
#expect(try Self.status(#"{"enabled": false, "state": "off", "lastSuccessAt": null}"#).line(now: Self.now) == nil)
let login = try Self.status(#"{"enabled": true, "state": "no-login", "lastSuccessAt": null, "errorCode": "login", "error": "server said x"}"#).line(now: Self.now)
#expect(login?.text == "Cursor login expired, open Cursor to sign in again")
#expect(login?.warn == true)
#expect(try Self.status(#"{"enabled": true, "state": "error", "lastSuccessAt": null, "errorCode": "network"}"#).line(now: Self.now)?.text == "Couldn't reach cursor.com, will retry")
#expect(try Self.status(#"{"enabled": true, "state": "error", "lastSuccessAt": null, "errorCode": "new-kind"}"#).line(now: Self.now)?.text == "Couldn't read the usage export from cursor.com, will retry")
}
@Test("the Settings footer names the env override and otherwise shows the last sync")
func settingsFooter() throws {
let off = try Self.status(#"{"enabled": false, "state": "off", "lastSuccessAt": null}"#)
#expect(CursorSyncStatus.envOff(configEnabled: true, status: off))
#expect(CursorSyncStatus.settingsFooter(configEnabled: true, status: off) == "Turned off by CODEBURN_CURSOR_SYNC=0")
#expect(!CursorSyncStatus.envOff(configEnabled: false, status: off))
#expect(CursorSyncStatus.settingsFooter(configEnabled: false, status: off) == "Downloads your own usage export with the Cursor app's login, at most once an hour.")
let ok = try Self.status(#"{"enabled": true, "state": "ok", "lastSuccessAt": "2026-10-05T11:48:00.000Z"}"#)
#expect(!CursorSyncStatus.envOff(configEnabled: true, status: ok))
#expect(CursorSyncStatus.settingsFooter(configEnabled: true, status: ok, now: Self.now) == "Synced from cursor.com 12m ago")
#expect(CursorSyncStatus.settingsFooter(configEnabled: true, status: nil) == "Downloads your own usage export with the Cursor app's login, at most once an hour.")
}
@Test("the toggle writes only the cursorSync key, clearing it when on")
func configRoundTrip() throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("cursor-sync-\(UUID().uuidString)").path
defer { try? FileManager.default.removeItem(atPath: dir) }
let config = (dir as NSString).appendingPathComponent("config.json")
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
try Data(#"{"language": "fr"}"#.utf8).write(to: URL(fileURLWithPath: config))
#expect(CLICursorSyncConfig.load(configDir: dir))
try CLICursorSyncConfig.persist(enabled: false, configDir: dir)
#expect(!CLICursorSyncConfig.load(configDir: dir))
var json = try JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: config))) as? [String: Any]
#expect(json?["language"] as? String == "fr")
try CLICursorSyncConfig.persist(enabled: true, configDir: dir)
json = try JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: config))) as? [String: Any]
#expect(json?["cursorSync"] == nil)
#expect(json?["language"] as? String == "fr")
try Data("not json".utf8).write(to: URL(fileURLWithPath: config))
#expect(throws: (any Error).self) { try CLICursorSyncConfig.persist(enabled: false, configDir: dir) }
#expect(try String(contentsOfFile: config, encoding: .utf8) == "not json")
}
}