@intentius/chant 0.1.6 → 0.1.8

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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/src/cli/commands/build.test.ts +58 -5
  3. package/src/cli/commands/build.ts +24 -3
  4. package/src/cli/handlers/graph.test.ts +91 -0
  5. package/src/cli/handlers/graph.ts +23 -0
  6. package/src/cli/handlers/run-client.ts +134 -0
  7. package/src/cli/handlers/run-report.ts +160 -0
  8. package/src/cli/handlers/run.test.ts +448 -0
  9. package/src/cli/handlers/run.ts +453 -0
  10. package/src/cli/handlers/state.test.ts +409 -0
  11. package/src/cli/handlers/state.ts +232 -10
  12. package/src/cli/main.test.ts +65 -0
  13. package/src/cli/main.ts +32 -18
  14. package/src/cli/mcp/op-tools.ts +204 -0
  15. package/src/cli/mcp/resource-handlers.ts +69 -50
  16. package/src/cli/mcp/resources/context.ts +27 -0
  17. package/src/cli/mcp/server.test.ts +176 -3
  18. package/src/cli/mcp/server.ts +7 -3
  19. package/src/cli/mcp/state-tools.ts +0 -51
  20. package/src/cli/mcp/tools/search.ts +6 -1
  21. package/src/cli/registry.ts +3 -0
  22. package/src/composite.ts +10 -5
  23. package/src/index.ts +1 -2
  24. package/src/lexicon-plugin-helpers.ts +13 -5
  25. package/src/lexicon.ts +57 -1
  26. package/src/lint/config.test.ts +21 -0
  27. package/src/lint/config.ts +19 -3
  28. package/src/op/discover.test.ts +43 -0
  29. package/src/op/discover.ts +89 -0
  30. package/src/op/index.ts +3 -1
  31. package/src/op/types.ts +13 -6
  32. package/src/state/digest.test.ts +117 -0
  33. package/src/state/git.test.ts +191 -0
  34. package/src/state/git.ts +63 -11
  35. package/src/state/live-diff.test.ts +184 -0
  36. package/src/state/live-diff.ts +215 -0
  37. package/src/state/snapshot.test.ts +171 -0
  38. package/src/state/snapshot.ts +39 -19
  39. package/src/state/types.ts +4 -2
  40. package/src/cli/handlers/spell.ts +0 -396
  41. package/src/spell/discovery.ts +0 -183
  42. package/src/spell/index.ts +0 -3
  43. package/src/spell/prompt.ts +0 -133
  44. package/src/spell/types.ts +0 -89
