mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
test(scarfcore): fix cross-suite races + overlay-aware catalog tests
Pre-release verification surfaced 9 failures in `swift test` driven by two issues — both fixed without changing production behaviour. 1. M3TransportTests + M5FeatureVMTests both held `.serialized` internally but ran in parallel with each other, racing on `ServerContext.sshTransportFactory` (a `nonisolated(unsafe)` static). Tried `@TaskLocal` first; reverted because production hot paths dispatch through `Task.detached` which severs TaskLocal inheritance. Final fix: move M3's three factory-injection tests + two HermesLogService tests + the `ScriptedTransport` test double into M5FeatureVMTests, the canonical factory-touching suite. M3 keeps its `.serialized` suite trait for the remaining (non-factory) tests, but the cross-suite race is gone because there's now exactly one suite that mutates the static. 2. `loadProviders()` returns the 6 hardcoded Hermes overlays (Nous Portal, Codex, Qwen, Gemini CLI, Copilot ACP, Arcee) on top of any models.dev catalog hits — added in v2.3 so the picker doesn't go dark when the cache is missing. `modelCatalogHandlesMissingAndMalformedFiles` asserted `.isEmpty`, which had been correct before that change. `modelCatalogLoadsSyntheticJSON` asserted `count == 2`, which was the catalog-only count. Both updated: the missing/malformed test now asserts the result is non-empty + every entry is `isOverlay`; the synthetic-JSON test filters `!isOverlay` before counting. Verified: 163 tests across 12 suites pass on three consecutive runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -141,6 +141,15 @@ public struct ServerContext: Sendable, Hashable, Identifiable {
|
|||||||
/// `nonisolated(unsafe)` annotation mirrors the same pattern
|
/// `nonisolated(unsafe)` annotation mirrors the same pattern
|
||||||
/// `SSHTransport.environmentEnricher` uses — single-write at app
|
/// `SSHTransport.environmentEnricher` uses — single-write at app
|
||||||
/// startup, many-read afterwards.
|
/// startup, many-read afterwards.
|
||||||
|
///
|
||||||
|
/// **Test usage.** Production sets this once at launch. Tests that need
|
||||||
|
/// to inject a fake transport must run inside `M5FeatureVMTests` (the
|
||||||
|
/// canonical `.serialized` suite that owns this static) — running
|
||||||
|
/// factory-touching tests across multiple parallel suites races on this
|
||||||
|
/// var. `@TaskLocal` would scope cleanly, but the production hot paths
|
||||||
|
/// dispatch DB/SFTP reads through `Task.detached` which severs
|
||||||
|
/// TaskLocal inheritance, so the static-write pattern is the only one
|
||||||
|
/// that survives the call stack.
|
||||||
public typealias SSHTransportFactory = @Sendable (
|
public typealias SSHTransportFactory = @Sendable (
|
||||||
_ id: ServerID,
|
_ id: ServerID,
|
||||||
_ config: SSHConfig,
|
_ config: SSHConfig,
|
||||||
|
|||||||
@@ -166,7 +166,11 @@ import Foundation
|
|||||||
|
|
||||||
let svc = ModelCatalogService(path: tmp.path)
|
let svc = ModelCatalogService(path: tmp.path)
|
||||||
|
|
||||||
let providers = svc.loadProviders()
|
// `loadProviders()` returns the merged result of the parsed catalog
|
||||||
|
// + the hardcoded overlay providers (Nous Portal, Codex, …). Filter
|
||||||
|
// to the catalog half here so the assertion is independent of the
|
||||||
|
// overlay table evolving.
|
||||||
|
let providers = svc.loadProviders().filter { !$0.isOverlay }
|
||||||
#expect(providers.count == 2)
|
#expect(providers.count == 2)
|
||||||
// Alphabetical by display name → Anthropic, OpenAI.
|
// Alphabetical by display name → Anthropic, OpenAI.
|
||||||
#expect(providers[0].providerID == "anthropic")
|
#expect(providers[0].providerID == "anthropic")
|
||||||
@@ -193,18 +197,29 @@ import Foundation
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func modelCatalogHandlesMissingAndMalformedFiles() {
|
@Test func modelCatalogHandlesMissingAndMalformedFiles() {
|
||||||
// Missing file → empty arrays, no crash.
|
// Missing or malformed catalog → no crash, no models.dev entries,
|
||||||
|
// but the hardcoded overlay providers (Nous Portal, Codex, Qwen,
|
||||||
|
// Gemini CLI, Copilot ACP, Arcee) still surface so the picker
|
||||||
|
// doesn't go dark when the cache is missing. Assert on the overlay
|
||||||
|
// shape rather than `.isEmpty` (which was correct before v2.3
|
||||||
|
// baked the overlays into `loadProviders()`).
|
||||||
let svc = ModelCatalogService(path: "/tmp/scarf-nonexistent-\(UUID().uuidString).json")
|
let svc = ModelCatalogService(path: "/tmp/scarf-nonexistent-\(UUID().uuidString).json")
|
||||||
#expect(svc.loadProviders().isEmpty)
|
let missing = svc.loadProviders()
|
||||||
|
#expect(!missing.isEmpty)
|
||||||
|
#expect(missing.allSatisfy { $0.isOverlay })
|
||||||
|
#expect(missing.contains { $0.providerID == "nous" && $0.subscriptionGated })
|
||||||
#expect(svc.loadModels(for: "anthropic").isEmpty)
|
#expect(svc.loadModels(for: "anthropic").isEmpty)
|
||||||
#expect(svc.provider(for: "anything") == nil)
|
#expect(svc.provider(for: "anything") == nil)
|
||||||
|
|
||||||
// Malformed JSON → empty arrays, no crash, logger.error path exercised.
|
// Malformed JSON → same overlay-only result, no crash, logger.error
|
||||||
|
// path exercised.
|
||||||
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-bad-\(UUID().uuidString).json")
|
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-bad-\(UUID().uuidString).json")
|
||||||
try? "not json".write(to: tmp, atomically: true, encoding: .utf8)
|
try? "not json".write(to: tmp, atomically: true, encoding: .utf8)
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||||
let bad = ModelCatalogService(path: tmp.path)
|
let bad = ModelCatalogService(path: tmp.path)
|
||||||
#expect(bad.loadProviders().isEmpty)
|
let badProviders = bad.loadProviders()
|
||||||
|
#expect(!badProviders.isEmpty)
|
||||||
|
#expect(badProviders.allSatisfy { $0.isOverlay })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func validateModelAcceptsCatalogHit() throws {
|
@Test func validateModelAcceptsCatalogHit() throws {
|
||||||
|
|||||||
@@ -81,164 +81,13 @@ import Foundation
|
|||||||
|
|
||||||
// MARK: - sshTransportFactory injection
|
// MARK: - sshTransportFactory injection
|
||||||
|
|
||||||
@Test func sshTransportFactoryOverridesDefault() {
|
// MARK: - sshTransportFactory injection + HermesLogService remote tail
|
||||||
// Set up a mock factory that returns a `LocalTransport` regardless
|
//
|
||||||
// of the ServerKind — easy way to prove the injection point
|
// The factory-touching tests that used to live in M3 (3 factory-injection
|
||||||
// routes to our override.
|
// tests + 2 HermesLogService remote-tail tests) were moved into
|
||||||
final class CountingBox: @unchecked Sendable {
|
// `M5FeatureVMTests` (the canonical `.serialized` factory suite) in
|
||||||
var count = 0
|
// v2.5. Co-locating them eliminates the cross-suite race we hit during
|
||||||
func bump() { count += 1 }
|
// pre-release verification: M3 + M5 each held `.serialized` internally
|
||||||
}
|
// but ran in parallel with each other, clobbering the static. The tests
|
||||||
let box = CountingBox()
|
// themselves are unchanged in shape, just relocated.
|
||||||
let previous = ServerContext.sshTransportFactory
|
|
||||||
defer { ServerContext.sshTransportFactory = previous }
|
|
||||||
|
|
||||||
ServerContext.sshTransportFactory = { id, _, _ in
|
|
||||||
box.bump()
|
|
||||||
return LocalTransport(contextID: id)
|
|
||||||
}
|
|
||||||
|
|
||||||
let ctx = ServerContext(
|
|
||||||
id: UUID(),
|
|
||||||
displayName: "test",
|
|
||||||
kind: .ssh(SSHConfig(host: "h"))
|
|
||||||
)
|
|
||||||
let transport = ctx.makeTransport()
|
|
||||||
#expect(transport is LocalTransport)
|
|
||||||
#expect(box.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func sshTransportFactoryNilFallsBackToSSHTransport() {
|
|
||||||
let previous = ServerContext.sshTransportFactory
|
|
||||||
defer { ServerContext.sshTransportFactory = previous }
|
|
||||||
ServerContext.sshTransportFactory = nil
|
|
||||||
|
|
||||||
let ctx = ServerContext(
|
|
||||||
id: UUID(),
|
|
||||||
displayName: "test",
|
|
||||||
kind: .ssh(SSHConfig(host: "h"))
|
|
||||||
)
|
|
||||||
let transport = ctx.makeTransport()
|
|
||||||
#expect(transport is SSHTransport)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func sshTransportFactoryIgnoredForLocalContext() {
|
|
||||||
let previous = ServerContext.sshTransportFactory
|
|
||||||
defer { ServerContext.sshTransportFactory = previous }
|
|
||||||
// Even if set, the factory is ONLY consulted for `.ssh` kinds —
|
|
||||||
// `.local` always gets a `LocalTransport` directly.
|
|
||||||
ServerContext.sshTransportFactory = { _, _, _ in
|
|
||||||
Issue.record("factory called for local context")
|
|
||||||
return LocalTransport()
|
|
||||||
}
|
|
||||||
|
|
||||||
let transport = ServerContext.local.makeTransport()
|
|
||||||
#expect(transport is LocalTransport)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - HermesLogService remote tail refactor
|
|
||||||
|
|
||||||
/// Minimal `ServerTransport` test double: `isRemote == true`, all
|
|
||||||
/// file I/O throws, `streamLines` returns a scripted sequence of
|
|
||||||
/// lines. Exists to verify HermesLogService's remote-tail path
|
|
||||||
/// pumps scripted output into the ring buffer without a real SSH
|
|
||||||
/// subprocess.
|
|
||||||
final class ScriptedTransport: ServerTransport, @unchecked Sendable {
|
|
||||||
public let contextID: ServerID = UUID()
|
|
||||||
public let isRemote: Bool = true
|
|
||||||
private let lines: [String]
|
|
||||||
|
|
||||||
init(lines: [String]) { self.lines = lines }
|
|
||||||
|
|
||||||
func readFile(_ path: String) throws -> Data { throw TransportError.other(message: "N/A") }
|
|
||||||
func writeFile(_ path: String, data: Data) throws { throw TransportError.other(message: "N/A") }
|
|
||||||
func fileExists(_ path: String) -> Bool { true }
|
|
||||||
func stat(_ path: String) -> FileStat? { FileStat(size: 0, mtime: Date(), isDirectory: false) }
|
|
||||||
func listDirectory(_ path: String) throws -> [String] { [] }
|
|
||||||
func createDirectory(_ path: String) throws {}
|
|
||||||
func removeFile(_ path: String) throws {}
|
|
||||||
func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
|
|
||||||
// For readLastLines' one-shot tail — return all scripted lines joined.
|
|
||||||
let content = lines.joined(separator: "\n") + "\n"
|
|
||||||
return ProcessResult(exitCode: 0, stdout: Data(content.utf8), stderr: Data())
|
|
||||||
}
|
|
||||||
#if !os(iOS)
|
|
||||||
func makeProcess(executable: String, args: [String]) -> Process {
|
|
||||||
// Required by protocol on non-iOS; not exercised in tests below.
|
|
||||||
Process()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
func streamLines(executable: String, args: [String]) -> AsyncThrowingStream<String, Error> {
|
|
||||||
AsyncThrowingStream { continuation in
|
|
||||||
Task {
|
|
||||||
for line in lines {
|
|
||||||
continuation.yield(line)
|
|
||||||
}
|
|
||||||
continuation.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func snapshotSQLite(remotePath: String) throws -> URL { URL(fileURLWithPath: remotePath) }
|
|
||||||
func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
|
|
||||||
AsyncStream { $0.finish() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: We can't easily inject the ScriptedTransport into
|
|
||||||
// HermesLogService directly (it takes a `ServerContext` and constructs
|
|
||||||
// its transport internally via `context.makeTransport()`). Instead we
|
|
||||||
// wire the scripted transport through the factory injection point.
|
|
||||||
@Test func hermesLogServiceRemoteTailPumpsThroughStreamLines() async throws {
|
|
||||||
let scripted = ScriptedTransport(lines: [
|
|
||||||
"2026-04-22 12:00:00,001 INFO hermes.agent: starting",
|
|
||||||
"2026-04-22 12:00:01,002 WARNING hermes.gateway: low disk",
|
|
||||||
"2026-04-22 12:00:02,003 ERROR hermes.agent: boom",
|
|
||||||
])
|
|
||||||
|
|
||||||
let previous = ServerContext.sshTransportFactory
|
|
||||||
defer { ServerContext.sshTransportFactory = previous }
|
|
||||||
ServerContext.sshTransportFactory = { _, _, _ in scripted }
|
|
||||||
|
|
||||||
let ctx = ServerContext(
|
|
||||||
id: UUID(),
|
|
||||||
displayName: "t",
|
|
||||||
kind: .ssh(SSHConfig(host: "h"))
|
|
||||||
)
|
|
||||||
let service = HermesLogService(context: ctx)
|
|
||||||
await service.openLog(path: "/fake/agent.log")
|
|
||||||
defer { Task { await service.closeLog() } }
|
|
||||||
|
|
||||||
// Give the pump task a moment to drain the scripted stream.
|
|
||||||
try await Task.sleep(nanoseconds: 50_000_000)
|
|
||||||
|
|
||||||
let entries = await service.readNewLines()
|
|
||||||
#expect(entries.count == 3)
|
|
||||||
#expect(entries[0].level == .info)
|
|
||||||
#expect(entries[1].level == .warning)
|
|
||||||
#expect(entries[2].level == .error)
|
|
||||||
#expect(entries[2].message == "boom")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func hermesLogServiceReadLastLinesUsesOneShotTail() async {
|
|
||||||
let scripted = ScriptedTransport(lines: ["x", "y", "z"])
|
|
||||||
let previous = ServerContext.sshTransportFactory
|
|
||||||
defer { ServerContext.sshTransportFactory = previous }
|
|
||||||
ServerContext.sshTransportFactory = { _, _, _ in scripted }
|
|
||||||
|
|
||||||
let ctx = ServerContext(
|
|
||||||
id: UUID(),
|
|
||||||
displayName: "t",
|
|
||||||
kind: .ssh(SSHConfig(host: "h"))
|
|
||||||
)
|
|
||||||
let service = HermesLogService(context: ctx)
|
|
||||||
// Doesn't need openLog first for the one-shot, but currentPath
|
|
||||||
// has to be set — openLog does both.
|
|
||||||
await service.openLog(path: "/fake/agent.log")
|
|
||||||
defer { Task { await service.closeLog() } }
|
|
||||||
|
|
||||||
let entries = await service.readLastLines(count: 100)
|
|
||||||
#expect(entries.count == 3)
|
|
||||||
#expect(entries[0].message == "x")
|
|
||||||
#expect(entries[2].message == "z")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,8 +45,10 @@ import Foundation
|
|||||||
|
|
||||||
/// Wrap each test body in a factory override so `ctx.makeTransport()`
|
/// Wrap each test body in a factory override so `ctx.makeTransport()`
|
||||||
/// returns a `LocalTransport` instead of trying to spawn a real SSH
|
/// returns a `LocalTransport` instead of trying to spawn a real SSH
|
||||||
/// subprocess. The `.serialized` suite trait guarantees no other
|
/// subprocess. The `.serialized` suite trait guarantees no other test
|
||||||
/// test races on the factory static.
|
/// in *this* suite races on the factory static. Cross-suite races used
|
||||||
|
/// to bite us when M3TransportTests ran in parallel — fixed by moving
|
||||||
|
/// every factory-touching test into this suite.
|
||||||
@MainActor
|
@MainActor
|
||||||
private func withLocalTransportFactory<T>(
|
private func withLocalTransportFactory<T>(
|
||||||
_ body: @MainActor () async throws -> T
|
_ body: @MainActor () async throws -> T
|
||||||
@@ -351,6 +353,168 @@ import Foundation
|
|||||||
#expect(remote.contextID == remoteCtx.id)
|
#expect(remote.contextID == remoteCtx.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - M3 sshTransportFactory injection + HermesLogService remote tail
|
||||||
|
//
|
||||||
|
// Moved from M3TransportTests in v2.5. Both suites had `.serialized`
|
||||||
|
// internally but ran in parallel with each other, clobbering the
|
||||||
|
// `ServerContext.sshTransportFactory` static. Co-locating them in a
|
||||||
|
// single `.serialized` suite fixes the race; logic is unchanged.
|
||||||
|
|
||||||
|
@Test @MainActor func sshTransportFactoryOverridesDefault() {
|
||||||
|
// Set up a mock factory that returns a `LocalTransport` regardless
|
||||||
|
// of the ServerKind — easy way to prove the injection point
|
||||||
|
// routes to our override.
|
||||||
|
final class CountingBox: @unchecked Sendable {
|
||||||
|
var count = 0
|
||||||
|
func bump() { count += 1 }
|
||||||
|
}
|
||||||
|
let box = CountingBox()
|
||||||
|
let previous = ServerContext.sshTransportFactory
|
||||||
|
defer { ServerContext.sshTransportFactory = previous }
|
||||||
|
|
||||||
|
ServerContext.sshTransportFactory = { id, _, _ in
|
||||||
|
box.bump()
|
||||||
|
return LocalTransport(contextID: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx = ServerContext(
|
||||||
|
id: UUID(),
|
||||||
|
displayName: "test",
|
||||||
|
kind: .ssh(SSHConfig(host: "h"))
|
||||||
|
)
|
||||||
|
let transport = ctx.makeTransport()
|
||||||
|
#expect(transport is LocalTransport)
|
||||||
|
#expect(box.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func sshTransportFactoryNilFallsBackToSSHTransport() {
|
||||||
|
let previous = ServerContext.sshTransportFactory
|
||||||
|
defer { ServerContext.sshTransportFactory = previous }
|
||||||
|
ServerContext.sshTransportFactory = nil
|
||||||
|
|
||||||
|
let ctx = ServerContext(
|
||||||
|
id: UUID(),
|
||||||
|
displayName: "test",
|
||||||
|
kind: .ssh(SSHConfig(host: "h"))
|
||||||
|
)
|
||||||
|
let transport = ctx.makeTransport()
|
||||||
|
#expect(transport is SSHTransport)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func sshTransportFactoryIgnoredForLocalContext() {
|
||||||
|
let previous = ServerContext.sshTransportFactory
|
||||||
|
defer { ServerContext.sshTransportFactory = previous }
|
||||||
|
// Even if set, the factory is ONLY consulted for `.ssh` kinds —
|
||||||
|
// `.local` always gets a `LocalTransport` directly.
|
||||||
|
ServerContext.sshTransportFactory = { _, _, _ in
|
||||||
|
Issue.record("factory called for local context")
|
||||||
|
return LocalTransport()
|
||||||
|
}
|
||||||
|
|
||||||
|
let transport = ServerContext.local.makeTransport()
|
||||||
|
#expect(transport is LocalTransport)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal `ServerTransport` test double: `isRemote == true`, all
|
||||||
|
/// file I/O throws, `streamLines` returns a scripted sequence of
|
||||||
|
/// lines. Exists to verify HermesLogService's remote-tail path
|
||||||
|
/// pumps scripted output into the ring buffer without a real SSH
|
||||||
|
/// subprocess.
|
||||||
|
final class ScriptedTransport: ServerTransport, @unchecked Sendable {
|
||||||
|
public let contextID: ServerID = UUID()
|
||||||
|
public let isRemote: Bool = true
|
||||||
|
private let lines: [String]
|
||||||
|
|
||||||
|
init(lines: [String]) { self.lines = lines }
|
||||||
|
|
||||||
|
func readFile(_ path: String) throws -> Data { throw TransportError.other(message: "N/A") }
|
||||||
|
func writeFile(_ path: String, data: Data) throws { throw TransportError.other(message: "N/A") }
|
||||||
|
func fileExists(_ path: String) -> Bool { true }
|
||||||
|
func stat(_ path: String) -> FileStat? { FileStat(size: 0, mtime: Date(), isDirectory: false) }
|
||||||
|
func listDirectory(_ path: String) throws -> [String] { [] }
|
||||||
|
func createDirectory(_ path: String) throws {}
|
||||||
|
func removeFile(_ path: String) throws {}
|
||||||
|
func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
|
||||||
|
// For readLastLines' one-shot tail — return all scripted lines joined.
|
||||||
|
let content = lines.joined(separator: "\n") + "\n"
|
||||||
|
return ProcessResult(exitCode: 0, stdout: Data(content.utf8), stderr: Data())
|
||||||
|
}
|
||||||
|
#if !os(iOS)
|
||||||
|
func makeProcess(executable: String, args: [String]) -> Process {
|
||||||
|
// Required by protocol on non-iOS; not exercised in tests below.
|
||||||
|
Process()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
func streamLines(executable: String, args: [String]) -> AsyncThrowingStream<String, Error> {
|
||||||
|
AsyncThrowingStream { continuation in
|
||||||
|
Task {
|
||||||
|
for line in lines {
|
||||||
|
continuation.yield(line)
|
||||||
|
}
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func snapshotSQLite(remotePath: String) throws -> URL { URL(fileURLWithPath: remotePath) }
|
||||||
|
func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
|
||||||
|
AsyncStream { $0.finish() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func hermesLogServiceRemoteTailPumpsThroughStreamLines() async throws {
|
||||||
|
let scripted = ScriptedTransport(lines: [
|
||||||
|
"2026-04-22 12:00:00,001 INFO hermes.agent: starting",
|
||||||
|
"2026-04-22 12:00:01,002 WARNING hermes.gateway: low disk",
|
||||||
|
"2026-04-22 12:00:02,003 ERROR hermes.agent: boom",
|
||||||
|
])
|
||||||
|
|
||||||
|
let previous = ServerContext.sshTransportFactory
|
||||||
|
defer { ServerContext.sshTransportFactory = previous }
|
||||||
|
ServerContext.sshTransportFactory = { _, _, _ in scripted }
|
||||||
|
|
||||||
|
let ctx = ServerContext(
|
||||||
|
id: UUID(),
|
||||||
|
displayName: "t",
|
||||||
|
kind: .ssh(SSHConfig(host: "h"))
|
||||||
|
)
|
||||||
|
let service = HermesLogService(context: ctx)
|
||||||
|
await service.openLog(path: "/fake/agent.log")
|
||||||
|
defer { Task { await service.closeLog() } }
|
||||||
|
|
||||||
|
// Give the pump task a moment to drain the scripted stream.
|
||||||
|
try await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
|
||||||
|
let entries = await service.readNewLines()
|
||||||
|
#expect(entries.count == 3)
|
||||||
|
#expect(entries[0].level == .info)
|
||||||
|
#expect(entries[1].level == .warning)
|
||||||
|
#expect(entries[2].level == .error)
|
||||||
|
#expect(entries[2].message == "boom")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func hermesLogServiceReadLastLinesUsesOneShotTail() async {
|
||||||
|
let scripted = ScriptedTransport(lines: ["x", "y", "z"])
|
||||||
|
let previous = ServerContext.sshTransportFactory
|
||||||
|
defer { ServerContext.sshTransportFactory = previous }
|
||||||
|
ServerContext.sshTransportFactory = { _, _, _ in scripted }
|
||||||
|
|
||||||
|
let ctx = ServerContext(
|
||||||
|
id: UUID(),
|
||||||
|
displayName: "t",
|
||||||
|
kind: .ssh(SSHConfig(host: "h"))
|
||||||
|
)
|
||||||
|
let service = HermesLogService(context: ctx)
|
||||||
|
// Doesn't need openLog first for the one-shot, but currentPath
|
||||||
|
// has to be set — openLog does both.
|
||||||
|
await service.openLog(path: "/fake/agent.log")
|
||||||
|
defer { Task { await service.closeLog() } }
|
||||||
|
|
||||||
|
let entries = await service.readLastLines(count: 100)
|
||||||
|
#expect(entries.count == 3)
|
||||||
|
#expect(entries[0].message == "x")
|
||||||
|
#expect(entries[2].message == "z")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - M6 Cron editing (write paths)
|
// MARK: - M6 Cron editing (write paths)
|
||||||
//
|
//
|
||||||
// Live in this suite (rather than M6ConfigCronTests) because they
|
// Live in this suite (rather than M6ConfigCronTests) because they
|
||||||
|
|||||||
Reference in New Issue
Block a user