@decocms/blocks-cli 7.11.1 → 7.12.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.
@@ -0,0 +1,568 @@
1
+ /**
2
+ * Integration tests for scripts/generate.ts — the unified incremental
3
+ * orchestrator. Fixture-driven like the sibling generate-*.test.ts suites:
4
+ * a minimal site tree is built in a tmp dir and the orchestrator is spawned
5
+ * as a subprocess (`npx tsx generate.ts`), exactly how sites invoke it.
6
+ *
7
+ * "The generator was NOT invoked" is asserted two ways:
8
+ * - the `(cached)` marker in the orchestrator's own log, and
9
+ * - output-file mtimes that do not move across a cached run
10
+ * (generate-blocks rewrites blocks.gen.json unconditionally whenever it
11
+ * runs, so a stable mtime proves the child never spawned).
12
+ */
13
+ import * as cp from "node:child_process";
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
18
+ import { parseCliOptions } from "./generate";
19
+
20
+ const SCRIPT = path.resolve(__dirname, "generate.ts");
21
+
22
+ function run(
23
+ args: string[],
24
+ cwd: string,
25
+ ): { stdout: string; stderr: string; code: number } {
26
+ const r = cp.spawnSync("npx", ["tsx", SCRIPT, ...args], { encoding: "utf8", cwd });
27
+ return { stdout: r.stdout || "", stderr: r.stderr || "", code: r.status ?? -1 };
28
+ }
29
+
30
+ const VALID_INVOKE_TS = `\
31
+ import { createInvokeFn } from "@decocms/start/sdk/createInvoke";
32
+ import { getOrCreateCart } from "./actions/checkout";
33
+ import type { OrderForm } from "./types";
34
+
35
+ export const invoke = {
36
+ vtex: {
37
+ actions: {
38
+ getOrCreateCart: createInvokeFn(
39
+ (data: { orderFormId?: string }) => getOrCreateCart(data),
40
+ ) as unknown as (ctx: { data: { orderFormId?: string } }) => Promise<OrderForm>,
41
+ },
42
+ },
43
+ } as const;
44
+ `;
45
+
46
+ interface FixtureOptions {
47
+ framework?: "tanstack" | "nextjs";
48
+ /** Also scaffold a fake apps package with a parseable invoke.ts. */
49
+ withInvoke?: boolean;
50
+ /** Pre-create .deco/blocksManifest.gen.ts so manifest is adopt-enabled. */
51
+ withManifestArtifact?: boolean;
52
+ }
53
+
54
+ function makeFixture(opts: FixtureOptions = {}): string {
55
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-orchestrator-"));
56
+ const write = (rel: string, content: string) => {
57
+ const abs = path.join(dir, rel);
58
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
59
+ fs.writeFileSync(abs, content);
60
+ };
61
+
62
+ write("package.json", JSON.stringify({ name: "orchestrator-fixture", type: "module" }));
63
+ write(
64
+ "tsconfig.json",
65
+ JSON.stringify({
66
+ compilerOptions: {
67
+ target: "ES2020",
68
+ module: "ESNext",
69
+ moduleResolution: "Bundler",
70
+ jsx: "react-jsx",
71
+ skipLibCheck: true,
72
+ strict: true,
73
+ },
74
+ }),
75
+ );
76
+ write(".deco/blocks/Site.json", JSON.stringify({ __resolveType: "site" }));
77
+ write(
78
+ ".deco/blocks/pages-Home.json",
79
+ JSON.stringify({
80
+ path: "/",
81
+ sections: [{ __resolveType: "site/sections/Hero.tsx", title: "hi" }],
82
+ }),
83
+ );
84
+ write(
85
+ "src/sections/Hero.tsx",
86
+ [
87
+ "export interface Props { title: string; }",
88
+ "export const sync = true;",
89
+ "export default function Hero({ title }: Props) { return <h1>{title}</h1>; }",
90
+ "",
91
+ ].join("\n"),
92
+ );
93
+ write(
94
+ "src/loaders/foo.ts",
95
+ [
96
+ "export interface Props { q?: string; }",
97
+ "export default async function foo(props: Props): Promise<string[]> {",
98
+ ' return [props.q ?? ""];',
99
+ "}",
100
+ "",
101
+ ].join("\n"),
102
+ );
103
+
104
+ // Fake installed @decocms/* packages — enough for framework detection and
105
+ // for the version fingerprint that a lockstep bump must invalidate.
106
+ write(
107
+ "node_modules/@decocms/blocks/package.json",
108
+ JSON.stringify({ name: "@decocms/blocks", version: "7.11.0" }),
109
+ );
110
+ const framework = opts.framework ?? "tanstack";
111
+ write(
112
+ `node_modules/@decocms/${framework}/package.json`,
113
+ JSON.stringify({ name: `@decocms/${framework}`, version: "7.11.0" }),
114
+ );
115
+
116
+ if (opts.withInvoke) {
117
+ write("fake-apps/invoke.ts", VALID_INVOKE_TS);
118
+ write(
119
+ "fake-apps/actions/checkout.ts",
120
+ "export async function getOrCreateCart(_d: any): Promise<any> { return null; }\n",
121
+ );
122
+ write("fake-apps/types.ts", "export type OrderForm = unknown;\n");
123
+ write(
124
+ "node_modules/@tanstack/react-start/package.json",
125
+ JSON.stringify({ name: "@tanstack/react-start", version: "1.166.8" }),
126
+ );
127
+ }
128
+
129
+ if (opts.withManifestArtifact) {
130
+ // Adopt-existing rule: a committed manifest artifact keeps the manifest
131
+ // generator enabled even off-Next.js.
132
+ write(".deco/blocksManifest.gen.ts", "export default {};\n");
133
+ }
134
+
135
+ return dir;
136
+ }
137
+
138
+ function mtime(dir: string, rel: string): number {
139
+ return fs.statSync(path.join(dir, rel)).mtimeMs;
140
+ }
141
+
142
+ const DIGESTS = ".deco/generate.digests.json";
143
+ const STAT_MEMO = ".deco/.cache/stat-memo.json";
144
+
145
+ function readDigests(dir: string): { version: number; generators: Record<string, any> } {
146
+ return JSON.parse(fs.readFileSync(path.join(dir, DIGESTS), "utf8"));
147
+ }
148
+
149
+ /** Bump mtime (fresh-clone checkouts have arbitrary mtimes) without touching
150
+ * content. */
151
+ function touch(abs: string): void {
152
+ const later = new Date(Date.now() + 5_000);
153
+ fs.utimesSync(abs, later, later);
154
+ }
155
+
156
+ /** Every fixture input file, recursively (skipping node_modules/.deco — the
157
+ * @decocms package.jsons are fingerprinted by version, not by stat). */
158
+ function touchAllInputs(dir: string): number {
159
+ let touched = 0;
160
+ const visit = (d: string) => {
161
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
162
+ const full = path.join(d, e.name);
163
+ if (e.isDirectory()) {
164
+ if (e.name === "node_modules" || e.name === ".cache") continue;
165
+ visit(full);
166
+ } else if (e.isFile() && !e.name.includes(".gen.")) {
167
+ touch(full);
168
+ touched++;
169
+ }
170
+ }
171
+ };
172
+ visit(dir);
173
+ return touched;
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // parseCliOptions — pure unit coverage
178
+ // ---------------------------------------------------------------------------
179
+
180
+ describe("parseCliOptions", () => {
181
+ it("parses selection, forwarding, and toggles", () => {
182
+ const o = parseCliOptions([
183
+ "--only",
184
+ "blocks,blocks-manifest",
185
+ "--skip",
186
+ "invoke",
187
+ "--force",
188
+ "--site",
189
+ "mysite",
190
+ "--namespace",
191
+ "site",
192
+ "--skip-apps",
193
+ "--registry",
194
+ "--exclude",
195
+ "site/loaders/a,site/loaders/b",
196
+ ]);
197
+ expect(o.only).toEqual(["blocks", "manifest"]); // alias normalized
198
+ expect(o.skip).toEqual(["invoke"]);
199
+ expect(o.force).toBe(true);
200
+ expect(o.site).toBe("mysite");
201
+ expect(o.namespace).toBe("site");
202
+ expect(o.skipApps).toBe(true);
203
+ expect(o.registry).toBe(true);
204
+ expect(o.exclude).toBe("site/loaders/a,site/loaders/b");
205
+ });
206
+
207
+ it("rejects unknown generators and unknown flags", () => {
208
+ expect(() => parseCliOptions(["--only", "nope"])).toThrow(/Unknown generator/);
209
+ expect(() => parseCliOptions(["--wat"])).toThrow(/Unknown option/);
210
+ });
211
+
212
+ it("--no-registry forces the registry off; default is auto (null)", () => {
213
+ expect(parseCliOptions(["--no-registry"]).registry).toBe(false);
214
+ expect(parseCliOptions([]).registry).toBe(null);
215
+ });
216
+ });
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Lifecycle: fresh → cached → selective invalidation → force
220
+ // ---------------------------------------------------------------------------
221
+
222
+ describe("orchestrator lifecycle (tanstack-shaped fixture)", () => {
223
+ let dir: string;
224
+
225
+ beforeAll(() => {
226
+ dir = makeFixture({ withInvoke: true, withManifestArtifact: true });
227
+ });
228
+ afterAll(() => {
229
+ fs.rmSync(dir, { recursive: true, force: true });
230
+ });
231
+
232
+ it("fresh run generates every artifact and writes the cache scaffold", () => {
233
+ const r = run(["--apps-dir", "fake-apps"], dir);
234
+ expect(r.code).toBe(0);
235
+ for (const out of [
236
+ ".deco/blocks.gen.json",
237
+ ".deco/blocks.gen.ts",
238
+ ".deco/blocksManifest.gen.ts",
239
+ ".deco/sections.gen.ts",
240
+ ".deco/loaders.gen.ts",
241
+ "src/server/invoke.gen.ts",
242
+ ".deco/meta.gen.json",
243
+ ]) {
244
+ expect(fs.existsSync(path.join(dir, out)), `${out} should exist`).toBe(true);
245
+ }
246
+ // All six ran fresh.
247
+ for (const name of ["blocks", "manifest", "sections", "loaders", "invoke", "schema"]) {
248
+ expect(r.stdout).toMatch(new RegExp(`\\[generate\\] ${name} \\d+ms \\(fresh\\)`));
249
+ }
250
+ // Committed tier: one record per generator in .deco/generate.digests.json
251
+ // (meant to be committed). Local tier: the stat memo under .deco/.cache/,
252
+ // with the .gitignore that keeps IT out of git (sites commit .deco/).
253
+ const cache = readDigests(dir);
254
+ expect(Object.keys(cache.generators).sort()).toEqual([
255
+ "blocks",
256
+ "invoke",
257
+ "loaders",
258
+ "manifest",
259
+ "schema",
260
+ "sections",
261
+ ]);
262
+ expect(fs.existsSync(path.join(dir, STAT_MEMO))).toBe(true);
263
+ expect(fs.readFileSync(path.join(dir, ".deco/.cache/.gitignore"), "utf8").trim()).toBe("*");
264
+ // Records are machine-independent: content hashes + versions + argv,
265
+ // never size/mtime.
266
+ const record = cache.generators.blocks;
267
+ expect(record.inputs).toMatch(/^[0-9a-f]{64}$/);
268
+ expect(record.deco).toContain("@decocms/blocks@7.11.0");
269
+ expect(JSON.stringify(record)).not.toMatch(/mtime/i);
270
+ }, 90_000);
271
+
272
+ it("second run is fully cached and never invokes the generators", () => {
273
+ const before = mtime(dir, ".deco/blocks.gen.json"); // rewritten on EVERY blocks run
274
+ const beforeMeta = mtime(dir, ".deco/meta.gen.json");
275
+ const r = run(["--apps-dir", "fake-apps"], dir);
276
+ expect(r.code).toBe(0);
277
+ for (const name of ["blocks", "manifest", "sections", "loaders", "invoke", "schema"]) {
278
+ expect(r.stdout).toMatch(new RegExp(`\\[generate\\] ${name} \\d+ms \\(cached\\)`));
279
+ }
280
+ expect(r.stdout).not.toContain("(fresh)");
281
+ expect(mtime(dir, ".deco/blocks.gen.json")).toBe(before);
282
+ expect(mtime(dir, ".deco/meta.gen.json")).toBe(beforeMeta);
283
+ }, 30_000);
284
+
285
+ it("touching one .deco/blocks file re-runs only blocks + manifest", () => {
286
+ fs.writeFileSync(
287
+ path.join(dir, ".deco/blocks/pages-Home.json"),
288
+ JSON.stringify({
289
+ path: "/",
290
+ sections: [{ __resolveType: "site/sections/Hero.tsx", title: "edited" }],
291
+ }),
292
+ );
293
+ const sectionsBefore = mtime(dir, ".deco/sections.gen.ts");
294
+ const r = run(["--apps-dir", "fake-apps"], dir);
295
+ expect(r.code).toBe(0);
296
+ expect(r.stdout).toMatch(/\[generate\] blocks \d+ms \(fresh\)/);
297
+ expect(r.stdout).toMatch(/\[generate\] manifest \d+ms \(fresh\)/);
298
+ for (const name of ["sections", "loaders", "invoke", "schema"]) {
299
+ expect(r.stdout).toMatch(new RegExp(`\\[generate\\] ${name} \\d+ms \\(cached\\)`));
300
+ }
301
+ expect(mtime(dir, ".deco/sections.gen.ts")).toBe(sectionsBefore);
302
+ }, 30_000);
303
+
304
+ it("changing a forwarded flag re-runs only the generator it maps to", () => {
305
+ const r = run(["--apps-dir", "fake-apps", "--exclude", "site/loaders/foo"], dir);
306
+ expect(r.code).toBe(0);
307
+ expect(r.stdout).toMatch(/\[generate\] loaders \d+ms \(fresh\)/);
308
+ for (const name of ["blocks", "manifest", "sections", "invoke", "schema"]) {
309
+ expect(r.stdout).toMatch(new RegExp(`\\[generate\\] ${name} \\d+ms \\(cached\\)`));
310
+ }
311
+ // The excluded loader is gone from the emitted registry.
312
+ expect(fs.readFileSync(path.join(dir, ".deco/loaders.gen.ts"), "utf8")).not.toContain(
313
+ '"site/loaders/foo"',
314
+ );
315
+ }, 30_000);
316
+
317
+ it("a @decocms/* version change busts every generator", () => {
318
+ fs.writeFileSync(
319
+ path.join(dir, "node_modules/@decocms/blocks/package.json"),
320
+ JSON.stringify({ name: "@decocms/blocks", version: "7.12.0" }),
321
+ );
322
+ const r = run(["--apps-dir", "fake-apps", "--exclude", "site/loaders/foo"], dir);
323
+ expect(r.code).toBe(0);
324
+ for (const name of ["blocks", "manifest", "sections", "loaders", "invoke", "schema"]) {
325
+ expect(r.stdout).toMatch(new RegExp(`\\[generate\\] ${name} \\d+ms \\(fresh\\)`));
326
+ }
327
+ }, 90_000);
328
+
329
+ it("a deleted output is a cache miss even with a matching digest", () => {
330
+ fs.rmSync(path.join(dir, ".deco/sections.gen.ts"));
331
+ const r = run(["--apps-dir", "fake-apps", "--exclude", "site/loaders/foo"], dir);
332
+ expect(r.code).toBe(0);
333
+ expect(r.stdout).toMatch(/\[generate\] sections \d+ms \(fresh\)/);
334
+ expect(r.stdout).toMatch(/\[generate\] blocks \d+ms \(cached\)/);
335
+ expect(fs.existsSync(path.join(dir, ".deco/sections.gen.ts"))).toBe(true);
336
+ }, 30_000);
337
+
338
+ it("--force re-runs everything despite a warm cache", () => {
339
+ const r = run(["--apps-dir", "fake-apps", "--exclude", "site/loaders/foo", "--force"], dir);
340
+ expect(r.code).toBe(0);
341
+ for (const name of ["blocks", "manifest", "sections", "loaders", "invoke", "schema"]) {
342
+ expect(r.stdout).toMatch(new RegExp(`\\[generate\\] ${name} \\d+ms \\(fresh\\)`));
343
+ }
344
+ expect(r.stdout).not.toContain("(cached)");
345
+ }, 90_000);
346
+
347
+ it("--dry-run reports the plan without touching anything", () => {
348
+ const digestsBefore = fs.readFileSync(path.join(dir, DIGESTS), "utf8");
349
+ const memoBefore = fs.readFileSync(path.join(dir, STAT_MEMO), "utf8");
350
+ const blocksBefore = mtime(dir, ".deco/blocks.gen.json");
351
+ const r = run(["--apps-dir", "fake-apps", "--dry-run"], dir);
352
+ expect(r.code).toBe(0);
353
+ expect(r.stdout).toContain("dry run");
354
+ // --exclude was dropped again, so loaders would re-run; the rest is cached.
355
+ expect(r.stdout).toMatch(/\[generate\] loaders: would run \(flags changed\)/);
356
+ expect(r.stdout).toMatch(/\[generate\] blocks: skip — cached/);
357
+ expect(fs.readFileSync(path.join(dir, DIGESTS), "utf8")).toBe(digestsBefore);
358
+ // Not even the stat memo — dry run is strictly read-only.
359
+ expect(fs.readFileSync(path.join(dir, STAT_MEMO), "utf8")).toBe(memoBefore);
360
+ expect(mtime(dir, ".deco/blocks.gen.json")).toBe(blocksBefore);
361
+ }, 30_000);
362
+ });
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Two-tier cache: committed content-hash digests + local stat memo
366
+ // ---------------------------------------------------------------------------
367
+
368
+ describe("committed digests + stat memo (fresh-clone semantics)", () => {
369
+ let dir: string;
370
+ /** Digest-file bytes after the first successful full run — the baseline
371
+ * every later assertion of determinism/reconciliation compares against. */
372
+ let baseline: string;
373
+
374
+ beforeAll(() => {
375
+ dir = makeFixture({ withInvoke: true, withManifestArtifact: true });
376
+ });
377
+ afterAll(() => {
378
+ fs.rmSync(dir, { recursive: true, force: true });
379
+ });
380
+
381
+ it("serialization is deterministic: two runs produce byte-identical digests", () => {
382
+ expect(run(["--apps-dir", "fake-apps"], dir).code).toBe(0);
383
+ baseline = fs.readFileSync(path.join(dir, DIGESTS), "utf8");
384
+ // Sanity: committed tier lives OUTSIDE the gitignored .deco/.cache/.
385
+ expect(baseline).toContain('"generators"');
386
+ const r = run(["--apps-dir", "fake-apps", "--force"], dir);
387
+ expect(r.code).toBe(0);
388
+ expect(fs.readFileSync(path.join(dir, DIGESTS), "utf8")).toBe(baseline);
389
+ }, 180_000);
390
+
391
+ it("fresh clone: no local cache + churned mtimes still cache-hits everything", () => {
392
+ // Simulate `git clone`: the committed digests + artifacts exist, but the
393
+ // machine-local memo does not, and every checkout mtime is arbitrary.
394
+ fs.rmSync(path.join(dir, ".deco", ".cache"), { recursive: true, force: true });
395
+ expect(touchAllInputs(dir)).toBeGreaterThan(5);
396
+ const blocksBefore = mtime(dir, ".deco/blocks.gen.json");
397
+ const metaBefore = mtime(dir, ".deco/meta.gen.json");
398
+ const r = run(["--apps-dir", "fake-apps"], dir);
399
+ expect(r.code).toBe(0);
400
+ // Every generator validated by hashing CONTENT (memo was gone), not stats.
401
+ for (const name of ["blocks", "manifest", "sections", "loaders", "invoke", "schema"]) {
402
+ expect(r.stdout).toMatch(
403
+ new RegExp(`\\[generate\\] ${name} \\d+ms \\(cached, content-verified\\)`),
404
+ );
405
+ }
406
+ expect(r.stdout).not.toContain("(fresh)");
407
+ expect(mtime(dir, ".deco/blocks.gen.json")).toBe(blocksBefore);
408
+ expect(mtime(dir, ".deco/meta.gen.json")).toBe(metaBefore);
409
+ // The local tier was rebuilt (with its .gitignore) for the next run.
410
+ expect(fs.existsSync(path.join(dir, STAT_MEMO))).toBe(true);
411
+ expect(fs.readFileSync(path.join(dir, ".deco/.cache/.gitignore"), "utf8").trim()).toBe("*");
412
+ }, 30_000);
413
+
414
+ it("warm run validates via the stat memo alone (no content-verified marker)", () => {
415
+ const r = run(["--apps-dir", "fake-apps"], dir);
416
+ expect(r.code).toBe(0);
417
+ for (const name of ["blocks", "manifest", "sections", "loaders", "invoke", "schema"]) {
418
+ expect(r.stdout).toMatch(new RegExp(`\\[generate\\] ${name} \\d+ms \\(cached\\)`));
419
+ }
420
+ expect(r.stdout).not.toContain("content-verified");
421
+ // Documented limitation (same trade as git's index): a content edit that
422
+ // preserves BOTH size and mtimeMs would be trusted by the stat memo and
423
+ // not detected. Not exercised here — engineering it cross-platform is
424
+ // exactly the pathological case git also accepts.
425
+ }, 30_000);
426
+
427
+ it("content-verified marks only the generators whose inputs were rehashed", () => {
428
+ touch(path.join(dir, ".deco/blocks/Site.json"));
429
+ const r = run(["--apps-dir", "fake-apps"], dir);
430
+ expect(r.code).toBe(0);
431
+ // blocks + manifest fingerprint .deco/blocks/*.json → they rehashed it.
432
+ expect(r.stdout).toMatch(/\[generate\] blocks \d+ms \(cached, content-verified\)/);
433
+ expect(r.stdout).toMatch(/\[generate\] manifest \d+ms \(cached, content-verified\)/);
434
+ // Everything else came straight from the memo.
435
+ for (const name of ["sections", "loaders", "invoke", "schema"]) {
436
+ expect(r.stdout).toMatch(new RegExp(`\\[generate\\] ${name} \\d+ms \\(cached\\)`));
437
+ }
438
+ }, 30_000);
439
+
440
+ it("a merge-conflicted digests file reconciles by regeneration", () => {
441
+ // Two branches both regenerated → git leaves conflict markers → the file
442
+ // no longer parses. The orchestrator must treat that as "no records",
443
+ // regenerate, and rewrite a valid file — which, with unchanged inputs,
444
+ // is byte-identical to the pre-conflict baseline.
445
+ const conflicted = `<<<<<<< ours\n${baseline}=======\n${baseline.replace(/./, "!")}\n>>>>>>> theirs\n`;
446
+ fs.writeFileSync(path.join(dir, DIGESTS), conflicted);
447
+ const r = run(["--apps-dir", "fake-apps"], dir);
448
+ expect(r.code).toBe(0);
449
+ for (const name of ["blocks", "manifest", "sections", "loaders", "invoke", "schema"]) {
450
+ expect(r.stdout).toMatch(new RegExp(`\\[generate\\] ${name} \\d+ms \\(fresh\\)`));
451
+ }
452
+ expect(fs.readFileSync(path.join(dir, DIGESTS), "utf8")).toBe(baseline);
453
+ expect(readDigests(dir).version).toBe(2);
454
+ }, 180_000);
455
+ });
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Crash handling — a failed generator must leave NO digest behind
459
+ // ---------------------------------------------------------------------------
460
+
461
+ describe("crashed generator leaves its digest absent so the next run retries", () => {
462
+ // Note: the prompt-obvious injection (malformed JSON in .deco/blocks) does
463
+ // NOT crash generate-blocks — it warns and skips bad files by design. A
464
+ // deterministic crash is generate-invoke against an invoke.ts with no
465
+ // `export const invoke` (console.error + exit 1).
466
+ let dir: string;
467
+
468
+ beforeAll(() => {
469
+ dir = makeFixture({ withInvoke: true });
470
+ fs.writeFileSync(path.join(dir, "fake-apps/invoke.ts"), "export const nothing = 1;\n");
471
+ });
472
+ afterAll(() => {
473
+ fs.rmSync(dir, { recursive: true, force: true });
474
+ });
475
+
476
+ it("fails the run, caches the successes, and omits the crashed generator", () => {
477
+ const r = run(["--apps-dir", "fake-apps", "--skip", "schema"], dir);
478
+ expect(r.code).toBe(1);
479
+ expect(r.stderr).toMatch(/\[generate\] invoke \d+ms FAILED/);
480
+ const cache = readDigests(dir);
481
+ expect(cache.generators.invoke).toBeUndefined();
482
+ // Concurrent stage-1 successes still landed and got cached.
483
+ expect(cache.generators.blocks).toBeDefined();
484
+ expect(cache.generators.sections).toBeDefined();
485
+ expect(cache.generators.loaders).toBeDefined();
486
+ }, 90_000);
487
+
488
+ it("retries the crashed generator on the next run (identical inputs)", () => {
489
+ const r = run(["--apps-dir", "fake-apps", "--skip", "schema"], dir);
490
+ expect(r.code).toBe(1);
491
+ // Not `(cached)` — the digest was never written, so it runs (and fails) again.
492
+ expect(r.stderr).toMatch(/\[generate\] invoke \d+ms FAILED/);
493
+ expect(r.stdout).toMatch(/\[generate\] blocks \d+ms \(cached\)/);
494
+ }, 30_000);
495
+
496
+ it("succeeds once the input is fixed", () => {
497
+ fs.writeFileSync(path.join(dir, "fake-apps/invoke.ts"), VALID_INVOKE_TS);
498
+ const r = run(["--apps-dir", "fake-apps", "--skip", "schema"], dir);
499
+ expect(r.code).toBe(0);
500
+ expect(r.stdout).toMatch(/\[generate\] invoke \d+ms \(fresh\)/);
501
+ expect(readDigests(dir).generators.invoke).toBeDefined();
502
+ expect(fs.existsSync(path.join(dir, "src/server/invoke.gen.ts"))).toBe(true);
503
+ }, 30_000);
504
+
505
+ it("a stage-1 failure skips stage 2 (schema never builds on a broken pass)", () => {
506
+ fs.writeFileSync(path.join(dir, "fake-apps/invoke.ts"), "export const nothing = 1;\n");
507
+ const r = run(["--apps-dir", "fake-apps", "--force"], dir);
508
+ expect(r.code).toBe(1);
509
+ expect(r.stderr).toMatch(/\[generate\] schema skipped \(stage 1 failed\)/);
510
+ expect(readDigests(dir).generators.schema).toBeUndefined();
511
+ }, 90_000);
512
+ });
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // Framework-shaped defaults
516
+ // ---------------------------------------------------------------------------
517
+
518
+ describe("per-framework defaults", () => {
519
+ it("tanstack: blocks+invoke in, manifest out; sections has no registry", () => {
520
+ const dir = makeFixture({ withInvoke: true });
521
+ try {
522
+ const r = run(["--apps-dir", "fake-apps", "--dry-run"], dir);
523
+ expect(r.code).toBe(0);
524
+ expect(r.stdout).toMatch(/blocks: would run/);
525
+ expect(r.stdout).toMatch(/manifest: skip — not a @decocms\/nextjs site/);
526
+ expect(r.stdout).toMatch(/invoke: would run/);
527
+ expect(r.stdout).toMatch(/sections: would run .*--sections-dir src\/sections\n/);
528
+ expect(r.stdout).not.toContain("--registry");
529
+ } finally {
530
+ fs.rmSync(dir, { recursive: true, force: true });
531
+ }
532
+ }, 30_000);
533
+
534
+ it("nextjs: manifest+registry in, blocks+invoke out", () => {
535
+ const dir = makeFixture({ framework: "nextjs" });
536
+ try {
537
+ const dry = run(["--dry-run"], dir);
538
+ expect(dry.code).toBe(0);
539
+ expect(dry.stdout).toMatch(/manifest: would run/);
540
+ expect(dry.stdout).toMatch(/blocks: skip — @decocms\/nextjs site/);
541
+ expect(dry.stdout).toMatch(/invoke: skip — no apps invoke\.ts/);
542
+ expect(dry.stdout).toMatch(/sections: would run .*--registry/);
543
+
544
+ const r = run(["--only", "sections,manifest"], dir);
545
+ expect(r.code).toBe(0);
546
+ expect(fs.readFileSync(path.join(dir, ".deco/sections.gen.ts"), "utf8")).toContain(
547
+ "export const sectionImports",
548
+ );
549
+ expect(fs.readFileSync(path.join(dir, ".deco/blocksManifest.gen.ts"), "utf8")).toContain(
550
+ "pages-Home",
551
+ );
552
+ } finally {
553
+ fs.rmSync(dir, { recursive: true, force: true });
554
+ }
555
+ }, 60_000);
556
+
557
+ it("--only forces a detection-disabled generator on", () => {
558
+ const dir = makeFixture(); // tanstack, no manifest artifact → manifest disabled
559
+ try {
560
+ const r = run(["--only", "manifest"], dir);
561
+ expect(r.code).toBe(0);
562
+ expect(r.stdout).toMatch(/\[generate\] manifest \d+ms \(fresh\)/);
563
+ expect(fs.existsSync(path.join(dir, ".deco/blocksManifest.gen.ts"))).toBe(true);
564
+ } finally {
565
+ fs.rmSync(dir, { recursive: true, force: true });
566
+ }
567
+ }, 30_000);
568
+ });