@decocms/blocks-cli 7.38.0 → 7.40.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/package.json +2 -2
- package/scripts/generate-sections.test.ts +83 -0
- package/scripts/generate-sections.ts +54 -0
- package/scripts/migrate/phase-scaffold.ts +25 -1
- package/scripts/migrate/phase-verify.ts +6 -0
- package/scripts/migrate/templates/ci-workflows.test.ts +108 -0
- package/scripts/migrate/templates/ci-yml.ts +182 -0
- package/scripts/migrate/templates/main-push-guard-yml.ts +45 -0
- package/scripts/migrate/templates/package-json.ts +10 -0
- package/scripts/migrate/templates/playwright-yml.ts +127 -0
- package/scripts/migrate/templates/react-doctor-yml.ts +46 -0
- package/scripts/migrate/templates/setup.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.40.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
|
|
6
6
|
"repository": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"lint:unused": "knip"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@decocms/blocks": "7.
|
|
34
|
+
"@decocms/blocks": "7.40.0",
|
|
35
35
|
"ts-morph": "^27.0.0",
|
|
36
36
|
"tsx": "^4.22.5"
|
|
37
37
|
},
|
|
@@ -336,6 +336,89 @@ describe("generate-sections neverDefer convention", () => {
|
|
|
336
336
|
}, 60_000);
|
|
337
337
|
});
|
|
338
338
|
|
|
339
|
+
describe("generate-sections renderJson convention", () => {
|
|
340
|
+
let tmpDir: string;
|
|
341
|
+
let sectionsDir: string;
|
|
342
|
+
let outFile: string;
|
|
343
|
+
|
|
344
|
+
beforeEach(() => {
|
|
345
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-renderjson-"));
|
|
346
|
+
sectionsDir = path.join(tmpDir, "sections");
|
|
347
|
+
outFile = path.join(tmpDir, "out", "sections.gen.ts");
|
|
348
|
+
fs.mkdirSync(sectionsDir, { recursive: true });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
afterEach(() => {
|
|
352
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("emits `renderJson: false` into sectionMeta and a `renderJsons` map for projection fns; the output typechecks + imports", () => {
|
|
356
|
+
// `= false` opt-out: web-only section dropped from ?renderJson.
|
|
357
|
+
fs.writeFileSync(
|
|
358
|
+
path.join(sectionsDir, "Theme.tsx"),
|
|
359
|
+
"export const renderJson = false;\nexport default function Theme() { return null; }\n",
|
|
360
|
+
);
|
|
361
|
+
// projection function: trims props for the mobile app.
|
|
362
|
+
fs.writeFileSync(
|
|
363
|
+
path.join(sectionsDir, "ProductDetails.tsx"),
|
|
364
|
+
[
|
|
365
|
+
"export const renderJson = (props: Record<string, unknown>) => {",
|
|
366
|
+
" const { storeConfig: _s, ...rest } = props;",
|
|
367
|
+
" return rest;",
|
|
368
|
+
"};",
|
|
369
|
+
"export default function ProductDetails() { return null; }",
|
|
370
|
+
"",
|
|
371
|
+
].join("\n"),
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]);
|
|
375
|
+
expect(code).toBe(0);
|
|
376
|
+
|
|
377
|
+
const generated = fs.readFileSync(outFile, "utf-8");
|
|
378
|
+
|
|
379
|
+
// The `= false` section carries the literal false flag; the fn section
|
|
380
|
+
// carries hasRenderJson (its actual fn lives in the renderJsons map).
|
|
381
|
+
expect(generated).toMatch(/"site\/sections\/Theme\.tsx":\s*\{[^}]*renderJson:\s*false/);
|
|
382
|
+
expect(generated).toMatch(/"site\/sections\/ProductDetails\.tsx":\s*\{[^}]*hasRenderJson:\s*true/);
|
|
383
|
+
// The emitted interface declares both (excess-property guard, cf. neverDefer).
|
|
384
|
+
expect(generated).toContain("renderJson?: false;");
|
|
385
|
+
expect(generated).toContain("hasRenderJson?: boolean;");
|
|
386
|
+
// The fn is imported and wired into renderJsons; the `= false` one is NOT.
|
|
387
|
+
expect(generated).toContain("import { renderJson as _rj0 }");
|
|
388
|
+
expect(generated).toMatch(/export const renderJsons: Record<string, any> = \{[\s\S]*ProductDetails\.tsx": _rj0/);
|
|
389
|
+
expect(generated).not.toMatch(/renderJsons: Record<string, any> = \{[\s\S]*Theme\.tsx/);
|
|
390
|
+
|
|
391
|
+
// Typecheck the output (the real failure mode for a bad emit is TS2353, not
|
|
392
|
+
// a runtime error) and confirm it imports with a working projection fn.
|
|
393
|
+
// --jsx: the generated file imports a projection fn from a `.tsx` section,
|
|
394
|
+
// so tsc needs jsx set (a real site's tsconfig always does; the standalone
|
|
395
|
+
// invocation must too, or it fails with TS6142).
|
|
396
|
+
const tscResult = cp.spawnSync(
|
|
397
|
+
"npx",
|
|
398
|
+
["tsc", "--noEmit", "--strict", "--skipLibCheck", "--jsx", "react-jsx", outFile],
|
|
399
|
+
{ encoding: "utf8" },
|
|
400
|
+
);
|
|
401
|
+
expect(tscResult.status, tscResult.stdout + tscResult.stderr).toBe(0);
|
|
402
|
+
|
|
403
|
+
const checkerFile = path.join(tmpDir, "check-import.mjs");
|
|
404
|
+
fs.writeFileSync(
|
|
405
|
+
checkerFile,
|
|
406
|
+
[
|
|
407
|
+
`const m = await import(${JSON.stringify(pathToFileURL(outFile).href)});`,
|
|
408
|
+
`if (m.sectionMeta?.["site/sections/Theme.tsx"]?.renderJson !== false) {`,
|
|
409
|
+
` throw new Error("Theme renderJson:false missing from sectionMeta");`,
|
|
410
|
+
`}`,
|
|
411
|
+
`const fn = m.renderJsons?.["site/sections/ProductDetails.tsx"];`,
|
|
412
|
+
`if (typeof fn !== "function") throw new Error("ProductDetails renderJson fn missing");`,
|
|
413
|
+
`const out = fn({ storeConfig: 1, page: 2 });`,
|
|
414
|
+
`if (out.storeConfig !== undefined || out.page !== 2) throw new Error("projection fn wrong");`,
|
|
415
|
+
].join("\n"),
|
|
416
|
+
);
|
|
417
|
+
const importResult = cp.spawnSync("npx", ["tsx", checkerFile], { encoding: "utf8" });
|
|
418
|
+
expect(importResult.status, importResult.stderr).toBe(0);
|
|
419
|
+
}, 60_000);
|
|
420
|
+
});
|
|
421
|
+
|
|
339
422
|
describe("generate-sections output hygiene (non-registry)", () => {
|
|
340
423
|
let tmpDir: string;
|
|
341
424
|
let sectionsDir: string;
|
|
@@ -54,9 +54,19 @@ interface SectionMeta {
|
|
|
54
54
|
clientOnly?: boolean;
|
|
55
55
|
seo?: boolean;
|
|
56
56
|
hasLoadingFallback?: boolean;
|
|
57
|
+
renderJson?: false;
|
|
58
|
+
hasRenderJson?: boolean;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
const EXPORT_CONST_RE = /export\s+const\s+(eager|neverDefer|deferred|cache|layout|sync|clientOnly|seo)\s*=\s*(.+?)(?:;|\n)/g;
|
|
62
|
+
// renderJson (?renderJson mobile path). `= false` drops the section; a function
|
|
63
|
+
// (`export const renderJson = (props) => ...` / `export function renderJson`)
|
|
64
|
+
// projects its props. Kept separate from EXPORT_CONST_RE because the value is a
|
|
65
|
+
// literal `false` OR a function, not the `= true` booleans that regex handles.
|
|
66
|
+
const RENDER_JSON_FALSE_RE = /export\s+const\s+renderJson\s*=\s*false\b/;
|
|
67
|
+
const RENDER_JSON_FN_INLINE_RE =
|
|
68
|
+
/export\s+(?:function\s+renderJson\b|const\s+renderJson\s*=\s*(?!false\b)\S)/;
|
|
69
|
+
const RENDER_JSON_REEXPORT_RE = /export\s*\{[^}]*\brenderJson\b[^}]*\}/;
|
|
60
70
|
// Detects `export function LoadingFallback(...)`, `export const LoadingFallback = ...`, etc.
|
|
61
71
|
const LOADING_FALLBACK_INLINE_RE = /export\s+(?:function|const|let|var)\s+LoadingFallback\b/;
|
|
62
72
|
// Detects re-exports like:
|
|
@@ -96,6 +106,14 @@ function extractMeta(content: string): SectionMeta | null {
|
|
|
96
106
|
found = true;
|
|
97
107
|
}
|
|
98
108
|
|
|
109
|
+
if (RENDER_JSON_FALSE_RE.test(content)) {
|
|
110
|
+
meta.renderJson = false;
|
|
111
|
+
found = true;
|
|
112
|
+
} else if (RENDER_JSON_FN_INLINE_RE.test(content) || RENDER_JSON_REEXPORT_RE.test(content)) {
|
|
113
|
+
meta.hasRenderJson = true;
|
|
114
|
+
found = true;
|
|
115
|
+
}
|
|
116
|
+
|
|
99
117
|
return found ? meta : null;
|
|
100
118
|
}
|
|
101
119
|
|
|
@@ -168,6 +186,8 @@ const lines: string[] = [
|
|
|
168
186
|
"// export const clientOnly = true → skip SSR (client-only rendering)",
|
|
169
187
|
"// export const seo = true → SEO section (provides page head data)",
|
|
170
188
|
"// export function LoadingFallback → skeleton shown while section loads",
|
|
189
|
+
"// export const renderJson = false → drop section from ?renderJson (mobile)",
|
|
190
|
+
"// export const renderJson = (p)=> → project props for ?renderJson (mobile)",
|
|
171
191
|
"",
|
|
172
192
|
];
|
|
173
193
|
|
|
@@ -187,6 +207,16 @@ for (let i = 0; i < nonSyncFallbacks.length; i++) {
|
|
|
187
207
|
lines.push(`import { LoadingFallback as _fb${i} } from "${importPath}";`);
|
|
188
208
|
}
|
|
189
209
|
|
|
210
|
+
// renderJson projection-function imports — sections whose renderJson is a
|
|
211
|
+
// function (not `= false`) and aren't sync-imported.
|
|
212
|
+
const renderJsonEntries = entries.filter((e) => e.meta.hasRenderJson);
|
|
213
|
+
const nonSyncRenderJsons = renderJsonEntries.filter((e) => !e.meta.sync);
|
|
214
|
+
for (let i = 0; i < nonSyncRenderJsons.length; i++) {
|
|
215
|
+
const e = nonSyncRenderJsons[i];
|
|
216
|
+
const importPath = relativeImportPath(outFile, e.filePath);
|
|
217
|
+
lines.push(`import { renderJson as _rj${i} } from "${importPath}";`);
|
|
218
|
+
}
|
|
219
|
+
|
|
190
220
|
lines.push("");
|
|
191
221
|
|
|
192
222
|
// Metadata map
|
|
@@ -205,6 +235,8 @@ lines.push(" sync?: boolean;");
|
|
|
205
235
|
lines.push(" clientOnly?: boolean;");
|
|
206
236
|
lines.push(" seo?: boolean;");
|
|
207
237
|
lines.push(" hasLoadingFallback?: boolean;");
|
|
238
|
+
lines.push(" renderJson?: false;");
|
|
239
|
+
lines.push(" hasRenderJson?: boolean;");
|
|
208
240
|
lines.push("}");
|
|
209
241
|
lines.push("");
|
|
210
242
|
lines.push("export const sectionMeta: Record<string, SectionMetaEntry> = {");
|
|
@@ -248,6 +280,28 @@ if (allFallbacks.length > 0) {
|
|
|
248
280
|
}
|
|
249
281
|
lines.push("");
|
|
250
282
|
|
|
283
|
+
// renderJson projection-functions map (?renderJson mobile path). `= false`
|
|
284
|
+
// sections are NOT here — they carry `renderJson: false` in sectionMeta, which
|
|
285
|
+
// applySectionConventions reads directly. Only projection functions need a live
|
|
286
|
+
// reference. Typed `any` (like syncComponents) to avoid importing the RenderJson
|
|
287
|
+
// type into the generated file.
|
|
288
|
+
if (renderJsonEntries.length > 0) {
|
|
289
|
+
lines.push("export const renderJsons: Record<string, any> = {");
|
|
290
|
+
for (const e of renderJsonEntries) {
|
|
291
|
+
if (e.meta.sync) {
|
|
292
|
+
const syncIdx = syncEntries.indexOf(e);
|
|
293
|
+
lines.push(` "${e.key}": _sync${syncIdx}.renderJson,`);
|
|
294
|
+
} else {
|
|
295
|
+
const rjIdx = nonSyncRenderJsons.indexOf(e);
|
|
296
|
+
lines.push(` "${e.key}": _rj${rjIdx},`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
lines.push("};");
|
|
300
|
+
} else {
|
|
301
|
+
lines.push("export const renderJsons: Record<string, any> = {};");
|
|
302
|
+
}
|
|
303
|
+
lines.push("");
|
|
304
|
+
|
|
251
305
|
// Lazy section-import registry — opt-in via --registry, built from every
|
|
252
306
|
// scanned section file (not just convention-carrying `entries`).
|
|
253
307
|
if (EMIT_REGISTRY) {
|
|
@@ -2,7 +2,11 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { MigrationContext } from "./types";
|
|
4
4
|
import { log, logPhase } from "./types";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
CANONICAL_BUN_VERSION,
|
|
7
|
+
CANONICAL_NODE_VERSION,
|
|
8
|
+
generatePackageJson,
|
|
9
|
+
} from "./templates/package-json";
|
|
6
10
|
import { generateLockfileCheckYml } from "./templates/lockfile-check-yml";
|
|
7
11
|
import { generateTsconfig } from "./templates/tsconfig";
|
|
8
12
|
import { generateViteConfig } from "./templates/vite-config";
|
|
@@ -21,6 +25,10 @@ import { generateCacheConfig } from "./templates/cache-config";
|
|
|
21
25
|
import { generateSdkFiles } from "./templates/sdk-gen";
|
|
22
26
|
import { generateMigrationPolicyPointerRule } from "./templates/cursor-rules";
|
|
23
27
|
import { generatePerfFiles } from "./templates/perf-yml";
|
|
28
|
+
import { generateCiFiles } from "./templates/ci-yml";
|
|
29
|
+
import { generateMainPushGuardYml } from "./templates/main-push-guard-yml";
|
|
30
|
+
import { generatePlaywrightFiles } from "./templates/playwright-yml";
|
|
31
|
+
import { generateReactDoctorYml } from "./templates/react-doctor-yml";
|
|
24
32
|
// `lib-utils` is imported lazily — see end of phase-cleanup. Eager
|
|
25
33
|
// generation of all 11 shims left every site with dead code that had
|
|
26
34
|
// to be cleaned up by hand.
|
|
@@ -93,6 +101,22 @@ export function scaffold(ctx: MigrationContext): void {
|
|
|
93
101
|
// (PR vs main), and posts a comparison comment. Gate = CLS + TBT only.
|
|
94
102
|
writeMultiFile(ctx, generatePerfFiles(ctx.siteName));
|
|
95
103
|
|
|
104
|
+
// Per-PR quality pipeline (ci.yml) + its no-suppressions gate. BLOCKS on
|
|
105
|
+
// generate + build; the migration-cleanliness gates (no-suppressions,
|
|
106
|
+
// typecheck, format, knip) ship advisory so day-one CI is green. Node pinned
|
|
107
|
+
// in lockstep with the perf/playwright workflows.
|
|
108
|
+
writeMultiFile(ctx, generateCiFiles(CANONICAL_NODE_VERSION, CANONICAL_BUN_VERSION));
|
|
109
|
+
|
|
110
|
+
// Branch-protection surrogate: fails visibly if a commit reaches main
|
|
111
|
+
// without a PR (never blocks the push itself).
|
|
112
|
+
writeFile(ctx, ".github/workflows/main-push-guard.yml", generateMainPushGuardYml());
|
|
113
|
+
|
|
114
|
+
// Functional E2E harness (chromium + webkit) + self-contained config/smoke.
|
|
115
|
+
writeMultiFile(ctx, generatePlaywrightFiles(CANONICAL_BUN_VERSION));
|
|
116
|
+
|
|
117
|
+
// Advisory React lint (react-doctor): comments on PRs, never fails.
|
|
118
|
+
writeFile(ctx, ".github/workflows/react-doctor.yml", generateReactDoctorYml());
|
|
119
|
+
|
|
96
120
|
// Server entry files (server.ts, worker-entry.ts, router.tsx, runtime.ts, context.ts)
|
|
97
121
|
writeMultiFile(ctx, generateServerEntry(ctx));
|
|
98
122
|
|
|
@@ -21,6 +21,12 @@ const REQUIRED_FILES = [
|
|
|
21
21
|
// Workers Builds (D6.3) -- configured in the CF dashboard, not via
|
|
22
22
|
// GitHub workflow files in the site repo.
|
|
23
23
|
".github/workflows/lockfile-check.yml",
|
|
24
|
+
".github/workflows/ci.yml",
|
|
25
|
+
".github/workflows/main-push-guard.yml",
|
|
26
|
+
".github/workflows/playwright.yml",
|
|
27
|
+
".github/workflows/react-doctor.yml",
|
|
28
|
+
"tools/gates/no-suppressions.sh",
|
|
29
|
+
"playwright.config.ts",
|
|
24
30
|
"knip.config.ts",
|
|
25
31
|
".prettierrc",
|
|
26
32
|
"src/server.ts",
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { generateCiFiles } from "./ci-yml";
|
|
3
|
+
import { generateMainPushGuardYml } from "./main-push-guard-yml";
|
|
4
|
+
import { generatePlaywrightFiles } from "./playwright-yml";
|
|
5
|
+
import { generateReactDoctorYml } from "./react-doctor-yml";
|
|
6
|
+
|
|
7
|
+
describe("generateCiFiles", () => {
|
|
8
|
+
const files = generateCiFiles("22.15.0", "1.3.5");
|
|
9
|
+
const ci = files[".github/workflows/ci.yml"];
|
|
10
|
+
|
|
11
|
+
it("emits the workflow plus the no-suppressions gate + allowlist", () => {
|
|
12
|
+
expect(Object.keys(files).sort()).toEqual([
|
|
13
|
+
".github/workflows/ci.yml",
|
|
14
|
+
"tools/gates/no-suppressions.sh",
|
|
15
|
+
"tools/gates/suppressions-allowlist.txt",
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("pins node + bun and installs frozen", () => {
|
|
20
|
+
expect(ci).toContain("name: CI");
|
|
21
|
+
expect(ci).toContain('NODE_VERSION: "22.15.0"');
|
|
22
|
+
expect(ci).toContain('BUN_VERSION: "1.3.5"');
|
|
23
|
+
expect(ci).toContain("bun install --frozen-lockfile");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("blocks on generate + build, keeps cleanliness gates advisory", () => {
|
|
27
|
+
// generate + build have NO continue-on-error
|
|
28
|
+
expect(ci).toMatch(/Generate artifacts[\s\S]*?run: bun run generate && bun run generate:routes/);
|
|
29
|
+
expect(ci).toMatch(/Build \(vite\)\n\s+run: bun run build\n/);
|
|
30
|
+
// the four cleanliness gates are advisory
|
|
31
|
+
for (const advisory of ["Typecheck", "Format check", "Knip", "no new suppression"]) {
|
|
32
|
+
const stepIdx = ci.indexOf(advisory);
|
|
33
|
+
expect(stepIdx, `${advisory} step missing`).toBeGreaterThan(-1);
|
|
34
|
+
// a continue-on-error appears within the step block
|
|
35
|
+
expect(ci.slice(stepIdx, stepIdx + 200)).toContain("continue-on-error: true");
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("strips a bun@ prefix from the version", () => {
|
|
40
|
+
const f = generateCiFiles("22.15.0", "bun@1.3.5");
|
|
41
|
+
expect(f[".github/workflows/ci.yml"]).toContain('BUN_VERSION: "1.3.5"');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("is de-projectized — no colombo migration-debt refs", () => {
|
|
45
|
+
const blob = Object.values(files).join("\n");
|
|
46
|
+
expect(blob).not.toMatch(/oficina/i);
|
|
47
|
+
expect(blob).not.toMatch(/#7[89]|#81/); // issues #78/#79/#81
|
|
48
|
+
expect(blob).not.toMatch(/AGENTS\.md/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("ships an empty allowlist (only comments)", () => {
|
|
52
|
+
const allow = files["tools/gates/suppressions-allowlist.txt"];
|
|
53
|
+
const entries = allow.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#"));
|
|
54
|
+
expect(entries).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("generateMainPushGuardYml", () => {
|
|
59
|
+
const yml = generateMainPushGuardYml();
|
|
60
|
+
it("guards main pushes via the commits/{sha}/pulls API", () => {
|
|
61
|
+
expect(yml).toContain("name: main-push-guard");
|
|
62
|
+
expect(yml).toMatch(/on:\s*\n\s*push:\s*\n\s*branches: \[main\]/);
|
|
63
|
+
expect(yml).toContain("commits/${SHA}/pulls");
|
|
64
|
+
expect(yml).toContain("exit 1");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("generatePlaywrightFiles", () => {
|
|
69
|
+
const files = generatePlaywrightFiles("1.3.5");
|
|
70
|
+
|
|
71
|
+
it("emits the workflow, a self-contained config, and a smoke spec", () => {
|
|
72
|
+
expect(Object.keys(files).sort()).toEqual([
|
|
73
|
+
".github/workflows/playwright.yml",
|
|
74
|
+
"playwright.config.ts",
|
|
75
|
+
"tests/e2e/smoke.spec.ts",
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("installs chromium + webkit and pins bun", () => {
|
|
80
|
+
const wf = files[".github/workflows/playwright.yml"];
|
|
81
|
+
expect(wf).toContain('bun-version: "1.3.5"');
|
|
82
|
+
expect(wf).toContain("playwright install --with-deps chromium webkit");
|
|
83
|
+
expect(wf).toContain("bun run test:e2e");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("config is self-contained (no support-helper imports) and targets tests/e2e", () => {
|
|
87
|
+
const cfg = files["playwright.config.ts"];
|
|
88
|
+
expect(cfg).toContain('testDir: "./tests/e2e"');
|
|
89
|
+
expect(cfg).not.toContain("./tests/support");
|
|
90
|
+
expect(cfg).toContain("webkit");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("smoke needs no app boot or network (uses setContent)", () => {
|
|
94
|
+
const spec = files["tests/e2e/smoke.spec.ts"];
|
|
95
|
+
expect(spec).toContain("page.setContent");
|
|
96
|
+
expect(spec).not.toContain("await page.goto"); // no real navigation (comment may mention page.goto)
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("generateReactDoctorYml", () => {
|
|
101
|
+
const yml = generateReactDoctorYml();
|
|
102
|
+
it("runs react-doctor advisory (no uncommented blocking)", () => {
|
|
103
|
+
expect(yml).toContain("name: React Doctor");
|
|
104
|
+
expect(yml).toContain("millionco/react-doctor@v2");
|
|
105
|
+
expect(yml).toContain("fetch-depth: 0");
|
|
106
|
+
expect(yml).not.toMatch(/^\s*blocking: error/m);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffolds the per-PR quality pipeline and its companion gate script:
|
|
3
|
+
* .github/workflows/ci.yml — GitHub Actions workflow
|
|
4
|
+
* tools/gates/no-suppressions.sh — bans NEW type suppressions + preact
|
|
5
|
+
* tools/gates/suppressions-allowlist.txt — grandfathered offenders (seeded empty)
|
|
6
|
+
*
|
|
7
|
+
* Design rationale:
|
|
8
|
+
* - A freshly migrated site carries inherited debt (leftover
|
|
9
|
+
* @ts-expect-error, not-yet-prettier-clean files, unused exports). So the
|
|
10
|
+
* only BLOCKING gates are the ones a site MUST pass to run at all:
|
|
11
|
+
* `generate` (CMS + route artifacts) and `build` (vite). Everything that
|
|
12
|
+
* depends on migration cleanliness — no-suppressions, typecheck,
|
|
13
|
+
* format:check, knip — ships ADVISORY (`continue-on-error: true`) so day-one
|
|
14
|
+
* CI is green. Each carries a comment on how to promote it to blocking as
|
|
15
|
+
* the debt zeroes out.
|
|
16
|
+
* - The scripts the steps call (`generate`, `generate:routes`, `typecheck`,
|
|
17
|
+
* `format:check`, `knip`) are all defined in the scaffolded package.json.
|
|
18
|
+
*
|
|
19
|
+
* @param nodeVersion Node version for setup-node (lockstep with package.json engines).
|
|
20
|
+
* @param bunVersion Bun version for setup-bun (= CANONICAL_BUN_VERSION).
|
|
21
|
+
*/
|
|
22
|
+
export function generateCiFiles(nodeVersion: string, bunVersion: string): Record<string, string> {
|
|
23
|
+
const bun = bunVersion.replace(/^bun@/, "");
|
|
24
|
+
return {
|
|
25
|
+
".github/workflows/ci.yml": generateCiYml(nodeVersion, bun),
|
|
26
|
+
"tools/gates/no-suppressions.sh": NO_SUPPRESSIONS_SH,
|
|
27
|
+
"tools/gates/suppressions-allowlist.txt": SUPPRESSIONS_ALLOWLIST,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function generateCiYml(nodeVersion: string, bunVersion: string): string {
|
|
32
|
+
return `name: CI
|
|
33
|
+
|
|
34
|
+
# Per-PR quality pipeline. No auto-merge: it only informs the human review.
|
|
35
|
+
#
|
|
36
|
+
# BLOCKS (the site must run): generate (CMS + routes), build.
|
|
37
|
+
# ADVISORY (continue-on-error — inherited migration debt varies per site):
|
|
38
|
+
# no-suppressions, typecheck, format:check, knip. Promote each to blocking by
|
|
39
|
+
# removing its \`continue-on-error\` once that debt is zero.
|
|
40
|
+
|
|
41
|
+
on:
|
|
42
|
+
pull_request:
|
|
43
|
+
push:
|
|
44
|
+
branches: [main]
|
|
45
|
+
|
|
46
|
+
permissions:
|
|
47
|
+
contents: read
|
|
48
|
+
|
|
49
|
+
concurrency:
|
|
50
|
+
group: ci-\${{ github.workflow }}-\${{ github.ref }}
|
|
51
|
+
cancel-in-progress: true
|
|
52
|
+
|
|
53
|
+
env:
|
|
54
|
+
NODE_VERSION: "${nodeVersion}"
|
|
55
|
+
BUN_VERSION: "${bunVersion}"
|
|
56
|
+
|
|
57
|
+
jobs:
|
|
58
|
+
gates:
|
|
59
|
+
name: gates (no-suppressions, generate, typecheck, format, knip, build)
|
|
60
|
+
runs-on: ubuntu-latest
|
|
61
|
+
steps:
|
|
62
|
+
- uses: actions/checkout@v4
|
|
63
|
+
|
|
64
|
+
- uses: actions/setup-node@v4
|
|
65
|
+
with:
|
|
66
|
+
node-version: \${{ env.NODE_VERSION }}
|
|
67
|
+
|
|
68
|
+
- uses: oven-sh/setup-bun@v2
|
|
69
|
+
with:
|
|
70
|
+
bun-version: \${{ env.BUN_VERSION }}
|
|
71
|
+
|
|
72
|
+
- name: bun install --frozen-lockfile
|
|
73
|
+
run: bun install --frozen-lockfile
|
|
74
|
+
|
|
75
|
+
# ADVISORY — a fresh migration ships inherited suppressions. Seed the
|
|
76
|
+
# allowlist with the current offenders, then remove \`continue-on-error\`:
|
|
77
|
+
# git grep -nE '@ts-expect-error|@ts-ignore' -- src/ tools/ \\
|
|
78
|
+
# > tools/gates/suppressions-allowlist.txt
|
|
79
|
+
- name: Gate — no new suppression + zero preact (advisory)
|
|
80
|
+
run: bash tools/gates/no-suppressions.sh
|
|
81
|
+
continue-on-error: true
|
|
82
|
+
|
|
83
|
+
# BLOCKS — CMS artifacts + route tree (the build depends on these). knip
|
|
84
|
+
# also needs routeTree.gen.ts (src/router.tsx imports it).
|
|
85
|
+
- name: Generate artifacts (CMS + routes)
|
|
86
|
+
run: bun run generate && bun run generate:routes
|
|
87
|
+
|
|
88
|
+
# ADVISORY — flip to blocking once typecheck is clean.
|
|
89
|
+
- name: Typecheck (advisory)
|
|
90
|
+
run: bun run typecheck
|
|
91
|
+
continue-on-error: true
|
|
92
|
+
|
|
93
|
+
# ADVISORY — flip to blocking after a repo-wide \`bun run format\` pass.
|
|
94
|
+
- name: Format check (advisory)
|
|
95
|
+
run: bun run format:check
|
|
96
|
+
continue-on-error: true
|
|
97
|
+
|
|
98
|
+
# ADVISORY — dead code / orphan deps.
|
|
99
|
+
- name: Knip (advisory)
|
|
100
|
+
run: bun run knip
|
|
101
|
+
continue-on-error: true
|
|
102
|
+
|
|
103
|
+
# BLOCKS — the site must compile (vite build does NOT typecheck).
|
|
104
|
+
- name: Build (vite)
|
|
105
|
+
run: bun run build
|
|
106
|
+
`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const NO_SUPPRESSIONS_SH = `#!/usr/bin/env bash
|
|
110
|
+
set -euo pipefail
|
|
111
|
+
|
|
112
|
+
# Gate: NO new type suppression + zero preact import.
|
|
113
|
+
#
|
|
114
|
+
# A migration leaves behind some @ts-expect-error/@ts-ignore. This gate keeps
|
|
115
|
+
# that debt from GROWING: existing suppressions are grandfathered in the
|
|
116
|
+
# allowlist (tools/gates/suppressions-allowlist.txt); any suppression outside it
|
|
117
|
+
# fails the PR. As the type debt zeroes out, remove entries from the allowlist —
|
|
118
|
+
# it only shrinks, never grows.
|
|
119
|
+
#
|
|
120
|
+
# Allowlist format: "path/file.ts:123" (the exact path:line from \`git grep -n\`).
|
|
121
|
+
# Blank lines and "#" are ignored. If you touched a file and an existing
|
|
122
|
+
# suppression's line number moved, update its entry — do not add a new one.
|
|
123
|
+
#
|
|
124
|
+
# Usage: bash tools/gates/no-suppressions.sh
|
|
125
|
+
|
|
126
|
+
ROOT_DIR="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
127
|
+
cd "\$ROOT_DIR"
|
|
128
|
+
|
|
129
|
+
ALLOWLIST="tools/gates/suppressions-allowlist.txt"
|
|
130
|
+
SELF_PATH="tools/gates/no-suppressions.sh"
|
|
131
|
+
EXIT_CODE=0
|
|
132
|
+
|
|
133
|
+
SCAN_PATHS=(-- 'src/' 'tools/' ":(exclude)\${SELF_PATH}" ":(exclude)\${ALLOWLIST}")
|
|
134
|
+
SUPPRESSION_PATTERN='@ts-expect-error|@ts-ignore'
|
|
135
|
+
|
|
136
|
+
echo "== gate: no new type suppression (\${SUPPRESSION_PATTERN}) =="
|
|
137
|
+
RAW_MATCHES="\$(git grep -nE "\${SUPPRESSION_PATTERN}" "\${SCAN_PATHS[@]}" 2>/dev/null || true)"
|
|
138
|
+
|
|
139
|
+
if [ -n "\$RAW_MATCHES" ]; then
|
|
140
|
+
VIOLATIONS=""
|
|
141
|
+
while IFS= read -r match; do
|
|
142
|
+
[ -z "\$match" ] && continue
|
|
143
|
+
path_line="\$(echo "\$match" | cut -d: -f1,2)"
|
|
144
|
+
if [ -f "\$ALLOWLIST" ] && grep -qxF "\$path_line" "\$ALLOWLIST"; then
|
|
145
|
+
continue
|
|
146
|
+
fi
|
|
147
|
+
VIOLATIONS="\${VIOLATIONS}\${match}"\$'\\n'
|
|
148
|
+
done <<<"\$RAW_MATCHES"
|
|
149
|
+
|
|
150
|
+
if [ -n "\$VIOLATIONS" ]; then
|
|
151
|
+
echo "FAILED: type suppression outside the allowlist:"
|
|
152
|
+
echo "\$VIOLATIONS"
|
|
153
|
+
echo "Fix the type at the source instead of suppressing. If an existing line"
|
|
154
|
+
echo "only changed number, update its allowlist entry (do not create a new one)."
|
|
155
|
+
EXIT_CODE=1
|
|
156
|
+
fi
|
|
157
|
+
fi
|
|
158
|
+
|
|
159
|
+
echo "== gate: zero preact import =="
|
|
160
|
+
# Matches REAL import specifiers ("preact", "preact/hooks", "@preact/signals"…),
|
|
161
|
+
# not the bare word "preact". Requires straight quotes around the specifier.
|
|
162
|
+
PREACT_IMPORT_PATTERN='["'"'"'](@preact/[a-zA-Z0-9_-]+|preact(/[a-zA-Z0-9_-]+)*)["'"'"']'
|
|
163
|
+
PREACT_MATCHES="\$(git grep -nE "\${PREACT_IMPORT_PATTERN}" "\${SCAN_PATHS[@]}" 2>/dev/null || true)"
|
|
164
|
+
if [ -n "\$PREACT_MATCHES" ]; then
|
|
165
|
+
echo "FAILED: preact import found (this stack is React, not Preact):"
|
|
166
|
+
echo "\$PREACT_MATCHES"
|
|
167
|
+
EXIT_CODE=1
|
|
168
|
+
fi
|
|
169
|
+
|
|
170
|
+
if [ "\$EXIT_CODE" -eq 0 ]; then
|
|
171
|
+
echo "OK: no new suppression, no preact import."
|
|
172
|
+
fi
|
|
173
|
+
|
|
174
|
+
exit "\$EXIT_CODE"
|
|
175
|
+
`;
|
|
176
|
+
|
|
177
|
+
const SUPPRESSIONS_ALLOWLIST = `# Grandfathered type suppressions (path:line from \`git grep -n\`).
|
|
178
|
+
# Seed this with the migration's leftover offenders to flip the CI
|
|
179
|
+
# no-suppressions gate from advisory to blocking:
|
|
180
|
+
# git grep -nE '@ts-expect-error|@ts-ignore' -- src/ tools/ >> tools/gates/suppressions-allowlist.txt
|
|
181
|
+
# This list only shrinks — never add a new entry for new code.
|
|
182
|
+
`;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffolds `.github/workflows/main-push-guard.yml` — a branch-protection
|
|
3
|
+
* surrogate. It does NOT block the push (real branch protection would, before
|
|
4
|
+
* the fact); it fails visibly in the Actions history when a commit reaches main
|
|
5
|
+
* without going through a PR (the "never commit straight to main" convention).
|
|
6
|
+
*
|
|
7
|
+
* Heuristic: GET /repos/{owner}/{repo}/commits/{sha}/pulls returns the PRs
|
|
8
|
+
* associated with a commit (including the merge/squash/rebase GitHub records on
|
|
9
|
+
* main when a PR merges). Empty list = the commit did not come from a PR.
|
|
10
|
+
*
|
|
11
|
+
* Fully generic — no per-site parameters.
|
|
12
|
+
*/
|
|
13
|
+
export function generateMainPushGuardYml(): string {
|
|
14
|
+
return `name: main-push-guard
|
|
15
|
+
|
|
16
|
+
# Branch-protection surrogate: detects a commit that reaches main WITHOUT a PR.
|
|
17
|
+
# Does not block the push — only fails visibly in the Actions history when the
|
|
18
|
+
# "never commit straight to main" convention is broken.
|
|
19
|
+
|
|
20
|
+
on:
|
|
21
|
+
push:
|
|
22
|
+
branches: [main]
|
|
23
|
+
|
|
24
|
+
permissions:
|
|
25
|
+
contents: read
|
|
26
|
+
pull-requests: read
|
|
27
|
+
|
|
28
|
+
jobs:
|
|
29
|
+
guard:
|
|
30
|
+
runs-on: ubuntu-latest
|
|
31
|
+
steps:
|
|
32
|
+
- name: Check that the commit on main came from a merged PR
|
|
33
|
+
env:
|
|
34
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
35
|
+
REPO: \${{ github.repository }}
|
|
36
|
+
SHA: \${{ github.sha }}
|
|
37
|
+
run: |
|
|
38
|
+
count="\$(gh api "repos/\${REPO}/commits/\${SHA}/pulls" --jq 'length')"
|
|
39
|
+
if [ "\$count" -eq 0 ]; then
|
|
40
|
+
echo "::error::commit \${SHA} reached main with no associated PR (direct push). Convention: one issue = one PR, never commit straight to main."
|
|
41
|
+
exit 1
|
|
42
|
+
fi
|
|
43
|
+
echo "OK: commit \${SHA} associated with \${count} PR(s)."
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
@@ -12,6 +12,14 @@ import type { MigrationContext } from "../types";
|
|
|
12
12
|
*/
|
|
13
13
|
export const CANONICAL_BUN_VERSION = "1.3.5";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Fleet-wide canonical Node version. Used by the scaffolded CI / Playwright
|
|
17
|
+
* workflows' setup-node step (kept in lockstep with the perf workflow's pinned
|
|
18
|
+
* node-version). 22.15 is the floor for @cloudflare/vite-plugin's
|
|
19
|
+
* module.registerHooks.
|
|
20
|
+
*/
|
|
21
|
+
export const CANONICAL_NODE_VERSION = "22.15.0";
|
|
22
|
+
|
|
15
23
|
/**
|
|
16
24
|
* Get the latest published version of an npm package.
|
|
17
25
|
* Falls back to the provided default if the lookup fails.
|
|
@@ -145,6 +153,7 @@ export function generatePackageJson(ctx: MigrationContext): string {
|
|
|
145
153
|
format: 'prettier --write "src/**/*.{ts,tsx}"',
|
|
146
154
|
"format:check": 'prettier --check "src/**/*.{ts,tsx}"',
|
|
147
155
|
knip: "knip",
|
|
156
|
+
"test:e2e": "playwright test",
|
|
148
157
|
clean:
|
|
149
158
|
"rm -rf node_modules .cache dist .wrangler/state node_modules/.vite && bun install",
|
|
150
159
|
"tailwind:lint":
|
|
@@ -174,6 +183,7 @@ export function generatePackageJson(ctx: MigrationContext): string {
|
|
|
174
183
|
devDependencies: {
|
|
175
184
|
"@cloudflare/vite-plugin": "^1.27.0",
|
|
176
185
|
"@decocms/blocks-cli": `^${frameworkVersion}`,
|
|
186
|
+
"@playwright/test": "^1.62.1",
|
|
177
187
|
// CLI build of src/styles/app.css — used by the migration's own
|
|
178
188
|
// css-compile-check.ts (phase-compile) and by `tailwind:lint`, so a
|
|
179
189
|
// dropped custom theme token fails migration/CI instead of shipping
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffolds the E2E harness and its config/smoke companions:
|
|
3
|
+
* .github/workflows/playwright.yml — GitHub Actions workflow (chromium + webkit)
|
|
4
|
+
* playwright.config.ts — self-contained config (no support helpers)
|
|
5
|
+
* tests/e2e/smoke.spec.ts — harness smoke, no app boot, no network
|
|
6
|
+
*
|
|
7
|
+
* Design rationale:
|
|
8
|
+
* - The smoke proves the Playwright harness runs in CI (chromium + webkit)
|
|
9
|
+
* without the app booting or hitting the network — it renders inline HTML
|
|
10
|
+
* via page.setContent. Real storefront specs (page.goto("/")) need a VCR
|
|
11
|
+
* layer so CI does not hit the commerce API; add a `webServer` block to the
|
|
12
|
+
* config and specs that navigate the app once that harness exists.
|
|
13
|
+
* - webkit is included because the storefront audience is majority iOS Safari.
|
|
14
|
+
* - The workflow needs `@playwright/test` + the `test:e2e` script, both in the
|
|
15
|
+
* scaffolded package.json.
|
|
16
|
+
*
|
|
17
|
+
* @param bunVersion Bun version for setup-bun (= CANONICAL_BUN_VERSION).
|
|
18
|
+
*/
|
|
19
|
+
export function generatePlaywrightFiles(bunVersion: string): Record<string, string> {
|
|
20
|
+
const bun = bunVersion.replace(/^bun@/, "");
|
|
21
|
+
return {
|
|
22
|
+
".github/workflows/playwright.yml": generatePlaywrightYml(bun),
|
|
23
|
+
"playwright.config.ts": PLAYWRIGHT_CONFIG,
|
|
24
|
+
"tests/e2e/smoke.spec.ts": SMOKE_SPEC,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function generatePlaywrightYml(bunVersion: string): string {
|
|
29
|
+
return `name: Playwright
|
|
30
|
+
|
|
31
|
+
# Functional E2E (chromium + webkit). Today only a harness smoke that renders
|
|
32
|
+
# inline HTML — no app boot, no network — so it runs on a plain ubuntu runner.
|
|
33
|
+
# When specs navigate the real app (via VCR), pin the container and add a
|
|
34
|
+
# webServer block to playwright.config.ts.
|
|
35
|
+
|
|
36
|
+
on:
|
|
37
|
+
pull_request:
|
|
38
|
+
push:
|
|
39
|
+
branches: [main]
|
|
40
|
+
|
|
41
|
+
permissions:
|
|
42
|
+
contents: read
|
|
43
|
+
|
|
44
|
+
concurrency:
|
|
45
|
+
group: playwright-\${{ github.workflow }}-\${{ github.ref }}
|
|
46
|
+
cancel-in-progress: true
|
|
47
|
+
|
|
48
|
+
jobs:
|
|
49
|
+
e2e:
|
|
50
|
+
runs-on: ubuntu-latest
|
|
51
|
+
steps:
|
|
52
|
+
- uses: actions/checkout@v4
|
|
53
|
+
|
|
54
|
+
- uses: oven-sh/setup-bun@v2
|
|
55
|
+
with:
|
|
56
|
+
bun-version: "${bunVersion}"
|
|
57
|
+
|
|
58
|
+
- name: bun install --frozen-lockfile
|
|
59
|
+
run: bun install --frozen-lockfile
|
|
60
|
+
|
|
61
|
+
- name: Install browsers (chromium + webkit)
|
|
62
|
+
run: bunx playwright install --with-deps chromium webkit
|
|
63
|
+
|
|
64
|
+
- name: E2E
|
|
65
|
+
run: bun run test:e2e
|
|
66
|
+
|
|
67
|
+
- name: Upload HTML report
|
|
68
|
+
if: \${{ !cancelled() }}
|
|
69
|
+
uses: actions/upload-artifact@v4
|
|
70
|
+
with:
|
|
71
|
+
name: playwright-report
|
|
72
|
+
path: playwright-report/
|
|
73
|
+
retention-days: 14
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const PLAYWRIGHT_CONFIG = `/**
|
|
78
|
+
* Playwright config — E2E base.
|
|
79
|
+
*
|
|
80
|
+
* Today only a harness smoke (tests/e2e/smoke.spec.ts) that renders inline HTML,
|
|
81
|
+
* proving the harness runs in CI without the app booting or hitting the network.
|
|
82
|
+
* Real app E2E (page.goto("/")) needs a VCR layer so CI does not call the
|
|
83
|
+
* commerce API — add a \`webServer\` block (e.g. { command: "bun run build && bun
|
|
84
|
+
* run preview", url: "http://localhost:4173" }) and the navigating specs then.
|
|
85
|
+
*
|
|
86
|
+
* chromium + webkit (webkit because the storefront audience is majority iOS
|
|
87
|
+
* Safari). 1 worker, 0 retries on purpose: retry masks flake instead of exposing it.
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
import { defineConfig, devices } from "@playwright/test";
|
|
91
|
+
|
|
92
|
+
export default defineConfig({
|
|
93
|
+
testDir: "./tests/e2e",
|
|
94
|
+
fullyParallel: true,
|
|
95
|
+
forbidOnly: !!process.env.CI,
|
|
96
|
+
retries: 0,
|
|
97
|
+
workers: 1,
|
|
98
|
+
reporter: [["html", { open: "never" }], ["list"]],
|
|
99
|
+
use: {
|
|
100
|
+
trace: "retain-on-failure",
|
|
101
|
+
screenshot: "only-on-failure",
|
|
102
|
+
},
|
|
103
|
+
projects: [
|
|
104
|
+
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
|
105
|
+
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
|
|
106
|
+
],
|
|
107
|
+
});
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
const SMOKE_SPEC = `/**
|
|
111
|
+
* Smoke E2E — proves the Playwright harness runs (chromium + webkit) in CI
|
|
112
|
+
* without the app booting or hitting the network. Does not test the real app;
|
|
113
|
+
* the storefront E2E (page.goto("/")) comes with the VCR + cassettes harness.
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
import { expect, test } from "@playwright/test";
|
|
117
|
+
|
|
118
|
+
test("harness renders inline content", async ({ page }) => {
|
|
119
|
+
await page.setContent(
|
|
120
|
+
'<title>e2e smoke</title><h1>ok</h1><div data-testid="marker">ready</div>',
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
await expect(page).toHaveTitle("e2e smoke");
|
|
124
|
+
await expect(page.getByRole("heading", { name: "ok" })).toBeVisible();
|
|
125
|
+
await expect(page.getByTestId("marker")).toHaveText("ready");
|
|
126
|
+
});
|
|
127
|
+
`;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffolds `.github/workflows/react-doctor.yml` — flags React
|
|
3
|
+
* security/perf/correctness/a11y/bundle-size/architecture issues on PRs.
|
|
4
|
+
*
|
|
5
|
+
* Advisory by construction (the action's default: comments, never fails the
|
|
6
|
+
* build). Separate workflow from ci.yml on purpose — zero coupling with the
|
|
7
|
+
* blocking gates. `fetch-depth: 0` gives the merge-base so it reports only what
|
|
8
|
+
* the PR introduces.
|
|
9
|
+
*
|
|
10
|
+
* Fully generic — no per-site parameters. Docs: https://www.react.doctor/ci
|
|
11
|
+
*/
|
|
12
|
+
export function generateReactDoctorYml(): string {
|
|
13
|
+
return `name: React Doctor
|
|
14
|
+
|
|
15
|
+
# Flags React security/perf/correctness/a11y/bundle-size/architecture issues.
|
|
16
|
+
# Advisory by construction (comments, never fails the check). To make it a hard
|
|
17
|
+
# gate, add \`with: { blocking: error }\` to the action step below.
|
|
18
|
+
|
|
19
|
+
on:
|
|
20
|
+
pull_request:
|
|
21
|
+
types: [opened, synchronize, reopened, ready_for_review]
|
|
22
|
+
push:
|
|
23
|
+
branches: [main]
|
|
24
|
+
|
|
25
|
+
permissions:
|
|
26
|
+
contents: read
|
|
27
|
+
pull-requests: write
|
|
28
|
+
issues: write
|
|
29
|
+
statuses: write
|
|
30
|
+
|
|
31
|
+
concurrency:
|
|
32
|
+
group: react-doctor-\${{ github.event.pull_request.number || github.ref }}
|
|
33
|
+
cancel-in-progress: true
|
|
34
|
+
|
|
35
|
+
jobs:
|
|
36
|
+
react-doctor:
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
steps:
|
|
39
|
+
# fetch-depth: 0 gives the merge-base so it reports only PR-introduced findings.
|
|
40
|
+
- uses: actions/checkout@v4
|
|
41
|
+
with:
|
|
42
|
+
fetch-depth: 0
|
|
43
|
+
|
|
44
|
+
- uses: millionco/react-doctor@v2
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
@@ -103,7 +103,7 @@ import { createInstrumentedFetch } from "@decocms/blocks/sdk/instrumentedFetch";
|
|
|
103
103
|
import { initVtexFromBlocks, setVtexFetch } from "@decocms/apps-vtex";` : ""}${hasLocationMatcher ? `
|
|
104
104
|
import { registerLocationMatcher } from "./matchers/location";` : ""}
|
|
105
105
|
import { blocks as generatedBlocks } from "../.deco/blocks.gen";
|
|
106
|
-
import { sectionMeta, syncComponents, loadingFallbacks } from "../.deco/sections.gen";
|
|
106
|
+
import { sectionMeta, syncComponents, loadingFallbacks, renderJsons } from "../.deco/sections.gen";
|
|
107
107
|
import { PreviewProviders } from "@decocms/tanstack";
|
|
108
108
|
// @ts-ignore Vite ?url import
|
|
109
109
|
import appCss from "./styles/app.css?url";
|
|
@@ -140,6 +140,7 @@ applySectionConventions({
|
|
|
140
140
|
meta: sectionMeta,
|
|
141
141
|
syncComponents,
|
|
142
142
|
loadingFallbacks,
|
|
143
|
+
renderJsons,
|
|
143
144
|
sectionGlob: import.meta.glob("./sections/**/*.tsx") as Record<string, () => Promise<any>>,
|
|
144
145
|
});
|
|
145
146
|
|