@intentius/chant 0.1.7 → 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.
- package/package.json +1 -1
- package/src/cli/commands/build.test.ts +58 -5
- package/src/cli/commands/build.ts +7 -3
- package/src/cli/handlers/graph.test.ts +91 -0
- package/src/cli/handlers/run.test.ts +448 -0
- package/src/cli/handlers/state.test.ts +409 -0
- package/src/cli/handlers/state.ts +232 -10
- package/src/cli/main.test.ts +1 -0
- package/src/cli/main.ts +4 -0
- package/src/cli/mcp/tools/search.ts +6 -1
- package/src/cli/registry.ts +1 -0
- package/src/lexicon-plugin-helpers.ts +13 -5
- package/src/lexicon.ts +57 -1
- package/src/lint/config.test.ts +21 -0
- package/src/lint/config.ts +19 -3
- package/src/op/types.ts +13 -0
- package/src/state/digest.test.ts +117 -0
- package/src/state/git.test.ts +191 -0
- package/src/state/git.ts +63 -11
- package/src/state/live-diff.test.ts +184 -0
- package/src/state/live-diff.ts +215 -0
- package/src/state/snapshot.test.ts +171 -0
- package/src/state/snapshot.ts +39 -19
- package/src/state/types.ts +4 -2
package/package.json
CHANGED
|
@@ -132,18 +132,71 @@ export const testEntity = {
|
|
|
132
132
|
expect(result.fileCount).toBeDefined();
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
-
test("
|
|
135
|
+
test("creates parent directories for the primary output path (#38)", async () => {
|
|
136
|
+
// Write into a nested temp path whose parent dirs don't yet exist —
|
|
137
|
+
// chant build should mkdir -p the parents rather than fail with ENOENT.
|
|
138
|
+
const nestedOutput = join(testDir, "deep", "nested", "out", "file.json");
|
|
139
|
+
|
|
136
140
|
const options: BuildOptions = {
|
|
137
141
|
path: testDir,
|
|
138
|
-
output:
|
|
142
|
+
output: nestedOutput,
|
|
139
143
|
format: "json",
|
|
140
144
|
serializers: [mockSerializer],
|
|
141
145
|
};
|
|
142
146
|
|
|
143
147
|
const result = await buildCommand(options);
|
|
144
148
|
|
|
145
|
-
|
|
146
|
-
expect(
|
|
147
|
-
|
|
149
|
+
expect(result.errors).toEqual([]);
|
|
150
|
+
expect(existsSync(nestedOutput)).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("creates parent directories for nested additional-files (#38)", async () => {
|
|
154
|
+
// Simulate a multi-file serializer (like the Op codegen) that emits
|
|
155
|
+
// additional files under nested subpaths.
|
|
156
|
+
const multiFileSerializer: Serializer = {
|
|
157
|
+
name: "multi",
|
|
158
|
+
rulePrefix: "MULTI",
|
|
159
|
+
serialize: () => ({
|
|
160
|
+
primary: "{}",
|
|
161
|
+
files: {
|
|
162
|
+
"ops/alb-deploy/workflow.ts": "// workflow\n",
|
|
163
|
+
"ops/alb-deploy/activities.ts": "// activities\n",
|
|
164
|
+
"ops/alb-deploy/worker.ts": "// worker\n",
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Need at least one entity in the "multi" lexicon so the build pipeline
|
|
170
|
+
// invokes the serializer and the additional files surface.
|
|
171
|
+
await writeFile(
|
|
172
|
+
join(testDir, "infra.ts"),
|
|
173
|
+
[
|
|
174
|
+
`export const x = {`,
|
|
175
|
+
` [Symbol.for("chant.declarable")]: true,`,
|
|
176
|
+
` entityType: "X",`,
|
|
177
|
+
` lexicon: "multi",`,
|
|
178
|
+
` kind: "resource",`,
|
|
179
|
+
` props: {},`,
|
|
180
|
+
` attributes: {},`,
|
|
181
|
+
`};`,
|
|
182
|
+
].join("\n"),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const outputPath = join(testDir, "dist", "manifest.json");
|
|
186
|
+
|
|
187
|
+
const options: BuildOptions = {
|
|
188
|
+
path: testDir,
|
|
189
|
+
output: outputPath,
|
|
190
|
+
format: "json",
|
|
191
|
+
serializers: [multiFileSerializer],
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const result = await buildCommand(options);
|
|
195
|
+
|
|
196
|
+
expect(result.errors).toEqual([]);
|
|
197
|
+
expect(existsSync(outputPath)).toBe(true);
|
|
198
|
+
expect(existsSync(join(testDir, "dist", "ops", "alb-deploy", "workflow.ts"))).toBe(true);
|
|
199
|
+
expect(existsSync(join(testDir, "dist", "ops", "alb-deploy", "activities.ts"))).toBe(true);
|
|
200
|
+
expect(existsSync(join(testDir, "dist", "ops", "alb-deploy", "worker.ts"))).toBe(true);
|
|
148
201
|
});
|
|
149
202
|
});
|
|
@@ -5,7 +5,7 @@ import { runPostSynthChecks } from "../../lint/post-synth";
|
|
|
5
5
|
import type { PostSynthCheck } from "../../lint/post-synth";
|
|
6
6
|
import { sortedJsonReplacer } from "../../utils";
|
|
7
7
|
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
8
|
-
import { writeFileSync } from "fs";
|
|
8
|
+
import { writeFileSync, mkdirSync } from "fs";
|
|
9
9
|
import { resolve, dirname, join } from "path";
|
|
10
10
|
import { watchDirectory, formatTimestamp, formatChangedFiles } from "../watch";
|
|
11
11
|
|
|
@@ -188,9 +188,11 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
if (options.output) {
|
|
191
|
-
// Write to file
|
|
191
|
+
// Write to file — ensure parent directories exist for both the primary
|
|
192
|
+
// output path and any nested additional-file paths (e.g. ops/<name>/...).
|
|
192
193
|
try {
|
|
193
194
|
const outputPath = resolve(options.output);
|
|
195
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
194
196
|
writeFileSync(outputPath, output);
|
|
195
197
|
|
|
196
198
|
// Write additional files (e.g. nested stack templates) alongside the primary output
|
|
@@ -208,7 +210,9 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
208
210
|
} catch {
|
|
209
211
|
// If not JSON, write as-is
|
|
210
212
|
}
|
|
211
|
-
|
|
213
|
+
const targetPath = join(outputDir, filename);
|
|
214
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
215
|
+
writeFileSync(targetPath, fileContent);
|
|
212
216
|
}
|
|
213
217
|
}
|
|
214
218
|
} catch (err) {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import type { ParsedArgs } from "../registry";
|
|
3
|
+
|
|
4
|
+
const discoverOpsMock = vi.fn();
|
|
5
|
+
vi.mock("../../op/discover", () => ({
|
|
6
|
+
discoverOps: () => discoverOpsMock(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
const { runGraph } = await import("./graph");
|
|
10
|
+
|
|
11
|
+
function makeArgs(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
|
|
12
|
+
return {
|
|
13
|
+
command: "graph", path: ".",
|
|
14
|
+
format: "", fix: false, watch: false, verbose: false, help: false, live: false,
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeOp(name: string, depends: string[] = []): [string, { config: { name: string; depends?: string[] } }] {
|
|
20
|
+
return [name, { config: { name, depends } }];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("runGraph", () => {
|
|
24
|
+
let stdoutBuf: string[];
|
|
25
|
+
let stderrBuf: string[];
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
stdoutBuf = [];
|
|
29
|
+
stderrBuf = [];
|
|
30
|
+
vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
|
|
31
|
+
vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
|
|
32
|
+
discoverOpsMock.mockReset();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("prints 'No Ops found' when discovery is empty", async () => {
|
|
36
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
|
|
37
|
+
const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
|
|
38
|
+
expect(exit).toBe(0);
|
|
39
|
+
expect(stdoutBuf.join("\n")).toContain("No Ops found");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("prints 'No Op dependencies' when ops have no depends", async () => {
|
|
43
|
+
discoverOpsMock.mockResolvedValue({
|
|
44
|
+
ops: new Map([makeOp("solo")]),
|
|
45
|
+
errors: [],
|
|
46
|
+
});
|
|
47
|
+
const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
|
|
48
|
+
expect(exit).toBe(0);
|
|
49
|
+
expect(stdoutBuf.join("\n")).toContain("No Op dependencies");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("prints `dep -> name` edge per dependency", async () => {
|
|
53
|
+
discoverOpsMock.mockResolvedValue({
|
|
54
|
+
ops: new Map([
|
|
55
|
+
makeOp("infra"),
|
|
56
|
+
makeOp("app", ["infra"]),
|
|
57
|
+
]),
|
|
58
|
+
errors: [],
|
|
59
|
+
});
|
|
60
|
+
const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
|
|
61
|
+
expect(exit).toBe(0);
|
|
62
|
+
const out = stdoutBuf.join("\n");
|
|
63
|
+
expect(out).toContain("infra → app");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("handles multi-edge graphs", async () => {
|
|
67
|
+
discoverOpsMock.mockResolvedValue({
|
|
68
|
+
ops: new Map([
|
|
69
|
+
makeOp("a"),
|
|
70
|
+
makeOp("b", ["a"]),
|
|
71
|
+
makeOp("c", ["a", "b"]),
|
|
72
|
+
]),
|
|
73
|
+
errors: [],
|
|
74
|
+
});
|
|
75
|
+
await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
|
|
76
|
+
const out = stdoutBuf.join("\n");
|
|
77
|
+
expect(out).toContain("a → b");
|
|
78
|
+
expect(out).toContain("a → c");
|
|
79
|
+
expect(out).toContain("b → c");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("forwards discovery errors to stderr", async () => {
|
|
83
|
+
discoverOpsMock.mockResolvedValue({
|
|
84
|
+
ops: new Map(),
|
|
85
|
+
errors: ["failed to parse ops/bad.op.ts"],
|
|
86
|
+
});
|
|
87
|
+
const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
|
|
88
|
+
expect(exit).toBe(0);
|
|
89
|
+
expect(stderrBuf.join("\n")).toContain("failed to parse ops/bad.op.ts");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -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
|
+
});
|