@decocms/blocks-cli 7.37.6 → 7.39.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.37.6",
3
+ "version": "7.39.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.37.6",
34
+ "@decocms/blocks": "7.39.0",
35
35
  "ts-morph": "^27.0.0",
36
36
  "tsx": "^4.22.5"
37
37
  },
@@ -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 { CANONICAL_BUN_VERSION, generatePackageJson } from "./templates/package-json";
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
@@ -3,14 +3,16 @@
3
3
  * .github/workflows/perf.yml — GitHub Actions workflow
4
4
  * tools/perf/changed-paths.sh — maps edited sections → CMS page paths
5
5
  * tools/perf/compare.mjs — compares two LHCI result dirs, emits markdown
6
- * lighthouserc.json — LHCI collect settings (3 runs, mobile UA)
6
+ * lighthouserc.json — LHCI collect settings (3 runs, mobile UA, 4 categories)
7
7
  *
8
8
  * Design rationale:
9
9
  * - CF Workers Builds generates a per-PR preview URL (workers.dev). Both the
10
10
  * PR and main previews are workers.dev — no edge cache — so the delta is
11
11
  * purely code, not cache state.
12
- * - Gate signal = CLS + TBT (layout stability / JS work; cache-insensitive).
13
- * LCP and score are informational only (noisy on cold SSR).
12
+ * - Gate signal = CLS + TBT + a11y/best-practices/SEO scores. CLS/TBT are
13
+ * layout stability / JS work; the three category scores are static-analysis
14
+ * audits. All cache-insensitive, so a regression is real code, not cold SSR.
15
+ * LCP and performance score are informational only (noisy on cold SSR).
14
16
  * - Advisory: `continue-on-error: true`. Remove it once the site stabilises.
15
17
  * - LHCI 0.15 does not write manifest.json; compare.mjs reads lhr-*.json
16
18
  * directly and selects the median run by performance score.
