@intentius/chant 0.50.0 → 0.51.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/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/op-progress.d.ts +57 -0
- package/dist/cli/handlers/op-progress.d.ts.map +1 -0
- package/dist/cli/handlers/run-client.d.ts +21 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/run-report.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +22 -0
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +33 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/run-progress.d.ts +7 -5
- package/dist/components/run-progress.d.ts.map +1 -1
- package/dist/lexicon.d.ts +41 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/assert-live.d.ts +77 -0
- package/dist/lifecycle/assert-live.d.ts.map +1 -0
- package/dist/lifecycle/change-set.d.ts +17 -0
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/disruption.d.ts +96 -0
- package/dist/lifecycle/disruption.d.ts.map +1 -0
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts +2 -0
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +7 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/testing.d.ts +23 -2
- package/dist/testing.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/lifecycle.test.ts +90 -0
- package/src/cli/handlers/lifecycle.ts +18 -1
- package/src/cli/handlers/op-progress.test.ts +202 -0
- package/src/cli/handlers/op-progress.ts +192 -0
- package/src/cli/handlers/run-client.test.ts +82 -0
- package/src/cli/handlers/run-client.ts +85 -2
- package/src/cli/handlers/run-report.test.ts +62 -0
- package/src/cli/handlers/run-report.ts +20 -58
- package/src/cli/handlers/run.test.ts +144 -0
- package/src/cli/handlers/run.ts +40 -18
- package/src/cli/handlers/search-drift.test.ts +263 -0
- package/src/cli/handlers/search.ts +150 -1
- package/src/cli/main.ts +9 -0
- package/src/cli/mcp/op-tools.ts +17 -6
- package/src/cli/mcp/resource-handlers.ts +13 -5
- package/src/cli/registry.ts +33 -1
- package/src/components/run-progress.ts +9 -5
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/assert-live.test.ts +125 -0
- package/src/lifecycle/assert-live.ts +154 -0
- package/src/lifecycle/change-set.ts +35 -3
- package/src/lifecycle/disruption.test.ts +186 -0
- package/src/lifecycle/disruption.ts +224 -0
- package/src/lifecycle/index.ts +2 -0
- package/src/lifecycle/replay.test.ts +25 -0
- package/src/lifecycle/replay.ts +11 -3
- package/src/op/local-executor.ts +7 -1
- package/src/op/local-output.ts +1 -1
- package/src/testing.test.ts +89 -2
- package/src/testing.ts +63 -3
|
@@ -50,6 +50,9 @@ vi.mock("./run-client", () => ({
|
|
|
50
50
|
connectionOptions: (profile: { address: string }) => ({ address: profile.address }),
|
|
51
51
|
resolveProfile: (...args: unknown[]) => resolveProfileMock(...args),
|
|
52
52
|
resolveWorkflowId: (name: string) => `chant-op-${name}`,
|
|
53
|
+
// Test fixtures already use the short event-type form fetchNormalizedHistory
|
|
54
|
+
// produces (see mock-temporal-client.ts) — a passthrough keeps them valid.
|
|
55
|
+
fetchNormalizedHistory: (handle: { fetchHistory(): Promise<unknown> }) => handle.fetchHistory(),
|
|
53
56
|
}));
|
|
54
57
|
vi.mock("node:fs", async () => {
|
|
55
58
|
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
|
@@ -297,6 +300,54 @@ describe("runOpStatus", () => {
|
|
|
297
300
|
expect(out).toContain("COMPLETED");
|
|
298
301
|
expect(out).toContain("1/2 completed");
|
|
299
302
|
});
|
|
303
|
+
|
|
304
|
+
test("a pending gate prints a Gate line via the gateState query (#1676)", async () => {
|
|
305
|
+
setupTemporalClient(createMockTemporalClient({
|
|
306
|
+
describeByWorkflowId: {
|
|
307
|
+
"chant-op-alb-deploy": {
|
|
308
|
+
workflowId: "chant-op-alb-deploy", runId: "r1",
|
|
309
|
+
status: { name: "RUNNING" },
|
|
310
|
+
startTime: new Date("2026-05-01T00:00:00Z"),
|
|
311
|
+
taskQueue: "alb-deploy", type: { name: "albDeployWorkflow" },
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
historyByWorkflowId: { "chant-op-alb-deploy": [] },
|
|
315
|
+
queryResultByWorkflowId: {
|
|
316
|
+
"chant-op-alb-deploy:gateState": {
|
|
317
|
+
signalName: "gate-dns-delegation",
|
|
318
|
+
description: "Approve DNS delegation",
|
|
319
|
+
since: "2026-05-01T00:05:00.000Z",
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
}));
|
|
323
|
+
const stdout = makeStdoutSpy();
|
|
324
|
+
const exit = await runOpStatus({ args: makeArgs({ extraPositional: "alb-deploy" }), plugins: [], serializers: [] });
|
|
325
|
+
expect(exit).toBe(0);
|
|
326
|
+
const out = stdout.join("\n");
|
|
327
|
+
expect(out).toContain("Gate");
|
|
328
|
+
expect(out).toContain("gate-dns-delegation");
|
|
329
|
+
expect(out).toContain("Approve DNS delegation");
|
|
330
|
+
expect(out).toContain("2026-05-01T00:05:00.000Z");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("an Op with no pending gate (query unregistered or answers null) prints no Gate line", async () => {
|
|
334
|
+
setupTemporalClient(createMockTemporalClient({
|
|
335
|
+
describeByWorkflowId: {
|
|
336
|
+
"chant-op-alb-deploy": {
|
|
337
|
+
workflowId: "chant-op-alb-deploy", runId: "r1",
|
|
338
|
+
status: { name: "COMPLETED" },
|
|
339
|
+
startTime: new Date("2026-05-01T00:00:00Z"),
|
|
340
|
+
closeTime: new Date("2026-05-01T01:00:00Z"),
|
|
341
|
+
taskQueue: "alb-deploy", type: { name: "albDeployWorkflow" },
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
historyByWorkflowId: { "chant-op-alb-deploy": [] },
|
|
345
|
+
}));
|
|
346
|
+
const stdout = makeStdoutSpy();
|
|
347
|
+
const exit = await runOpStatus({ args: makeArgs({ extraPositional: "alb-deploy" }), plugins: [], serializers: [] });
|
|
348
|
+
expect(exit).toBe(0);
|
|
349
|
+
expect(stdout.join("\n")).not.toContain("Gate");
|
|
350
|
+
});
|
|
300
351
|
});
|
|
301
352
|
|
|
302
353
|
// ── chant run status <name> --components (#599) ─────────────────────────────
|
|
@@ -731,6 +782,99 @@ describe("runOp", () => {
|
|
|
731
782
|
}
|
|
732
783
|
});
|
|
733
784
|
|
|
785
|
+
// ── --progress-json (#1676) ──────────────────────────────────────────────
|
|
786
|
+
|
|
787
|
+
test("--progress-json streams one NDJSON StepRecord per settled step, plus a final skipped record", async () => {
|
|
788
|
+
vi.useFakeTimers();
|
|
789
|
+
try {
|
|
790
|
+
discoverOpsMock.mockResolvedValue({
|
|
791
|
+
ops: new Map([[
|
|
792
|
+
"alb-deploy",
|
|
793
|
+
{
|
|
794
|
+
config: {
|
|
795
|
+
name: "alb-deploy", overview: "o",
|
|
796
|
+
phases: [
|
|
797
|
+
{ name: "Build", steps: [{ kind: "activity", fn: "build" }] },
|
|
798
|
+
{ name: "Deploy", steps: [{ kind: "activity", fn: "deploy" }] },
|
|
799
|
+
],
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
]]),
|
|
803
|
+
errors: [],
|
|
804
|
+
});
|
|
805
|
+
setupTemporalClient(createMockTemporalClient({
|
|
806
|
+
describeByWorkflowId: {
|
|
807
|
+
"chant-op-alb-deploy": {
|
|
808
|
+
workflowId: "chant-op-alb-deploy", runId: "r1",
|
|
809
|
+
status: { name: "COMPLETED" }, startTime: new Date(),
|
|
810
|
+
taskQueue: "alb-deploy", type: { name: "albDeployWorkflow" },
|
|
811
|
+
},
|
|
812
|
+
},
|
|
813
|
+
historyByWorkflowId: {
|
|
814
|
+
"chant-op-alb-deploy": [
|
|
815
|
+
{ eventId: "1", eventType: "ActivityTaskScheduled", activityTaskScheduledEventAttributes: { activityId: "1", activityType: { name: "build" } } },
|
|
816
|
+
{ eventType: "ActivityTaskCompleted", activityTaskCompletedEventAttributes: { scheduledEventId: "1" } },
|
|
817
|
+
],
|
|
818
|
+
},
|
|
819
|
+
}));
|
|
820
|
+
existsSyncMock.mockReturnValue(true);
|
|
821
|
+
const { proc } = makeFakeChildProcess();
|
|
822
|
+
spawnChildMock.mockReturnValue(proc);
|
|
823
|
+
generateReportMock.mockReturnValue("# Report");
|
|
824
|
+
writeReportMock.mockReturnValue("/tmp/report.md");
|
|
825
|
+
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
826
|
+
const stdoutLines: string[] = [];
|
|
827
|
+
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { stdoutLines.push(String(chunk)); return true; });
|
|
828
|
+
|
|
829
|
+
const promise = runOp({ args: makeArgs({ path: "alb-deploy", progressJson: true }), plugins: [], serializers: [] });
|
|
830
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
831
|
+
const exit = await promise;
|
|
832
|
+
|
|
833
|
+
expect(exit).toBe(0);
|
|
834
|
+
const records = stdoutLines.filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
835
|
+
expect(records).toEqual([
|
|
836
|
+
{ phase: "Build", fn: "build", status: "ok", durationMs: 0 },
|
|
837
|
+
{ phase: "Deploy", fn: "deploy", status: "skipped", durationMs: 0 },
|
|
838
|
+
]);
|
|
839
|
+
stdoutSpy.mockRestore();
|
|
840
|
+
} finally {
|
|
841
|
+
vi.useRealTimers();
|
|
842
|
+
}
|
|
843
|
+
}, 10_000);
|
|
844
|
+
|
|
845
|
+
test("without --progress-json, nothing is written to stdout during the poll loop", async () => {
|
|
846
|
+
vi.useFakeTimers();
|
|
847
|
+
try {
|
|
848
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("alb-deploy")]), errors: [] });
|
|
849
|
+
setupTemporalClient(createMockTemporalClient({
|
|
850
|
+
describeByWorkflowId: {
|
|
851
|
+
"chant-op-alb-deploy": {
|
|
852
|
+
workflowId: "chant-op-alb-deploy", runId: "r1",
|
|
853
|
+
status: { name: "COMPLETED" }, startTime: new Date(),
|
|
854
|
+
taskQueue: "alb-deploy", type: { name: "albDeployWorkflow" },
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
historyByWorkflowId: { "chant-op-alb-deploy": [] },
|
|
858
|
+
}));
|
|
859
|
+
existsSyncMock.mockReturnValue(true);
|
|
860
|
+
const { proc } = makeFakeChildProcess();
|
|
861
|
+
spawnChildMock.mockReturnValue(proc);
|
|
862
|
+
generateReportMock.mockReturnValue("# Report");
|
|
863
|
+
writeReportMock.mockReturnValue("/tmp/report.md");
|
|
864
|
+
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
865
|
+
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
866
|
+
|
|
867
|
+
const promise = runOp({ args: makeArgs({ path: "alb-deploy" }), plugins: [], serializers: [] });
|
|
868
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
869
|
+
await promise;
|
|
870
|
+
|
|
871
|
+
expect(stdoutSpy).not.toHaveBeenCalled();
|
|
872
|
+
stdoutSpy.mockRestore();
|
|
873
|
+
} finally {
|
|
874
|
+
vi.useRealTimers();
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
|
|
734
878
|
test("workflow ends in FAILED → exit 1, worker still killed", async () => {
|
|
735
879
|
vi.useFakeTimers();
|
|
736
880
|
try {
|
package/src/cli/handlers/run.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { spawn as spawnChild, type ChildProcess } from "node:child_process";
|
|
|
5
5
|
import { loadChantConfig, resolveAutoReleaseDisabled, type ChantConfig } from "../../config";
|
|
6
6
|
import { discoverOps } from "../../op/discover";
|
|
7
7
|
import { loadActivities, loadProfiles } from "../../op/activity-registry";
|
|
8
|
-
import { runOpLocally, findGate, LocalGateUnsupportedError, OpRunFailure } from "../../op/local-executor";
|
|
8
|
+
import { runOpLocally, findGate, LocalGateUnsupportedError, OpRunFailure, type StepRecord } from "../../op/local-executor";
|
|
9
9
|
import { renderHuman, renderJson } from "../../op/local-output";
|
|
10
10
|
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
11
11
|
import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
|
|
@@ -15,11 +15,13 @@ import {
|
|
|
15
15
|
connectionOptions,
|
|
16
16
|
resolveWorkflowId,
|
|
17
17
|
resolveProfile,
|
|
18
|
+
fetchNormalizedHistory,
|
|
18
19
|
type WorkflowHandleRaw,
|
|
19
20
|
type WorkflowExecutionDescription,
|
|
20
21
|
type WorkflowHistoryRaw,
|
|
21
22
|
} from "./run-client";
|
|
22
23
|
import { generateReport, writeReport } from "./run-report";
|
|
24
|
+
import { extractStepRecords, countActivities, queryGateState } from "./op-progress";
|
|
23
25
|
import { runComponents, resolveComponentTargets, findComponentGate, listComponents } from "../../components/cli-support";
|
|
24
26
|
import { renderDriverHuman, renderDriverJson } from "../../components/driver-output";
|
|
25
27
|
import { ndjsonProgressSink } from "../../components/run-progress";
|
|
@@ -250,12 +252,12 @@ export async function runOpStatus(ctx: CommandContext): Promise<number> {
|
|
|
250
252
|
if (ctx.args.components) return runComponentStatus(ctx, name);
|
|
251
253
|
|
|
252
254
|
const projectPath = resolve(".");
|
|
253
|
-
let client, desc: WorkflowExecutionDescription, history: WorkflowHistoryRaw;
|
|
255
|
+
let client, desc: WorkflowExecutionDescription, history: WorkflowHistoryRaw, handle: WorkflowHandleRaw;
|
|
254
256
|
try {
|
|
255
257
|
({ client } = await makeTemporalClient(ctx.args.profile, projectPath));
|
|
256
|
-
|
|
258
|
+
handle = client.workflow.getHandle(resolveWorkflowId(name));
|
|
257
259
|
desc = await handle.describe();
|
|
258
|
-
history = await handle
|
|
260
|
+
history = await fetchNormalizedHistory(handle);
|
|
259
261
|
} catch (err) {
|
|
260
262
|
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
261
263
|
return 1;
|
|
@@ -269,13 +271,19 @@ export async function runOpStatus(ctx: CommandContext): Promise<number> {
|
|
|
269
271
|
console.log(` Started : ${desc.startTime.toISOString()}`);
|
|
270
272
|
if (desc.closeTime) console.log(` Closed : ${desc.closeTime.toISOString()}`);
|
|
271
273
|
|
|
272
|
-
const
|
|
273
|
-
const completed = events.filter((e) => e.eventType === "ActivityTaskCompleted").length;
|
|
274
|
-
const scheduled = events.filter((e) => e.eventType === "ActivityTaskScheduled").length;
|
|
274
|
+
const { completed, scheduled } = countActivities(history);
|
|
275
275
|
if (scheduled > 0) {
|
|
276
276
|
console.log(` Activities : ${completed}/${scheduled} completed`);
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
// Queryable gate state (#1676) — undefined means the query itself failed
|
|
280
|
+
// (an ungated Op never registers a "gateState" handler), so print nothing;
|
|
281
|
+
// null means the query answered "no gate pending" — also nothing to print.
|
|
282
|
+
const gate = await queryGateState(handle);
|
|
283
|
+
if (gate) {
|
|
284
|
+
console.log(` Gate : ${gate.signalName}${gate.description ? ` — ${gate.description}` : ""} (waiting since ${gate.since})`);
|
|
285
|
+
}
|
|
286
|
+
|
|
279
287
|
return 0;
|
|
280
288
|
}
|
|
281
289
|
|
|
@@ -293,7 +301,7 @@ async function runComponentStatus(ctx: CommandContext, name: string): Promise<nu
|
|
|
293
301
|
({ client } = await makeTemporalClient(ctx.args.profile, projectPath));
|
|
294
302
|
const handle = client.workflow.getHandle(componentWorkflowId(name));
|
|
295
303
|
desc = await handle.describe();
|
|
296
|
-
history = await handle
|
|
304
|
+
history = await fetchNormalizedHistory(handle);
|
|
297
305
|
} catch (err) {
|
|
298
306
|
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
299
307
|
return 1;
|
|
@@ -307,9 +315,7 @@ async function runComponentStatus(ctx: CommandContext, name: string): Promise<nu
|
|
|
307
315
|
console.log(` Started : ${desc.startTime.toISOString()}`);
|
|
308
316
|
if (desc.closeTime) console.log(` Closed : ${desc.closeTime.toISOString()}`);
|
|
309
317
|
|
|
310
|
-
const
|
|
311
|
-
const completed = events.filter((e) => e.eventType === "ActivityTaskCompleted").length;
|
|
312
|
-
const scheduled = events.filter((e) => e.eventType === "ActivityTaskScheduled").length;
|
|
318
|
+
const { completed, scheduled } = countActivities(history);
|
|
313
319
|
if (scheduled > 0) {
|
|
314
320
|
console.log(` Activities : ${completed}/${scheduled} completed`);
|
|
315
321
|
}
|
|
@@ -528,9 +534,7 @@ async function waitForTemporalServer(address: string, maxWaitMs = 30_000): Promi
|
|
|
528
534
|
}
|
|
529
535
|
|
|
530
536
|
function renderProgress(opName: string, history: WorkflowHistoryRaw): void {
|
|
531
|
-
const
|
|
532
|
-
const completed = events.filter((e) => e.eventType === "ActivityTaskCompleted").length;
|
|
533
|
-
const scheduled = events.filter((e) => e.eventType === "ActivityTaskScheduled").length;
|
|
537
|
+
const { completed, scheduled } = countActivities(history);
|
|
534
538
|
process.stderr.write(
|
|
535
539
|
`\r${formatInfo(`[${opName}]`)} ${completed}/${scheduled} activities completed`,
|
|
536
540
|
);
|
|
@@ -954,7 +958,7 @@ async function runComponentTemporal(
|
|
|
954
958
|
while (true) {
|
|
955
959
|
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
956
960
|
const desc = await handle.describe();
|
|
957
|
-
const history = await handle
|
|
961
|
+
const history = await fetchNormalizedHistory(handle);
|
|
958
962
|
renderProgress(component.name, history);
|
|
959
963
|
if (TERMINAL_STATUSES.has(desc.status.name)) {
|
|
960
964
|
process.stderr.write("\n");
|
|
@@ -1160,7 +1164,7 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
|
|
|
1160
1164
|
({ client } = await makeTemporalClient(ctx.args.profile, projectPath));
|
|
1161
1165
|
const handle = client.workflow.getHandle(resolveWorkflowId(opName));
|
|
1162
1166
|
desc = await handle.describe();
|
|
1163
|
-
history = await handle
|
|
1167
|
+
history = await fetchNormalizedHistory(handle);
|
|
1164
1168
|
} catch (err) {
|
|
1165
1169
|
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
1166
1170
|
return 1;
|
|
@@ -1248,7 +1252,23 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
|
|
|
1248
1252
|
return 1;
|
|
1249
1253
|
}
|
|
1250
1254
|
|
|
1251
|
-
// Poll for progress until terminal state
|
|
1255
|
+
// Poll for progress until terminal state.
|
|
1256
|
+
//
|
|
1257
|
+
// `--progress-json` (chant #1676): stream one NDJSON StepRecord per line to
|
|
1258
|
+
// stdout as each declared step settles — the durable-path counterpart to
|
|
1259
|
+
// `chant run <name> --json`'s local-executor record shape (op-progress.ts).
|
|
1260
|
+
// Purely additive: when the flag is absent `emitProgress` stays undefined
|
|
1261
|
+
// and every `emitProgress?.(...)` call is a no-op, so poll-loop behavior
|
|
1262
|
+
// is unchanged. `extractStepRecords` is stable and append-only across
|
|
1263
|
+
// polls (same declared order, a growing history), so a plain emitted-count
|
|
1264
|
+
// watermark is enough to emit only what's new each round.
|
|
1265
|
+
const emitProgress = ctx.args.progressJson ? ndjsonProgressSink<StepRecord>() : undefined;
|
|
1266
|
+
let emittedCount = 0;
|
|
1267
|
+
const emitNewRecords = (records: StepRecord[]) => {
|
|
1268
|
+
for (const record of records.slice(emittedCount)) emitProgress?.(record);
|
|
1269
|
+
emittedCount = records.length;
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1252
1272
|
let finalDesc: WorkflowExecutionDescription | undefined;
|
|
1253
1273
|
let finalHistory: WorkflowHistoryRaw | undefined;
|
|
1254
1274
|
|
|
@@ -1256,12 +1276,14 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
|
|
|
1256
1276
|
while (true) {
|
|
1257
1277
|
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
1258
1278
|
const desc = await handle.describe();
|
|
1259
|
-
const history = await handle
|
|
1279
|
+
const history = await fetchNormalizedHistory(handle);
|
|
1260
1280
|
|
|
1261
1281
|
renderProgress(opName, history);
|
|
1282
|
+
if (emitProgress) emitNewRecords(extractStepRecords(config, history));
|
|
1262
1283
|
|
|
1263
1284
|
if (TERMINAL_STATUSES.has(desc.status.name)) {
|
|
1264
1285
|
process.stderr.write("\n");
|
|
1286
|
+
if (emitProgress) emitNewRecords(extractStepRecords(config, history, { final: true }));
|
|
1265
1287
|
finalDesc = desc;
|
|
1266
1288
|
finalHistory = history;
|
|
1267
1289
|
break;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import type { ParsedArgs } from "../registry";
|
|
3
|
+
import { DECLARABLE_MARKER, type Declarable } from "../../declarable";
|
|
4
|
+
import { createMockPlugin } from "../../../../test-utils/src/mock-plugin";
|
|
5
|
+
|
|
6
|
+
// #1268 — query-scoped drift: `chant search --at latest --check-live` (and its
|
|
7
|
+
// reverse, `--live --check-snapshot`) compares the matched rows against the
|
|
8
|
+
// observation the primary answer did NOT use, reusing `diffLive` — the same
|
|
9
|
+
// engine `lifecycle diff --live` uses — scoped to just those rows.
|
|
10
|
+
|
|
11
|
+
const discoverMock = vi.fn();
|
|
12
|
+
vi.mock("../../discovery/index", () => ({
|
|
13
|
+
discover: (...a: unknown[]) => discoverMock(...a),
|
|
14
|
+
}));
|
|
15
|
+
const loadPluginsMock = vi.fn();
|
|
16
|
+
const resolveLexMock = vi.fn();
|
|
17
|
+
vi.mock("../plugins", async () => {
|
|
18
|
+
const actual = await vi.importActual<typeof import("../plugins")>("../plugins");
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
loadPlugins: (...a: unknown[]) => loadPluginsMock(...a),
|
|
22
|
+
resolveProjectLexicons: (...a: unknown[]) => resolveLexMock(...a),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
const replaySnapshotsMock = vi.fn();
|
|
26
|
+
const hasSnapshotMock = vi.fn((..._a: unknown[]) => Promise.resolve(false));
|
|
27
|
+
vi.mock("../../lifecycle/replay", () => ({
|
|
28
|
+
replaySnapshots: (...a: unknown[]) => replaySnapshotsMock(...a),
|
|
29
|
+
hasSnapshot: (...a: unknown[]) => hasSnapshotMock(...a),
|
|
30
|
+
}));
|
|
31
|
+
const loadChantConfigMock = vi.fn();
|
|
32
|
+
vi.mock("../../config", async () => {
|
|
33
|
+
const actual = await vi.importActual<typeof import("../../config")>("../../config");
|
|
34
|
+
return { ...actual, loadChantConfig: (...a: unknown[]) => loadChantConfigMock(...a) };
|
|
35
|
+
});
|
|
36
|
+
const buildResultMock = vi.fn();
|
|
37
|
+
vi.mock("../../build", async () => {
|
|
38
|
+
const actual = await vi.importActual<typeof import("../../build")>("../../build");
|
|
39
|
+
return {
|
|
40
|
+
...actual,
|
|
41
|
+
build: (...a: unknown[]) => Promise.resolve(buildResultMock(...a)),
|
|
42
|
+
buildProject: (...a: unknown[]) => Promise.resolve(buildResultMock(...a)),
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const { runSearch } = await import("./search");
|
|
47
|
+
|
|
48
|
+
function decl<T extends object>(base: T): Declarable & T {
|
|
49
|
+
return { [DECLARABLE_MARKER]: true, ...base } as Declarable & T;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const entities = new Map<string, Declarable>([
|
|
53
|
+
["webServer", decl({ lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} })],
|
|
54
|
+
["launchTemplateServer", decl({ lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} })],
|
|
55
|
+
["dbServer", decl({ lexicon: "aws", entityType: "AWS::RDS::Instance", props: {} })],
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
function makeArgs(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
|
|
59
|
+
return {
|
|
60
|
+
command: "search", path: "kind:Instance",
|
|
61
|
+
format: "", fix: false, watch: false, verbose: false, help: false, live: false, env: "dev",
|
|
62
|
+
...overrides,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("search query-scoped drift (#1268)", () => {
|
|
67
|
+
let out: string[];
|
|
68
|
+
let err: string[];
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
out = [];
|
|
72
|
+
err = [];
|
|
73
|
+
vi.spyOn(console, "log").mockImplementation((s: string) => { out.push(s); });
|
|
74
|
+
vi.spyOn(console, "error").mockImplementation((s: string) => { err.push(s); });
|
|
75
|
+
discoverMock.mockReset();
|
|
76
|
+
discoverMock.mockResolvedValue({ entities, errors: [], sourceFiles: [] });
|
|
77
|
+
buildResultMock.mockReset();
|
|
78
|
+
buildResultMock.mockReturnValue({ errors: [], entities, outputs: new Map([["aws", ""]]), warnings: [] });
|
|
79
|
+
loadChantConfigMock.mockReset();
|
|
80
|
+
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
81
|
+
resolveLexMock.mockReset();
|
|
82
|
+
resolveLexMock.mockResolvedValue(["aws"]);
|
|
83
|
+
hasSnapshotMock.mockReset();
|
|
84
|
+
hasSnapshotMock.mockResolvedValue(false);
|
|
85
|
+
replaySnapshotsMock.mockReset();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("--check-live without --at is refused", async () => {
|
|
89
|
+
const exit = await runSearch({ args: makeArgs({ checkLive: true }), plugins: [], serializers: [] });
|
|
90
|
+
expect(exit).toBe(1);
|
|
91
|
+
expect(err.join("\n")).toContain("--check-live needs --at");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("--check-snapshot without --live is refused", async () => {
|
|
95
|
+
const exit = await runSearch({ args: makeArgs({ checkSnapshot: true }), plugins: [], serializers: [] });
|
|
96
|
+
expect(exit).toBe(1);
|
|
97
|
+
expect(err.join("\n")).toContain("--check-snapshot needs --live");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("--fail-on-drift without a check flag is refused", async () => {
|
|
101
|
+
const exit = await runSearch({ args: makeArgs({ failOnDrift: true }), plugins: [], serializers: [] });
|
|
102
|
+
expect(exit).toBe(1);
|
|
103
|
+
expect(err.join("\n")).toContain("--fail-on-drift needs --check-live or --check-snapshot");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("--at --check-live: drift only for matched rows, using diffLive's categories", async () => {
|
|
107
|
+
replaySnapshotsMock.mockResolvedValue({
|
|
108
|
+
observations: [{
|
|
109
|
+
lexicon: "aws",
|
|
110
|
+
resources: {
|
|
111
|
+
webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK", attributes: { SubnetId: "subnet-old" } },
|
|
112
|
+
launchTemplateServer: { type: "AWS::EC2::Instance", physicalId: "i-2", status: "OK", attributes: {} },
|
|
113
|
+
},
|
|
114
|
+
}],
|
|
115
|
+
commit: "a1b2c3d4e5f6", timestamp: "2026-08-01T03:15:00.000Z", depth: "identity",
|
|
116
|
+
});
|
|
117
|
+
loadPluginsMock.mockResolvedValue([
|
|
118
|
+
createMockPlugin({
|
|
119
|
+
name: "aws",
|
|
120
|
+
describeResources: async () => ({
|
|
121
|
+
webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK", attributes: { SubnetId: "subnet-new" } },
|
|
122
|
+
launchTemplateServer: { type: "AWS::EC2::Instance", physicalId: "i-2", status: "OK", attributes: {} },
|
|
123
|
+
dbServer: { type: "AWS::RDS::Instance", physicalId: "db-1", status: "OK" },
|
|
124
|
+
}),
|
|
125
|
+
}),
|
|
126
|
+
]);
|
|
127
|
+
const exit = await runSearch({
|
|
128
|
+
args: makeArgs({ path: "kind:EC2::Instance", at: "latest", checkLive: true }),
|
|
129
|
+
plugins: [], serializers: [],
|
|
130
|
+
});
|
|
131
|
+
expect(exit).toBe(0);
|
|
132
|
+
const stdout = out.join("\n");
|
|
133
|
+
expect(stdout).toContain("webServer attributes.SubnetId: subnet-old → subnet-new — drifted");
|
|
134
|
+
expect(stdout).toContain("checked against a live read · 1 of 2 matched drifted");
|
|
135
|
+
// dbServer isn't matched by kind:EC2::Instance and must never enter the diff.
|
|
136
|
+
expect(stdout).not.toContain("dbServer");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("--fail-on-drift exits non-zero when the scoped check finds drift", async () => {
|
|
140
|
+
replaySnapshotsMock.mockResolvedValue({
|
|
141
|
+
observations: [{ lexicon: "aws", resources: { webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK" } } }],
|
|
142
|
+
commit: "a1b2c3d", timestamp: "t", depth: "identity",
|
|
143
|
+
});
|
|
144
|
+
loadPluginsMock.mockResolvedValue([
|
|
145
|
+
createMockPlugin({
|
|
146
|
+
name: "aws",
|
|
147
|
+
describeResources: async () => ({
|
|
148
|
+
webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "UPDATING" },
|
|
149
|
+
}),
|
|
150
|
+
}),
|
|
151
|
+
]);
|
|
152
|
+
const exit = await runSearch({
|
|
153
|
+
args: makeArgs({ path: "kind:EC2::Instance", at: "latest", checkLive: true, failOnDrift: true }),
|
|
154
|
+
plugins: [], serializers: [],
|
|
155
|
+
});
|
|
156
|
+
expect(exit).toBe(1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("no drift exits 0 even with --fail-on-drift", async () => {
|
|
160
|
+
replaySnapshotsMock.mockResolvedValue({
|
|
161
|
+
observations: [{ lexicon: "aws", resources: { webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK" } } }],
|
|
162
|
+
commit: "a1b2c3d", timestamp: "t", depth: "identity",
|
|
163
|
+
});
|
|
164
|
+
loadPluginsMock.mockResolvedValue([
|
|
165
|
+
createMockPlugin({
|
|
166
|
+
name: "aws",
|
|
167
|
+
describeResources: async () => ({
|
|
168
|
+
webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK" },
|
|
169
|
+
}),
|
|
170
|
+
}),
|
|
171
|
+
]);
|
|
172
|
+
const exit = await runSearch({
|
|
173
|
+
args: makeArgs({ path: "webServer", at: "latest", checkLive: true, failOnDrift: true }),
|
|
174
|
+
plugins: [], serializers: [],
|
|
175
|
+
});
|
|
176
|
+
expect(exit).toBe(0);
|
|
177
|
+
expect(out.join("\n")).toContain("no drift across 1 matched");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("--live --check-snapshot: a resource newly observed since the snapshot", async () => {
|
|
181
|
+
loadPluginsMock.mockResolvedValue([
|
|
182
|
+
createMockPlugin({
|
|
183
|
+
name: "aws",
|
|
184
|
+
describeResources: async () => ({
|
|
185
|
+
webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK" },
|
|
186
|
+
}),
|
|
187
|
+
}),
|
|
188
|
+
]);
|
|
189
|
+
replaySnapshotsMock.mockResolvedValue({
|
|
190
|
+
observations: [{ lexicon: "aws", resources: {} }],
|
|
191
|
+
commit: "a1b2c3d", timestamp: "t", depth: "identity",
|
|
192
|
+
});
|
|
193
|
+
const exit = await runSearch({
|
|
194
|
+
args: makeArgs({ path: "webServer", live: true, checkSnapshot: true }),
|
|
195
|
+
plugins: [], serializers: [],
|
|
196
|
+
});
|
|
197
|
+
expect(exit).toBe(0);
|
|
198
|
+
expect(out.join("\n")).toContain("webServer — newly observed since the recorded snapshot");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("--check-snapshot with nothing recorded is a note, not a failure", async () => {
|
|
202
|
+
loadPluginsMock.mockResolvedValue([
|
|
203
|
+
createMockPlugin({
|
|
204
|
+
name: "aws",
|
|
205
|
+
describeResources: async () => ({
|
|
206
|
+
webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK" },
|
|
207
|
+
}),
|
|
208
|
+
}),
|
|
209
|
+
]);
|
|
210
|
+
replaySnapshotsMock.mockResolvedValue({ error: `No snapshots found for environment "dev"` });
|
|
211
|
+
const exit = await runSearch({
|
|
212
|
+
args: makeArgs({ path: "kind:EC2::Instance", live: true, checkSnapshot: true }),
|
|
213
|
+
plugins: [], serializers: [],
|
|
214
|
+
});
|
|
215
|
+
expect(exit).toBe(0);
|
|
216
|
+
expect(err.join("\n")).toContain("--check-snapshot: No snapshots found");
|
|
217
|
+
expect(out.join("\n")).not.toContain("checked against");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("a live read that fails during --check-live reports unobserved, never missing (#1089)", async () => {
|
|
221
|
+
replaySnapshotsMock.mockResolvedValue({
|
|
222
|
+
observations: [{ lexicon: "aws", resources: { webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK" } } }],
|
|
223
|
+
commit: "a1b2c3d", timestamp: "t", depth: "identity",
|
|
224
|
+
});
|
|
225
|
+
loadPluginsMock.mockResolvedValue([
|
|
226
|
+
createMockPlugin({
|
|
227
|
+
name: "aws",
|
|
228
|
+
describeResources: async () => { throw new Error("ECONNREFUSED"); },
|
|
229
|
+
}),
|
|
230
|
+
]);
|
|
231
|
+
const exit = await runSearch({
|
|
232
|
+
args: makeArgs({ path: "kind:EC2::Instance", at: "latest", checkLive: true }),
|
|
233
|
+
plugins: [], serializers: [],
|
|
234
|
+
});
|
|
235
|
+
// The check-live read itself failed, which is the existing --live failure
|
|
236
|
+
// path (#1263) — a real problem distinct from any drift verdict.
|
|
237
|
+
expect(exit).toBe(1);
|
|
238
|
+
const stdout = out.join("\n");
|
|
239
|
+
expect(stdout).toContain("? webServer");
|
|
240
|
+
expect(stdout).not.toContain("missing");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("depth note: a deep-recorded snapshot compared here at identity says so", async () => {
|
|
244
|
+
replaySnapshotsMock.mockResolvedValue({
|
|
245
|
+
observations: [{ lexicon: "aws", resources: { webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK" } } }],
|
|
246
|
+
commit: "a1b2c3d", timestamp: "t", depth: "deep",
|
|
247
|
+
});
|
|
248
|
+
loadPluginsMock.mockResolvedValue([
|
|
249
|
+
createMockPlugin({
|
|
250
|
+
name: "aws",
|
|
251
|
+
describeResources: async () => ({
|
|
252
|
+
webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK" },
|
|
253
|
+
}),
|
|
254
|
+
}),
|
|
255
|
+
]);
|
|
256
|
+
const exit = await runSearch({
|
|
257
|
+
args: makeArgs({ path: "webServer", at: "latest", checkLive: true }),
|
|
258
|
+
plugins: [], serializers: [],
|
|
259
|
+
});
|
|
260
|
+
expect(exit).toBe(0);
|
|
261
|
+
expect(out.join("\n")).toContain("snapshot recorded at deep depth, compared here at identity");
|
|
262
|
+
});
|
|
263
|
+
});
|