@@ -0,0 +1,448 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+ import { createMockTemporalClient } from "@intentius/chant-test-utils";
3
+ import type { ParsedArgs } from "../registry";
4
+ import { EventEmitter } from "node:events";
5
+
6
+ const discoverOpsMock = vi.fn();
7
+ const loadChantConfigMock = vi.fn();
8
+ const loadTemporalClientMock = vi.fn();
9
+ const resolveProfileMock = vi.fn();
10
+ const existsSyncMock = vi.fn();
11
+ const spawnChildMock = vi.fn();
12
+ const generateReportMock = vi.fn();
13
+ const writeReportMock = vi.fn();
14
+ const waitForTemporalSpy = vi.fn();
15
+
16
+ vi.mock("../../op/discover", () => ({ discoverOps: () => discoverOpsMock() }));
17
+ vi.mock("../../config", () => ({ loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args) }));
18
+ vi.mock("./run-client", () => ({
19
+ loadTemporalClient: () => loadTemporalClientMock(),
20
+ connectionOptions: (profile: { address: string }) => ({ address: profile.address }),
21
+ resolveProfile: (...args: unknown[]) => resolveProfileMock(...args),
22
+ resolveWorkflowId: (name: string) => `chant-op-${name}`,
23
+ }));
24
+ vi.mock("node:fs", async () => {
25
+ const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
26
+ return { ...actual, existsSync: (p: string) => existsSyncMock(p) };
27
+ });
28
+ vi.mock("node:child_process", async () => {
29
+ const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
30
+ return { ...actual, spawn: (...args: unknown[]) => spawnChildMock(...args) };
31
+ });
32
+ vi.mock("./run-report", () => ({
33
+ generateReport: (...args: unknown[]) => generateReportMock(...args),
34
+ writeReport: (...args: unknown[]) => writeReportMock(...args),
35
+ }));
36
+
37
+ // Speed up runOp polling — POLL_INTERVAL_MS is 3000 in production. We use
38
+ // fake timers in the runOp suite below; vi.advanceTimersByTime drives the loop.
39
+
40
+ const { runOpList, runOpStatus, runOpLog, runOpSignal, runOpCancel, runOp } = await import("./run");
41
+
42
+ function makeArgs(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
43
+ return {
44
+ command: "run", path: ".",
45
+ format: "", fix: false, watch: false, verbose: false, help: false, live: false,
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ function makeOp(name: string, depends: string[] = []): [string, { config: { name: string; phases: unknown[]; taskQueue?: string; depends?: string[]; overview: string } }] {
51
+ return [name, { config: { name, phases: [], depends, overview: `${name} overview` } }];
52
+ }
53
+
54
+ function setupTemporalClient(mock: ReturnType<typeof createMockTemporalClient>) {
55
+ loadTemporalClientMock.mockResolvedValue({
56
+ Connection: { connect: vi.fn(async () => ({})) },
57
+ Client: vi.fn(() => mock.client) as unknown as new () => unknown,
58
+ });
59
+ loadChantConfigMock.mockResolvedValue({ config: {} });
60
+ resolveProfileMock.mockReturnValue({ address: "localhost:7233", namespace: "default", taskQueue: "q" });
61
+ }
62
+
63
+ function makeStdoutSpy() {
64
+ const buf: string[] = [];
65
+ vi.spyOn(console, "log").mockImplementation((s: string) => { buf.push(s); });
66
+ return buf;
67
+ }
68
+
69
+ function makeStderrSpy() {
70
+ const buf: string[] = [];
71
+ vi.spyOn(console, "error").mockImplementation((s: string) => { buf.push(s); });
72
+ return buf;
73
+ }
74
+
75
+ describe("runOpList", () => {
76
+ beforeEach(() => {
77
+ discoverOpsMock.mockReset();
78
+ loadTemporalClientMock.mockReset();
79
+ loadChantConfigMock.mockReset();
80
+ resolveProfileMock.mockReset();
81
+ });
82
+
83
+ test("warns when no Ops discovered, returns 0", async () => {
84
+ discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
85
+ const stderr = makeStderrSpy();
86
+ const exit = await runOpList({ args: makeArgs(), plugins: [], serializers: [] });
87
+ expect(exit).toBe(0);
88
+ expect(stderr.join("\n")).toContain("No Op definitions found");
89
+ });
90
+
91
+ test("prints table with one row per Op when Temporal connection fails", async () => {
92
+ discoverOpsMock.mockResolvedValue({
93
+ ops: new Map([makeOp("alb-deploy"), makeOp("infra")]),
94
+ errors: [],
95
+ });
96
+ // No Temporal — make loadTemporalClient throw so degraded path is exercised
97
+ loadTemporalClientMock.mockRejectedValue(new Error("not installed"));
98
+ const stdout = makeStdoutSpy();
99
+ const exit = await runOpList({ args: makeArgs(), plugins: [], serializers: [] });
100
+ expect(exit).toBe(0);
101
+ const out = stdout.join("\n");
102
+ expect(out).toContain("NAME");
103
+ expect(out).toContain("alb-deploy");
104
+ expect(out).toContain("infra");
105
+ });
106
+
107
+ test("annotates Ops with Temporal status when client is available", async () => {
108
+ discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("alb-deploy")]), errors: [] });
109
+ setupTemporalClient(createMockTemporalClient({
110
+ describeByWorkflowId: {
111
+ "chant-op-alb-deploy": {
112
+ workflowId: "chant-op-alb-deploy", runId: "r1",
113
+ status: { name: "RUNNING" }, startTime: new Date(),
114
+ taskQueue: "alb-deploy", type: { name: "albDeployWorkflow" },
115
+ },
116
+ },
117
+ }));
118
+ const stdout = makeStdoutSpy();
119
+ const exit = await runOpList({ args: makeArgs(), plugins: [], serializers: [] });
120
+ expect(exit).toBe(0);
121
+ expect(stdout.join("\n")).toContain("RUNNING");
122
+ });
123
+ });
124
+
125
+ describe("runOpStatus", () => {
126
+ beforeEach(() => {
127
+ discoverOpsMock.mockReset();
128
+ loadTemporalClientMock.mockReset();
129
+ loadChantConfigMock.mockReset();
130
+ resolveProfileMock.mockReset();
131
+ });
132
+
133
+ test("missing op name → exit 1", async () => {
134
+ const stderr = makeStderrSpy();
135
+ const exit = await runOpStatus({ args: makeArgs({ extraPositional: undefined }), plugins: [], serializers: [] });
136
+ expect(exit).toBe(1);
137
+ expect(stderr.join("\n")).toContain("Op name is required");
138
+ });
139
+
140
+ test("connection error → exit 1 with message", async () => {
141
+ loadTemporalClientMock.mockRejectedValue(new Error("UNAVAILABLE"));
142
+ loadChantConfigMock.mockResolvedValue({ config: {} });
143
+ resolveProfileMock.mockReturnValue({ address: "localhost:7233", namespace: "default", taskQueue: "q" });
144
+ const stderr = makeStderrSpy();
145
+ const exit = await runOpStatus({ args: makeArgs({ extraPositional: "alb-deploy" }), plugins: [], serializers: [] });
146
+ expect(exit).toBe(1);
147
+ expect(stderr.join("\n")).toContain("UNAVAILABLE");
148
+ });
149
+
150
+ test("happy path: prints workflow id, run id, status, activity counts", async () => {
151
+ setupTemporalClient(createMockTemporalClient({
152
+ describeByWorkflowId: {
153
+ "chant-op-alb-deploy": {
154
+ workflowId: "chant-op-alb-deploy", runId: "r1",
155
+ status: { name: "COMPLETED" },
156
+ startTime: new Date("2026-05-01T00:00:00Z"),
157
+ closeTime: new Date("2026-05-01T01:00:00Z"),
158
+ taskQueue: "alb-deploy", type: { name: "albDeployWorkflow" },
159
+ },
160
+ },
161
+ historyByWorkflowId: {
162
+ "chant-op-alb-deploy": [
163
+ { eventType: "ActivityTaskScheduled" },
164
+ { eventType: "ActivityTaskScheduled" },
165
+ { eventType: "ActivityTaskCompleted" },
166
+ ],
167
+ },
168
+ }));
169
+ const stdout = makeStdoutSpy();
170
+ const exit = await runOpStatus({ args: makeArgs({ extraPositional: "alb-deploy" }), plugins: [], serializers: [] });
171
+ expect(exit).toBe(0);
172
+ const out = stdout.join("\n");
173
+ expect(out).toContain("chant-op-alb-deploy");
174
+ expect(out).toContain("COMPLETED");
175
+ expect(out).toContain("1/2 completed");
176
+ });
177
+ });
178
+
179
+ describe("runOpLog", () => {
180
+ beforeEach(() => {
181
+ loadTemporalClientMock.mockReset();
182
+ loadChantConfigMock.mockReset();
183
+ resolveProfileMock.mockReset();
184
+ });
185
+
186
+ test("missing op name → exit 1", async () => {
187
+ const stderr = makeStderrSpy();
188
+ const exit = await runOpLog({ args: makeArgs({ extraPositional: undefined }), plugins: [], serializers: [] });
189
+ expect(exit).toBe(1);
190
+ expect(stderr.join("\n")).toContain("Op name is required");
191
+ });
192
+
193
+ test("prints one row per matching workflow execution", async () => {
194
+ setupTemporalClient(createMockTemporalClient({
195
+ list: [
196
+ { workflowId: "chant-op-alb-deploy", runId: "r1", type: { name: "albDeployWorkflow" }, status: { name: "COMPLETED" }, startTime: new Date("2026-05-01T00:00:00Z"), closeTime: new Date("2026-05-01T01:00:00Z") },
197
+ { workflowId: "chant-op-alb-deploy", runId: "r2", type: { name: "albDeployWorkflow" }, status: { name: "RUNNING" }, startTime: new Date("2026-05-02T00:00:00Z") },
198
+ ],
199
+ }));
200
+ const stdout = makeStdoutSpy();
201
+ const exit = await runOpLog({ args: makeArgs({ extraPositional: "alb-deploy" }), plugins: [], serializers: [] });
202
+ expect(exit).toBe(0);
203
+ const out = stdout.join("\n");
204
+ expect(out).toContain("RUN-ID");
205
+ expect(out).toContain("r1");
206
+ expect(out).toContain("r2");
207
+ expect(out).toContain("COMPLETED");
208
+ expect(out).toContain("RUNNING");
209
+ });
210
+ });
211
+
212
+ describe("runOpSignal", () => {
213
+ beforeEach(() => {
214
+ loadTemporalClientMock.mockReset();
215
+ loadChantConfigMock.mockReset();
216
+ resolveProfileMock.mockReset();
217
+ });
218
+
219
+ test("missing op or signal name → exit 1", async () => {
220
+ const stderr = makeStderrSpy();
221
+ const exit = await runOpSignal({ args: makeArgs({ extraPositional: "op-only" }), plugins: [], serializers: [] });
222
+ expect(exit).toBe(1);
223
+ expect(stderr.join("\n")).toContain("Usage:");
224
+ });
225
+
226
+ test("happy path: signal is sent and success message logged", async () => {
227
+ const mockClient = createMockTemporalClient();
228
+ setupTemporalClient(mockClient);
229
+ const stderr = makeStderrSpy();
230
+ const exit = await runOpSignal({
231
+ args: makeArgs({ extraPositional: "alb-deploy", extraPositional2: "gate-dns" }),
232
+ plugins: [], serializers: [],
233
+ });
234
+ expect(exit).toBe(0);
235
+ expect(mockClient.calls.signalCalls).toEqual([
236
+ { workflowId: "chant-op-alb-deploy", signalName: "gate-dns" },
237
+ ]);
238
+ expect(stderr.join("\n")).toContain("Signal");
239
+ expect(stderr.join("\n")).toContain("gate-dns");
240
+ });
241
+ });
242
+
243
+ describe("runOpCancel", () => {
244
+ beforeEach(() => {
245
+ loadTemporalClientMock.mockReset();
246
+ loadChantConfigMock.mockReset();
247
+ resolveProfileMock.mockReset();
248
+ });
249
+
250
+ test("missing op name → exit 1", async () => {
251
+ const stderr = makeStderrSpy();
252
+ const exit = await runOpCancel({ args: makeArgs({ extraPositional: undefined }), plugins: [], serializers: [] });
253
+ expect(exit).toBe(1);
254
+ expect(stderr.join("\n")).toContain("Op name is required");
255
+ });
256
+
257
+ test("requires --force → exit 1 without it", async () => {
258
+ const stderr = makeStderrSpy();
259
+ const exit = await runOpCancel({
260
+ args: makeArgs({ extraPositional: "alb-deploy", force: false }),
261
+ plugins: [], serializers: [],
262
+ });
263
+ expect(exit).toBe(1);
264
+ expect(stderr.join("\n")).toContain("--force");
265
+ });
266
+
267
+ test("with --force: cancel is sent and success logged", async () => {
268
+ const mockClient = createMockTemporalClient();
269
+ setupTemporalClient(mockClient);
270
+ const stderr = makeStderrSpy();
271
+ const exit = await runOpCancel({
272
+ args: makeArgs({ extraPositional: "alb-deploy", force: true }),
273
+ plugins: [], serializers: [],
274
+ });
275
+ expect(exit).toBe(0);
276
+ expect(mockClient.calls.cancelCalls).toEqual([{ workflowId: "chant-op-alb-deploy" }]);
277
+ expect(stderr.join("\n")).toContain("Cancellation requested");
278
+ });
279
+ });
280
+
281
+ // ── runOp (the main `chant run <name>` command) ─────────────────────────────
282
+
283
+ function makeFakeChildProcess(): { proc: EventEmitter & { kill: () => void } } {
284
+ const proc = Object.assign(new EventEmitter(), { kill: vi.fn() });
285
+ return { proc };
286
+ }
287
+
288
+ describe("runOp", () => {
289
+ beforeEach(() => {
290
+ discoverOpsMock.mockReset();
291
+ loadTemporalClientMock.mockReset();
292
+ loadChantConfigMock.mockReset();
293
+ resolveProfileMock.mockReset();
294
+ existsSyncMock.mockReset();
295
+ spawnChildMock.mockReset();
296
+ generateReportMock.mockReset();
297
+ writeReportMock.mockReset();
298
+ waitForTemporalSpy.mockReset();
299
+ });
300
+
301
+ test("path defaults to '.' → exit 1 with hint", async () => {
302
+ const stderr = makeStderrSpy();
303
+ const exit = await runOp({ args: makeArgs({ path: "." }), plugins: [], serializers: [] });
304
+ expect(exit).toBe(1);
305
+ expect(stderr.join("\n")).toContain("Op name is required");
306
+ });
307
+
308
+ test("unknown op name → exit 1 + lists available", async () => {
309
+ discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("alb-deploy"), makeOp("infra")]), errors: [] });
310
+ const stderr = makeStderrSpy();
311
+ const exit = await runOp({ args: makeArgs({ path: "missing" }), plugins: [], serializers: [] });
312
+ expect(exit).toBe(1);
313
+ const out = stderr.join("\n");
314
+ expect(out).toContain('Op "missing" not found');
315
+ expect(out).toContain("alb-deploy");
316
+ expect(out).toContain("infra");
317
+ });
318
+
319
+ test("unknown op + zero discovered ops → exit 1 with create-one hint", async () => {
320
+ discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
321
+ const stderr = makeStderrSpy();
322
+ const exit = await runOp({ args: makeArgs({ path: "missing" }), plugins: [], serializers: [] });
323
+ expect(exit).toBe(1);
324
+ expect(stderr.join("\n")).toContain("No *.op.ts files found");
325
+ });
326
+
327
+ test("profile resolution failure → exit 1", async () => {
328
+ discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("alb-deploy")]), errors: [] });
329
+ loadChantConfigMock.mockResolvedValue({ config: {} });
330
+ resolveProfileMock.mockImplementation(() => { throw new Error("Profile not found: prod"); });
331
+ const stderr = makeStderrSpy();
332
+ const exit = await runOp({ args: makeArgs({ path: "alb-deploy" }), plugins: [], serializers: [] });
333
+ expect(exit).toBe(1);
334
+ expect(stderr.join("\n")).toContain("Profile not found: prod");
335
+ });
336
+
337
+ test("missing dist/ops/<name>/worker.ts → exit 1 with build hint", async () => {
338
+ discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("alb-deploy")]), errors: [] });
339
+ loadChantConfigMock.mockResolvedValue({ config: {} });
340
+ resolveProfileMock.mockReturnValue({ address: "localhost:7233", namespace: "default", taskQueue: "q" });
341
+ existsSyncMock.mockReturnValue(false);
342
+ const stderr = makeStderrSpy();
343
+ const exit = await runOp({ args: makeArgs({ path: "alb-deploy" }), plugins: [], serializers: [] });
344
+ expect(exit).toBe(1);
345
+ expect(stderr.join("\n")).toContain("worker.ts not found");
346
+ expect(stderr.join("\n")).toContain("`chant build` first");
347
+ });
348
+
349
+ test("--report path: prints generated report from describe + history", async () => {
350
+ discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("alb-deploy")]), errors: [] });
351
+ setupTemporalClient(createMockTemporalClient({
352
+ describeByWorkflowId: {
353
+ "chant-op-alb-deploy": {
354
+ workflowId: "chant-op-alb-deploy", runId: "r1",
355
+ status: { name: "COMPLETED" }, startTime: new Date(),
356
+ taskQueue: "alb-deploy", type: { name: "albDeployWorkflow" },
357
+ },
358
+ },
359
+ historyByWorkflowId: { "chant-op-alb-deploy": [] },
360
+ }));
361
+ generateReportMock.mockReturnValue("# Report\nDeploy completed.");
362
+ const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
363
+
364
+ const exit = await runOp({ args: makeArgs({ path: "alb-deploy", report: true }), plugins: [], serializers: [] });
365
+
366
+ expect(exit).toBe(0);
367
+ expect(generateReportMock).toHaveBeenCalledTimes(1);
368
+ expect(stdoutSpy).toHaveBeenCalledWith("# Report\nDeploy completed.");
369
+ stdoutSpy.mockRestore();
370
+ });
371
+
372
+ test("happy path: spawns worker, starts workflow, polls until COMPLETED, writes report, exits 0", async () => {
373
+ vi.useFakeTimers();
374
+ try {
375
+ discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("alb-deploy")]), errors: [] });
376
+ const mockClient = createMockTemporalClient({
377
+ describeByWorkflowId: {
378
+ "chant-op-alb-deploy": {
379
+ workflowId: "chant-op-alb-deploy", runId: "r1",
380
+ status: { name: "COMPLETED" }, startTime: new Date(),
381
+ taskQueue: "alb-deploy", type: { name: "albDeployWorkflow" },
382
+ },
383
+ },
384
+ historyByWorkflowId: {
385
+ "chant-op-alb-deploy": [{ eventType: "ActivityTaskScheduled" }, { eventType: "ActivityTaskCompleted" }],
386
+ },
387
+ });
388
+ setupTemporalClient(mockClient);
389
+ existsSyncMock.mockReturnValue(true);
390
+ const { proc } = makeFakeChildProcess();
391
+ spawnChildMock.mockReturnValue(proc);
392
+ generateReportMock.mockReturnValue("# Report");
393
+ writeReportMock.mockReturnValue("/tmp/report.md");
394
+ const stderrWriteSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
395
+
396
+ const promise = runOp({ args: makeArgs({ path: "alb-deploy" }), plugins: [], serializers: [] });
397
+ // Drive the polling loop forward.
398
+ await vi.advanceTimersByTimeAsync(5000);
399
+
400
+ const exit = await promise;
401
+ expect(exit).toBe(0);
402
+ expect(spawnChildMock).toHaveBeenCalledTimes(1);
403
+ expect(spawnChildMock.mock.calls[0][0]).toBe("npx");
404
+ expect(mockClient.calls.startCalls).toHaveLength(1);
405
+ expect(mockClient.calls.startCalls[0].opts.workflowId).toBe("chant-op-alb-deploy");
406
+ expect(generateReportMock).toHaveBeenCalledTimes(1);
407
+ expect(writeReportMock).toHaveBeenCalledTimes(1);
408
+ expect(proc.kill).toHaveBeenCalled();
409
+
410
+ stderrWriteSpy.mockRestore();
411
+ } finally {
412
+ vi.useRealTimers();
413
+ }
414
+ });
415
+
416
+ test("workflow ends in FAILED → exit 1, worker still killed", async () => {
417
+ vi.useFakeTimers();
418
+ try {
419
+ discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("alb-deploy")]), errors: [] });
420
+ const mockClient = createMockTemporalClient({
421
+ describeByWorkflowId: {
422
+ "chant-op-alb-deploy": {
423
+ workflowId: "chant-op-alb-deploy", runId: "r1",
424
+ status: { name: "FAILED" }, startTime: new Date(),
425
+ taskQueue: "alb-deploy", type: { name: "albDeployWorkflow" },
426
+ },
427
+ },
428
+ historyByWorkflowId: { "chant-op-alb-deploy": [] },
429
+ });
430
+ setupTemporalClient(mockClient);
431
+ existsSyncMock.mockReturnValue(true);
432
+ const { proc } = makeFakeChildProcess();
433
+ spawnChildMock.mockReturnValue(proc);
434
+ generateReportMock.mockReturnValue("# Report");
435
+ writeReportMock.mockReturnValue("/tmp/report.md");
436
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
437
+
438
+ const promise = runOp({ args: makeArgs({ path: "alb-deploy" }), plugins: [], serializers: [] });
439
+ await vi.advanceTimersByTimeAsync(5000);
440
+
441
+ const exit = await promise;
442
+ expect(exit).toBe(1);
443
+ expect(proc.kill).toHaveBeenCalled();
444
+ } finally {
445
+ vi.useRealTimers();
446
+ }
447
+ });
448
+ });