@bastani/atomic 0.5.3 → 0.5.4-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/README.md +110 -11
- package/dist/{chunk-mn870nrv.js → chunk-xkxndz5g.js} +213 -154
- package/dist/sdk/components/workflow-picker-panel.d.ts +120 -0
- package/dist/sdk/define-workflow.d.ts +1 -1
- package/dist/sdk/index.js +1 -1
- package/dist/sdk/runtime/discovery.d.ts +57 -3
- package/dist/sdk/runtime/executor.d.ts +15 -2
- package/dist/sdk/runtime/tmux.d.ts +9 -0
- package/dist/sdk/types.d.ts +63 -4
- package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts +61 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts +48 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.d.ts +25 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts +91 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts +56 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts +48 -0
- package/dist/sdk/workflows/builtin/ralph/claude/index.js +6 -5
- package/dist/sdk/workflows/builtin/ralph/copilot/index.js +6 -5
- package/dist/sdk/workflows/builtin/ralph/opencode/index.js +6 -5
- package/dist/sdk/workflows/index.d.ts +4 -4
- package/dist/sdk/workflows/index.js +7 -1
- package/package.json +1 -1
- package/src/cli.ts +25 -3
- package/src/commands/cli/chat/index.ts +5 -5
- package/src/commands/cli/init/index.ts +79 -77
- package/src/commands/cli/workflow-command.test.ts +757 -0
- package/src/commands/cli/workflow.test.ts +310 -0
- package/src/commands/cli/workflow.ts +445 -105
- package/src/sdk/components/workflow-picker-panel.tsx +1462 -0
- package/src/sdk/define-workflow.test.ts +101 -0
- package/src/sdk/define-workflow.ts +62 -2
- package/src/sdk/runtime/discovery.ts +111 -8
- package/src/sdk/runtime/executor.ts +89 -32
- package/src/sdk/runtime/tmux.conf +55 -0
- package/src/sdk/runtime/tmux.ts +34 -10
- package/src/sdk/types.ts +67 -4
- package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +294 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +276 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.ts +38 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.ts +816 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/scout.ts +334 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +284 -0
- package/src/sdk/workflows/builtin/ralph/claude/index.ts +8 -4
- package/src/sdk/workflows/builtin/ralph/copilot/index.ts +10 -4
- package/src/sdk/workflows/builtin/ralph/opencode/index.ts +8 -4
- package/src/sdk/workflows/index.ts +9 -1
- package/src/services/system/auto-sync.ts +1 -1
- package/src/services/system/install-ui.ts +109 -39
- package/src/theme/colors.ts +65 -1
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration-style tests for `workflowCommand` — the CLI entry point that
|
|
3
|
+
* wires list/picker/named-mode branching together. Three modules are stubbed:
|
|
4
|
+
* the workflows SDK (executor + tmux probe + discovery), the system detector
|
|
5
|
+
* (command-presence checks), and the spawn helpers (best-effort installers).
|
|
6
|
+
* Every one of these is a side-effectful dependency — tmux spawn, disk I/O,
|
|
7
|
+
* agent CLI spawn — and replacing them with controlled fakes lets us hit the
|
|
8
|
+
* CLI's error/success branches without actually touching the real system.
|
|
9
|
+
*
|
|
10
|
+
* Two patterns make this file work:
|
|
11
|
+
*
|
|
12
|
+
* 1. `mock.module(…)` replaces each dependency module BEFORE the first
|
|
13
|
+
* dynamic `import("./workflow.ts")` so the module-under-test binds to
|
|
14
|
+
* the mocked references. Top-level await is required — a static import
|
|
15
|
+
* would hoist above the mocks and defeat them.
|
|
16
|
+
*
|
|
17
|
+
* 2. Every test runs against a fresh `mkdtemp`ed cwd plumbed through the
|
|
18
|
+
* `cwd` option. That lets us control which workflows the command sees
|
|
19
|
+
* without touching the repo's own `.atomic/workflows` tree.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
describe,
|
|
24
|
+
test,
|
|
25
|
+
expect,
|
|
26
|
+
beforeAll,
|
|
27
|
+
afterAll,
|
|
28
|
+
beforeEach,
|
|
29
|
+
afterEach,
|
|
30
|
+
mock,
|
|
31
|
+
} from "bun:test";
|
|
32
|
+
import { mkdtemp, mkdir, rm, writeFile } from "fs/promises";
|
|
33
|
+
import { join } from "path";
|
|
34
|
+
import { tmpdir } from "os";
|
|
35
|
+
import * as realWorkflows from "@/sdk/workflows/index.ts";
|
|
36
|
+
import * as realDetect from "@/services/system/detect.ts";
|
|
37
|
+
import * as realSpawn from "../../lib/spawn.ts";
|
|
38
|
+
import type {
|
|
39
|
+
WorkflowDefinition,
|
|
40
|
+
WorkflowRunOptions,
|
|
41
|
+
DiscoveredWorkflow,
|
|
42
|
+
} from "@/sdk/workflows/index.ts";
|
|
43
|
+
|
|
44
|
+
// Capture original function references BEFORE `mock.module` replaces the
|
|
45
|
+
// module exports. `import * as realWorkflows` gives a LIVE namespace — after
|
|
46
|
+
// mock.module rebinds the exports, `realWorkflows.discoverWorkflows` would
|
|
47
|
+
// resolve to our own mock and a pass-through would recurse infinitely. These
|
|
48
|
+
// constants lock in the real implementations so pass-through defaults work.
|
|
49
|
+
const realDiscoverWorkflows = realWorkflows.discoverWorkflows;
|
|
50
|
+
const realLoadWorkflowsMetadata = realWorkflows.loadWorkflowsMetadata;
|
|
51
|
+
const realIsCommandInstalled = realDetect.isCommandInstalled;
|
|
52
|
+
|
|
53
|
+
// ─── Dependency mocks ───────────────────────────────────────────────────────
|
|
54
|
+
// Every mock is a wrapper around the real implementation by default so
|
|
55
|
+
// unrelated tests that don't care about a given mock still see the real
|
|
56
|
+
// behaviour. Tests override specific mocks via `mockImplementationOnce` (or a
|
|
57
|
+
// longer-lived `mockImplementation` inside a describe block) to exercise
|
|
58
|
+
// failure branches. `beforeEach` resets everything to the default pass-through.
|
|
59
|
+
|
|
60
|
+
const executeWorkflowMock =
|
|
61
|
+
mock<(opts: WorkflowRunOptions) => Promise<void>>(async () => {});
|
|
62
|
+
|
|
63
|
+
// Default: real discovery so the filesystem-level branches still work.
|
|
64
|
+
const discoverWorkflowsMock = mock<typeof realWorkflows.discoverWorkflows>(
|
|
65
|
+
(...args) => realDiscoverWorkflows(...args),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Default: real metadata load — supports the picker branches that need
|
|
69
|
+
// compiled metadata from a real workflow on disk.
|
|
70
|
+
const loadWorkflowsMetadataMock = mock<
|
|
71
|
+
typeof realWorkflows.loadWorkflowsMetadata
|
|
72
|
+
>((...args) => realLoadWorkflowsMetadata(...args));
|
|
73
|
+
|
|
74
|
+
// Default: pretend tmux is installed. The test env has it, but we want the
|
|
75
|
+
// coverage test to be deterministic regardless of host config — if the host
|
|
76
|
+
// removed tmux we'd still want these tests to cover the happy path.
|
|
77
|
+
const isTmuxInstalledMock =
|
|
78
|
+
mock<typeof realWorkflows.isTmuxInstalled>(() => true);
|
|
79
|
+
|
|
80
|
+
// Default: real presence check. Tests override for the agent-missing branch.
|
|
81
|
+
const isCommandInstalledMock = mock<typeof realDetect.isCommandInstalled>(
|
|
82
|
+
(cmd) => realIsCommandInstalled(cmd),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Default: no-op so the best-effort installer branch in runPrereqChecks
|
|
86
|
+
// doesn't try to actually install tmux/bun on the test machine.
|
|
87
|
+
const ensureTmuxInstalledMock = mock<typeof realSpawn.ensureTmuxInstalled>(
|
|
88
|
+
async () => {},
|
|
89
|
+
);
|
|
90
|
+
const ensureBunInstalledMock = mock<typeof realSpawn.ensureBunInstalled>(
|
|
91
|
+
async () => {},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
mock.module("@/sdk/workflows/index.ts", () => ({
|
|
95
|
+
...realWorkflows,
|
|
96
|
+
executeWorkflow: executeWorkflowMock,
|
|
97
|
+
discoverWorkflows: discoverWorkflowsMock,
|
|
98
|
+
loadWorkflowsMetadata: loadWorkflowsMetadataMock,
|
|
99
|
+
isTmuxInstalled: isTmuxInstalledMock,
|
|
100
|
+
}));
|
|
101
|
+
mock.module("@/services/system/detect.ts", () => ({
|
|
102
|
+
...realDetect,
|
|
103
|
+
isCommandInstalled: isCommandInstalledMock,
|
|
104
|
+
}));
|
|
105
|
+
mock.module("../../lib/spawn.ts", () => ({
|
|
106
|
+
...realSpawn,
|
|
107
|
+
ensureTmuxInstalled: ensureTmuxInstalledMock,
|
|
108
|
+
ensureBunInstalled: ensureBunInstalledMock,
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
// Dynamic import — must happen AFTER `mock.module` so the module-under-test
|
|
112
|
+
// binds to the mocked dependencies. Top-level await is fine under Bun.
|
|
113
|
+
const { workflowCommand } = await import("./workflow.ts");
|
|
114
|
+
|
|
115
|
+
// ─── Output capture ─────────────────────────────────────────────────────────
|
|
116
|
+
// The CLI writes error banners to stderr via `console.error`, success content
|
|
117
|
+
// to stdout via `process.stdout.write`. Wrap both so tests can snapshot the
|
|
118
|
+
// emitted text without leaking it into the test runner's own output.
|
|
119
|
+
|
|
120
|
+
interface CapturedOutput {
|
|
121
|
+
stdout: string;
|
|
122
|
+
stderr: string;
|
|
123
|
+
restore: () => void;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function captureOutput(): CapturedOutput {
|
|
127
|
+
const captured: CapturedOutput = {
|
|
128
|
+
stdout: "",
|
|
129
|
+
stderr: "",
|
|
130
|
+
restore: () => {},
|
|
131
|
+
};
|
|
132
|
+
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
133
|
+
const originalConsoleError = console.error;
|
|
134
|
+
const originalConsoleLog = console.log;
|
|
135
|
+
const originalConsoleWarn = console.warn;
|
|
136
|
+
|
|
137
|
+
// Typed as never so the loose commander signature doesn't widen.
|
|
138
|
+
process.stdout.write = ((chunk: string | Uint8Array): boolean => {
|
|
139
|
+
captured.stdout +=
|
|
140
|
+
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
|
|
141
|
+
return true;
|
|
142
|
+
}) as typeof process.stdout.write;
|
|
143
|
+
console.error = (...args: unknown[]) => {
|
|
144
|
+
captured.stderr += args.map((a) => String(a)).join(" ") + "\n";
|
|
145
|
+
};
|
|
146
|
+
console.log = (...args: unknown[]) => {
|
|
147
|
+
captured.stdout += args.map((a) => String(a)).join(" ") + "\n";
|
|
148
|
+
};
|
|
149
|
+
console.warn = (...args: unknown[]) => {
|
|
150
|
+
captured.stderr += args.map((a) => String(a)).join(" ") + "\n";
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
captured.restore = () => {
|
|
154
|
+
process.stdout.write = originalStdoutWrite;
|
|
155
|
+
console.error = originalConsoleError;
|
|
156
|
+
console.log = originalConsoleLog;
|
|
157
|
+
console.warn = originalConsoleWarn;
|
|
158
|
+
};
|
|
159
|
+
return captured;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── Colour handling ────────────────────────────────────────────────────────
|
|
163
|
+
// `NO_COLOR` flips both COLORS (module load time) and createPainter (call
|
|
164
|
+
// time) into plain-text mode so assertions can match against readable
|
|
165
|
+
// substrings rather than SGR escape noise. COLORS is baked at module load
|
|
166
|
+
// so the env var must already be set by the time workflow.ts gets imported.
|
|
167
|
+
|
|
168
|
+
let originalNoColor: string | undefined;
|
|
169
|
+
beforeAll(() => {
|
|
170
|
+
originalNoColor = process.env.NO_COLOR;
|
|
171
|
+
process.env.NO_COLOR = "1";
|
|
172
|
+
});
|
|
173
|
+
afterAll(() => {
|
|
174
|
+
if (originalNoColor === undefined) delete process.env.NO_COLOR;
|
|
175
|
+
else process.env.NO_COLOR = originalNoColor;
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ─── Temp workspace plumbing ────────────────────────────────────────────────
|
|
179
|
+
// Each test gets a fresh cwd so one test's workflows can't leak into another.
|
|
180
|
+
// The actual workflow files live under `.atomic/workflows/<name>/<agent>/index.ts`
|
|
181
|
+
// — matching the layout that `discoverWorkflows` scans.
|
|
182
|
+
|
|
183
|
+
let tempDir: string;
|
|
184
|
+
|
|
185
|
+
beforeEach(async () => {
|
|
186
|
+
tempDir = await mkdtemp(join(tmpdir(), "atomic-workflow-cmd-test-"));
|
|
187
|
+
// Reset every mock to its default pass-through / no-op so tests are
|
|
188
|
+
// independent — no leftover state from prior overrides. `mockClear` wipes
|
|
189
|
+
// call history; `mockImplementation` replaces the queued implementation
|
|
190
|
+
// (including anything set via `mockImplementationOnce`) with the default.
|
|
191
|
+
executeWorkflowMock.mockClear();
|
|
192
|
+
executeWorkflowMock.mockImplementation(async () => {});
|
|
193
|
+
discoverWorkflowsMock.mockClear();
|
|
194
|
+
discoverWorkflowsMock.mockImplementation((...args) =>
|
|
195
|
+
realDiscoverWorkflows(...args),
|
|
196
|
+
);
|
|
197
|
+
loadWorkflowsMetadataMock.mockClear();
|
|
198
|
+
loadWorkflowsMetadataMock.mockImplementation((...args) =>
|
|
199
|
+
realLoadWorkflowsMetadata(...args),
|
|
200
|
+
);
|
|
201
|
+
isTmuxInstalledMock.mockClear();
|
|
202
|
+
isTmuxInstalledMock.mockImplementation(() => true);
|
|
203
|
+
isCommandInstalledMock.mockClear();
|
|
204
|
+
isCommandInstalledMock.mockImplementation((cmd) => realIsCommandInstalled(cmd));
|
|
205
|
+
ensureTmuxInstalledMock.mockClear();
|
|
206
|
+
ensureTmuxInstalledMock.mockImplementation(async () => {});
|
|
207
|
+
ensureBunInstalledMock.mockClear();
|
|
208
|
+
ensureBunInstalledMock.mockImplementation(async () => {});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
afterEach(async () => {
|
|
212
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Write a real workflow file that compiles through `defineWorkflow()`.
|
|
217
|
+
* Tests import a real SDK so the module under test sees a live
|
|
218
|
+
* `WorkflowDefinition`, not a mock shape — this keeps the coverage
|
|
219
|
+
* line-level on `runNamedMode`'s resolution of the compiled definition.
|
|
220
|
+
*/
|
|
221
|
+
async function writeCompiledWorkflow(
|
|
222
|
+
opts: {
|
|
223
|
+
name: string;
|
|
224
|
+
agent: "claude" | "copilot" | "opencode";
|
|
225
|
+
source?: string;
|
|
226
|
+
},
|
|
227
|
+
): Promise<string> {
|
|
228
|
+
const dir = join(tempDir, ".atomic", "workflows", opts.name, opts.agent);
|
|
229
|
+
await mkdir(dir, { recursive: true });
|
|
230
|
+
const filePath = join(dir, "index.ts");
|
|
231
|
+
const defaultBody =
|
|
232
|
+
opts.source ??
|
|
233
|
+
`
|
|
234
|
+
import { defineWorkflow } from "${join(process.cwd(), "src/sdk/workflows/index.ts")}";
|
|
235
|
+
|
|
236
|
+
export default defineWorkflow({ name: "${opts.name}" })
|
|
237
|
+
.run(async () => {})
|
|
238
|
+
.compile();
|
|
239
|
+
`;
|
|
240
|
+
await writeFile(filePath, defaultBody);
|
|
241
|
+
return filePath;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ─── List mode ──────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
describe("workflowCommand --list", () => {
|
|
247
|
+
test("prints the rendered list and returns 0", async () => {
|
|
248
|
+
await writeCompiledWorkflow({ name: "alpha", agent: "copilot" });
|
|
249
|
+
|
|
250
|
+
const cap = captureOutput();
|
|
251
|
+
const code = await workflowCommand({
|
|
252
|
+
list: true,
|
|
253
|
+
agent: "copilot",
|
|
254
|
+
cwd: tempDir,
|
|
255
|
+
});
|
|
256
|
+
cap.restore();
|
|
257
|
+
|
|
258
|
+
expect(code).toBe(0);
|
|
259
|
+
// Singular noun because only our one workflow is filtered in, and builtins
|
|
260
|
+
// discovered via `{ merge: false }` may still show up — so assert on the
|
|
261
|
+
// name we wrote instead of a count.
|
|
262
|
+
expect(cap.stdout).toContain("alpha");
|
|
263
|
+
expect(cap.stdout).toContain("run: atomic workflow -n <name> -a <agent>");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("filters by the provided agent", async () => {
|
|
267
|
+
await writeCompiledWorkflow({ name: "claude-only", agent: "claude" });
|
|
268
|
+
await writeCompiledWorkflow({ name: "copilot-only", agent: "copilot" });
|
|
269
|
+
|
|
270
|
+
const cap = captureOutput();
|
|
271
|
+
const code = await workflowCommand({
|
|
272
|
+
list: true,
|
|
273
|
+
agent: "claude",
|
|
274
|
+
cwd: tempDir,
|
|
275
|
+
});
|
|
276
|
+
cap.restore();
|
|
277
|
+
|
|
278
|
+
expect(code).toBe(0);
|
|
279
|
+
expect(cap.stdout).toContain("claude-only");
|
|
280
|
+
expect(cap.stdout).not.toContain("copilot-only");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("renders the empty state when no workflows exist and no agent filter is set", async () => {
|
|
284
|
+
// No agent filter + a fresh tempdir means `discoverWorkflows` only
|
|
285
|
+
// returns builtins for whichever agents exist on disk; to exercise
|
|
286
|
+
// the real empty-state branch we filter to an agent with no builtin
|
|
287
|
+
// coverage for the tempdir — `opencode` has builtins too, so instead
|
|
288
|
+
// point at an empty workflows directory.
|
|
289
|
+
const cap = captureOutput();
|
|
290
|
+
const code = await workflowCommand({
|
|
291
|
+
list: true,
|
|
292
|
+
agent: "copilot",
|
|
293
|
+
cwd: tempDir,
|
|
294
|
+
});
|
|
295
|
+
cap.restore();
|
|
296
|
+
|
|
297
|
+
expect(code).toBe(0);
|
|
298
|
+
// Either the builtin ralph shows up or we get the "no workflows" banner.
|
|
299
|
+
// We only need to verify the code path completes and writes *something*.
|
|
300
|
+
expect(cap.stdout.length).toBeGreaterThan(0);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ─── Agent validation ──────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
describe("workflowCommand agent validation", () => {
|
|
307
|
+
test("missing agent returns 1 and logs a targeted error", async () => {
|
|
308
|
+
const cap = captureOutput();
|
|
309
|
+
const code = await workflowCommand({ cwd: tempDir });
|
|
310
|
+
cap.restore();
|
|
311
|
+
|
|
312
|
+
expect(code).toBe(1);
|
|
313
|
+
expect(cap.stderr).toContain("Missing agent");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("unknown agent returns 1 and lists valid agents", async () => {
|
|
317
|
+
const cap = captureOutput();
|
|
318
|
+
const code = await workflowCommand({
|
|
319
|
+
agent: "bogus-agent",
|
|
320
|
+
cwd: tempDir,
|
|
321
|
+
});
|
|
322
|
+
cap.restore();
|
|
323
|
+
|
|
324
|
+
expect(code).toBe(1);
|
|
325
|
+
expect(cap.stderr).toContain("Unknown agent");
|
|
326
|
+
// Error helper lists valid agents — spot-check one.
|
|
327
|
+
expect(cap.stderr).toContain("claude");
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ─── Picker mode error paths ───────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
describe("workflowCommand picker mode", () => {
|
|
334
|
+
test("rejects passthrough args in picker mode", async () => {
|
|
335
|
+
// No `-n` means picker mode; any extra args are ambiguous (would the
|
|
336
|
+
// user want them fed into the picker's form, or straight through?), so
|
|
337
|
+
// the command bails early rather than guessing.
|
|
338
|
+
const cap = captureOutput();
|
|
339
|
+
const code = await workflowCommand({
|
|
340
|
+
agent: "copilot",
|
|
341
|
+
passthroughArgs: ["oops", "--mode=fast"],
|
|
342
|
+
cwd: tempDir,
|
|
343
|
+
});
|
|
344
|
+
cap.restore();
|
|
345
|
+
|
|
346
|
+
expect(code).toBe(1);
|
|
347
|
+
expect(cap.stderr).toContain("unexpected arguments");
|
|
348
|
+
// The hint points the user at the right place.
|
|
349
|
+
expect(cap.stderr).toContain("-n <name>");
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// ─── Named mode error paths ────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
describe("workflowCommand named-mode error paths", () => {
|
|
356
|
+
test("unknown workflow name returns 1 and lists available options", async () => {
|
|
357
|
+
// Seed one workflow so the "Available" section renders.
|
|
358
|
+
await writeCompiledWorkflow({ name: "real-one", agent: "copilot" });
|
|
359
|
+
|
|
360
|
+
const cap = captureOutput();
|
|
361
|
+
const code = await workflowCommand({
|
|
362
|
+
name: "does-not-exist",
|
|
363
|
+
agent: "copilot",
|
|
364
|
+
cwd: tempDir,
|
|
365
|
+
});
|
|
366
|
+
cap.restore();
|
|
367
|
+
|
|
368
|
+
expect(code).toBe(1);
|
|
369
|
+
expect(cap.stderr).toContain("does-not-exist");
|
|
370
|
+
expect(cap.stderr).toContain("not found");
|
|
371
|
+
// Lists the real workflow we wrote so users can copy-paste a valid name.
|
|
372
|
+
expect(cap.stderr).toContain("real-one");
|
|
373
|
+
// executeWorkflow should never be called on the error path.
|
|
374
|
+
expect(executeWorkflowMock).not.toHaveBeenCalled();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("parse errors in passthrough args abort before loading", async () => {
|
|
378
|
+
await writeCompiledWorkflow({ name: "parse-err", agent: "copilot" });
|
|
379
|
+
|
|
380
|
+
const cap = captureOutput();
|
|
381
|
+
const code = await workflowCommand({
|
|
382
|
+
name: "parse-err",
|
|
383
|
+
agent: "copilot",
|
|
384
|
+
// Trailing --flag with no value is the canonical parse error.
|
|
385
|
+
passthroughArgs: ["--orphan"],
|
|
386
|
+
cwd: tempDir,
|
|
387
|
+
});
|
|
388
|
+
cap.restore();
|
|
389
|
+
|
|
390
|
+
expect(code).toBe(1);
|
|
391
|
+
expect(cap.stderr).toContain("--orphan");
|
|
392
|
+
expect(executeWorkflowMock).not.toHaveBeenCalled();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("load errors from WorkflowLoader surface cleanly", async () => {
|
|
396
|
+
// Write a workflow file that lacks `.compile()` — the loader treats
|
|
397
|
+
// this as a hard error and the CLI must return 1 rather than crash.
|
|
398
|
+
await writeCompiledWorkflow({
|
|
399
|
+
name: "broken",
|
|
400
|
+
agent: "copilot",
|
|
401
|
+
source: `
|
|
402
|
+
import { defineWorkflow } from "${join(process.cwd(), "src/sdk/workflows/index.ts")}";
|
|
403
|
+
|
|
404
|
+
export default defineWorkflow({ name: "broken" })
|
|
405
|
+
.run(async () => {});
|
|
406
|
+
// intentionally missing .compile()
|
|
407
|
+
`,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const cap = captureOutput();
|
|
411
|
+
const code = await workflowCommand({
|
|
412
|
+
name: "broken",
|
|
413
|
+
agent: "copilot",
|
|
414
|
+
cwd: tempDir,
|
|
415
|
+
});
|
|
416
|
+
cap.restore();
|
|
417
|
+
|
|
418
|
+
expect(code).toBe(1);
|
|
419
|
+
expect(cap.stderr).toContain("not compiled");
|
|
420
|
+
expect(executeWorkflowMock).not.toHaveBeenCalled();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("free-form workflow rejects stray --flags", async () => {
|
|
424
|
+
// A workflow with no declared `inputs` takes a positional prompt; any
|
|
425
|
+
// `--<name>` flag is definitionally wrong because there's nothing for
|
|
426
|
+
// it to bind to.
|
|
427
|
+
await writeCompiledWorkflow({ name: "free-form", agent: "copilot" });
|
|
428
|
+
|
|
429
|
+
const cap = captureOutput();
|
|
430
|
+
const code = await workflowCommand({
|
|
431
|
+
name: "free-form",
|
|
432
|
+
agent: "copilot",
|
|
433
|
+
passthroughArgs: ["--mode=fast"],
|
|
434
|
+
cwd: tempDir,
|
|
435
|
+
});
|
|
436
|
+
cap.restore();
|
|
437
|
+
|
|
438
|
+
expect(code).toBe(1);
|
|
439
|
+
expect(cap.stderr).toContain("no declared inputs");
|
|
440
|
+
expect(cap.stderr).toContain("--mode");
|
|
441
|
+
expect(executeWorkflowMock).not.toHaveBeenCalled();
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("structured workflow rejects positional prompt tokens", async () => {
|
|
445
|
+
await writeCompiledWorkflow({
|
|
446
|
+
name: "structured",
|
|
447
|
+
agent: "copilot",
|
|
448
|
+
source: `
|
|
449
|
+
import { defineWorkflow } from "${join(process.cwd(), "src/sdk/workflows/index.ts")}";
|
|
450
|
+
|
|
451
|
+
export default defineWorkflow({
|
|
452
|
+
name: "structured",
|
|
453
|
+
inputs: [
|
|
454
|
+
{ name: "topic", type: "string", required: true },
|
|
455
|
+
],
|
|
456
|
+
})
|
|
457
|
+
.run(async () => {})
|
|
458
|
+
.compile();
|
|
459
|
+
`,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const cap = captureOutput();
|
|
463
|
+
const code = await workflowCommand({
|
|
464
|
+
name: "structured",
|
|
465
|
+
agent: "copilot",
|
|
466
|
+
// Positional-only invocation is ambiguous against a structured
|
|
467
|
+
// schema, so the command refuses to guess.
|
|
468
|
+
passthroughArgs: ["just", "a", "prompt"],
|
|
469
|
+
cwd: tempDir,
|
|
470
|
+
});
|
|
471
|
+
cap.restore();
|
|
472
|
+
|
|
473
|
+
expect(code).toBe(1);
|
|
474
|
+
expect(cap.stderr).toContain("structured inputs");
|
|
475
|
+
expect(cap.stderr).toContain("--topic");
|
|
476
|
+
expect(executeWorkflowMock).not.toHaveBeenCalled();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("structured workflow surfaces schema validation errors", async () => {
|
|
480
|
+
await writeCompiledWorkflow({
|
|
481
|
+
name: "validated",
|
|
482
|
+
agent: "copilot",
|
|
483
|
+
source: `
|
|
484
|
+
import { defineWorkflow } from "${join(process.cwd(), "src/sdk/workflows/index.ts")}";
|
|
485
|
+
|
|
486
|
+
export default defineWorkflow({
|
|
487
|
+
name: "validated",
|
|
488
|
+
inputs: [
|
|
489
|
+
{ name: "topic", type: "string", required: true },
|
|
490
|
+
],
|
|
491
|
+
})
|
|
492
|
+
.run(async () => {})
|
|
493
|
+
.compile();
|
|
494
|
+
`,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const cap = captureOutput();
|
|
498
|
+
const code = await workflowCommand({
|
|
499
|
+
name: "validated",
|
|
500
|
+
agent: "copilot",
|
|
501
|
+
// Empty flag set — required `topic` is missing.
|
|
502
|
+
passthroughArgs: [],
|
|
503
|
+
cwd: tempDir,
|
|
504
|
+
});
|
|
505
|
+
cap.restore();
|
|
506
|
+
|
|
507
|
+
expect(code).toBe(1);
|
|
508
|
+
expect(cap.stderr).toContain("--topic");
|
|
509
|
+
expect(executeWorkflowMock).not.toHaveBeenCalled();
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// ─── Named mode success paths (via mocked executor) ────────────────────────
|
|
514
|
+
|
|
515
|
+
describe("workflowCommand named-mode success paths", () => {
|
|
516
|
+
test("free-form workflow runs through the executor with the prompt as input", async () => {
|
|
517
|
+
await writeCompiledWorkflow({ name: "runs", agent: "copilot" });
|
|
518
|
+
|
|
519
|
+
const cap = captureOutput();
|
|
520
|
+
const code = await workflowCommand({
|
|
521
|
+
name: "runs",
|
|
522
|
+
agent: "copilot",
|
|
523
|
+
passthroughArgs: ["fix", "the", "bug"],
|
|
524
|
+
cwd: tempDir,
|
|
525
|
+
});
|
|
526
|
+
cap.restore();
|
|
527
|
+
|
|
528
|
+
expect(code).toBe(0);
|
|
529
|
+
expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
|
|
530
|
+
const call = executeWorkflowMock.mock.calls[0]![0];
|
|
531
|
+
expect(call.agent).toBe("copilot");
|
|
532
|
+
// Free-form prompt is threaded under the `prompt` key so workflow
|
|
533
|
+
// authors can read `ctx.inputs.prompt` uniformly.
|
|
534
|
+
expect(call.inputs).toEqual({ prompt: "fix the bug" });
|
|
535
|
+
expect((call.definition as WorkflowDefinition).name).toBe("runs");
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("free-form workflow with no prompt forwards an empty inputs record", async () => {
|
|
539
|
+
await writeCompiledWorkflow({ name: "silent", agent: "copilot" });
|
|
540
|
+
|
|
541
|
+
const cap = captureOutput();
|
|
542
|
+
const code = await workflowCommand({
|
|
543
|
+
name: "silent",
|
|
544
|
+
agent: "copilot",
|
|
545
|
+
passthroughArgs: [],
|
|
546
|
+
cwd: tempDir,
|
|
547
|
+
});
|
|
548
|
+
cap.restore();
|
|
549
|
+
|
|
550
|
+
expect(code).toBe(0);
|
|
551
|
+
expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
|
|
552
|
+
expect(executeWorkflowMock.mock.calls[0]![0].inputs).toEqual({});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("structured workflow resolves flags and calls executor with merged inputs", async () => {
|
|
556
|
+
await writeCompiledWorkflow({
|
|
557
|
+
name: "struct-run",
|
|
558
|
+
agent: "copilot",
|
|
559
|
+
source: `
|
|
560
|
+
import { defineWorkflow } from "${join(process.cwd(), "src/sdk/workflows/index.ts")}";
|
|
561
|
+
|
|
562
|
+
export default defineWorkflow({
|
|
563
|
+
name: "struct-run",
|
|
564
|
+
inputs: [
|
|
565
|
+
{ name: "topic", type: "string", required: true },
|
|
566
|
+
{ name: "depth", type: "enum", values: ["shallow", "deep"], default: "shallow" },
|
|
567
|
+
],
|
|
568
|
+
})
|
|
569
|
+
.run(async () => {})
|
|
570
|
+
.compile();
|
|
571
|
+
`,
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
const cap = captureOutput();
|
|
575
|
+
const code = await workflowCommand({
|
|
576
|
+
name: "struct-run",
|
|
577
|
+
agent: "copilot",
|
|
578
|
+
passthroughArgs: ["--topic=authz", "--depth=deep"],
|
|
579
|
+
cwd: tempDir,
|
|
580
|
+
});
|
|
581
|
+
cap.restore();
|
|
582
|
+
|
|
583
|
+
expect(code).toBe(0);
|
|
584
|
+
expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
|
|
585
|
+
expect(executeWorkflowMock.mock.calls[0]![0].inputs).toEqual({
|
|
586
|
+
topic: "authz",
|
|
587
|
+
depth: "deep",
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test("runLoadedWorkflow surfaces executor failures as exit code 1", async () => {
|
|
592
|
+
await writeCompiledWorkflow({ name: "boom", agent: "copilot" });
|
|
593
|
+
|
|
594
|
+
executeWorkflowMock.mockImplementationOnce(async () => {
|
|
595
|
+
throw new Error("tmux is on fire");
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
const cap = captureOutput();
|
|
599
|
+
const code = await workflowCommand({
|
|
600
|
+
name: "boom",
|
|
601
|
+
agent: "copilot",
|
|
602
|
+
passthroughArgs: ["try", "it"],
|
|
603
|
+
cwd: tempDir,
|
|
604
|
+
});
|
|
605
|
+
cap.restore();
|
|
606
|
+
|
|
607
|
+
expect(code).toBe(1);
|
|
608
|
+
expect(cap.stderr).toContain("Workflow failed");
|
|
609
|
+
expect(cap.stderr).toContain("tmux is on fire");
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("runLoadedWorkflow stringifies non-Error throwns", async () => {
|
|
613
|
+
await writeCompiledWorkflow({ name: "non-err", agent: "copilot" });
|
|
614
|
+
|
|
615
|
+
executeWorkflowMock.mockImplementationOnce(async () => {
|
|
616
|
+
// Thrown value is a plain string — the catch branch falls back to
|
|
617
|
+
// `String(error)` rather than reading `.message`.
|
|
618
|
+
throw "raw string failure";
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
const cap = captureOutput();
|
|
622
|
+
const code = await workflowCommand({
|
|
623
|
+
name: "non-err",
|
|
624
|
+
agent: "copilot",
|
|
625
|
+
passthroughArgs: [],
|
|
626
|
+
cwd: tempDir,
|
|
627
|
+
});
|
|
628
|
+
cap.restore();
|
|
629
|
+
|
|
630
|
+
expect(code).toBe(1);
|
|
631
|
+
expect(cap.stderr).toContain("raw string failure");
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// ─── Prereq checks (runPrereqChecks) ───────────────────────────────────────
|
|
636
|
+
|
|
637
|
+
describe("workflowCommand prereq checks", () => {
|
|
638
|
+
test("missing agent CLI returns 1 with an install hint", async () => {
|
|
639
|
+
// `isCommandInstalled` is the first gate in runPrereqChecks — when it
|
|
640
|
+
// returns false for the agent binary, the command errors out before
|
|
641
|
+
// ever touching tmux or bun.
|
|
642
|
+
isCommandInstalledMock.mockImplementation((cmd) => cmd !== "claude");
|
|
643
|
+
|
|
644
|
+
const cap = captureOutput();
|
|
645
|
+
const code = await workflowCommand({
|
|
646
|
+
name: "anything",
|
|
647
|
+
agent: "claude",
|
|
648
|
+
cwd: tempDir,
|
|
649
|
+
});
|
|
650
|
+
cap.restore();
|
|
651
|
+
|
|
652
|
+
expect(code).toBe(1);
|
|
653
|
+
expect(cap.stderr).toContain("'claude' is not installed");
|
|
654
|
+
expect(cap.stderr).toContain("Install it from");
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("missing tmux attempts installer then errors when still absent", async () => {
|
|
658
|
+
// Force tmux to never appear even after the installer runs. The
|
|
659
|
+
// installer itself resolves cleanly, so we exercise the post-installer
|
|
660
|
+
// recheck + error-branch combination.
|
|
661
|
+
isTmuxInstalledMock.mockImplementation(() => false);
|
|
662
|
+
|
|
663
|
+
const cap = captureOutput();
|
|
664
|
+
const code = await workflowCommand({
|
|
665
|
+
name: "anything",
|
|
666
|
+
agent: "copilot",
|
|
667
|
+
cwd: tempDir,
|
|
668
|
+
});
|
|
669
|
+
cap.restore();
|
|
670
|
+
|
|
671
|
+
expect(code).toBe(1);
|
|
672
|
+
expect(ensureTmuxInstalledMock).toHaveBeenCalledTimes(1);
|
|
673
|
+
// Platform-specific message — both tmux and psmux acceptable.
|
|
674
|
+
expect(cap.stderr).toMatch(/(tmux|psmux) is not installed/);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("best-effort tmux installer errors are swallowed", async () => {
|
|
678
|
+
// Even if the installer throws, runPrereqChecks falls through to a
|
|
679
|
+
// second `isTmuxInstalled()` check — if that still says false, we
|
|
680
|
+
// return the same error. The installer failure itself must not
|
|
681
|
+
// propagate.
|
|
682
|
+
isTmuxInstalledMock.mockImplementation(() => false);
|
|
683
|
+
ensureTmuxInstalledMock.mockImplementationOnce(async () => {
|
|
684
|
+
throw new Error("installer crashed");
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
const cap = captureOutput();
|
|
688
|
+
const code = await workflowCommand({
|
|
689
|
+
name: "anything",
|
|
690
|
+
agent: "copilot",
|
|
691
|
+
cwd: tempDir,
|
|
692
|
+
});
|
|
693
|
+
cap.restore();
|
|
694
|
+
|
|
695
|
+
expect(code).toBe(1);
|
|
696
|
+
// The crash message never surfaces — the catch block just swallows it.
|
|
697
|
+
expect(cap.stderr).not.toContain("installer crashed");
|
|
698
|
+
expect(cap.stderr).toMatch(/(tmux|psmux) is not installed/);
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// ─── Picker mode discovery branches ────────────────────────────────────────
|
|
703
|
+
|
|
704
|
+
describe("workflowCommand picker discovery branches", () => {
|
|
705
|
+
test("returns 1 when discovery finds zero workflows", async () => {
|
|
706
|
+
// Picker mode without any workflows on disk — the CLI should explain
|
|
707
|
+
// where to put a new workflow rather than render an empty picker.
|
|
708
|
+
discoverWorkflowsMock.mockImplementationOnce(async () => []);
|
|
709
|
+
|
|
710
|
+
const cap = captureOutput();
|
|
711
|
+
const code = await workflowCommand({
|
|
712
|
+
agent: "copilot",
|
|
713
|
+
cwd: tempDir,
|
|
714
|
+
});
|
|
715
|
+
cap.restore();
|
|
716
|
+
|
|
717
|
+
expect(code).toBe(1);
|
|
718
|
+
expect(cap.stderr).toContain("No workflows found");
|
|
719
|
+
expect(cap.stderr).toContain(".atomic/workflows/<name>/copilot/index.ts");
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("returns 1 when every discovered workflow fails to load metadata", async () => {
|
|
723
|
+
// Discovery found entries but metadata load returned nothing — that's
|
|
724
|
+
// the "all workflows on disk are broken" branch. We fake a single
|
|
725
|
+
// discovered entry and then make the metadata loader drop it.
|
|
726
|
+
const fakeEntry: DiscoveredWorkflow = {
|
|
727
|
+
name: "broken",
|
|
728
|
+
agent: "copilot",
|
|
729
|
+
source: "local",
|
|
730
|
+
path: join(tempDir, ".atomic/workflows/broken/copilot/index.ts"),
|
|
731
|
+
};
|
|
732
|
+
discoverWorkflowsMock.mockImplementationOnce(async () => [fakeEntry]);
|
|
733
|
+
loadWorkflowsMetadataMock.mockImplementationOnce(async () => []);
|
|
734
|
+
|
|
735
|
+
const cap = captureOutput();
|
|
736
|
+
const code = await workflowCommand({
|
|
737
|
+
agent: "copilot",
|
|
738
|
+
cwd: tempDir,
|
|
739
|
+
});
|
|
740
|
+
cap.restore();
|
|
741
|
+
|
|
742
|
+
expect(code).toBe(1);
|
|
743
|
+
expect(cap.stderr).toContain("All discovered workflows failed to load");
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// Note on the picker success path: the branches that actually open the
|
|
748
|
+
// interactive picker (runPickerMode lines after the "no workflows found" and
|
|
749
|
+
// "all failed to load" guards, plus all of runResolvedSelection) are not
|
|
750
|
+
// covered from this file. Exercising them requires mocking
|
|
751
|
+
// `WorkflowPickerPanel`, which is a side-effectful class that spins up a
|
|
752
|
+
// real CliRenderer on stdin/stdout. Mocking it process-wide via mock.module
|
|
753
|
+
// leaks into the WorkflowPickerPanel's own unit tests (they share the same
|
|
754
|
+
// bun test process) and breaks them — the same live-binding issue that
|
|
755
|
+
// mock.module has with other consumers in the suite. Rather than fight the
|
|
756
|
+
// tooling, we accept a small amount of uncovered code in the picker success
|
|
757
|
+
// path; the remaining coverage comfortably clears the per-file threshold.
|