Skip to content

Commit d476e9c

Browse files
committed
Add REPL hint for unjoined-room sends, migrate three errors to notConnected(UUID), and consolidate CLI tests
1 parent 609cbca commit d476e9c

18 files changed

Lines changed: 452 additions & 310 deletions

IntegrationTests/Tests/DuckoIntegrationTests/CLI/CLIAccountTests.swift

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Testing
33

44
extension DuckoIntegrationTests.CLILayer {
55
struct CLIAccountTests {
6-
@Test(.enabled(if: CLIProcess.binaryExists, "DuckoCLI binary missing"))
6+
@Test
77
@MainActor func `account list reports no accounts when none configured`() async throws {
88
try await CLIProcess.withProcess { cli in
99
let output = try await cli.run(["account", "list", "--output", "plain"])
@@ -12,7 +12,7 @@ extension DuckoIntegrationTests.CLILayer {
1212
}
1313
}
1414

15-
@Test(.enabled(if: CLIProcess.binaryExists, "DuckoCLI binary missing"))
15+
@Test
1616
@MainActor func `account add stores a new account`() async throws {
1717
try await CLIProcess.withProcess { cli in
1818
let alice = TestCredentials.alice
@@ -24,7 +24,7 @@ extension DuckoIntegrationTests.CLILayer {
2424
}
2525
}
2626

27-
@Test(.enabled(if: CLIProcess.binaryExists, "DuckoCLI binary missing"))
27+
@Test
2828
@MainActor func `account delete removes an account`() async throws {
2929
try await CLIProcess.withProcess { cli in
3030
let alice = TestCredentials.alice
@@ -38,5 +38,19 @@ extension DuckoIntegrationTests.CLILayer {
3838
#expect(listed.stdout.contains("No accounts configured."))
3939
}
4040
}
41+
42+
@Test
43+
@MainActor func `account delete reports an error for an unknown JID`() async throws {
44+
try await CLIProcess.withProcess { cli in
45+
// No `seedAccount` here — `accountService.accounts` is empty,
46+
// so `Account.Delete.run` throws `CLIError.accountNotFound(jid)`,
47+
// whose description reads "Account not found: <jid>".
48+
// ArgumentParser routes throws to stderr with a non-zero exit.
49+
let alice = TestCredentials.alice
50+
let output = try await cli.run(["account", "delete", alice.jid])
51+
#expect(output.exitCode != 0)
52+
#expect(output.stderr.contains("Account not found: \(alice.jid)"))
53+
}
54+
}
4155
}
4256
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Foundation
2+
import Testing
3+
4+
extension DuckoIntegrationTests.CLILayer {
5+
struct CLIHistoryTests {
6+
@Test
7+
@MainActor func `ducko history reports empty state on a fresh profile`() async throws {
8+
try await CLIProcess.withProcess { cli in
9+
// The standalone `History` subcommand has its own argument
10+
// parsing and process lifecycle distinct from the REPL
11+
// `/history` handler (covered by `CLIREPLTests`). A fresh
12+
// inttest profile has no transcript, so `printHistory`
13+
// emits "No messages found." and the process exits 0
14+
// without contacting the server (`--server` not passed).
15+
let alice = TestCredentials.alice
16+
let bob = TestCredentials.bob
17+
try await cli.seedAccount(alice)
18+
19+
let output = try await cli.run([
20+
"history", bob.jid, "--output", "plain"
21+
])
22+
#expect(output.exitCode == 0)
23+
#expect(output.stdout.contains("No messages found."))
24+
}
25+
}
26+
}
27+
}

IntegrationTests/Tests/DuckoIntegrationTests/CLI/CLIOutputFormatTests.swift

Lines changed: 63 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,49 +3,44 @@ import Testing
33

44
extension DuckoIntegrationTests.CLILayer {
55
struct CLIOutputFormatTests {
6-
@Test(.enabled(if: CLIProcess.binaryExists, "DuckoCLI binary missing"))
6+
@Test
77
@MainActor func `ducko send --output json emits a message JSON object`() async throws {
8-
let aliceProfile = "inttest-alice-\(UUID().uuidString.prefix(8))"
9-
let bobProfile = "inttest-bob-\(UUID().uuidString.prefix(8))"
10-
11-
try await CLIProcess.withProcess(profile: aliceProfile) { aliceCLI in
12-
try await CLIProcess.withProcess(profile: bobProfile) { bobCLI in
13-
let alice = TestCredentials.alice
14-
let bob = TestCredentials.bob
8+
try await CLIProcess.withProcessPair { aliceCLI, bobCLI in
9+
let alice = TestCredentials.alice
10+
let bob = TestCredentials.bob
1511

16-
// Bob's account is added so the recipient is registered server-side
17-
// and ready for delivery; no REPL is needed for this assertion since
18-
// we only validate alice's emitted JSON.
19-
try await bobCLI.seedAccount(bob)
20-
try await aliceCLI.seedAccount(alice)
12+
// Bob's account is added so the recipient is registered server-side
13+
// and ready for delivery; no REPL is needed for this assertion since
14+
// we only validate alice's emitted JSON.
15+
try await bobCLI.seedAccount(bob)
16+
try await aliceCLI.seedAccount(alice)
2117

22-
let body = "msg-\(UUID().uuidString.prefix(8))"
23-
let output = try await aliceCLI.run([
24-
"send", bob.jid, body, "--output", "json"
25-
])
26-
#expect(output.exitCode == 0)
18+
let body = "msg-\(UUID().uuidString.prefix(8))"
19+
let output = try await aliceCLI.run([
20+
"send", bob.jid, body, "--output", "json"
21+
])
22+
#expect(output.exitCode == 0)
2723

28-
// `JSONFormatter` emits one flat `[String: String]` JSON object
29-
// per line with a `"type"` discriminator
30-
// (`Tests/DuckoCLITests/JSONFormatterTests.swift:10-29`).
31-
let lines = output.stdout.split(separator: "\n", omittingEmptySubsequences: true)
32-
var sawMessage = false
33-
for line in lines {
34-
let data = try #require(String(line).data(using: .utf8))
35-
guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: String] else {
36-
continue
37-
}
38-
if dict["type"] == "message", dict["body"] == body {
39-
sawMessage = true
40-
break
41-
}
24+
// `JSONFormatter` emits one flat `[String: String]` JSON
25+
// object per line with a `"type"` discriminator (see
26+
// `JSONFormatterTests`).
27+
let lines = output.stdout.split(separator: "\n", omittingEmptySubsequences: true)
28+
var sawMessage = false
29+
for line in lines {
30+
let data = try #require(String(line).data(using: .utf8))
31+
guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: String] else {
32+
continue
33+
}
34+
if dict["type"] == "message", dict["body"] == body {
35+
sawMessage = true
36+
break
4237
}
43-
#expect(sawMessage, "expected a message JSON object with body=\(body) in stdout, got: \(output.stdout)")
4438
}
39+
#expect(sawMessage, "expected a message JSON object with body=\(body) in stdout, got: \(output.stdout)")
4540
}
4641
}
4742

48-
@Test(.enabled(if: CLIProcess.binaryExists, "DuckoCLI binary missing"))
43+
@Test
4944
@MainActor func `roster list --output plain emits human-readable text`() async throws {
5045
try await CLIProcess.withProcess { cli in
5146
let alice = TestCredentials.alice
@@ -54,19 +49,45 @@ extension DuckoIntegrationTests.CLILayer {
5449
let listed = try await cli.run(["roster", "list", "--output", "plain"])
5550
#expect(listed.exitCode == 0)
5651
let trimmed = listed.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
57-
// Plain output never starts with `{` (which would indicate JSON) and
58-
// never contains ANSI escape sequences. `OutputFormat.swift:14-15`
59-
// defaults to plain when stdout is a `Pipe` (not a TTY), so the
60-
// explicit `--output plain` here just pins the format.
52+
// Plain output never starts with `{` (which would indicate
53+
// JSON) and never contains ANSI escape sequences.
54+
// `OutputFormat.defaultForTerminal` falls back to plain
55+
// when stdout is a `Pipe` (not a TTY), so the explicit
56+
// `--output plain` here just pins the format.
6157
// Positive marker: `PlainFormatter.formatGroupHeader`
62-
// (`PlainFormatter.swift:53-55`) emits `--- <name> (<count>) ---`
63-
// for non-empty rosters, or `DuckoCLI.swift:2401-2404` prints
64-
// "No contacts in roster." for the empty baseline. Asserting one
65-
// of these is present rules out a vacuously-passing empty stdout.
58+
// emits `--- <name> (<count>) ---` for non-empty rosters,
59+
// or the REPL roster handler prints "No contacts in
60+
// roster." for the empty baseline. Asserting one of
61+
// these is present rules out a vacuously-passing empty
62+
// stdout.
6663
#expect(trimmed.contains("---") || trimmed.contains("No contacts in roster."))
6764
#expect(!trimmed.hasPrefix("{"))
6865
#expect(!listed.stdout.contains("\u{1B}["))
6966
}
7067
}
68+
69+
@Test
70+
@MainActor func `account list --output ansi emits ANSI escape sequences`() async throws {
71+
try await CLIProcess.withProcess { cli in
72+
let alice = TestCredentials.alice
73+
try await cli.seedAccount(alice)
74+
75+
// Local-only assertion: `account list` reads accounts
76+
// from the local credential store and prints them
77+
// through the formatter without contacting the server.
78+
// `ANSIFormatter.formatAccount` always wraps the JID
79+
// and account UUID in `Color.bold`/`Color.dim` escapes,
80+
// so the `\u{1B}[` substring is guaranteed when
81+
// `--output ansi` overrides
82+
// `OutputFormat.defaultForTerminal`. Cheaper and less
83+
// flake-prone than driving `presence` through a live
84+
// connect/broadcast/disconnect cycle.
85+
let output = try await cli.run([
86+
"account", "list", "--output", "ansi"
87+
])
88+
#expect(output.exitCode == 0)
89+
#expect(output.stdout.contains("\u{1B}["))
90+
}
91+
}
7192
}
7293
}

IntegrationTests/Tests/DuckoIntegrationTests/CLI/CLIPresenceTests.swift

Lines changed: 13 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@ import Testing
33

44
extension DuckoIntegrationTests.CLILayer {
55
struct CLIPresenceTests {
6-
@Test(.enabled(if: CLIProcess.binaryExists, "DuckoCLI binary missing"))
6+
@Test
77
@MainActor func `ducko presence away echoes formatted presence locally`() async throws {
88
try await CLIProcess.withProcess { cli in
99
let alice = TestCredentials.alice
1010
try await cli.seedAccount(alice)
1111

12-
// `presence` connects, broadcasts, then disconnects (`DuckoCLI.swift:316-365`),
13-
// so peer-observed presence is unreachable from this command. Only verify
14-
// alice's own echoed presence: `PlainFormatter.formatPresence` emits
15-
// "<jid> is <status>: <message>" (`PlainFormatter.swift:57-62`).
12+
// `Presence.run` connects, broadcasts, then disconnects,
13+
// so peer-observed presence is unreachable from this
14+
// command. Only verify alice's own echoed presence:
15+
// `PlainFormatter.formatPresence` emits "<jid> is
16+
// <status>: <message>".
1617
let output = try await cli.run([
1718
"presence", "away", "BRB", "--output", "plain"
1819
])
@@ -22,73 +23,13 @@ extension DuckoIntegrationTests.CLILayer {
2223
}
2324
}
2425

25-
// The CLI's `/roster` command short-circuits with "No contacts in
26-
// roster." when `rosterService.groups` is empty — even when alice's
27-
// away presence has reached bob's `presenceService.contactPresences`,
28-
// it is unreachable through bob's `/roster` output. Verifying this
29-
// flow end-to-end requires bob to have alice in his roster (mutual
30-
// subscription). The protocol-layer harness sets this up explicitly
31-
// (`Protocol/PresenceTests.swift:119` via
32-
// `setUpBobSubscribedToAlice`); the CLI layer has no equivalent
33-
// surface today. Track in `.turbo/improvements.md` until the CLI
34-
// exposes a way to seed mutual subscription per-test.
26+
// Disabled: CLI `/roster` short-circuits with "No contacts in
27+
// roster." when the roster is empty, so alice's away presence is
28+
// not observable through bob's `/roster` output without mutual
29+
// subscription, which the CLI layer has no helper to seed. The
30+
// protocol-layer harness has `setUpBobSubscribedToAlice`; track
31+
// re-enabling in `.turbo/improvements.md`.
3532
@Test(.disabled("CLI /roster does not surface presence for non-roster peers; test premise requires mutual alice↔bob subscription which is not reliably seeded by this layer. See improvements backlog."))
36-
@MainActor func `REPL /status changes presence visible to a peer`() async throws {
37-
let aliceProfile = "inttest-alice-\(UUID().uuidString.prefix(8))"
38-
let bobProfile = "inttest-bob-\(UUID().uuidString.prefix(8))"
39-
40-
try await CLIProcess.withProcess(profile: aliceProfile) { aliceCLI in
41-
try await CLIProcess.withProcess(profile: bobProfile) { bobCLI in
42-
let alice = TestCredentials.alice
43-
let bob = TestCredentials.bob
44-
45-
let aliceREPL = try await REPLSession.start(cli: aliceCLI, credentials: alice)
46-
await aliceCLI.addCleanup { await aliceREPL.terminate() }
47-
48-
// The PTY makes `OutputFormat.defaultForTerminal` resolve to
49-
// `.ansi`, which renders presence as colored `●`/`○` dots
50-
// (`ANSIFormatter.swift:53-68`). We assert against the plain
51-
// `[~]` marker (`PlainFormatter.swift:38-51`), so pin bob's
52-
// REPL to plain output.
53-
let bobREPL = try await REPLSession.start(
54-
cli: bobCLI, credentials: bob, arguments: ["--output", "plain"]
55-
)
56-
await bobCLI.addCleanup { await bobREPL.terminate() }
57-
58-
try await aliceREPL.send("/status away BRB")
59-
60-
// Peer status MESSAGE text is not retained in
61-
// `PresenceService.contactPresencesByAccount`
62-
// (`PresenceService.swift:184-191` stores only the status enum), so
63-
// bob's /roster surfaces the away indicator but not "BRB".
64-
// Use the connect timeout (15s) — propagation through the
65-
// server can lag behind the smaller event timeout under
66-
// load, especially on the heels of two prior REPL spawns
67-
// in this suite.
68-
let deadline = ContinuousClock.now.advanced(by: TestTimeout.connect)
69-
var seen = false
70-
while ContinuousClock.now < deadline {
71-
try await bobREPL.send("/roster")
72-
try await Task.sleep(for: .milliseconds(500))
73-
let snapshot = await bobREPL.snapshot()
74-
if snapshot
75-
.split(separator: "\n")
76-
.contains(where: { $0.contains(alice.jid) && $0.contains("[~]") }) {
77-
seen = true
78-
break
79-
}
80-
}
81-
if !seen {
82-
// Surface bob's full /roster snapshot in the failure
83-
// so a missing mutual subscription, an absent away
84-
// marker, or an empty roster shows up immediately
85-
// instead of as an opaque timeout.
86-
let snapshot = await bobREPL.snapshot()
87-
Issue.record("bob's /roster never showed alice as away. snapshot:\n\(snapshot)")
88-
throw TestHarnessError.timeout
89-
}
90-
}
91-
}
92-
}
33+
@MainActor func `REPL /status changes presence visible to a peer`() {}
9334
}
9435
}

IntegrationTests/Tests/DuckoIntegrationTests/CLI/CLIProcess.swift

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ actor CLIProcess {
4747

4848
/// Path to the debug-built CLI binary. Resolved relative to this file via
4949
/// `#filePath` so checkout location does not matter; mirrors the walk-up
50-
/// pattern in `TestCredentials.swift:90-94`.
50+
/// pattern used by `TestCredentials`.
5151
static var binaryPath: URL {
5252
// CLIProcess.swift lives at:
5353
// IntegrationTests/Tests/DuckoIntegrationTests/CLI/CLIProcess.swift
@@ -73,8 +73,7 @@ actor CLIProcess {
7373

7474
/// Runs `body` with a fresh `CLIProcess`, awaiting profile cleanup and any
7575
/// registered REPL terminations on both success and failure paths. Mirrors
76-
/// `TestHarness.withHarness` (`TestHarness.swift:33-58`) since Swift `defer`
77-
/// cannot await.
76+
/// `TestHarness.withHarness` since Swift `defer` cannot await.
7877
static func withProcess<T: Sendable>(
7978
profile: String? = nil,
8079
_ body: sending (CLIProcess) async throws -> T
@@ -91,6 +90,33 @@ actor CLIProcess {
9190
}
9291
}
9392

93+
/// Runs `body` with a pair of fresh `CLIProcess` instances. Profile names
94+
/// embed the labels (default `alice`/`bob`) so per-process directories are
95+
/// distinguishable in the developer's `Application Support` tree when a
96+
/// test crashes mid-flight; the random suffix keeps runs isolated.
97+
/// Inlines the teardown loop instead of nesting two `withProcess` calls
98+
/// because passing a closure that captures a `sending` parameter to
99+
/// another `sending` parameter trips Swift 6 strict concurrency
100+
/// (`SendingClosureRisksDataRace`).
101+
static func withProcessPair<T: Sendable>(
102+
aliceLabel: String = "alice",
103+
bobLabel: String = "bob",
104+
_ body: sending (CLIProcess, CLIProcess) async throws -> T
105+
) async throws -> T {
106+
let aliceCLI = CLIProcess(profile: "inttest-\(aliceLabel)-\(UUID().uuidString.prefix(8))")
107+
let bobCLI = CLIProcess(profile: "inttest-\(bobLabel)-\(UUID().uuidString.prefix(8))")
108+
do {
109+
let result = try await body(aliceCLI, bobCLI)
110+
await bobCLI.tearDown()
111+
await aliceCLI.tearDown()
112+
return result
113+
} catch {
114+
await bobCLI.tearDown()
115+
await aliceCLI.tearDown()
116+
throw error
117+
}
118+
}
119+
94120
/// Appends a cleanup action; actions run in reverse order during teardown.
95121
func addCleanup(_ action: @escaping @Sendable () async -> Void) {
96122
cleanupActions.append(action)

0 commit comments

Comments
 (0)