Skip to content

Commit 54ace18

Browse files
fix(cli): os migrate meta --out writes its snapshot on a run with nothing to migrate (#22121)
Fixes #22116 Clause-②: no ## What this changes `os migrate meta --from 18 --to 18 --out FILE` (a range that crosses no step) exited 0, printed no snapshot line and wrote no FILE. The same command with `--json` wrote FILE. `printMigrationReport` returns early when the chain applied no edit and listed no manual change, and that return came before the `--out` write. - `packages/cli/src/commands/migrate/meta.ts`: the `--out` write and its line move into one helper, `writeStackSnapshot(out, stack)`, called from both exits of `printMigrationReport`. On the early return it runs after the range answer and before `--write`'s outcome and the data migrations. That is the main path's order: the snapshot after group ③, then `--write`, then the data migrations last. - Why a helper rather than moving the write above the branch: above the branch, the snapshot line would print before group ② on the main path and change that path's order. The helper keeps one writer and leaves every main-path line where it was. - Untouched, per the ruling: the `--json` path (it writes FILE on its own branch), `--write`, `--stored` / `--apply` and the chain. - `.changeset/22116-migrate-meta-out-snapshot.md`: `@objectstack/cli` patch. ## Measured before the change (base `a959493c`) Source CLI (`packages/cli/bin/run-dev.js`) over a one-object fixture project: | run | exit | snapshot line | FILE | |---|---|---|---| | `--from 18 --to 18 --out h.json` | 0 | none | absent (`ls`: No such file) | | `--from 18 --to 18 --out j.json --json` | 0 | n/a | written, 663 bytes | | `--from 17 --to 18 --out c.json` (control) | 0 | printed after `306 manual change(s)` | written, byte-identical to `j.json` | ### Which ranges reach the early return The early return is keyed on "nothing applied and nothing listed", not on "no step". So a range WITH steps over canonical metadata could in principle take its other arm (`Nothing to migrate`). Measured on `a959493c`, no range does. Every major in the registry carries semantic entries (protocol 17: 77, protocol 18: 306), and the chain lists every entry of every hop it crosses, whatever the stack holds. Over a canonical stack, as hops / applied / todos: - 16 → 16: 0 / 0 / 0; 17 → 17: 0 / 0 / 0; 18 → 18: 0 / 0 / 0 - 16 → 17: 1 / 0 / 77; 17 → 18: 1 / 0 / 306; 16 → 18: 2 / 0 / 383 So today only an empty range reaches the early return. The change sits in the branch both arms share, so it covers both, and the second arm is pinned in-process over a real chain result with its notices taken away. The draft PR #22115 keeps `result.todos` whole, filters only what it lists, and does not touch the early-return condition, so that arm stays unreachable after it lands too. ## Pins `packages/cli/test/migrate-meta-out-snapshot.test.ts`, unit tier (in-process `MigrateMeta.run` over a temp project that links the real `@objectstack/spec`; nothing is spawned and no kernel is booted): 1. Every empty range the command accepts (`--from N --to N` for N = 16, 17, 18, derived from the registry): the human mode exits 0, writes FILE and names it on exactly one line, after the range answer. The `--json` mode writes FILE too. The two FILEs are byte-identical, and FILE is the stack. 2. A stale FILE left by an earlier run is overwritten. 3. With `--write`, the snapshot line prints before `--write`'s outcome. 4. Control: `--from 17 --to 18` writes FILE and names it after the manual-change header, and its bytes equal the `--json` mode's. 5. `printMigrationReport` on both arms of the early return (an empty range, and a range with steps whose notices are taken away): FILE is written, and the snapshot line, `--write`'s outcome and the data migrations print in that order. Red before, green after, and one ablation: - Red on `76439ebd` (the pins, before the change): 7 failed, 1 passed (the control). The failures were FILE absent (`ENOENT`) or the stale bytes kept. - Green on `0bd4955e`: the new pin plus `meta.report-order.test.ts` and `migrate-meta-write.test.ts`, 3 files, 47 tests passed. - Ablation on `0bd4955e`, through `scripts/ablation-replace.mjs` in wrap mode (trap restore). It deleted only the early-return call `if (report.out) writeStackSnapshot(report.out, result.stack);` (anchor 1 → 0, blob `20cadb91c148` → `2542a9eca0f5`). Result: 7 failed, 1 passed, with the control green. Restored: blob equal to HEAD `20cadb91c148`, `git diff HEAD` empty. ## Verification (all on `0bd4955e`) - `@objectstack/cli` unit tier (`vitest run --project unit --maxWorkers=2`): 263 of 263 files, 3869 tests passed. The first full run reported 2 files failed with `packages/cli is not built (./dist/index.js is absent)`. That is a prerequisite, not a measurement, and both files passed (29 tests) after `pnpm build`. The integration tier is declared to CI: the diff touches no integration-tier file and no spawn entry. - `pnpm --filter @objectstack/cli typecheck` (`tsc --noEmit`, then `check:test-typecheck`): exit 0. `tsc --listFiles` shows the new test in `tsconfig.test.json`'s program. - Gates: each of the 65 commands `node scripts/pm/dispatch-gates.mjs --commands` derives for this diff exited 0. Four first exited 3 (`PREREQUISITE NOT MET`, an unbuilt workspace) and passed when re-run after `pnpm build`: `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`. The `--ran` reconciliation, over recorded exit codes, reads 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. The four artifact-roster gates whose roster sits under a changed path (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`) also exited 0. - `pnpm lint` (full repository, `eslint . --no-inline-config`): exit 0, no findings. ## Acceptance notes - Neighbour: the draft PR #22115 edits `printMigrationReport` next to this region, inserting a group ④ call directly above `if (report.out) {`. This diff leaves that `if` line byte-identical and changes only the two lines under it, so an unchanged line separates the two diffs. Whichever lands later merges `main`. - The snapshot line's text is unchanged. On the early return it follows the blank line after the range answer, just as it follows the blank line after group ③ on the main path. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent db4c45b commit 54ace18

3 files changed

Lines changed: 359 additions & 3 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os migrate meta --out FILE` writes its snapshot on a range that crosses no step (#22116)
6+
7+
Clause-②: no
8+
9+
`os migrate meta --from 18 --to 18 --out FILE` exited 0, printed no snapshot line and wrote no `FILE`. The same command with `--json` wrote `FILE`. The human report returned early on a run with nothing to migrate, and that return came before the `--out` write. An operator or a CI step that keeps `FILE` as the record of the run then found no file, or read an earlier run's file as this one's.
10+
11+
- The human mode now writes `FILE` and prints the line that names it on every run. On a run with nothing to migrate, the line comes after the range answer, before `--write`'s outcome and the data migrations, the same order as on every other run.
12+
- The bytes are the ones `--json` writes for the same run: the stack the chain returned, which for a range with no step is the stack as loaded.
13+
- The fix sits in the branch both "nothing to migrate" answers share. A range with steps that applies and lists nothing takes the same branch, but no range reaches it on this build, because every major carries semantic notices and the chain lists them all.
14+
- Unchanged: the `--json` mode, `--write`, `--stored` and the chain. A range with steps writes `FILE` exactly as before.

‎packages/cli/src/commands/migrate/meta.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,19 @@ function printPendingDataMigrations(pending: readonly PendingDataMigration[]): v
251251
console.log('');
252252
}
253253

254+
/**
255+
* `--out`: write the migrated stack as a JSON snapshot, and print the line that
256+
* names it. The one writer for both exits of {@link printMigrationReport}: the
257+
* main path, and the early return of a run with nothing to migrate (#22116).
258+
* An operator or a CI step keeps this file as the record of the run, so a run
259+
* that returned without it left no file, or an earlier run's file read as this
260+
* one's, behind an exit 0. `--json` writes the same bytes on its own branch.
261+
*/
262+
function writeStackSnapshot(out: string, stack: Record<string, unknown>): void {
263+
writeFileSync(out, JSON.stringify(stack, null, 2));
264+
printInfo(`Wrote migrated stack snapshot → ${chalk.white(out)}`);
265+
}
266+
254267
/** One schema refusal of the migrated stack, in the shape `formatZodIssue` renders. */
255268
export type MigrationRefusal = Parameters<typeof formatZodIssue>[0];
256269

@@ -526,8 +539,12 @@ export function printMigrationReport(report: MigrationReport): void {
526539
printSuccess('Nothing to migrate — the metadata is already canonical for this range.');
527540
}
528541
// Still advertise: metadata needing no rewrite says nothing about whether
529-
// this deployment's DATA has been migrated.
542+
// this deployment's DATA has been migrated. And still write `--out`, in the
543+
// main path's order — the snapshot, then `--write`, then the data
544+
// migrations: the snapshot is this run's record whatever the run found, and
545+
// `--json` writes it regardless (#22116).
530546
console.log('');
547+
if (report.out) writeStackSnapshot(report.out, result.stack);
531548
if (report.write) printWriteOutcome(report.write, 0);
532549
printPendingDataMigrations(report.dataMigrations);
533550
// Returning is safe only because ① has already printed: the schema verdict
@@ -563,8 +580,7 @@ export function printMigrationReport(report: MigrationReport): void {
563580
}
564581

565582
if (report.out) {
566-
writeFileSync(report.out, JSON.stringify(result.stack, null, 2));
567-
printInfo(`Wrote migrated stack snapshot → ${chalk.white(report.out)}`);
583+
writeStackSnapshot(report.out, result.stack);
568584
}
569585

570586
// ④ `--write`: the mechanical changes written into the sources, and the rest.
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate meta --out FILE` writes its snapshot on a run with nothing to
5+
* migrate, exactly as it does on every other run (#22116).
6+
*
7+
* The human report returns early when the chain applied no edit and listed no
8+
* manual change, and that return used to come before the `--out` write. So a
9+
* range that crosses no step (`--from 18 --to 18 --out FILE`) exited 0, printed
10+
* no snapshot line and wrote no FILE, while the same run with `--json` wrote
11+
* FILE. An operator or a CI step keeps FILE as the record of the run: a missing
12+
* one fails the next step that opens it, and a stale one left by an earlier run
13+
* is read as this run's.
14+
*
15+
* ## What is pinned
16+
*
17+
* 1. Every empty range this build accepts (`--from N --to N`, N from the
18+
* support floor to the chain's terminus) writes FILE in the human mode and
19+
* names it on exactly one line, after the range answer. The `--json` mode
20+
* writes FILE too, and the two FILEs are byte-identical. A stale FILE is
21+
* overwritten.
22+
* 2. Control: a range with steps writes FILE and names it where it always
23+
* did, after the manual changes; its bytes equal the `--json` mode's.
24+
* 3. Order on the early return, as on the main path: the snapshot line, then
25+
* `--write`'s outcome, then the data migrations, which stay last. The first
26+
* two through the command; the data migrations in-process on
27+
* `printMigrationReport`, because an empty range never lists any.
28+
* 4. Both arms of the early return. The other arm ("Nothing to migrate": a
29+
* range WITH steps that applied and listed nothing) is not reachable
30+
* through the chain on this build. Measured on `a959493c`: every major
31+
* carries semantic entries (77 for protocol 17, 306 for protocol 18), and
32+
* the chain lists every entry of every hop it crosses, whatever the stack
33+
* holds. It is pinned in-process over a real chain result with its notices
34+
* taken away, because the write sits in the branch both arms share.
35+
*
36+
* In-process over the real command (`MigrateMeta.run`) against a temp project
37+
* that links the real `@objectstack/spec`: no process is spawned and no kernel
38+
* is booted, so this file sits in the `unit` tier.
39+
*/
40+
41+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
42+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
43+
import { tmpdir } from 'node:os';
44+
import { dirname, join, resolve } from 'node:path';
45+
import { createRequire } from 'node:module';
46+
import { fileURLToPath } from 'node:url';
47+
import { stripVTControlCharacters } from 'node:util';
48+
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
49+
import {
50+
applyMetaMigrations,
51+
MIGRATION_MAJORS,
52+
MIGRATION_SUPPORT_FLOOR,
53+
type MigrationChainResult,
54+
} from '@objectstack/spec/migrations';
55+
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
56+
import MigrateMeta, {
57+
printMigrationReport,
58+
type MigrationReport,
59+
type PendingDataMigration,
60+
type WriteOutcome,
61+
} from '../src/commands/migrate/meta.js';
62+
63+
const CLI_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..');
64+
const RUN_TIMEOUT = 120_000;
65+
66+
/** Where the chain ends by default — derived, so the file survives the next major. */
67+
const TERMINUS = Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS);
68+
69+
/** Every empty range the command accepts: `--from N --to N`, floor to terminus. */
70+
const EMPTY_RANGES = Array.from(
71+
{ length: TERMINUS - MIGRATION_SUPPORT_FLOOR + 1 },
72+
(_, i) => MIGRATION_SUPPORT_FLOOR + i,
73+
);
74+
75+
/** `packages/cli` depends on `@objectstack/spec`; resolved as a package, not a source path. */
76+
const requireFromCli = createRequire(import.meta.url);
77+
const SPEC_PACKAGE_ROOT = dirname(requireFromCli.resolve('@objectstack/spec/package.json'));
78+
79+
const MANIFEST_ID = 'com.example.out-snapshot';
80+
81+
/** A one-object stack, already canonical — the shape of the run the defect was measured on. */
82+
const STACK = {
83+
manifest: { id: MANIFEST_ID, name: 'Out snapshot', version: '1.0.0', type: 'app' },
84+
objects: [{ name: 'os_thing', label: 'Thing', fields: { title: { type: 'text', label: 'Title' } } }],
85+
};
86+
87+
let root: string;
88+
let specLink: string;
89+
let project: string;
90+
let fileSeq = 0;
91+
92+
/** A fresh, absent snapshot path under the temp root. */
93+
function freshOut(): string {
94+
return join(root, `snapshot-${++fileSeq}.json`);
95+
}
96+
97+
interface Run {
98+
stdout: string;
99+
lines: string[];
100+
exitCode: number;
101+
}
102+
103+
/** Run the real command in-process, capturing stdout and any exit. */
104+
async function runMeta(argv: string[]): Promise<Run> {
105+
const out: string[] = [];
106+
const priorExitCode = process.exitCode;
107+
const write = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => {
108+
out.push(String(chunk));
109+
const done = rest.find((r) => typeof r === 'function') as (() => void) | undefined;
110+
done?.();
111+
return true;
112+
}) as never);
113+
const log = vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { out.push(a.join(' ')); });
114+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
115+
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
116+
let exitCode: number | undefined;
117+
try {
118+
await MigrateMeta.run([join(project, 'objectstack.config.ts'), ...argv], { root: CLI_ROOT });
119+
} catch (e: any) {
120+
if (typeof e?.oclif?.exit !== 'number') throw e;
121+
exitCode = e.oclif.exit;
122+
} finally {
123+
write.mockRestore();
124+
log.mockRestore();
125+
warn.mockRestore();
126+
error.mockRestore();
127+
if (exitCode === undefined && typeof process.exitCode === 'number' && process.exitCode !== 0) {
128+
exitCode = process.exitCode;
129+
}
130+
process.exitCode = priorExitCode;
131+
}
132+
const stdout = stripVTControlCharacters(out.join('\n'));
133+
return { stdout, lines: stdout.split('\n'), exitCode: exitCode ?? 0 };
134+
}
135+
136+
/** The indices of the lines that name `file`. */
137+
function linesNaming(lines: string[], file: string): number[] {
138+
return lines.flatMap((l, i) => (l.includes(file) ? [i] : []));
139+
}
140+
141+
function indexOf(lines: string[], re: RegExp): number {
142+
return lines.findIndex((l) => re.test(l));
143+
}
144+
145+
const RANGE_ANSWER_RE = /No migration step exists for protocol/;
146+
const SEMANTIC_HEADER_RE = /^ {2}\d+ manual change\(s\) require your judgment:$/;
147+
const WRITE_OUTCOME_RE = /^ {2}ℹ --write: /;
148+
149+
beforeAll(() => {
150+
root = mkdtempSync(join(tmpdir(), 'os-migrate-meta-out-'));
151+
mkdirSync(join(root, 'node_modules', '@objectstack'), { recursive: true });
152+
specLink = join(root, 'node_modules', '@objectstack', 'spec');
153+
symlinkSync(SPEC_PACKAGE_ROOT, specLink, 'dir');
154+
project = join(root, 'project');
155+
mkdirSync(project, { recursive: true });
156+
writeFileSync(
157+
join(project, 'objectstack.config.ts'),
158+
`import { defineStack } from '@objectstack/spec';
159+
160+
export default defineStack(${JSON.stringify(STACK, null, 2)});
161+
`,
162+
);
163+
});
164+
165+
afterAll(() => {
166+
// Unlinked BEFORE the recursive remove, and named explicitly: this symlink
167+
// points at the real `packages/spec`, and a cleanup must never follow it.
168+
try { unlinkSync(specLink); } catch { /* already gone */ }
169+
try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
170+
});
171+
172+
// ── 1: an empty range, through the command ─────────────────────────────────
173+
174+
describe('os migrate meta --out over a range that crosses no step', () => {
175+
it.each(EMPTY_RANGES)('--from %i --to the same writes FILE in the human mode and the --json mode alike', async (n) => {
176+
const human = freshOut();
177+
const machine = freshOut();
178+
179+
const h = await runMeta(['--from', String(n), '--to', String(n), '--out', human]);
180+
expect(h.exitCode).toBe(0);
181+
// Anti-vacuity: this run took the empty-range answer, not the main path.
182+
const answer = indexOf(h.lines, RANGE_ANSWER_RE);
183+
expect(answer, 'the run answered an empty range').toBeGreaterThan(-1);
184+
expect(existsSync(human), 'the human mode wrote FILE').toBe(true);
185+
const named = linesNaming(h.lines, human);
186+
expect(named, 'one line names FILE').toHaveLength(1);
187+
expect(named[0]!).toBeGreaterThan(answer);
188+
189+
const j = await runMeta(['--from', String(n), '--to', String(n), '--out', machine, '--json']);
190+
expect(j.exitCode).toBe(0);
191+
expect(JSON.parse(j.stdout).applied).toEqual([]);
192+
expect(existsSync(machine), 'the --json mode wrote FILE').toBe(true);
193+
194+
// The two modes agree, byte for byte, and FILE is the stack.
195+
const bytes = readFileSync(human, 'utf8');
196+
expect(bytes).toBe(readFileSync(machine, 'utf8'));
197+
const snapshot = JSON.parse(bytes);
198+
expect(snapshot.manifest.id).toBe(MANIFEST_ID);
199+
expect(snapshot.objects.map((o: { name: string }) => o.name)).toEqual(['os_thing']);
200+
}, RUN_TIMEOUT);
201+
202+
it('overwrites a stale FILE left by an earlier run', async () => {
203+
const out = freshOut();
204+
writeFileSync(out, '{ "stale": true }\n');
205+
const run = await runMeta(['--from', String(TERMINUS), '--to', String(TERMINUS), '--out', out]);
206+
expect(run.exitCode).toBe(0);
207+
const snapshot = JSON.parse(readFileSync(out, 'utf8'));
208+
expect(snapshot.stale).toBeUndefined();
209+
expect(snapshot.manifest.id).toBe(MANIFEST_ID);
210+
}, RUN_TIMEOUT);
211+
212+
it('prints the snapshot line before --write\'s outcome', async () => {
213+
const out = freshOut();
214+
const run = await runMeta(['--from', String(TERMINUS), '--to', String(TERMINUS), '--out', out, '--write']);
215+
expect(run.exitCode).toBe(0);
216+
expect(existsSync(out)).toBe(true);
217+
const named = linesNaming(run.lines, out);
218+
expect(named).toHaveLength(1);
219+
const outcome = indexOf(run.lines, WRITE_OUTCOME_RE);
220+
expect(outcome, '--write printed its outcome').toBeGreaterThan(-1);
221+
expect(named[0]!).toBeGreaterThan(indexOf(run.lines, RANGE_ANSWER_RE));
222+
expect(named[0]!).toBeLessThan(outcome);
223+
}, RUN_TIMEOUT);
224+
});
225+
226+
// ── 2: control — a range with steps ────────────────────────────────────────
227+
228+
describe('os migrate meta --out over a range with steps (control)', () => {
229+
it('writes FILE and names it after the manual changes, as before; the --json mode agrees', async () => {
230+
const from = TERMINUS - 1;
231+
const human = freshOut();
232+
const machine = freshOut();
233+
234+
const h = await runMeta(['--from', String(from), '--to', String(TERMINUS), '--out', human]);
235+
expect(h.exitCode).toBe(0);
236+
const header = indexOf(h.lines, SEMANTIC_HEADER_RE);
237+
expect(header, 'anti-vacuity: the run took the main path and listed manual changes').toBeGreaterThan(-1);
238+
expect(indexOf(h.lines, RANGE_ANSWER_RE)).toBe(-1);
239+
expect(existsSync(human)).toBe(true);
240+
const named = linesNaming(h.lines, human);
241+
expect(named).toHaveLength(1);
242+
expect(named[0]!).toBeGreaterThan(header);
243+
244+
const j = await runMeta(['--from', String(from), '--to', String(TERMINUS), '--out', machine, '--json']);
245+
expect(j.exitCode).toBe(0);
246+
expect(readFileSync(human, 'utf8')).toBe(readFileSync(machine, 'utf8'));
247+
}, RUN_TIMEOUT);
248+
});
249+
250+
// ── 3–4: the early return's order, on both of its arms ─────────────────────
251+
252+
describe('printMigrationReport on a run with nothing to migrate', () => {
253+
let printed: string[] = [];
254+
255+
afterEach(() => {
256+
vi.restoreAllMocks();
257+
});
258+
259+
/** A data migration named by a command no real one uses, so its line is found by name. */
260+
const PROBE_DATA_MIGRATION: PendingDataMigration = {
261+
id: 'probe_data_migration',
262+
command: 'os migrate probe-data-migration',
263+
unlocks: 'nothing; a probe.',
264+
};
265+
266+
/** `--write` over a chain that applied nothing: a plan with nothing in it. */
267+
const EMPTY_WRITE: WriteOutcome = {
268+
plan: { projectRoot: '/nowhere', rewrites: [], written: [], manual: [], unexplained: [] },
269+
status: 'written',
270+
};
271+
272+
function report(result: MigrationChainResult, normalized: Record<string, unknown>, out: string): MigrationReport {
273+
const parsed = ObjectStackDefinitionSchema.safeParse(result.stack);
274+
return {
275+
result,
276+
normalized,
277+
schemaValid: parsed.success,
278+
refusals: parsed.success ? [] : parsed.error.issues,
279+
dataMigrations: [PROBE_DATA_MIGRATION],
280+
step: false,
281+
out,
282+
write: EMPTY_WRITE,
283+
elapsed: '1ms',
284+
};
285+
}
286+
287+
const ARMS: Array<[string, () => { result: MigrationChainResult; normalized: Record<string, unknown> }]> = [
288+
['an empty range', () => {
289+
const normalized = normalizeStackInput(STACK, { convert: false });
290+
const result = applyMetaMigrations(normalized, TERMINUS, TERMINUS);
291+
expect(result.hops, 'anti-vacuity: the range holds no step').toHaveLength(0);
292+
return { result, normalized };
293+
}],
294+
['a range with steps that applied and listed nothing', () => {
295+
const normalized = normalizeStackInput(STACK, { convert: false });
296+
const real = applyMetaMigrations(normalized, TERMINUS - 1, TERMINUS);
297+
expect(real.applied, 'the canonical stack needs no mechanical edit').toEqual([]);
298+
// Not reachable through the chain on this build (see the header): the
299+
// notices are taken away so the report takes this arm.
300+
const result = { ...real, todos: [], hops: real.hops.map((hop) => ({ ...hop, todos: [] })) };
301+
expect(result.hops.length, 'anti-vacuity: the range holds a step').toBeGreaterThan(0);
302+
return { result, normalized };
303+
}],
304+
];
305+
306+
it.each(ARMS)('%s: writes FILE, then prints the snapshot line, --write\'s outcome and the data migrations, in that order', (_, arm) => {
307+
const { result, normalized } = arm();
308+
const out = freshOut();
309+
printed = [];
310+
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
311+
printed.push(args.map(String).join(' '));
312+
});
313+
printMigrationReport(report(result, normalized, out));
314+
const lines = stripVTControlCharacters(printed.join('\n')).split('\n');
315+
316+
expect(readFileSync(out, 'utf8')).toBe(JSON.stringify(result.stack, null, 2));
317+
const named = linesNaming(lines, out);
318+
expect(named).toHaveLength(1);
319+
const outcome = indexOf(lines, WRITE_OUTCOME_RE);
320+
const data = lines.findIndex((l) => l.includes(PROBE_DATA_MIGRATION.command));
321+
expect(outcome).toBeGreaterThan(-1);
322+
expect(data).toBeGreaterThan(-1);
323+
expect(named[0]!).toBeLessThan(outcome);
324+
expect(outcome).toBeLessThan(data);
325+
});
326+
});

0 commit comments

Comments
 (0)