codeburn/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift
Resham Joshi b876a311e2
feat: telemetry for CLI error causes, provider read failures and update results (#1702)
cli_error gains duration, exit, a locally classified reason and the scoped provider. New provider_read_fail (desktop) and update_result (desktop, menubar, tray) events, enums only.
2026-10-09 12:47:49 +02:00

280 lines
13 KiB
Swift

import Foundation
import Testing
@testable import CodeBurnMenubar
@Suite("UpdateChecker")
struct UpdateCheckerTests {
@Test("selects newest mac release with zip and checksum")
func selectsNewestMacReleaseWithChecksum() {
let releases = [
GitHubRelease(
tag_name: "v0.9.9",
assets: [GitHubAsset(name: "codeburn-0.9.9.tgz", browser_download_url: "https://example.test/cli")]
),
GitHubRelease(
tag_name: "mac-v0.9.8",
assets: [
GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip", browser_download_url: "https://example.test/app"),
GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip.sha256", browser_download_url: "https://example.test/app.sha256"),
]
),
]
let resolved = UpdateChecker.resolveLatestMenubarRelease(in: releases)
#expect(resolved?.release.tag_name == "mac-v0.9.8")
#expect(resolved?.asset.name == "CodeBurnMenubar-v0.9.8.zip")
}
@Test("ignores mac release missing checksum")
func ignoresMacReleaseMissingChecksum() {
let releases = [
GitHubRelease(
tag_name: "mac-v0.9.8",
assets: [GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip", browser_download_url: "https://example.test/app")]
),
]
#expect(UpdateChecker.resolveLatestMenubarRelease(in: releases) == nil)
}
@Test("flags CLI older than the menubar-update fix as too old")
func flagsCliBelowMinimumAsTooOld() {
#expect(UpdateChecker.isCliTooOld(installed: "0.9.8"))
#expect(UpdateChecker.isCliTooOld(installed: "v0.9.8"))
#expect(UpdateChecker.isCliTooOld(installed: "0.8.12"))
}
@Test("accepts CLI at or above the menubar-update fix version")
func acceptsCliAtOrAboveMinimum() {
#expect(!UpdateChecker.isCliTooOld(installed: "0.9.9"))
#expect(!UpdateChecker.isCliTooOld(installed: "0.9.10"))
#expect(!UpdateChecker.isCliTooOld(installed: "0.9.14"))
#expect(!UpdateChecker.isCliTooOld(installed: "1.0.0"))
}
@Test("does not flag when the CLI version is unknown")
func ignoresUnknownCliVersion() {
#expect(!UpdateChecker.isCliTooOld(installed: nil))
#expect(!UpdateChecker.isCliTooOld(installed: ""))
}
}
@Suite("Update failure presentation")
@MainActor
struct UpdateFailurePresentationTests {
@Test("identifies an automatic update check failure")
func updateCheckFailure() {
let checker = UpdateChecker()
checker.updateFailureStage = .check
checker.updateError = "GitHub returned HTTP 403."
#expect(checker.updateBadgeLabel == "Update Check Failed")
#expect(checker.updateHelpText.contains("could not check GitHub for updates"))
#expect(checker.updateHelpText.contains("GitHub returned HTTP 403."))
#expect(checker.updateHelpText.contains("retry the update check"))
}
@Test("distinguishes CLI update failures")
func cliUpdateFailure() {
let checker = UpdateChecker()
checker.updateFailureStage = .cliUpdate
checker.updateError = "npm exited with status 1."
#expect(checker.updateBadgeLabel == "CLI Update Failed")
#expect(checker.updateHelpText.contains("could not update the CLI"))
#expect(checker.updateHelpText.contains("npm exited with status 1."))
}
@Test("distinguishes menubar update failures")
func menubarUpdateFailure() {
let checker = UpdateChecker()
checker.updateFailureStage = .menubarUpdate
checker.updateError = "Checksum mismatch."
#expect(checker.updateBadgeLabel == "Menubar Update Failed")
#expect(checker.updateHelpText.contains("could not update the menubar app"))
#expect(checker.updateHelpText.contains("Checksum mismatch."))
}
}
// MARK: - one-click full update: package-manager resolution
@Suite("cliUpdateInvocation")
struct CliUpdateInvocationTests {
@Test("homebrew path resolves brew upgrade")
func homebrewPath() {
let argv = UpdateChecker.cliUpdateInvocation(cliPath: "/opt/homebrew/bin/codeburn", fileExists: { $0 == "/opt/homebrew/bin/brew" }, resolvingSymlinks: { $0 })
#expect(argv == ["/opt/homebrew/bin/brew", "upgrade", "codeburn"])
}
@Test("Cellar path resolves brew upgrade")
func cellarPath() {
let argv = UpdateChecker.cliUpdateInvocation(cliPath: "/usr/local/Cellar/codeburn/0.9.18/bin/codeburn", fileExists: { $0 == "/usr/local/bin/brew" })
#expect(argv == ["/usr/local/bin/brew", "upgrade", "codeburn"])
}
@Test("sibling npm wins over global npm so the update lands in the same toolchain")
func siblingNpmWins() {
let exists: (String) -> Bool = { $0 == "/Users/u/.nvm/versions/node/v22.1.0/bin/npm" || $0 == "/opt/homebrew/bin/npm" }
let argv = UpdateChecker.cliUpdateInvocation(cliPath: "/Users/u/.nvm/versions/node/v22.1.0/bin/codeburn", fileExists: exists)
#expect(argv == ["/Users/u/.nvm/versions/node/v22.1.0/bin/npm", "install", "-g", "codeburn@latest", "--force"])
}
@Test("falls back to well-known npm locations")
func fallbackNpm() {
let argv = UpdateChecker.cliUpdateInvocation(cliPath: "/some/odd/place/codeburn", fileExists: { $0 == "/usr/local/bin/npm" })
#expect(argv == ["/usr/local/bin/npm", "install", "-g", "codeburn@latest", "--force"])
}
@Test("no known manager returns nil instead of guessing")
func unknownManager() {
#expect(UpdateChecker.cliUpdateInvocation(cliPath: "/some/odd/place/codeburn", fileExists: { _ in false }) == nil)
}
@Test("homebrew CLI without a findable brew never falls through to npm")
func brewMissingStaysNil() {
// Falling through to npm --force would create a second, conflicting install.
#expect(UpdateChecker.cliUpdateInvocation(cliPath: "/opt/homebrew/bin/codeburn", fileExists: { $0.hasSuffix("/npm") }, resolvingSymlinks: { $0 }) == nil)
}
@Test("brew-provided node with an npm-global CLI resolves npm, not brew")
func brewNodeNpmGlobal() {
// Homebrew's node sets the npm global prefix to /opt/homebrew, so the
// launcher sits in the brew prefix but points into node_modules.
let resolve: (String) -> String = { path in
path == "/opt/homebrew/bin/codeburn" ? "/opt/homebrew/lib/node_modules/codeburn/dist/cli.js" : path
}
let exists: (String) -> Bool = { $0 == "/opt/homebrew/bin/brew" || $0 == "/opt/homebrew/bin/npm" }
let argv = UpdateChecker.cliUpdateInvocation(
cliPath: "/opt/homebrew/bin/codeburn",
fileExists: exists,
resolvingSymlinks: resolve
)
#expect(argv == ["/opt/homebrew/bin/npm", "install", "-g", "codeburn@latest", "--force"])
}
@Test("a real Cellar symlink still resolves brew")
func cellarSymlink() {
let resolve: (String) -> String = { path in
path == "/opt/homebrew/bin/codeburn" ? "/opt/homebrew/Cellar/codeburn/0.9.25/bin/codeburn" : path
}
let argv = UpdateChecker.cliUpdateInvocation(
cliPath: "/opt/homebrew/bin/codeburn",
fileExists: { $0 == "/opt/homebrew/bin/brew" },
resolvingSymlinks: resolve
)
#expect(argv == ["/opt/homebrew/bin/brew", "upgrade", "codeburn"])
}
@Test("an unresolvable launcher keeps the previous directory heuristic")
func unresolvableKeepsHeuristic() {
let argv = UpdateChecker.cliUpdateInvocation(
cliPath: "/opt/homebrew/bin/codeburn",
fileExists: { $0 == "/opt/homebrew/bin/brew" },
resolvingSymlinks: { $0 }
)
#expect(argv == ["/opt/homebrew/bin/brew", "upgrade", "codeburn"])
}
}
@Suite("cliUpdateCommand")
struct CliUpdateCommandTests {
@Test("manual hint follows the same decision as the one-click path")
func hintMatchesInvocation() {
let resolve: (String) -> String = { path in
path == "/opt/homebrew/bin/codeburn" ? "/opt/homebrew/lib/node_modules/codeburn/dist/cli.js" : path
}
#expect(UpdateChecker.cliUpdateCommand(cliPath: "/opt/homebrew/bin/codeburn", resolvingSymlinks: resolve) == "npm update -g codeburn")
#expect(UpdateChecker.cliUpdateCommand(cliPath: "/usr/local/Cellar/codeburn/0.9.25/bin/codeburn") == "brew upgrade codeburn")
#expect(UpdateChecker.cliUpdateCommand(cliPath: "/Users/u/.nvm/versions/node/v22.1.0/bin/codeburn") == "npm update -g codeburn")
}
}
@Suite("Menubar update feed")
struct MenubarUpdateFeedTests {
private func feed(_ json: String) -> Data { Data(json.utf8) }
@Test("reads the version when the url is that version's mac-v zip")
func readsMatchingFeed() {
let data = feed(#"{"version":"0.9.30","url":"https://github.com/getagentseal/codeburn/releases/download/mac-v0.9.30/CodeBurnMenubar-v0.9.30.zip","sha256":"ab"}"#)
#expect(UpdateChecker.menubarVersion(fromFeed: data) == "v0.9.30")
}
@Test("rejects a feed whose url does not match its version")
func rejectsMismatchedFeed() {
let other = feed(#"{"version":"0.9.30","url":"https://github.com/getagentseal/codeburn/releases/download/mac-v0.9.29/CodeBurnMenubar-v0.9.29.zip"}"#)
let foreign = feed(#"{"version":"0.9.30","url":"https://evil.test/mac-v0.9.30/CodeBurnMenubar-v0.9.30.zip"}"#)
#expect(UpdateChecker.menubarVersion(fromFeed: other) == nil)
#expect(UpdateChecker.menubarVersion(fromFeed: foreign) == nil)
#expect(UpdateChecker.menubarVersion(fromFeed: feed("not json")) == nil)
}
}
@Suite("CLI the menubar cannot update")
struct UnmanagedCliTests {
@Test("recognizes the desktop app's bundled CLI launcher")
func desktopLauncher() {
#expect(UpdateChecker.isDesktopBundledCli("/Users/u/Library/Application Support/CodeBurn/codeburn-desktop-cli.sh"))
#expect(!UpdateChecker.isDesktopBundledCli("/opt/homebrew/bin/codeburn"))
}
@Test("runs the installer without a CLI update only when the CLI is ahead of the app")
func installAdvancesOnlyWhenCliAhead() {
#expect(UpdateChecker.menubarInstallAdvances(installedCli: "0.9.31", app: "0.9.30"))
#expect(!UpdateChecker.menubarInstallAdvances(installedCli: "0.9.30", app: "0.9.30"))
#expect(!UpdateChecker.menubarInstallAdvances(installedCli: nil, app: "0.9.30"))
#expect(!UpdateChecker.menubarInstallAdvances(installedCli: "0.9.31", app: "dev"))
}
@Test("npm permission errors read as a permission problem with the manual command")
func npmPermissionError() {
let stderr = "npm error code EACCES\nnpm error syscall rename\nnpm error path /usr/local/lib/node_modules/codeburn"
let message = UpdateChecker.cliUpdateFailureMessage(stderr: stderr, status: 243, manualCommand: "npm update -g codeburn")
#expect(message.contains("permission"))
#expect(message.contains("npm update -g codeburn"))
#expect(UpdateChecker.cliUpdateFailureMessage(stderr: "boom", status: 1, manualCommand: "x") == "boom")
}
}
@Suite("update_result telemetry")
@MainActor
struct UpdateResultTelemetryTests {
@Test("sorts installer failures by the installer's own messages")
func failureOutcome() {
#expect(UpdateChecker.updateFailureOutcome(stderr: "Checksum mismatch for /tmp/x.zip.") == "verify_fail")
#expect(UpdateChecker.updateFailureOutcome(stderr: "Refusing to install: the downloaded CodeBurn Menubar is not signed and notarized by AgentSeal") == "verify_fail")
#expect(UpdateChecker.updateFailureOutcome(stderr: "Menubar download failed after 3 attempts: ECONNRESET (https://x)") == "download_fail")
#expect(UpdateChecker.updateFailureOutcome(stderr: "ditto exited with status 1") == "install_fail")
#expect(UpdateChecker.updateFailureOutcome(stderr: "") == "install_fail")
}
@Test("settles a pending update once: ok when newer, install_fail when unchanged, nothing when older")
func settlesPendingUpdate() {
let cases: [(String, [String: String]?)] = [
("0.9.27", ["from": "0.9.26", "to": "0.9.27", "outcome": "ok"]),
("0.9.28", ["from": "0.9.26", "to": "0.9.28", "outcome": "ok"]),
("0.9.26", ["from": "0.9.26", "to": "0.9.27", "outcome": "install_fail"]),
("0.9.25", nil),
]
for (running, expected) in cases {
let (defaults, suiteName) = TestDefaults.make("codeburn.update-result.\(running)")
defer { TestDefaults.forget(suiteName) }
var tracked: [(String, JSONValue)] = []
let checker = UpdateChecker(defaults: defaults, track: { tracked.append(($0, $1)) })
defaults.set(["from": "0.9.26", "to": "0.9.27"], forKey: "UpdateChecker.pendingUpdate")
checker.settlePendingUpdate(running: running)
checker.settlePendingUpdate(running: running)
#expect(defaults.dictionary(forKey: "UpdateChecker.pendingUpdate") == nil)
guard let expected else {
#expect(tracked.isEmpty)
continue
}
#expect(tracked.count == 1)
#expect(tracked.first?.0 == "update_result")
#expect(tracked.first?.1 == .object(expected.mapValues { .string($0) }))
#expect(Telemetry.eventNames.contains("update_result"))
}
}
}