1684 lines
60 KiB
Swift
1684 lines
60 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodexBarCore
|
|
#if canImport(Darwin)
|
|
import Darwin
|
|
#else
|
|
import Glibc
|
|
#endif
|
|
|
|
private func waitForFile(_ url: URL) async throws {
|
|
for _ in 0..<100 where !FileManager.default.fileExists(atPath: url.path) {
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
#expect(FileManager.default.fileExists(atPath: url.path))
|
|
}
|
|
|
|
@Suite(.serialized)
|
|
struct KiroStatusProbeTests {
|
|
@Test
|
|
func `fetch returns usage when account probe times out`() async throws {
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ "$1" = "whoami" ]; then
|
|
sleep 5
|
|
printf 'Logged in with Google\\nEmail: person@example.com\\n'
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\\n'
|
|
printf '████████████████████ 25%%\\n'
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
exit 1
|
|
""")
|
|
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
|
|
|
|
let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }, accountProbeTimeout: 0.2)
|
|
let snapshot = try await probe.fetch()
|
|
|
|
#expect(snapshot.planName == "KIRO FREE")
|
|
#expect(snapshot.creditsUsed == 12.50)
|
|
#expect(snapshot.accountEmail == nil)
|
|
#expect(snapshot.authMethod == nil)
|
|
}
|
|
|
|
@Test
|
|
func `pipe and PTY share the account deadline`() async throws {
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ "$1" = "whoami" ]; then
|
|
if [ ! -t 1 ]; then
|
|
sleep 5
|
|
exit 1
|
|
fi
|
|
sleep 0.45
|
|
printf 'Logged in with Google\nEmail: person@example.com\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\n'
|
|
printf '████████████████████ 25%%\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
""")
|
|
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
|
|
|
|
let snapshot = try await KiroStatusProbe(
|
|
cliBinaryResolver: { cliURL.path },
|
|
accountProbeTimeout: 0.8,
|
|
pipeTimeoutCap: 0.4).fetch()
|
|
|
|
#expect(snapshot.planName == "KIRO FREE")
|
|
#expect(snapshot.accountEmail == nil)
|
|
#expect(snapshot.authMethod == nil)
|
|
}
|
|
|
|
@Test
|
|
func `accepted pipe output cannot overrun the usage deadline`() async throws {
|
|
let pipePIDFile = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-kiro-deadline-\(UUID().uuidString).pid")
|
|
let ptyMarker = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-kiro-deadline-\(UUID().uuidString).pty")
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\nEmail: person@example.com\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
if [ ! -t 1 ]; then
|
|
printf '%s\n' "$$" > '\(pipePIDFile.path)'
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\n'
|
|
printf '████████████████████ 25%%\n'
|
|
trap '' TERM
|
|
while true; do sleep 1; done
|
|
fi
|
|
: > '\(ptyMarker.path)'
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\n'
|
|
printf '████████████████████ 25%%\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
""")
|
|
defer {
|
|
if let text = try? String(contentsOf: pipePIDFile, encoding: .utf8),
|
|
let pipePID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
{
|
|
_ = kill(pipePID, SIGKILL)
|
|
}
|
|
try? FileManager.default.removeItem(at: pipePIDFile)
|
|
try? FileManager.default.removeItem(at: ptyMarker)
|
|
try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent())
|
|
}
|
|
|
|
let clock = ContinuousClock()
|
|
let startedAt = clock.now
|
|
let probe = KiroStatusProbe(
|
|
cliBinaryResolver: { cliURL.path },
|
|
usageProbeTimeout: 0.8,
|
|
pipeTimeoutCap: 0.4)
|
|
|
|
await #expect {
|
|
_ = try await probe.fetch()
|
|
} throws: { error in
|
|
guard case KiroStatusProbeError.timeout = error else { return false }
|
|
return true
|
|
}
|
|
|
|
#expect(startedAt.duration(to: clock.now) < .seconds(2))
|
|
#expect(!FileManager.default.fileExists(atPath: ptyMarker.path))
|
|
let pipePIDText = try String(contentsOf: pipePIDFile, encoding: .utf8)
|
|
let pipePID = try #require(pid_t(pipePIDText.trimmingCharacters(in: .whitespacesAndNewlines)))
|
|
#expect(kill(pipePID, 0) == -1)
|
|
}
|
|
|
|
@Test
|
|
func `fetch preserves account info when account probe succeeds`() async throws {
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\\nEmail: person@example.com\\n'
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\\n'
|
|
printf '████████████████████ 25%%\\n'
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
exit 1
|
|
""")
|
|
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
|
|
|
|
let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path })
|
|
let snapshot = try await probe.fetch()
|
|
|
|
#expect(snapshot.accountEmail == "person@example.com")
|
|
#expect(snapshot.authMethod == "Google")
|
|
}
|
|
}
|
|
|
|
extension KiroStatusProbeTests {
|
|
@Test
|
|
func `fetch supports kiro cli that only completes through pipes`() async throws {
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ -t 1 ]; then
|
|
sleep 30
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\nEmail: person@example.com\n'
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\n'
|
|
printf '████████████████████ 25%%\n'
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
exit 1
|
|
""")
|
|
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
|
|
|
|
let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path })
|
|
let snapshot = try await probe.fetch()
|
|
|
|
#expect(snapshot.planName == "KIRO FREE")
|
|
#expect(snapshot.creditsUsed == 12.50)
|
|
#expect(snapshot.accountEmail == "person@example.com")
|
|
}
|
|
|
|
@Test
|
|
func `slow pipe remains viable after PTY fallback starts`() async throws {
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ -t 1 ]; then
|
|
exit 97
|
|
fi
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\nEmail: person@example.com\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
sleep 1
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\n'
|
|
printf '████████████████████ 25%%\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
""")
|
|
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
|
|
|
|
let snapshot = try await KiroStatusProbe(
|
|
cliBinaryResolver: { cliURL.path },
|
|
usageProbeTimeout: 2,
|
|
pipeTimeoutCap: 0.2).fetch()
|
|
|
|
#expect(snapshot.planName == "KIRO FREE")
|
|
}
|
|
|
|
@Test
|
|
func `fetch falls back to PTY for older kiro cli`() async throws {
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ ! -t 1 ]; then
|
|
sleep 30
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\nEmail: person@example.com\n'
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\n'
|
|
printf '████████████████████ 25%%\n'
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
exit 1
|
|
""")
|
|
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
|
|
|
|
let probe = KiroStatusProbe(
|
|
cliBinaryResolver: { cliURL.path },
|
|
pipeTimeoutCap: 0.2)
|
|
let snapshot = try await probe.fetch()
|
|
|
|
#expect(snapshot.planName == "KIRO FREE")
|
|
#expect(snapshot.creditsUsed == 12.50)
|
|
#expect(snapshot.accountEmail == "person@example.com")
|
|
}
|
|
|
|
@Test
|
|
func `fetch falls back to PTY after incomplete pipe output`() async throws {
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ ! -t 1 ]; then
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\nEmail: person@example.com\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
printf 'Plan: loading...\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
fi
|
|
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\nEmail: person@example.com\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\n'
|
|
printf '████████████████████ 25%%\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
""")
|
|
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
|
|
|
|
let snapshot = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch()
|
|
|
|
#expect(snapshot.planName == "KIRO FREE")
|
|
#expect(snapshot.creditsUsed == 12.50)
|
|
}
|
|
|
|
@Test
|
|
func `pipe cleanup finishes before PTY fallback starts`() async throws {
|
|
let childPIDFile = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-kiro-pipe-child-\(UUID().uuidString).pid")
|
|
let ptyMarker = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-kiro-pty-fallback-\(UUID().uuidString).started")
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\nEmail: person@example.com\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
if [ ! -t 1 ]; then
|
|
(trap '' TERM; while true; do sleep 1; done) &
|
|
child=$!
|
|
printf '%s\n' "$child" > '\(childPIDFile.path)'
|
|
printf 'Plan: loading...\n'
|
|
exit 0
|
|
fi
|
|
if test -s '\(childPIDFile.path)' && kill -0 "$(cat '\(childPIDFile.path)')" 2>/dev/null; then
|
|
printf 'pipe child still running\n' >&2
|
|
exit 97
|
|
fi
|
|
: > '\(ptyMarker.path)'
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\n'
|
|
printf '████████████████████ 25%%\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
""")
|
|
defer {
|
|
if let text = try? String(contentsOf: childPIDFile, encoding: .utf8),
|
|
let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
{
|
|
_ = kill(childPID, SIGKILL)
|
|
}
|
|
try? FileManager.default.removeItem(at: childPIDFile)
|
|
try? FileManager.default.removeItem(at: ptyMarker)
|
|
try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent())
|
|
}
|
|
|
|
let snapshot = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch()
|
|
|
|
#expect(snapshot.planName == "KIRO FREE")
|
|
#expect(FileManager.default.fileExists(atPath: ptyMarker.path))
|
|
let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8)
|
|
let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines)))
|
|
#expect(kill(childPID, 0) == -1)
|
|
}
|
|
|
|
@Test
|
|
func `shutdown registry terminates an active pipe probe`() async throws {
|
|
let pipePIDFile = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-kiro-shutdown-\(UUID().uuidString).pid")
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\nEmail: person@example.com\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
if [ -t 1 ]; then
|
|
exit 97
|
|
fi
|
|
printf '%s\n' "$$" > '\(pipePIDFile.path)'
|
|
printf 'Plan: loading...\n'
|
|
trap '' TERM
|
|
while true; do sleep 1; done
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
""")
|
|
|
|
let registry = KiroTestProcessRegistry()
|
|
let task = Task {
|
|
try await KiroStatusProbe(
|
|
cliBinaryResolver: { cliURL.path },
|
|
pipeProcessRegistry: registry.dependencies).fetch()
|
|
}
|
|
defer {
|
|
task.cancel()
|
|
if let text = try? String(contentsOf: pipePIDFile, encoding: .utf8),
|
|
let pipePID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
{
|
|
_ = kill(pipePID, SIGKILL)
|
|
}
|
|
try? FileManager.default.removeItem(at: pipePIDFile)
|
|
try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent())
|
|
}
|
|
|
|
try await waitForFile(pipePIDFile)
|
|
let pipePIDText = try String(contentsOf: pipePIDFile, encoding: .utf8)
|
|
let pipePID = try #require(pid_t(pipePIDText.trimmingCharacters(in: .whitespacesAndNewlines)))
|
|
#expect(registry.isRegistered(pipePID))
|
|
|
|
let clock = ContinuousClock()
|
|
let shutdownStartedAt = clock.now
|
|
registry.terminate(pipePID)
|
|
await #expect(throws: (any Error).self) {
|
|
_ = try await task.value
|
|
}
|
|
|
|
#expect(shutdownStartedAt.duration(to: clock.now) < .seconds(2))
|
|
#expect(!registry.isRegistered(pipePID))
|
|
#expect(registry.didUnregister(pipePID))
|
|
#expect(kill(pipePID, 0) == -1)
|
|
}
|
|
|
|
@Test
|
|
func `fetch combines pipe stdout with stderr warnings`() async throws {
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ -t 1 ]; then
|
|
exit 91
|
|
fi
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\nEmail: person@example.com\n'
|
|
printf 'warning: cached session\n' >&2
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\n'
|
|
printf '████████████████████ 25%%\n'
|
|
printf 'warning: telemetry unavailable\n' >&2
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
""")
|
|
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
|
|
|
|
let snapshot = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch()
|
|
|
|
#expect(snapshot.planName == "KIRO FREE")
|
|
#expect(snapshot.accountEmail == "person@example.com")
|
|
#expect(snapshot.authMethod == "Google")
|
|
}
|
|
|
|
@Test
|
|
func `fetch falls back to PTY after pipe requires a terminal`() async throws {
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ ! -t 1 ]; then
|
|
printf 'terminal required\n' >&2
|
|
exit 2
|
|
fi
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\nEmail: person@example.com\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\n'
|
|
printf '████████████████████ 25%%\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
printf 'Context window: 7.5%% used (estimated)\n'
|
|
printf '█ Context files 2.5%% (estimated)\n'
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
""")
|
|
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
|
|
|
|
let snapshot = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch()
|
|
|
|
#expect(snapshot.planName == "KIRO FREE")
|
|
#expect(snapshot.accountEmail == "person@example.com")
|
|
#expect(snapshot.contextUsage?.totalPercentUsed == 7.5)
|
|
#expect(snapshot.contextUsage?.contextFilesPercent == 2.5)
|
|
}
|
|
|
|
@Test
|
|
func `pipe auth failure on stderr remains authoritative`() async throws {
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ ! -t 1 ]; then
|
|
printf 'Opening browser...\n'
|
|
printf 'Not logged in\n' >&2
|
|
exit 1
|
|
fi
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\nEmail: person@example.com\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\n'
|
|
printf '████████████████████ 25%%\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
""")
|
|
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
|
|
|
|
await #expect {
|
|
_ = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch()
|
|
} throws: { error in
|
|
guard case KiroStatusProbeError.notLoggedIn = error else { return false }
|
|
return true
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `fetch rejects account markers from failed whoami`() async throws {
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\nEmail: person@example.com\n'
|
|
exit 23
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\n'
|
|
printf '████████████████████ 25%%\n'
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
exit 1
|
|
""")
|
|
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
|
|
|
|
let snapshot = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch()
|
|
|
|
#expect(snapshot.accountEmail == nil)
|
|
#expect(snapshot.authMethod == nil)
|
|
}
|
|
|
|
@Test
|
|
func `fetch rejects valid-looking usage from failed command`() async throws {
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ -t 1 ]; then
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\nEmail: person@example.com\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\n'
|
|
printf '████████████████████ 25%%\n'
|
|
exit 0
|
|
fi
|
|
fi
|
|
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\nEmail: person@example.com\n'
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\n'
|
|
printf '████████████████████ 25%%\n'
|
|
exit 23
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
exit 1
|
|
""")
|
|
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
|
|
|
|
let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path })
|
|
await #expect {
|
|
_ = try await probe.fetch()
|
|
} throws: { error in
|
|
guard case KiroStatusProbeError.cliFailed = error else { return false }
|
|
return true
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `fetch preserves not logged in when usage fails without auth detail`() async throws {
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Not logged in\\n'
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
exit 1
|
|
""")
|
|
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
|
|
|
|
let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path })
|
|
await #expect {
|
|
_ = try await probe.fetch()
|
|
} throws: { error in
|
|
guard case KiroStatusProbeError.notLoggedIn = error else { return false }
|
|
return true
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `fetch preserves not logged in when whoami idles after login marker`() async throws {
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Not logged in\n'
|
|
sleep 5
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
exit 1
|
|
""")
|
|
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
|
|
|
|
let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }, accountProbeTimeout: 2)
|
|
await #expect {
|
|
_ = try await probe.fetch()
|
|
} throws: { error in
|
|
guard case KiroStatusProbeError.notLoggedIn = error else { return false }
|
|
return true
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `fetch preserves not logged in when usage output cannot be parsed`() async throws {
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Not logged in\\n'
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
exit 1
|
|
""")
|
|
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
|
|
|
|
let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path })
|
|
await #expect {
|
|
_ = try await probe.fetch()
|
|
} throws: { error in
|
|
guard case KiroStatusProbeError.notLoggedIn = error else { return false }
|
|
return true
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `fetch cancellation during context probe is preserved`() async throws {
|
|
let contextStarted = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-kiro-context-\(UUID().uuidString).started")
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\nEmail: person@example.com\n'
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\n'
|
|
printf '████████████████████ 25%%\n'
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
: > '\(contextStarted.path)'
|
|
trap '' TERM
|
|
while true; do sleep 1; done
|
|
fi
|
|
|
|
exit 1
|
|
""")
|
|
defer {
|
|
try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent())
|
|
try? FileManager.default.removeItem(at: contextStarted)
|
|
}
|
|
|
|
let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path })
|
|
let task = Task { try await probe.fetch() }
|
|
defer { task.cancel() }
|
|
|
|
try await waitForFile(contextStarted)
|
|
|
|
let cancelledAt = Date()
|
|
task.cancel()
|
|
await #expect(throws: CancellationError.self) {
|
|
try await task.value
|
|
}
|
|
#expect(Date().timeIntervalSince(cancelledAt) < 4)
|
|
}
|
|
|
|
@Test
|
|
func `cancellation during pipe cleanup wins over an expired context deadline`() async throws {
|
|
let cleanupStarted = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-kiro-cleanup-\(UUID().uuidString).started")
|
|
let unblockCleanup = DispatchSemaphore(value: 0)
|
|
let registry = KiroTestProcessRegistry(
|
|
blockOnUnregister: 3,
|
|
blockStartedURL: cleanupStarted,
|
|
unblock: unblockCleanup)
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\nEmail: person@example.com\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\n'
|
|
printf '████████████████████ 25%%\n'
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
""")
|
|
defer {
|
|
unblockCleanup.signal()
|
|
try? FileManager.default.removeItem(at: cleanupStarted)
|
|
try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent())
|
|
}
|
|
|
|
let probe = KiroStatusProbe(
|
|
cliBinaryResolver: { cliURL.path },
|
|
contextProbeTimeout: 0.2,
|
|
pipeProcessRegistry: registry.dependencies)
|
|
let task = Task { try await probe.fetch() }
|
|
defer { task.cancel() }
|
|
|
|
try await waitForFile(cleanupStarted)
|
|
task.cancel()
|
|
try await Task.sleep(for: .milliseconds(250))
|
|
unblockCleanup.signal()
|
|
|
|
await #expect(throws: CancellationError.self) {
|
|
try await task.value
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `fetch cancellation while waiting for account probe is preserved`() async throws {
|
|
let accountStarted = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-kiro-account-\(UUID().uuidString).started")
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ "$1" = "whoami" ]; then
|
|
: > '\(accountStarted.path)'
|
|
trap '' TERM
|
|
while true; do sleep 1; done
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\\n'
|
|
printf '████████████████████ 25%%\\n'
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
exit 1
|
|
""")
|
|
defer {
|
|
try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent())
|
|
try? FileManager.default.removeItem(at: accountStarted)
|
|
}
|
|
|
|
let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path })
|
|
let task = Task { try await probe.fetch() }
|
|
defer { task.cancel() }
|
|
|
|
try await waitForFile(accountStarted)
|
|
|
|
let cancelledAt = Date()
|
|
task.cancel()
|
|
await #expect(throws: CancellationError.self) {
|
|
try await task.value
|
|
}
|
|
#expect(Date().timeIntervalSince(cancelledAt) < 4)
|
|
}
|
|
|
|
@Test
|
|
func `fetch returns promptly when usage helper spawns a detached child`() async throws {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-kiro-pipe-\(UUID().uuidString)", isDirectory: true)
|
|
let childPIDFile = root.appendingPathComponent("child.pid")
|
|
let cliURL = root.appendingPathComponent("kiro-cli")
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
defer {
|
|
if let text = try? String(contentsOf: childPIDFile, encoding: .utf8),
|
|
let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
{
|
|
_ = kill(childPID, SIGKILL)
|
|
}
|
|
}
|
|
|
|
let script = """
|
|
#!/bin/bash
|
|
set -e
|
|
if [ "$1" = "whoami" ]; then
|
|
printf 'Logged in with Google\\nEmail: person@example.com\\n'
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
/usr/bin/python3 -c '
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
ready_read, ready_write = os.pipe()
|
|
child = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
"import os,signal,sys,time; "
|
|
"signal.signal(signal.SIGHUP, signal.SIG_IGN); "
|
|
"signal.signal(signal.SIGTERM, signal.SIG_IGN); "
|
|
"handle=open(sys.argv[1], \\\"w\\\"); handle.write(str(os.getpid())); handle.close(); "
|
|
"os.write(int(sys.argv[2]), b\\\"1\\\"); os.close(int(sys.argv[2])); time.sleep(30)",
|
|
os.environ["CODEXBAR_TEST_CHILD_PID_FILE"],
|
|
str(ready_write),
|
|
],
|
|
start_new_session=True,
|
|
pass_fds=(ready_write,),
|
|
)
|
|
os.close(ready_write)
|
|
if os.read(ready_read, 1) != b"1":
|
|
raise RuntimeError("detached helper exited before signaling readiness")
|
|
os.close(ready_read)
|
|
'
|
|
test -s "$CODEXBAR_TEST_CHILD_PID_FILE"
|
|
printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\\n'
|
|
printf 'Credits (12.50 of 50 covered in plan)\\n'
|
|
printf '████████████████████ 25%%\\n'
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
exit 1
|
|
"""
|
|
try script.write(to: cliURL, atomically: true, encoding: .utf8)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cliURL.path)
|
|
|
|
let previousPIDFile = ProcessInfo.processInfo.environment["CODEXBAR_TEST_CHILD_PID_FILE"]
|
|
setenv("CODEXBAR_TEST_CHILD_PID_FILE", childPIDFile.path, 1)
|
|
defer {
|
|
if let previousPIDFile {
|
|
setenv("CODEXBAR_TEST_CHILD_PID_FILE", previousPIDFile, 1)
|
|
} else {
|
|
unsetenv("CODEXBAR_TEST_CHILD_PID_FILE")
|
|
}
|
|
}
|
|
|
|
let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path })
|
|
let start = Date()
|
|
let snapshot = try await probe.fetch()
|
|
let elapsed = Date().timeIntervalSince(start)
|
|
|
|
let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8)
|
|
let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines)))
|
|
for _ in 0..<50 where kill(childPID, 0) == 0 {
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
|
|
// The PTY runner must return promptly and reap a detached helper that keeps the terminal open.
|
|
#expect(snapshot.planName == "KIRO FREE")
|
|
#expect(snapshot.creditsUsed == 12.50)
|
|
#expect(elapsed < 8, "Kiro usage capture should return promptly even with a detached child, took \(elapsed)s")
|
|
#expect(kill(childPID, 0) == -1)
|
|
}
|
|
|
|
@Test
|
|
func `tty runner hard stops a process that ignores SIGTERM`() throws {
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
trap '' TERM
|
|
printf 'partial output\\n'
|
|
while true; do sleep 1; done
|
|
""")
|
|
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
|
|
|
|
let start = Date()
|
|
let result = try TTYCommandRunner().run(
|
|
binary: cliURL.path,
|
|
send: "",
|
|
options: .init(timeout: 2, idleTimeout: 0.1))
|
|
let elapsed = Date().timeIntervalSince(start)
|
|
|
|
#expect(result.completion == .idleTimeout)
|
|
#expect(result.text.contains("partial output"))
|
|
#expect(elapsed < 3, "Ignored SIGTERM should escalate to SIGKILL, took \(elapsed)s")
|
|
}
|
|
|
|
@Test
|
|
func `tty runner kills a pipe holder that escapes the process group`() async throws {
|
|
let childPIDFile = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-kiro-escaped-\(UUID().uuidString).pid")
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/usr/bin/python3
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
child = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
"import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(30)",
|
|
],
|
|
start_new_session=True,
|
|
)
|
|
with open(sys.argv[1], "w") as handle:
|
|
handle.write(str(child.pid))
|
|
print("partial output", flush=True)
|
|
time.sleep(30)
|
|
""")
|
|
defer {
|
|
try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent())
|
|
try? FileManager.default.removeItem(at: childPIDFile)
|
|
}
|
|
|
|
let result = try TTYCommandRunner().run(
|
|
binary: cliURL.path,
|
|
send: "",
|
|
options: .init(
|
|
timeout: 2,
|
|
idleTimeout: 0.1,
|
|
extraArgs: [childPIDFile.path]))
|
|
|
|
#expect(result.completion == .idleTimeout)
|
|
#expect(result.text.contains("partial output"))
|
|
|
|
let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8)
|
|
let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines)))
|
|
defer { _ = kill(childPID, SIGKILL) }
|
|
|
|
let cleanupDeadline = Date().addingTimeInterval(1)
|
|
while kill(childPID, 0) == 0, Date() < cleanupDeadline {
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
#expect(kill(childPID, 0) == -1)
|
|
}
|
|
|
|
@Test
|
|
func `tty runner cleans a same group helper after normal exit`() async throws {
|
|
let childPIDFile = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-kiro-normal-exit-\(UUID().uuidString).pid")
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/usr/bin/python3
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
|
|
child = os.fork()
|
|
if child == 0:
|
|
os.close(1)
|
|
os.close(2)
|
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
with open(sys.argv[1], "w") as handle:
|
|
handle.write(str(os.getpid()))
|
|
time.sleep(30)
|
|
os._exit(0)
|
|
|
|
while not os.path.exists(sys.argv[1]):
|
|
time.sleep(0.01)
|
|
print("parent complete", flush=True)
|
|
os._exit(0)
|
|
""")
|
|
defer {
|
|
try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent())
|
|
try? FileManager.default.removeItem(at: childPIDFile)
|
|
}
|
|
|
|
let result = try TTYCommandRunner().run(
|
|
binary: cliURL.path,
|
|
send: "",
|
|
options: .init(timeout: 2, extraArgs: [childPIDFile.path]))
|
|
|
|
#expect(result.completion == .processExited(status: 0))
|
|
#expect(result.text.contains("parent complete"))
|
|
|
|
let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8)
|
|
let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines)))
|
|
defer { _ = kill(childPID, SIGKILL) }
|
|
|
|
let cleanupDeadline = Date().addingTimeInterval(1)
|
|
while kill(childPID, 0) == 0, Date() < cleanupDeadline {
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
#expect(kill(childPID, 0) == -1)
|
|
}
|
|
|
|
@Test
|
|
func `tty runner preserves completed no-output failure status`() throws {
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
exit 23
|
|
""")
|
|
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
|
|
|
|
let result = try TTYCommandRunner().run(
|
|
binary: cliURL.path,
|
|
send: "",
|
|
options: .init(timeout: 2, returnOnEmptyProcessExit: true))
|
|
|
|
#expect(result.text.isEmpty)
|
|
#expect(result.completion == .processExited(status: 23))
|
|
}
|
|
|
|
@Test
|
|
func `tty runner cancellation terminates the process`() async throws {
|
|
let pidFile = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-kiro-cancel-\(UUID().uuidString).pid")
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
printf '%s\\n' "$$" > "$1"
|
|
trap '' TERM
|
|
while true; do sleep 1; done
|
|
""")
|
|
defer {
|
|
try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent())
|
|
try? FileManager.default.removeItem(at: pidFile)
|
|
}
|
|
|
|
let task = Task {
|
|
try TTYCommandRunner().run(
|
|
binary: cliURL.path,
|
|
send: "",
|
|
options: .init(timeout: 20, extraArgs: [pidFile.path]))
|
|
}
|
|
defer { task.cancel() }
|
|
|
|
var capturedProcessID: pid_t?
|
|
for _ in 0..<100 {
|
|
if let text = try? String(contentsOf: pidFile, encoding: .utf8) {
|
|
capturedProcessID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
break
|
|
}
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
let processID = try #require(capturedProcessID)
|
|
defer { _ = kill(processID, SIGKILL) }
|
|
|
|
task.cancel()
|
|
await #expect(throws: CancellationError.self) {
|
|
try await task.value
|
|
}
|
|
#expect(kill(processID, 0) == -1)
|
|
}
|
|
}
|
|
|
|
extension KiroStatusProbeTests {
|
|
// MARK: - Happy Path Parsing
|
|
|
|
@Test
|
|
func `parses basic usage output`() throws {
|
|
let output = """
|
|
| KIRO FREE |
|
|
████████████████████████████████████████████████████ 25%
|
|
(12.50 of 50 covered in plan), resets on 01/15
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
let snapshot = try probe.parse(output: output)
|
|
|
|
#expect(snapshot.planName == "KIRO FREE")
|
|
#expect(snapshot.displayPlanName == "Kiro Free")
|
|
#expect(snapshot.creditsPercent == 25)
|
|
#expect(snapshot.creditsUsed == 12.50)
|
|
#expect(snapshot.creditsTotal == 50)
|
|
#expect(snapshot.bonusCreditsUsed == nil)
|
|
#expect(snapshot.bonusCreditsTotal == nil)
|
|
#expect(snapshot.bonusExpiryDays == nil)
|
|
#expect(snapshot.resetsAt != nil)
|
|
}
|
|
|
|
private func makeCLI(_ script: String) throws -> URL {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-kiro-cli-\(UUID().uuidString)", isDirectory: true)
|
|
let cliURL = root.appendingPathComponent("kiro-cli")
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
try script.write(to: cliURL, atomically: true, encoding: .utf8)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cliURL.path)
|
|
return cliURL
|
|
}
|
|
|
|
@Test
|
|
func `parses output with bonus credits`() throws {
|
|
let output = """
|
|
| KIRO PRO |
|
|
████████████████████████████████████████████████████ 80%
|
|
(40.00 of 50 covered in plan), resets on 02/01
|
|
Bonus credits: 5.00/10 credits used, expires in 7 days
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
let snapshot = try probe.parse(output: output)
|
|
|
|
#expect(snapshot.planName == "KIRO PRO")
|
|
#expect(snapshot.displayPlanName == "Kiro Pro")
|
|
#expect(snapshot.creditsPercent == 80)
|
|
#expect(snapshot.creditsUsed == 40.00)
|
|
#expect(snapshot.creditsTotal == 50)
|
|
#expect(snapshot.bonusCreditsUsed == 5.00)
|
|
#expect(snapshot.bonusCreditsTotal == 10)
|
|
#expect(snapshot.bonusExpiryDays == 7)
|
|
}
|
|
|
|
@Test
|
|
func `parses output without percent fallbacks to credits ratio`() throws {
|
|
let output = """
|
|
| KIRO FREE |
|
|
(12.50 of 50 covered in plan), resets on 01/15
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
let snapshot = try probe.parse(output: output)
|
|
|
|
#expect(snapshot.creditsPercent == 25)
|
|
}
|
|
|
|
@Test
|
|
func `parses bonus credits without expiry`() throws {
|
|
let output = """
|
|
| KIRO FREE |
|
|
████████████████████████████████████████████████████ 60%
|
|
(30.00 of 50 covered in plan), resets on 04/01
|
|
Bonus credits: 2.00/5 credits used
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
let snapshot = try probe.parse(output: output)
|
|
|
|
#expect(snapshot.bonusCreditsUsed == 2.0)
|
|
#expect(snapshot.bonusCreditsTotal == 5.0)
|
|
#expect(snapshot.bonusExpiryDays == nil)
|
|
}
|
|
|
|
@Test
|
|
func `parses output with ANSI codes`() throws {
|
|
let output = """
|
|
\u{001B}[32m| KIRO FREE |\u{001B}[0m
|
|
\u{001B}[38;5;11m████████████████████████████████████████████████████\u{001B}[0m 50%
|
|
(25.00 of 50 covered in plan), resets on 03/15
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
let snapshot = try probe.parse(output: output)
|
|
|
|
#expect(snapshot.planName == "KIRO FREE")
|
|
#expect(snapshot.creditsPercent == 50)
|
|
#expect(snapshot.creditsUsed == 25.00)
|
|
#expect(snapshot.creditsTotal == 50)
|
|
}
|
|
|
|
@Test
|
|
func `parses output with single day`() throws {
|
|
let output = """
|
|
| KIRO FREE |
|
|
████████████████████████████████████████████████████ 10%
|
|
(5.00 of 50 covered in plan)
|
|
Bonus credits: 2.00/5 credits used, expires in 1 day
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
let snapshot = try probe.parse(output: output)
|
|
|
|
#expect(snapshot.bonusExpiryDays == 1)
|
|
}
|
|
|
|
@Test
|
|
func `rejects output missing usage markers`() throws {
|
|
let output = """
|
|
| KIRO FREE |
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
#expect(throws: KiroStatusProbeError.self) {
|
|
try probe.parse(output: output)
|
|
}
|
|
}
|
|
|
|
// MARK: - New Format (kiro-cli 1.24+, Q Developer)
|
|
|
|
@Test
|
|
func `parses Q developer managed plan`() throws {
|
|
let output = """
|
|
Plan: Q Developer Pro
|
|
Your plan is managed by admin
|
|
|
|
Tip: to see context window usage, run /context
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
let snapshot = try probe.parse(output: output)
|
|
|
|
#expect(snapshot.planName == "Q Developer Pro")
|
|
#expect(snapshot.creditsPercent == 0)
|
|
#expect(snapshot.creditsUsed == 0)
|
|
#expect(snapshot.creditsTotal == 0)
|
|
#expect(snapshot.bonusCreditsUsed == nil)
|
|
#expect(snapshot.resetsAt == nil)
|
|
}
|
|
|
|
@Test
|
|
func `parses Q developer free plan`() throws {
|
|
let output = """
|
|
Plan: Q Developer Free
|
|
Your plan is managed by admin
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
let snapshot = try probe.parse(output: output)
|
|
|
|
#expect(snapshot.planName == "Q Developer Free")
|
|
#expect(snapshot.creditsPercent == 0)
|
|
}
|
|
|
|
@Test
|
|
func `parses new format with ANSI codes`() throws {
|
|
let output = """
|
|
\u{001B}[38;5;141mPlan: Q Developer Pro\u{001B}[0m
|
|
Your plan is managed by admin
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
let snapshot = try probe.parse(output: output)
|
|
|
|
#expect(snapshot.planName == "Q Developer Pro")
|
|
}
|
|
|
|
@Test
|
|
func `rejects header only new format without managed marker`() {
|
|
let output = """
|
|
Plan: Q Developer Pro
|
|
Tip: to see context window usage, run /context
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
#expect(throws: KiroStatusProbeError.self) {
|
|
try probe.parse(output: output)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `preserves parsed usage for managed plan with metrics`() throws {
|
|
let output = """
|
|
Plan: Q Developer Enterprise
|
|
Your plan is managed by admin
|
|
████████████████████████████████████████████████████ 40%
|
|
(20.00 of 50 covered in plan), resets on 03/15
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
let snapshot = try probe.parse(output: output)
|
|
|
|
#expect(snapshot.planName == "Q Developer Enterprise")
|
|
#expect(snapshot.creditsPercent == 40)
|
|
#expect(snapshot.creditsUsed == 20)
|
|
#expect(snapshot.creditsTotal == 50)
|
|
#expect(snapshot.resetsAt != nil)
|
|
}
|
|
|
|
@Test
|
|
func `parses kiro cli two usage format`() throws {
|
|
let output = """
|
|
\u{001B}[1mEstimated Usage\u{001B}[0m | resets on 2026-06-01 | \u{001B}[mKIRO FREE\u{001B}[0m
|
|
|
|
🎁 Bonus credits: 45.53/2000 credits used, expires in 19 days
|
|
|
|
\u{001B}[1mCredits\u{001B}[0m (0.17 of 50 covered in plan)
|
|
████████████████████████████████████████████████████████████████████████████████ 0%
|
|
|
|
Overages: \u{001B}[1mDisabled\u{001B}[0m
|
|
|
|
To manage your plan or configure overages navigate to https://app.kiro.dev/account/usage
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
let snapshot = try probe.parse(
|
|
output: output,
|
|
accountEmail: "person@example.com",
|
|
authMethod: "Google")
|
|
|
|
#expect(snapshot.planName == "KIRO FREE")
|
|
#expect(snapshot.displayPlanName == "Kiro Free")
|
|
#expect(snapshot.accountEmail == "person@example.com")
|
|
#expect(snapshot.authMethod == "Google")
|
|
#expect(snapshot.creditsUsed == 0.17)
|
|
#expect(snapshot.creditsTotal == 50)
|
|
#expect(snapshot.creditsRemaining == 49.83)
|
|
#expect(snapshot.bonusCreditsUsed == 45.53)
|
|
#expect(snapshot.bonusCreditsTotal == 2000)
|
|
#expect(snapshot.bonusCreditsRemaining == 1954.47)
|
|
#expect(snapshot.bonusExpiryDays == 19)
|
|
#expect(snapshot.overagesStatus == "Disabled")
|
|
#expect(snapshot.manageURL == "https://app.kiro.dev/account/usage")
|
|
#expect(snapshot.resetsAt != nil)
|
|
}
|
|
|
|
@Test
|
|
func `parses kiro overage credits and estimated cost`() throws {
|
|
let output = """
|
|
Estimated Usage | resets on 2026-06-01 | KIRO PRO
|
|
Credits (1000.00 of 1000 covered in plan)
|
|
████████████████████████████████████████████████████████████████████████████████ 100%
|
|
|
|
Overages: Enabled billed at $0.04 per request
|
|
Credits used: 40.29
|
|
Est. cost: $1.61 USD
|
|
|
|
To manage your plan or configure overages navigate to https://app.kiro.dev/account/usage
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
let snapshot = try probe.parse(output: output)
|
|
|
|
#expect(snapshot.planName == "KIRO PRO")
|
|
#expect(snapshot.creditsUsed == 1000)
|
|
#expect(snapshot.creditsTotal == 1000)
|
|
#expect(snapshot.overagesStatus == "Enabled billed at $0.04 per request")
|
|
#expect(snapshot.overageCreditsUsed == 40.29)
|
|
#expect(snapshot.estimatedOverageCostUSD == 1.61)
|
|
}
|
|
|
|
@Test
|
|
func `parses context usage`() throws {
|
|
let output = """
|
|
Context window: 1.3% used (estimated)
|
|
██████████████████████████████████████████████████████████████████████████████ 1.3%
|
|
|
|
█ Context files 0.5% (estimated)
|
|
█ Tools 0.8% (estimated)
|
|
█ Kiro responses 0.0% (estimated)
|
|
█ Your prompts 0.0% (estimated)
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
let context = try #require(probe.parseContextUsage(output: output))
|
|
|
|
#expect(context.totalPercentUsed == 1.3)
|
|
#expect(context.contextFilesPercent == 0.5)
|
|
#expect(context.toolsPercent == 0.8)
|
|
#expect(context.kiroResponsesPercent == 0)
|
|
#expect(context.promptsPercent == 0)
|
|
}
|
|
|
|
// MARK: - Snapshot Conversion
|
|
|
|
@Test
|
|
func `converts snapshot to usage snapshot`() throws {
|
|
let now = Date()
|
|
let resetDate = try #require(Calendar.current.date(byAdding: .day, value: 7, to: now))
|
|
|
|
let snapshot = KiroUsageSnapshot(
|
|
planName: "KIRO PRO",
|
|
creditsUsed: 25.0,
|
|
creditsTotal: 100.0,
|
|
creditsPercent: 25.0,
|
|
bonusCreditsUsed: 5.0,
|
|
bonusCreditsTotal: 20.0,
|
|
bonusExpiryDays: 14,
|
|
resetsAt: resetDate,
|
|
updatedAt: now)
|
|
|
|
let usage = snapshot.toUsageSnapshot()
|
|
|
|
#expect(usage.primary?.usedPercent == 25.0)
|
|
#expect(usage.primary?.resetsAt == resetDate)
|
|
#expect(usage.secondary?.usedPercent == 25.0) // 5/20 * 100
|
|
#expect(usage.loginMethod(for: .kiro) == nil)
|
|
#expect(usage.accountOrganization(for: .kiro) == nil)
|
|
#expect(usage.kiroUsage?.displayPlanName == "Kiro Pro")
|
|
#expect(usage.kiroUsage?.creditsRemaining == 75)
|
|
}
|
|
|
|
@Test
|
|
func `converts snapshot without bonus credits`() {
|
|
let snapshot = KiroUsageSnapshot(
|
|
planName: "KIRO FREE",
|
|
creditsUsed: 10.0,
|
|
creditsTotal: 50.0,
|
|
creditsPercent: 20.0,
|
|
bonusCreditsUsed: nil,
|
|
bonusCreditsTotal: nil,
|
|
bonusExpiryDays: nil,
|
|
resetsAt: nil,
|
|
updatedAt: Date())
|
|
|
|
let usage = snapshot.toUsageSnapshot()
|
|
|
|
#expect(usage.primary?.usedPercent == 20.0)
|
|
#expect(usage.secondary == nil)
|
|
}
|
|
|
|
// MARK: - Error Cases
|
|
|
|
@Test
|
|
func `empty output throws parse error`() {
|
|
let probe = KiroStatusProbe()
|
|
|
|
#expect(throws: KiroStatusProbeError.self) {
|
|
try probe.parse(output: "")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `warning output throws parse error`() {
|
|
let output = """
|
|
\u{001B}[38;5;11m⚠️ Warning: Could not retrieve usage information from backend
|
|
\u{001B}[38;5;8mError: dispatch failure (io error): an i/o error occurred
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
|
|
#expect(throws: KiroStatusProbeError.self) {
|
|
try probe.parse(output: output)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `unrecognized format throws parse error`() {
|
|
// Simulates a CLI format change where none of the expected patterns match
|
|
let output = """
|
|
Welcome to Kiro!
|
|
Your account is active.
|
|
Usage: unknown format
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
|
|
#expect {
|
|
try probe.parse(output: output)
|
|
} throws: { error in
|
|
guard case let KiroStatusProbeError.parseError(msg) = error else { return false }
|
|
return msg.contains("No recognizable usage patterns")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `login prompt throws not logged in`() {
|
|
let output = """
|
|
Failed to initialize auth portal.
|
|
Please try again with: kiro-cli login --use-device-flow
|
|
error: OAuth error: All callback ports are in use.
|
|
"""
|
|
|
|
let probe = KiroStatusProbe()
|
|
|
|
#expect {
|
|
try probe.parse(output: output)
|
|
} throws: { error in
|
|
guard case KiroStatusProbeError.notLoggedIn = error else { return false }
|
|
return true
|
|
}
|
|
}
|
|
|
|
// MARK: - WhoAmI Validation
|
|
|
|
@Test
|
|
func `whoami not logged in throws`() {
|
|
let probe = KiroStatusProbe()
|
|
|
|
#expect {
|
|
try probe.validateWhoAmIOutput(stdout: "Not logged in", stderr: "", terminationStatus: 1)
|
|
} throws: { error in
|
|
guard case KiroStatusProbeError.notLoggedIn = error else { return false }
|
|
return true
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `whoami login required throws`() {
|
|
let probe = KiroStatusProbe()
|
|
|
|
#expect {
|
|
try probe.validateWhoAmIOutput(stdout: "login required", stderr: "", terminationStatus: 1)
|
|
} throws: { error in
|
|
guard case KiroStatusProbeError.notLoggedIn = error else { return false }
|
|
return true
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `whoami empty output with zero status throws`() {
|
|
let probe = KiroStatusProbe()
|
|
|
|
#expect {
|
|
try probe.validateWhoAmIOutput(stdout: "", stderr: "", terminationStatus: 0)
|
|
} throws: { error in
|
|
guard case KiroStatusProbeError.cliFailed = error else { return false }
|
|
return true
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `whoami non zero status with message throws`() {
|
|
let probe = KiroStatusProbe()
|
|
|
|
#expect {
|
|
try probe.validateWhoAmIOutput(stdout: "", stderr: "Connection error", terminationStatus: 1)
|
|
} throws: { error in
|
|
guard case KiroStatusProbeError.cliFailed = error else { return false }
|
|
return true
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `whoami success does not throw`() throws {
|
|
let probe = KiroStatusProbe()
|
|
|
|
let account = try probe.validateWhoAmIOutput(
|
|
stdout: """
|
|
Logged in with Google
|
|
Email: user@example.com
|
|
""",
|
|
stderr: "",
|
|
terminationStatus: 0)
|
|
|
|
#expect(account.authMethod == "Google")
|
|
#expect(account.email == "user@example.com")
|
|
}
|
|
|
|
@Test
|
|
func `whoami legacy bare email parses account`() throws {
|
|
let probe = KiroStatusProbe()
|
|
|
|
let account = try probe.validateWhoAmIOutput(
|
|
stdout: "user@example.com",
|
|
stderr: "",
|
|
terminationStatus: 0)
|
|
|
|
#expect(account.authMethod == nil)
|
|
#expect(account.email == "user@example.com")
|
|
}
|
|
}
|
|
|
|
extension KiroStatusProbeTests {
|
|
@Test
|
|
func `fetch cancellation while joining account after usage failure is preserved`() async throws {
|
|
let accountStarted = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-kiro-failed-usage-account-\(UUID().uuidString).started")
|
|
let cliURL = try self.makeCLI(
|
|
"""
|
|
#!/bin/sh
|
|
if [ "$1" = "whoami" ]; then
|
|
: > '\(accountStarted.path)'
|
|
trap '' TERM
|
|
while true; do sleep 1; done
|
|
fi
|
|
|
|
if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then
|
|
exit 1
|
|
fi
|
|
|
|
exit 1
|
|
""")
|
|
defer {
|
|
try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent())
|
|
try? FileManager.default.removeItem(at: accountStarted)
|
|
}
|
|
|
|
let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }, accountProbeTimeout: 2.0)
|
|
let task = Task { try await probe.fetch() }
|
|
defer { task.cancel() }
|
|
|
|
try await waitForFile(accountStarted)
|
|
try await Task.sleep(for: .milliseconds(300))
|
|
|
|
task.cancel()
|
|
await #expect(throws: CancellationError.self) {
|
|
try await task.value
|
|
}
|
|
}
|
|
}
|