mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-02 04:45:21 +00:00
Scopes the menubar to one Claude config for multi-config (CLAUDE_CONFIG_DIRS) setups, with All as the default. Rebased onto main and fixed the review findings from the original #635: - Fix a TS2206 build break (a 'type' modifier inside an import type block). - Reject --claude-config-source with a non-Claude --provider, and scan Claude only in the scoped branch (a config is Claude-only): fixes provider data leaking into a scoped query and avoids parsing every provider's corpus. - Scope the macOS menu-bar figure to the selected config (badge matched the popover), clear the selection when switching to a non-Claude provider tab, and stop the on-disk badge fallback from showing an unscoped number while scoped. - Tag Claude Desktop / Cowork sessions as their own 'claude-desktop' source so they are a selectable bucket instead of silently vanishing from per-config views (sum of options now equals All). - Skip the redundant Claude discovery walk for plain single-config users while keeping idle configs and Claude Desktop selectable. Reviewed by Codex 5.6; all findings addressed. Full suite: 1581 TS tests, 76 Swift tests, tsc clean.
224 lines
8.8 KiB
Swift
224 lines
8.8 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodeBurnMenubar
|
|
|
|
@Suite("DataClient process")
|
|
struct DataClientProcessTests {
|
|
@Test("status argv local omits scope")
|
|
func statusSubcommandLocalOmitsScope() {
|
|
let args = DataClient.statusSubcommand(
|
|
period: .today,
|
|
provider: .claude,
|
|
includeOptimize: false,
|
|
scope: .local
|
|
)
|
|
|
|
#expect(!args.contains("--scope"))
|
|
#expect(value(after: "--provider", in: args) == "claude")
|
|
#expect(args.contains("--no-optimize"))
|
|
}
|
|
|
|
@Test("status argv combined adds scope and forces provider all")
|
|
func statusSubcommandCombinedAddsScopeAndForcesAllProvider() {
|
|
let args = DataClient.statusSubcommand(
|
|
period: .today,
|
|
provider: .codex,
|
|
includeOptimize: true,
|
|
scope: .combined
|
|
)
|
|
|
|
#expect(value(after: "--scope", in: args) == "combined")
|
|
#expect(value(after: "--provider", in: args) == "all")
|
|
#expect(!args.contains("--no-optimize"))
|
|
}
|
|
|
|
@Test("status argv combined multi-day coerces to local")
|
|
func statusSubcommandCombinedMultiDayCoercesToLocal() {
|
|
let args = DataClient.statusSubcommand(
|
|
period: .today,
|
|
days: ["2026-06-01", "2026-06-02"],
|
|
provider: .codex,
|
|
includeOptimize: false,
|
|
scope: .combined
|
|
)
|
|
|
|
#expect(!args.contains("--scope"))
|
|
#expect(value(after: "--provider", in: args) == "codex")
|
|
#expect(value(after: "--days", in: args) == "2026-06-01,2026-06-02")
|
|
}
|
|
|
|
@Test("status argv local includes Claude config source")
|
|
func statusSubcommandLocalIncludesClaudeConfigSource() {
|
|
let args = DataClient.statusSubcommand(
|
|
period: .today,
|
|
provider: .all,
|
|
includeOptimize: false,
|
|
scope: .local,
|
|
claudeConfigSourceId: "claude-config:work"
|
|
)
|
|
|
|
#expect(value(after: "--claude-config-source", in: args) == "claude-config:work")
|
|
}
|
|
|
|
@Test("status argv combined omits Claude config source")
|
|
func statusSubcommandCombinedOmitsClaudeConfigSource() {
|
|
let args = DataClient.statusSubcommand(
|
|
period: .today,
|
|
provider: .all,
|
|
includeOptimize: false,
|
|
scope: .combined,
|
|
claudeConfigSourceId: "claude-config:work"
|
|
)
|
|
|
|
#expect(value(after: "--scope", in: args) == "combined")
|
|
#expect(!args.contains("--claude-config-source"))
|
|
}
|
|
|
|
@Test("status argv supports LingTai TUI provider")
|
|
func statusSubcommandSupportsLingTaiTUI() {
|
|
let args = DataClient.statusSubcommand(
|
|
period: .month,
|
|
provider: .lingtaiTui,
|
|
includeOptimize: false,
|
|
scope: .local
|
|
)
|
|
|
|
#expect(value(after: "--provider", in: args) == "lingtai-tui")
|
|
#expect(value(after: "--period", in: args) == "month")
|
|
}
|
|
|
|
/// Concurrency + timeout smoke test: launch more hung subprocesses than
|
|
/// there are cooperative threads, all at once, with a short timeout, and
|
|
/// assert every call returns once the timeout kills its sleep.
|
|
///
|
|
/// NOTE: this does NOT reproduce the production permanent deadlock (16/16
|
|
/// cooperative threads parked in waitUntilExit). In a short-lived unit-test
|
|
/// process libdispatch spins up replacement threads for blocked workers, so
|
|
/// even the old blocking-on-the-pool code completes here. The real deadlock
|
|
/// built up over ~2 days under the @MainActor refresh loop and is confirmed
|
|
/// by the live `sample`, not by this test. Kept as a guard that the
|
|
/// off-pool wait + timeout path stays correct under concurrency.
|
|
@Test("concurrent timed-out processes all complete")
|
|
func concurrentTimedOutProcessesAllComplete() {
|
|
let count = ProcessInfo.processInfo.activeProcessorCount * 2 + 4
|
|
let done = DispatchSemaphore(value: 0)
|
|
|
|
Task {
|
|
await withTaskGroup(of: Void.self) { group in
|
|
for _ in 0..<count {
|
|
group.addTask {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/bin/sleep")
|
|
process.arguments = ["30"]
|
|
_ = try? await DataClient.runProcess(process, timeoutSeconds: 1, label: "sleep 30")
|
|
}
|
|
}
|
|
}
|
|
done.signal()
|
|
}
|
|
|
|
// Wait on the test thread (a real thread, not the cooperative pool) so
|
|
// the deadlock is detectable even when the pool is fully starved.
|
|
let outcome = done.wait(timeout: .now() + 15)
|
|
#expect(outcome == .success, "runProcess deadlocked: \(count) concurrent CLIs starved the cooperative pool")
|
|
}
|
|
|
|
/// A decode failure surfaces the CLI's actual stdout/stderr so a stray banner
|
|
/// on stdout (see #515) is self-diagnosing instead of an opaque "not valid JSON".
|
|
@Test("decode failure surfaces output")
|
|
func decodeFailureSurfacesOutput() {
|
|
struct Boom: Error {}
|
|
let failure = CLIDecodeFailure(
|
|
underlying: Boom(),
|
|
stdoutByteCount: 13,
|
|
stdoutSnippet: "(node) banner",
|
|
stderr: "warn: x"
|
|
)
|
|
let text = String(describing: failure)
|
|
#expect(text.contains("(node) banner"), "should include the stdout snippet")
|
|
#expect(text.contains("13 bytes"), "should include the stdout byte count")
|
|
#expect(text.contains("warn: x"), "should include stderr")
|
|
}
|
|
|
|
/// Empty stdout is reported distinctly (the JSONDecoder-on-empty-Data case).
|
|
@Test("decode failure with empty stdout")
|
|
func decodeFailureWithEmptyStdout() {
|
|
struct Boom: Error {}
|
|
let failure = CLIDecodeFailure(underlying: Boom(), stdoutByteCount: 0, stdoutSnippet: "", stderr: "")
|
|
let text = String(describing: failure)
|
|
#expect(text.contains("0 bytes"))
|
|
#expect(text.contains("<empty>"))
|
|
}
|
|
|
|
/// A normally-exiting process returns its real output and exit code through
|
|
/// the off-pool wait path.
|
|
@Test("process returns output and exit code")
|
|
func processReturnsOutputAndExitCode() async throws {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/bin/echo")
|
|
process.arguments = ["hello"]
|
|
let result = try await DataClient.runProcess(process, timeoutSeconds: 5, label: "echo hello")
|
|
#expect(result.exitCode == 0)
|
|
#expect(String(data: result.stdout, encoding: .utf8) == "hello\n")
|
|
}
|
|
|
|
/// Many NORMALLY-exiting processes, all at once, must every one complete
|
|
/// through the terminationHandler wait path. Guards against the wait path
|
|
/// leaking or wedging under concurrency (the production bug was the wait and
|
|
/// its timeout sharing one queue that saturated under sustained load).
|
|
@Test("many normal processes all complete")
|
|
func manyNormalProcessesAllComplete() async {
|
|
let count = 50
|
|
let codes = await withTaskGroup(of: Int32?.self) { group -> [Int32?] in
|
|
for _ in 0..<count {
|
|
group.addTask {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/bin/echo")
|
|
process.arguments = ["ok"]
|
|
return try? await DataClient.runProcess(process, timeoutSeconds: 5, label: "echo ok").exitCode
|
|
}
|
|
}
|
|
var out: [Int32?] = []
|
|
for await code in group { out.append(code) }
|
|
return out
|
|
}
|
|
#expect(codes.count == count)
|
|
#expect(codes.allSatisfy { $0 == 0 },
|
|
"every concurrent process should exit 0 via the terminationHandler wait path")
|
|
}
|
|
|
|
/// The async semaphore never lets more than its count run concurrently.
|
|
@Test("async semaphore caps concurrency")
|
|
func asyncSemaphoreCapsConcurrency() async {
|
|
let sem = AsyncSemaphore(2)
|
|
let peak = PeakCounter()
|
|
await withTaskGroup(of: Void.self) { group in
|
|
for _ in 0..<12 {
|
|
group.addTask {
|
|
await sem.acquire()
|
|
await peak.enter()
|
|
try? await Task.sleep(nanoseconds: 8_000_000)
|
|
await peak.leave()
|
|
await sem.release()
|
|
}
|
|
}
|
|
}
|
|
let observed = await peak.peak
|
|
#expect(observed <= 2, "semaphore should cap concurrency at 2, saw \(observed)")
|
|
#expect(observed > 0)
|
|
}
|
|
}
|
|
|
|
private func value(after flag: String, in args: [String]) -> String? {
|
|
guard let index = args.firstIndex(of: flag), args.indices.contains(index + 1) else {
|
|
return nil
|
|
}
|
|
return args[index + 1]
|
|
}
|
|
|
|
private actor PeakCounter {
|
|
private var current = 0
|
|
private(set) var peak = 0
|
|
func enter() { current += 1; peak = max(peak, current) }
|
|
func leave() { current -= 1 }
|
|
}
|