@bastani/atomic 0.5.20-0 → 0.5.21-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/workflow-creator/SKILL.md +56 -8
- package/dist/sdk/components/orchestrator-panel.d.ts +23 -1
- package/dist/sdk/components/orchestrator-panel.d.ts.map +1 -1
- package/dist/sdk/runtime/executor.d.ts.map +1 -1
- package/dist/sdk/runtime/status-writer.d.ts +101 -0
- package/dist/sdk/runtime/status-writer.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli.ts +57 -3
- package/src/commands/cli/session.test.ts +43 -0
- package/src/commands/cli/session.ts +18 -8
- package/src/commands/cli/workflow-inputs.test.ts +321 -0
- package/src/commands/cli/workflow-inputs.ts +219 -0
- package/src/commands/cli/workflow-status.test.ts +451 -0
- package/src/commands/cli/workflow-status.ts +330 -0
- package/src/sdk/components/orchestrator-panel.tsx +36 -1
- package/src/sdk/runtime/executor.ts +37 -0
- package/src/sdk/runtime/status-writer.test.ts +245 -0
- package/src/sdk/runtime/status-writer.ts +201 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `atomic workflow status` — covers the dependency-injected
|
|
3
|
+
* command shape end-to-end with no real tmux server, no real
|
|
4
|
+
* filesystem outside a temp dir, and JSON output capture so assertions
|
|
5
|
+
* can run on parsed objects.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, test, expect, beforeAll, afterAll, beforeEach, mock } from "bun:test";
|
|
9
|
+
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { workflowStatusCommand, type StatusDeps } from "./workflow-status.ts";
|
|
13
|
+
import {
|
|
14
|
+
buildSnapshot,
|
|
15
|
+
writeSnapshot,
|
|
16
|
+
type WorkflowStatusSnapshot,
|
|
17
|
+
} from "../../sdk/runtime/status-writer.ts";
|
|
18
|
+
import type { TmuxSession } from "../../sdk/runtime/tmux.ts";
|
|
19
|
+
import type { SessionData } from "../../sdk/components/orchestrator-panel-types.ts";
|
|
20
|
+
|
|
21
|
+
// ─── output capture ────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function captureStdout(): { read: () => string; restore: () => void } {
|
|
24
|
+
const chunks: string[] = [];
|
|
25
|
+
const orig = process.stdout.write;
|
|
26
|
+
process.stdout.write = ((c: string | Uint8Array) => {
|
|
27
|
+
chunks.push(typeof c === "string" ? c : new TextDecoder().decode(c));
|
|
28
|
+
return true;
|
|
29
|
+
}) as typeof process.stdout.write;
|
|
30
|
+
return {
|
|
31
|
+
read: () => chunks.join(""),
|
|
32
|
+
restore: () => {
|
|
33
|
+
process.stdout.write = orig;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let originalNoColor: string | undefined;
|
|
39
|
+
beforeAll(() => {
|
|
40
|
+
originalNoColor = process.env.NO_COLOR;
|
|
41
|
+
process.env.NO_COLOR = "1";
|
|
42
|
+
});
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
if (originalNoColor === undefined) delete process.env.NO_COLOR;
|
|
45
|
+
else process.env.NO_COLOR = originalNoColor;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function tmuxSession(name: string): TmuxSession {
|
|
49
|
+
return {
|
|
50
|
+
name,
|
|
51
|
+
windows: 1,
|
|
52
|
+
created: new Date().toISOString(),
|
|
53
|
+
attached: false,
|
|
54
|
+
type: "workflow",
|
|
55
|
+
agent: "claude",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function panelSession(
|
|
60
|
+
name: string,
|
|
61
|
+
status: SessionData["status"],
|
|
62
|
+
extra: Partial<SessionData> = {},
|
|
63
|
+
): SessionData {
|
|
64
|
+
return {
|
|
65
|
+
name,
|
|
66
|
+
status,
|
|
67
|
+
parents: [],
|
|
68
|
+
startedAt: 1000,
|
|
69
|
+
endedAt: null,
|
|
70
|
+
...extra,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function snapshotOf(
|
|
75
|
+
workflowName: string,
|
|
76
|
+
agent: string,
|
|
77
|
+
sessions: SessionData[],
|
|
78
|
+
opts: { fatalError?: string | null; completionReached?: boolean } = {},
|
|
79
|
+
): WorkflowStatusSnapshot {
|
|
80
|
+
return buildSnapshot({
|
|
81
|
+
workflowRunId: "abcd1234",
|
|
82
|
+
tmuxSession: `atomic-wf-${agent}-${workflowName}-abcd1234`,
|
|
83
|
+
workflowName,
|
|
84
|
+
agent,
|
|
85
|
+
prompt: "",
|
|
86
|
+
fatalError: opts.fatalError ?? null,
|
|
87
|
+
completionReached: opts.completionReached ?? false,
|
|
88
|
+
sessions,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface Mocks {
|
|
93
|
+
isTmuxInstalled: ReturnType<typeof mock>;
|
|
94
|
+
sessionExists: ReturnType<typeof mock>;
|
|
95
|
+
listSessions: ReturnType<typeof mock>;
|
|
96
|
+
readSnapshot: ReturnType<typeof mock>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function makeDeps(sessionsBaseDir: string): { deps: StatusDeps; mocks: Mocks } {
|
|
100
|
+
const mocks: Mocks = {
|
|
101
|
+
isTmuxInstalled: mock(() => true),
|
|
102
|
+
sessionExists: mock(() => true),
|
|
103
|
+
listSessions: mock<() => TmuxSession[]>(() => []),
|
|
104
|
+
readSnapshot: mock(async () => null),
|
|
105
|
+
};
|
|
106
|
+
const deps: StatusDeps = {
|
|
107
|
+
isTmuxInstalled: mocks.isTmuxInstalled,
|
|
108
|
+
sessionExists: mocks.sessionExists,
|
|
109
|
+
listSessions: mocks.listSessions as unknown as StatusDeps["listSessions"],
|
|
110
|
+
readSnapshot: mocks.readSnapshot as unknown as StatusDeps["readSnapshot"],
|
|
111
|
+
sessionsBaseDir,
|
|
112
|
+
};
|
|
113
|
+
return { deps, mocks };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── tests ─────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe("workflowStatusCommand", () => {
|
|
119
|
+
let tmpDir = "";
|
|
120
|
+
beforeEach(async () => {
|
|
121
|
+
tmpDir = await mkdtemp(join(tmpdir(), "atomic-status-cmd-"));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("prints empty list when no workflow sessions are running", async () => {
|
|
125
|
+
const { deps } = makeDeps(tmpDir);
|
|
126
|
+
const cap = captureStdout();
|
|
127
|
+
try {
|
|
128
|
+
const code = await workflowStatusCommand({ format: "json" }, deps);
|
|
129
|
+
expect(code).toBe(0);
|
|
130
|
+
const parsed = JSON.parse(cap.read());
|
|
131
|
+
expect(parsed).toEqual({ workflows: [] });
|
|
132
|
+
} finally {
|
|
133
|
+
cap.restore();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("derives 'in_progress' for an alive workflow with a running stage", async () => {
|
|
138
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
139
|
+
mocks.listSessions.mockReturnValue([
|
|
140
|
+
tmuxSession("atomic-wf-claude-ralph-abcd1234"),
|
|
141
|
+
]);
|
|
142
|
+
mocks.readSnapshot.mockResolvedValue(
|
|
143
|
+
snapshotOf("ralph", "claude", [panelSession("orchestrator", "running")]),
|
|
144
|
+
);
|
|
145
|
+
const cap = captureStdout();
|
|
146
|
+
try {
|
|
147
|
+
const code = await workflowStatusCommand({ format: "json" }, deps);
|
|
148
|
+
expect(code).toBe(0);
|
|
149
|
+
const parsed = JSON.parse(cap.read());
|
|
150
|
+
expect(parsed.workflows).toHaveLength(1);
|
|
151
|
+
expect(parsed.workflows[0].overall).toBe("in_progress");
|
|
152
|
+
expect(parsed.workflows[0].alive).toBe(true);
|
|
153
|
+
} finally {
|
|
154
|
+
cap.restore();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("returns 'needs_review' when any stage is awaiting input (HIL)", async () => {
|
|
159
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
160
|
+
mocks.listSessions.mockReturnValue([
|
|
161
|
+
tmuxSession("atomic-wf-claude-ralph-abcd1234"),
|
|
162
|
+
]);
|
|
163
|
+
mocks.readSnapshot.mockResolvedValue(
|
|
164
|
+
snapshotOf("ralph", "claude", [
|
|
165
|
+
panelSession("orchestrator", "running"),
|
|
166
|
+
panelSession("loop", "awaiting_input"),
|
|
167
|
+
]),
|
|
168
|
+
);
|
|
169
|
+
const cap = captureStdout();
|
|
170
|
+
try {
|
|
171
|
+
const code = await workflowStatusCommand(
|
|
172
|
+
{ format: "json", id: "atomic-wf-claude-ralph-abcd1234" },
|
|
173
|
+
deps,
|
|
174
|
+
);
|
|
175
|
+
expect(code).toBe(0);
|
|
176
|
+
const parsed = JSON.parse(cap.read());
|
|
177
|
+
expect(parsed.overall).toBe("needs_review");
|
|
178
|
+
} finally {
|
|
179
|
+
cap.restore();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("returns 'completed' when completionReached and no errors", async () => {
|
|
184
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
185
|
+
mocks.listSessions.mockReturnValue([
|
|
186
|
+
tmuxSession("atomic-wf-claude-ralph-abcd1234"),
|
|
187
|
+
]);
|
|
188
|
+
mocks.readSnapshot.mockResolvedValue(
|
|
189
|
+
snapshotOf(
|
|
190
|
+
"ralph",
|
|
191
|
+
"claude",
|
|
192
|
+
[panelSession("orchestrator", "complete")],
|
|
193
|
+
{ completionReached: true },
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
const cap = captureStdout();
|
|
197
|
+
try {
|
|
198
|
+
const code = await workflowStatusCommand(
|
|
199
|
+
{ format: "json", id: "atomic-wf-claude-ralph-abcd1234" },
|
|
200
|
+
deps,
|
|
201
|
+
);
|
|
202
|
+
expect(code).toBe(0);
|
|
203
|
+
const parsed = JSON.parse(cap.read());
|
|
204
|
+
expect(parsed.overall).toBe("completed");
|
|
205
|
+
} finally {
|
|
206
|
+
cap.restore();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("returns 'error' when fatalError is present in the snapshot", async () => {
|
|
211
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
212
|
+
mocks.listSessions.mockReturnValue([
|
|
213
|
+
tmuxSession("atomic-wf-claude-ralph-abcd1234"),
|
|
214
|
+
]);
|
|
215
|
+
mocks.readSnapshot.mockResolvedValue(
|
|
216
|
+
snapshotOf("ralph", "claude", [panelSession("orchestrator", "running")], {
|
|
217
|
+
fatalError: "boom",
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
const cap = captureStdout();
|
|
221
|
+
try {
|
|
222
|
+
const code = await workflowStatusCommand(
|
|
223
|
+
{ format: "json", id: "atomic-wf-claude-ralph-abcd1234" },
|
|
224
|
+
deps,
|
|
225
|
+
);
|
|
226
|
+
expect(code).toBe(0);
|
|
227
|
+
const parsed = JSON.parse(cap.read());
|
|
228
|
+
expect(parsed.overall).toBe("error");
|
|
229
|
+
expect(parsed.fatalError).toBe("boom");
|
|
230
|
+
} finally {
|
|
231
|
+
cap.restore();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("returns 1 with a JSON error envelope when the requested id is unknown", async () => {
|
|
236
|
+
const { deps } = makeDeps(tmpDir);
|
|
237
|
+
const cap = captureStdout();
|
|
238
|
+
try {
|
|
239
|
+
const code = await workflowStatusCommand(
|
|
240
|
+
{ format: "json", id: "atomic-wf-claude-ralph-deadbeef" },
|
|
241
|
+
deps,
|
|
242
|
+
);
|
|
243
|
+
expect(code).toBe(1);
|
|
244
|
+
const parsed = JSON.parse(cap.read());
|
|
245
|
+
expect(parsed.error).toContain("not found");
|
|
246
|
+
} finally {
|
|
247
|
+
cap.restore();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("falls back to a minimal report when the orchestrator hasn't written a snapshot yet", async () => {
|
|
252
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
253
|
+
mocks.listSessions.mockReturnValue([
|
|
254
|
+
tmuxSession("atomic-wf-claude-ralph-abcd1234"),
|
|
255
|
+
]);
|
|
256
|
+
mocks.readSnapshot.mockResolvedValue(null);
|
|
257
|
+
const cap = captureStdout();
|
|
258
|
+
try {
|
|
259
|
+
const code = await workflowStatusCommand({ format: "json" }, deps);
|
|
260
|
+
expect(code).toBe(0);
|
|
261
|
+
const parsed = JSON.parse(cap.read());
|
|
262
|
+
expect(parsed.workflows).toHaveLength(1);
|
|
263
|
+
expect(parsed.workflows[0].overall).toBe("in_progress");
|
|
264
|
+
expect(parsed.workflows[0].workflowName).toBe("");
|
|
265
|
+
} finally {
|
|
266
|
+
cap.restore();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("recognises a stale snapshot as 'error' when the tmux session is gone", async () => {
|
|
271
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
272
|
+
mocks.listSessions.mockReturnValue([]);
|
|
273
|
+
// Place a real snapshot on disk so the dead-session post-mortem
|
|
274
|
+
// path can read it.
|
|
275
|
+
const sessionDir = join(tmpDir, "abcd1234");
|
|
276
|
+
await mkdir(sessionDir, { recursive: true });
|
|
277
|
+
await writeSnapshot(
|
|
278
|
+
sessionDir,
|
|
279
|
+
snapshotOf("ralph", "claude", [panelSession("orchestrator", "running")]),
|
|
280
|
+
);
|
|
281
|
+
// Use the real reader for this test so the dead-session lookup
|
|
282
|
+
// hits the file we just wrote.
|
|
283
|
+
deps.readSnapshot = (await import(
|
|
284
|
+
"../../sdk/runtime/status-writer.ts"
|
|
285
|
+
)).readSnapshot;
|
|
286
|
+
const cap = captureStdout();
|
|
287
|
+
try {
|
|
288
|
+
const code = await workflowStatusCommand(
|
|
289
|
+
{ format: "json", id: "atomic-wf-claude-ralph-abcd1234" },
|
|
290
|
+
deps,
|
|
291
|
+
);
|
|
292
|
+
expect(code).toBe(0);
|
|
293
|
+
const parsed = JSON.parse(cap.read());
|
|
294
|
+
expect(parsed.alive).toBe(false);
|
|
295
|
+
expect(parsed.overall).toBe("error");
|
|
296
|
+
} finally {
|
|
297
|
+
cap.restore();
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("text format: empty list prints the 'no workflows running' hint", async () => {
|
|
302
|
+
const { deps } = makeDeps(tmpDir);
|
|
303
|
+
const cap = captureStdout();
|
|
304
|
+
try {
|
|
305
|
+
const code = await workflowStatusCommand({ format: "text" }, deps);
|
|
306
|
+
expect(code).toBe(0);
|
|
307
|
+
expect(cap.read()).toContain("no workflows running");
|
|
308
|
+
} finally {
|
|
309
|
+
cap.restore();
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("text format: renders a workflow list with indicator, id, and status", async () => {
|
|
314
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
315
|
+
mocks.listSessions.mockReturnValue([
|
|
316
|
+
tmuxSession("atomic-wf-claude-ralph-abcd1234"),
|
|
317
|
+
]);
|
|
318
|
+
mocks.readSnapshot.mockResolvedValue(
|
|
319
|
+
snapshotOf("ralph", "claude", [panelSession("orchestrator", "running")]),
|
|
320
|
+
);
|
|
321
|
+
const cap = captureStdout();
|
|
322
|
+
try {
|
|
323
|
+
const code = await workflowStatusCommand({ format: "text" }, deps);
|
|
324
|
+
expect(code).toBe(0);
|
|
325
|
+
const out = cap.read();
|
|
326
|
+
expect(out).toContain("atomic-wf-claude-ralph-abcd1234");
|
|
327
|
+
expect(out).toContain("in_progress");
|
|
328
|
+
expect(out).toContain("ralph");
|
|
329
|
+
// singular noun when list has exactly one workflow
|
|
330
|
+
expect(out).toContain("1");
|
|
331
|
+
} finally {
|
|
332
|
+
cap.restore();
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("text format: list uses '(no snapshot)' placeholder when workflowName is empty", async () => {
|
|
337
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
338
|
+
mocks.listSessions.mockReturnValue([
|
|
339
|
+
tmuxSession("atomic-wf-claude-ralph-abcd1234"),
|
|
340
|
+
]);
|
|
341
|
+
mocks.readSnapshot.mockResolvedValue(null);
|
|
342
|
+
const cap = captureStdout();
|
|
343
|
+
try {
|
|
344
|
+
const code = await workflowStatusCommand({ format: "text" }, deps);
|
|
345
|
+
expect(code).toBe(0);
|
|
346
|
+
expect(cap.read()).toContain("(no snapshot)");
|
|
347
|
+
} finally {
|
|
348
|
+
cap.restore();
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("text format: single-report render includes workflow, stages, fatal error, and updatedAt", async () => {
|
|
353
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
354
|
+
mocks.listSessions.mockReturnValue([
|
|
355
|
+
tmuxSession("atomic-wf-claude-ralph-abcd1234"),
|
|
356
|
+
]);
|
|
357
|
+
mocks.readSnapshot.mockResolvedValue(
|
|
358
|
+
snapshotOf(
|
|
359
|
+
"ralph",
|
|
360
|
+
"claude",
|
|
361
|
+
[
|
|
362
|
+
panelSession("orchestrator", "error", { error: "stage boom" }),
|
|
363
|
+
],
|
|
364
|
+
{ fatalError: "the whole thing" },
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
const cap = captureStdout();
|
|
368
|
+
try {
|
|
369
|
+
const code = await workflowStatusCommand(
|
|
370
|
+
{ format: "text", id: "atomic-wf-claude-ralph-abcd1234" },
|
|
371
|
+
deps,
|
|
372
|
+
);
|
|
373
|
+
expect(code).toBe(0);
|
|
374
|
+
const out = cap.read();
|
|
375
|
+
expect(out).toContain("atomic-wf-claude-ralph-abcd1234");
|
|
376
|
+
expect(out).toContain("workflow:");
|
|
377
|
+
expect(out).toContain("ralph");
|
|
378
|
+
expect(out).toContain("stages:");
|
|
379
|
+
expect(out).toContain("orchestrator");
|
|
380
|
+
expect(out).toContain("stage boom");
|
|
381
|
+
expect(out).toContain("the whole thing");
|
|
382
|
+
expect(out).toContain("updated:");
|
|
383
|
+
} finally {
|
|
384
|
+
cap.restore();
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("text format: unknown id writes 'not found' to stderr and returns 1", async () => {
|
|
389
|
+
const { deps } = makeDeps(tmpDir);
|
|
390
|
+
const errChunks: string[] = [];
|
|
391
|
+
const origErr = process.stderr.write;
|
|
392
|
+
process.stderr.write = ((c: string | Uint8Array) => {
|
|
393
|
+
errChunks.push(typeof c === "string" ? c : new TextDecoder().decode(c));
|
|
394
|
+
return true;
|
|
395
|
+
}) as typeof process.stderr.write;
|
|
396
|
+
try {
|
|
397
|
+
const code = await workflowStatusCommand(
|
|
398
|
+
{ format: "text", id: "atomic-wf-claude-ralph-deadbeef" },
|
|
399
|
+
deps,
|
|
400
|
+
);
|
|
401
|
+
expect(code).toBe(1);
|
|
402
|
+
expect(errChunks.join("")).toContain("not found");
|
|
403
|
+
} finally {
|
|
404
|
+
process.stderr.write = origErr;
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("reports zero workflows when tmux is not installed", async () => {
|
|
409
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
410
|
+
mocks.isTmuxInstalled.mockReturnValue(false);
|
|
411
|
+
const cap = captureStdout();
|
|
412
|
+
try {
|
|
413
|
+
const code = await workflowStatusCommand({ format: "json" }, deps);
|
|
414
|
+
expect(code).toBe(0);
|
|
415
|
+
const parsed = JSON.parse(cap.read());
|
|
416
|
+
expect(parsed).toEqual({ workflows: [] });
|
|
417
|
+
} finally {
|
|
418
|
+
cap.restore();
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("text format: tmux not installed prints the 'no sessions running' hint", async () => {
|
|
423
|
+
const { deps, mocks } = makeDeps(tmpDir);
|
|
424
|
+
mocks.isTmuxInstalled.mockReturnValue(false);
|
|
425
|
+
const cap = captureStdout();
|
|
426
|
+
try {
|
|
427
|
+
const code = await workflowStatusCommand({ format: "text" }, deps);
|
|
428
|
+
expect(code).toBe(0);
|
|
429
|
+
expect(cap.read()).toContain("tmux is not installed");
|
|
430
|
+
} finally {
|
|
431
|
+
cap.restore();
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("defaults format to 'json' when omitted", async () => {
|
|
436
|
+
const { deps } = makeDeps(tmpDir);
|
|
437
|
+
const cap = captureStdout();
|
|
438
|
+
try {
|
|
439
|
+
const code = await workflowStatusCommand({}, deps);
|
|
440
|
+
expect(code).toBe(0);
|
|
441
|
+
// JSON parses cleanly
|
|
442
|
+
JSON.parse(cap.read());
|
|
443
|
+
} finally {
|
|
444
|
+
cap.restore();
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
afterAll(async () => {
|
|
449
|
+
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
|
|
450
|
+
});
|
|
451
|
+
});
|