@@ -40,7 +42,7 @@ function generatePerfYml(workerName: string): string {
40
42
  # preview URL do output — sem token CF nem build local.
41
43
  #
42
44
  # Baseline: URL fixa da preview do main (https://${workerName}.deco-cx.workers.dev).
43
- # Gate: CLS + TBT (estrutura/JS, insensíveis a cache). LCP/score: informativos.
45
+ # Gate: CLS + TBT + A11y/BP/SEO (estrutura/JS + auditorias estáticas, insensíveis a cache). LCP/score: informativos.
44
46
 
45
47
  on:
46
48
  pull_request:
@@ -297,8 +299,10 @@ const COMPARE_MJS = `#!/usr/bin/env node
297
299
  // cache, same runner/region, same mobile UA — so the delta is the PR's code
298
300
  // effect, not cache warm/cold.
299
301
  //
300
- // Gate signal = CLS + TBT (page structure / JS work — cache-insensitive).
301
- // LCP / score are shown but informative only (noisy on cold workers.dev SSR).
302
+ // Gate signal = CLS + TBT + a11y/best-practices/SEO scores. CLS/TBT are page
303
+ // structure / JS work; the three category scores are static-analysis audits.
304
+ // All are cache-insensitive, so a regression is real code, not cold SSR.
305
+ // LCP / performance score are shown but informative only (noisy on cold workers.dev SSR).
302
306
  //
303
307
  // LHCI 0.15 does not write manifest.json; we read lhr-*.json directly.
304
308
  //
@@ -312,7 +316,12 @@ const TBT_ABS = 50; // ms — deltas below this are noise
312
316
  const TBT_REL = 0.15; // 15% relative change
313
317
  const CLS_ABS = 0.02; // absolute CLS delta that matters
314
318
  const CLS_FLOOR = 0.1; // only flag CLS regression when PR is above "good"
319
+ const SCORE_DROP = 3; // pts — a11y/bp/seo score drop that gates (tolerates 1–2pt audit jitter)
320
+ const SCORES = ["a11y", "bp", "seo"]; // gated category scores (0–100)
315
321
 
322
+ // base/pr = { tbt, cls, a11y, bp, seo }. Called from selftest with only
323
+ // { tbt, cls }: the SCORES deltas become NaN and NaN comparisons are false,
324
+ // so score gating is simply inert there.
316
325
  function verdict(base, pr) {
317
326
  const tbtUp = pr.tbt - base.tbt;
318
327
  const tbtBad = tbtUp > TBT_ABS && tbtUp > base.tbt * TBT_REL;
@@ -320,8 +329,10 @@ function verdict(base, pr) {
320
329
  const clsUp = pr.cls - base.cls;
321
330
  const clsBad = clsUp > CLS_ABS && pr.cls > CLS_FLOOR;
322
331
  const clsGood = -clsUp > CLS_ABS && base.cls > CLS_FLOOR;
323
- if (tbtBad || clsBad) return { icon: "🔴", gate: true };
324
- if (tbtGood || clsGood) return { icon: "🟢", gate: false };
332
+ const scoreBad = SCORES.some((k) => pr[k] - base[k] <= -SCORE_DROP);
333
+ const scoreGood = !scoreBad && SCORES.some((k) => pr[k] - base[k] >= SCORE_DROP);
334
+ if (tbtBad || clsBad || scoreBad) return { icon: "🔴", gate: true };
335
+ if (tbtGood || clsGood || scoreGood) return { icon: "🟢", gate: false };
325
336
  return { icon: "⚪", gate: false };
326
337
  }
327
338
 
@@ -340,7 +351,10 @@ function loadDir(dir) {
340
351
  const lhr = runs[Math.floor(runs.length / 2)];
341
352
  const a = lhr.audits;
342
353
  byPath[new URL(url).pathname] = {
343
- score: Math.round((lhr.categories.performance.score ?? 0) * 100),
354
+ score: Math.round((lhr.categories.performance?.score ?? 0) * 100),
355
+ a11y: Math.round((lhr.categories.accessibility?.score ?? 0) * 100),
356
+ bp: Math.round((lhr.categories["best-practices"]?.score ?? 0) * 100),
357
+ seo: Math.round((lhr.categories.seo?.score ?? 0) * 100),
344
358
  lcp: a["largest-contentful-paint"].numericValue,
345
359
  cls: a["cumulative-layout-shift"].numericValue,
346
360
  tbt: a["total-blocking-time"].numericValue,
@@ -351,11 +365,14 @@ function loadDir(dir) {
351
365
 
352
366
  const ms = (v) => \`\${Math.round(v)}ms\`;
353
367
  const cls = (v) => v.toFixed(3);
368
+ const pts = (v) => \`\${Math.round(v)}\`;
354
369
  function delta(base, pr, fmt) {
355
370
  const d = pr - base;
356
371
  const arrow = d < 0 ? "↓" : d > 0 ? "↑" : "";
357
372
  return \`\${d > 0 ? "+" : ""}\${fmt(d)} \${arrow}\`.trim();
358
373
  }
374
+ // base→PR (Δ) for a 0–100 category score.
375
+ const scoreCell = (b, p) => \`\${b}→\${p} (\${delta(b, p, pts)})\`;
359
376
 
360
377
  function render(base, pr) {
361
378
  const paths = [...new Set([...Object.keys(base), ...Object.keys(pr)])].sort();
@@ -365,29 +382,29 @@ function render(base, pr) {
365
382
  "",
366
383
  "Ambas as previews rodam em \`*.workers.dev\` (sem edge cache), mesmo runner e UA mobile — o delta é efeito do código, não de cache quente/frio.",
367
384
  "",
368
- "**Gate:** CLS + TBT (estrutura / JS). LCP e score são informativos (ruído esperado em SSR frio).",
385
+ "**Gate:** CLS + TBT + A11y/BP/SEO (estrutura/JS + auditorias estáticas). LCP e score de performance são informativos (ruído esperado em SSR frio).",
369
386
  "",
370
- "| Página | | CLS (base→PR) | TBT (base→PR) | LCP | Score |",
371
- "|---|:--:|---|---|---|---|",
387
+ "| Página | | CLS (base→PR) | TBT (base→PR) | A11y | BP | SEO | LCP | Score |",
388
+ "|---|:--:|---|---|---|---|---|---|---|",
372
389
  ];
373
390
  for (const p of paths) {
374
391
  const b = base[p];
375
392
  const r = pr[p];
376
393
  if (!b || !r) {
377
- lines.push(\`| \\\`\${p}\\\` | ⚠️ | \${b ? "faltou PR" : "faltou base"} | | | |\`);
394
+ lines.push(\`| \\\`\${p}\\\` | ⚠️ | \${b ? "faltou PR" : "faltou base"} | | | | | | |\`);
378
395
  continue;
379
396
  }
380
397
  const v = verdict(b, r);
381
398
  anyGate = anyGate || v.gate;
382
399
  lines.push(
383
- \`| \\\`\${p}\\\` | \${v.icon} | \${cls(b.cls)}→\${cls(r.cls)} (\${delta(b.cls, r.cls, cls)}) | \${ms(b.tbt)}→\${ms(r.tbt)} (\${delta(b.tbt, r.tbt, ms)}) | \${ms(b.lcp)}→\${ms(r.lcp)} | \${b.score}→\${r.score} |\`,
400
+ \`| \\\`\${p}\\\` | \${v.icon} | \${cls(b.cls)}→\${cls(r.cls)} (\${delta(b.cls, r.cls, cls)}) | \${ms(b.tbt)}→\${ms(r.tbt)} (\${delta(b.tbt, r.tbt, ms)}) | \${scoreCell(b.a11y, r.a11y)} | \${scoreCell(b.bp, r.bp)} | \${scoreCell(b.seo, r.seo)} | \${ms(b.lcp)}→\${ms(r.lcp)} | \${b.score}→\${r.score} |\`,
384
401
  );
385
402
  }
386
403
  lines.push("");
387
404
  lines.push(
388
405
  anyGate
389
- ? "🔴 **Regressão de CLS/TBT detectada** (advisory — não trava o merge)."
390
- : "🟢 Sem regressão de CLS/TBT.",
406
+ ? "🔴 **Regressão detectada em CLS/TBT/A11y/BP/SEO** (advisory — não trava o merge)."
407
+ : "🟢 Sem regressão nas métricas gateadas (CLS/TBT/A11y/BP/SEO).",
391
408
  );
392
409
  return { md: lines.join("\\n"), anyGate };
393
410
  }
@@ -400,6 +417,13 @@ function selftest() {
400
417
  ok(!verdict({ tbt: 100, cls: 0.02 }, { tbt: 100, cls: 0.08 }).gate, "cls under 0.1 floor no gate");
401
418
  ok(verdict({ tbt: 300, cls: 0.01 }, { tbt: 100, cls: 0.01 }).icon === "🟢", "tbt drop is green");
402
419
  ok(verdict({ tbt: 100, cls: 0.01 }, { tbt: 105, cls: 0.01 }).icon === "⚪", "tiny change is neutral");
420
+ // Category-score gating. S() supplies a neutral CLS/TBT baseline plus perfect scores.
421
+ const S = (o) => ({ tbt: 100, cls: 0.01, a11y: 100, bp: 100, seo: 100, ...o });
422
+ ok(verdict(S({}), S({ seo: 96 })).gate, "seo 100→96 (-4) should gate");
423
+ ok(verdict(S({ a11y: 90 }), S({ a11y: 87 })).gate, "a11y -3 should gate");
424
+ ok(!verdict(S({}), S({ bp: 98 })).gate, "bp -2 is jitter, no gate");
425
+ ok(verdict(S({ a11y: 90 }), S({ a11y: 95 })).icon === "🟢", "a11y +5 is green");
426
+ ok(verdict(S({}), S({ a11y: 99, seo: 97 })).icon === "🔴", "seo -3 gates even with a11y -1");
403
427
  console.log("compare.mjs selftest: OK");
404
428
  }
405
429
 
@@ -411,12 +435,12 @@ process.stdout.write(render(loadDir(baseDir), loadDir(prDir)).md + "\\n");
411
435
  `;
412
436
 
413
437
  const LIGHTHOUSERC_JSON = `{
414
- "//": "Lighthouse CI config for the per-PR perf workflow (.github/workflows/perf.yml). Mobile UA matches worker-entry.ts buildSegment (MOBILE_RE) so warmup and measurement hit the same device cache key.",
438
+ "//": "Lighthouse CI config for the per-PR perf workflow (.github/workflows/perf.yml). Mobile UA matches worker-entry.ts buildSegment (MOBILE_RE) so warmup and measurement hit the same device cache key. accessibility/best-practices/seo são análise estática determinística (não sofrem ruído de SSR frio como LCP/score), então compare.mjs os gateia por regressão de score.",
415
439
  "ci": {
416
440
  "collect": {
417
441
  "numberOfRuns": 3,
418
442
  "settings": {
419
- "onlyCategories": ["performance"],
443
+ "onlyCategories": ["performance", "accessibility", "best-practices", "seo"],
420
444
  "emulatedUserAgent": "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
421
445
  }
422
446
  }
@@ -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
+ }