diff --git a/.github/workflows/ci-frontend-lint.yaml b/.github/workflows/ci-frontend-lint.yaml index 9cb464f306..46e4e24abb 100644 --- a/.github/workflows/ci-frontend-lint.yaml +++ b/.github/workflows/ci-frontend-lint.yaml @@ -133,35 +133,12 @@ jobs: # esbuild ship per-platform binaries as optionalDependencies). run: bun install --frozen-lockfile --ignore-scripts + # src/v1 is excluded here: its checks run, and report, separately in + # ci-frontend-v1.yaml and ci-frontend-v1-seam.yaml. - name: Run TypeScript typecheck working-directory: frontend run: bun run typecheck - # src/v1 is excluded from the step above and checked under its own - # stricter tsconfig, after scripts/check-v1.mjs checks file types, - # placement, symlinks and the folder taxonomy. - - name: Run v1 typecheck - working-directory: frontend - run: bun run typecheck:v1 - - # Biome on src/v1, then scripts/check-v1.mjs: the folder import - # contract, v0 reaching v1 only through `@v1`, PropTypes and `ant-`. - - name: Run v1 lint - working-directory: frontend - run: bun run lint:v1 - - # The guardrail fixtures run under `node --test` (Node 22 APIs). - - name: Setup Node - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: "22" - - # v1 unit tests, then the fixtures proving each guard above fails and - # passes when it should. - - name: Run v1 tests - working-directory: frontend - run: bun run test:v1 - # ci-test.yaml runs the same suite as the rig's `frontend` group, but its # tiers skip frontend-only PRs; this job is what gates those. vitest: diff --git a/.github/workflows/ci-frontend-v1-seam.yaml b/.github/workflows/ci-frontend-v1-seam.yaml new file mode 100644 index 0000000000..e0982d35f9 --- /dev/null +++ b/.github/workflows/ci-frontend-v1-seam.yaml @@ -0,0 +1,43 @@ +name: Frontend v1 seam + +# Seam guard (P0-T10, UN-4363): until Phase 9, a PR that is frontend v1 work +# may change v0 only at the files in frontend/scripts/v1-seam-allowlist.json. +# A PR that touches no v1 file is v0 work and passes untouched. +# +# The `v0-change` label lets v1 work that genuinely needs a v0 change through; +# the guard then lists the files instead of failing. Its own workflow, because +# it re-runs on label changes and nothing else needs to. + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + branches: [main] + +permissions: + contents: read + +jobs: + seam: + name: v1 · Seam + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + # The merge base with the PR's base branch must be reachable. + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22" + + # Node built-ins only, so no install step. + - name: Run the seam guard + working-directory: frontend + env: + BASE_REF: ${{ github.base_ref }} + OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'v0-change') && '--override' || '' }} + run: node scripts/check-v1-seam.mjs --base "origin/$BASE_REF" $OVERRIDE diff --git a/.github/workflows/ci-frontend-v1.yaml b/.github/workflows/ci-frontend-v1.yaml new file mode 100644 index 0000000000..0b6d243014 --- /dev/null +++ b/.github/workflows/ci-frontend-v1.yaml @@ -0,0 +1,248 @@ +name: Frontend v1 checks + +# The frontend v1 rules as PR checks (P0-T10, UN-4363), reported apart from +# v0's checks in ci-frontend-lint.yaml. Each guard is its own job so a PR shows +# which rule failed by name. Every guard has fixture cases in +# frontend/test-fixtures/v1-boundary/ proving it fails when it should; the +# "Guard fixtures" job runs them. +# +# The seam guard reads PR labels, so it lives in ci-frontend-v1-seam.yaml: +# re-running these jobs on every label change would cost minutes for nothing. +# +# This workflow runs on a clean checkout of the public repository, with no +# cloud overlay, so green Typecheck, Biome, Purity and Unit tests jobs are the +# separation guard's "v1 works with no overlay" half. The Build job covers v1 +# only once v0 imports it (the version switch, P0-T03). + +on: + push: + branches: + - main + paths: + - "frontend/**" + # The guard fixtures also read these (the unit-gate drift check). + - ".github/workflows/ci-frontend-v1.yaml" + - "tests/groups.yaml" + # No `paths` on pull_request: an untriggered workflow reports no check run, + # so a required check would wait forever on non-frontend PRs. Filtering is + # done by the `changes` job instead; a job skipped via `if:` reports + # `skipped`, which satisfies a required check. Same as ci-frontend-lint.yaml. + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: [main] + +permissions: + contents: read + +jobs: + # Gating jobs run when `changes` finds frontend files, AND whenever `changes` + # itself did not succeed: a broken filter must fail open (run the checks), + # never skip them. `!cancelled()` lets them run past a failed dependency. + changes: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + permissions: + contents: read + # dorny/paths-filter lists PR files via the API. + pull-requests: read + outputs: + # Push is already path-filtered by its trigger, so force true off-PR. + frontend: ${{ github.event_name != 'pull_request' || steps.filter.outputs.frontend == 'true' }} + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Check for frontend changes + if: github.event_name == 'pull_request' + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + id: filter + with: + filters: | + frontend: + - "frontend/**" + - ".github/workflows/ci-frontend-v1.yaml" + - "tests/groups.yaml" + + # Biome's recommended rules and the accessibility rules as errors: biome.json + # raises the a11y rules for src/v1, and --error-on-warnings turns every + # remaining warning under src/v1 into a failure. + biome: + name: v1 · Biome + needs: changes + if: ${{ !cancelled() && github.event.pull_request.draft != true && (needs.changes.result != 'success' || needs.changes.outputs.frontend == 'true') }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Setup Biome + uses: biomejs/setup-biome@4c91541eaada48f67d7dbd7833600ce162b68f51 # v2.7.1 + with: + version: "2.3.13" + + - name: Run Biome on src/v1 + working-directory: frontend + run: biome ci --error-on-warnings src/v1/ + + typecheck: + name: v1 · Typecheck + needs: changes + if: ${{ !cancelled() && github.event.pull_request.draft != true && (needs.changes.result != 'success' || needs.changes.outputs.frontend == 'true') }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + + - name: Install dependencies + working-directory: frontend + # No dependency lifecycle scripts: nothing here needs them. + run: bun install --frozen-lockfile --ignore-scripts + + # scripts/check-v1.mjs --files (TypeScript only, styles only in styles/, + # allowed file types, no symlinks, folder taxonomy), then strict tsc. + - name: Run v1 typecheck + working-directory: frontend + run: bun run typecheck:v1 + + # No shim, antd, `ant-` class, CSS file, inline style, PropTypes or JS file + # under v1, plus the folder import contract and the styling policy. The JS + # and CSS-file rules run in the Typecheck job (check-v1 --files). + purity: + name: v1 · Purity + needs: changes + if: ${{ !cancelled() && github.event.pull_request.draft != true && (needs.changes.result != 'success' || needs.changes.outputs.frontend == 'true') }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + + - name: Install dependencies + working-directory: frontend + run: bun install --frozen-lockfile --ignore-scripts + + - name: Run the import and purity checks + working-directory: frontend + run: node scripts/check-v1.mjs --code + + - name: Run the styling policy + working-directory: frontend + run: node scripts/check-v1-styles.mjs + + # The v1 unit tests: the rig's `frontend-v1-unit` group (tests/groups.yaml), + # run with the same config so CI and the rig cannot disagree. The config + # fails the run below v1's coverage threshold. Run here rather than through + # ci-test.yaml because its tiers skip frontend-only PRs. Cloud v1 plugins + # (vitest.v1-plugins.config.mts) are for the cloud mirror (UN-4614), not here: + # OSS has none. + unit: + name: v1 · Unit tests + needs: changes + if: ${{ !cancelled() && github.event.pull_request.draft != true && (needs.changes.result != 'success' || needs.changes.outputs.frontend == 'true') }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + + - name: Install dependencies + working-directory: frontend + run: bun install --frozen-lockfile --ignore-scripts + + - name: Run v1 unit tests + working-directory: frontend + # The lockfile's vitest, never a fetched one. + run: ./node_modules/.bin/vitest run --config vitest.v1.config.mts + + # The summary is in the log; the lcov report is for reading what a + # failed threshold missed. + - name: Upload v1 coverage + if: ${{ !cancelled() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: frontend-v1-coverage + path: frontend/coverage/v1/ + if-no-files-found: ignore + retention-days: 7 + + # One production build, then the bundle guard (no v1 module reaches v0 or + # antd) and the separation guard (no enterprise plugin code in this repo). + build: + name: v1 · Build, bundle and separation + needs: changes + if: ${{ !cancelled() && github.event.pull_request.draft != true && (needs.changes.result != 'success' || needs.changes.outputs.frontend == 'true') }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22" + + - name: Install dependencies + working-directory: frontend + run: bun install --frozen-lockfile --ignore-scripts + + - name: Build and run the bundle guard + working-directory: frontend + run: node scripts/check-v1-bundle.mjs + + - name: Run the separation guard + working-directory: frontend + run: node scripts/check-v1-separation.mjs + + # Proves every guard above fails and passes when it should, and that the + # unit harness (src/v1/test) fails a test that sends an unmocked request, + # and that the Unit tests check fails below the coverage threshold and runs + # the rig group's config (node --test, Node 22 APIs). Spawns the real tools, two small + # Vite builds included. + guard-fixtures: + name: v1 · Guard fixtures + needs: changes + if: ${{ !cancelled() && github.event.pull_request.draft != true && (needs.changes.result != 'success' || needs.changes.outputs.frontend == 'true') }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22" + + - name: Install dependencies + working-directory: frontend + run: bun install --frozen-lockfile --ignore-scripts + + - name: Run the guard fixtures + working-directory: frontend + run: node --test test-fixtures/v1-boundary/boundary.fixtures.mjs test-fixtures/v1-harness/harness.fixtures.mjs test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs diff --git a/.gitignore b/.gitignore index 4b391d4453..ab73808bed 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,8 @@ lib64/ !frontend/src/lib/ !frontend/src/v1/**/lib/ !frontend/test-fixtures/**/lib/ +# The v1 guard fixtures drive git through shell scripts (test-fixtures/v1-boundary). +!frontend/test-fixtures/v1-boundary/**/*.sh parts/ sdist/ var/ diff --git a/frontend/biome.json b/frontend/biome.json index 253b99397c..1c8e4085c4 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -182,6 +182,19 @@ } } }, + "a11y": { + "noLabelWithoutControl": "error", + "noNoninteractiveElementInteractions": "error", + "noRedundantRoles": "error", + "noStaticElementInteractions": "error", + "useAriaPropsSupportedByRole": "error", + "useButtonType": "error", + "useFocusableInteractive": "error", + "useGenericFontNames": "error", + "useKeyWithClickEvents": "error", + "useSemanticElements": "error", + "useValidAnchor": "error" + }, "suspicious": { "noExplicitAny": "error" } diff --git a/frontend/package.json b/frontend/package.json index a106bc9e54..baa3d19eb3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -81,8 +81,11 @@ "typecheck": "tsc --noEmit", "tokens:v1": "bun scripts/build-v1-tokens.mjs", "typecheck:v1": "node scripts/check-v1.mjs --files && tsc -p src/v1/tsconfig.json", - "lint:v1": "biome lint src/v1/ && node scripts/check-v1.mjs --code && node scripts/check-v1-styles.mjs", - "test:v1": "vitest run --config vitest.v1.config.mts --coverage && vitest run --config vitest.v1-plugins.config.mts --coverage && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs test-fixtures/v1-harness/harness.fixtures.mjs" + "lint:v1": "biome lint --error-on-warnings src/v1/ && node scripts/check-v1.mjs --code && node scripts/check-v1-styles.mjs", + "test:v1": "vitest run --config vitest.v1.config.mts --coverage && vitest run --config vitest.v1-plugins.config.mts --coverage && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs test-fixtures/v1-harness/harness.fixtures.mjs test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs", + "seam:v1": "node scripts/check-v1-seam.mjs --base origin/main", + "separation:v1": "node scripts/check-v1-separation.mjs", + "bundle:v1": "node scripts/check-v1-bundle.mjs" }, "browserslist": { "production": [ diff --git a/frontend/scripts/check-v1-bundle.mjs b/frontend/scripts/check-v1-bundle.mjs new file mode 100755 index 0000000000..00f422bccc --- /dev/null +++ b/frontend/scripts/check-v1-bundle.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +/* + * Bundle guard for the frontend v1 epic (P0-T10): no v1 module reaches a v0 + * module or antd in the production module graph. + * + * Runs the production build (vite.config.js, unchanged) with one extra Rollup + * plugin that records the module graph Rollup resolved: for every module, the + * modules it imports statically and dynamically. Then, from every module + * under src/v1/, it walks that graph and fails on any module from src/ + * outside src/v1/ (v0, including the antd shims) or from the antd packages. + * + * It judges imports, not chunk placement. Rollup may put a package v1 uses + * into a chunk that also holds v0 code, and v1 may be inlined into a v0 + * chunk; neither changes what v1 code depends on. What the per-file import + * check (check-v1.mjs) cannot see, and this guard can, is a package that + * reaches antd or v0 code transitively. + * + * The graph covers only modules the build reaches. Until v1 is loaded through + * its own import (the version switch, P0-T03) the build reaches no v1 module + * and the guard passes with nothing to check; the fixtures prove it fails on + * a bad tree. + * + * (no flags) Build, then check. + * --report Check a saved report instead of building. + * --save-report Build, write the report there, then check. + */ +import fs from "node:fs"; +import path from "node:path"; + +const FRONTEND = path.join(import.meta.dirname, ".."); +const ANTD = /\/node_modules\/(antd|@ant-design\/[^/]+)\//; + +function usage(message) { + console.error(`check-v1-bundle: ${message}`); + console.error( + "usage: check-v1-bundle.mjs [--report | --save-report ]", + ); + process.exit(2); +} + +function parseArgs(argv) { + const opts = { report: null, saveReport: null }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const key = { "--report": "report", "--save-report": "saveReport" }[arg]; + if (!key) { + usage(`unknown argument '${arg}'`); + } + const value = argv[i + 1]; + if (!value || value.startsWith("--")) { + usage(`${arg} needs a value`); + } + opts[key] = value; + i += 1; + } + if (opts.report && opts.saveReport) { + usage("pass at most one of --report and --save-report"); + } + return opts; +} + +/* A module id as the report keeps it: frontend-relative for our sources, + * as-is (absolute) for packages, null for virtual modules. */ +function moduleName(id) { + if (id.startsWith("\0")) { + return null; + } + const file = id.split("?")[0]; + if (file.includes("/node_modules/")) { + return file; + } + const rel = path.relative(FRONTEND, file).split(path.sep).join("/"); + return rel.startsWith("..") ? file : rel; +} + +const byName = (a, b) => a.localeCompare(b); + +/* Records { module: [modules it imports] } for every module in the build. */ +function reporter(graph) { + return { + name: "v1-bundle-report", + apply: "build", + generateBundle() { + for (const id of this.getModuleIds()) { + const name = moduleName(id); + const info = this.getModuleInfo(id); + if (!name || !info) { + continue; + } + const deps = [...info.importedIds, ...info.dynamicallyImportedIds] + .map(moduleName) + .filter(Boolean); + graph[name] = [...new Set(deps)].sort(byName); + } + }, + }; +} + +async function buildReport() { + const { build } = await import("vite"); + const modules = {}; + await build({ + root: FRONTEND, + configFile: path.join(FRONTEND, "vite.config.js"), + logLevel: "warn", + plugins: [reporter(modules)], + }); + return { modules }; +} + +const kind = (name) => { + if (ANTD.test(name)) { + return "antd"; + } + if (name.startsWith("src/v1/")) { + return "v1"; + } + if (name.startsWith("src/")) { + return "v0"; + } + return "other"; +}; + +/* Every module reachable from root, with the modules in between. */ +function reachable(root, modules) { + const seen = new Map([[root, []]]); + const queue = [root]; + while (queue.length > 0) { + const name = queue.shift(); + const via = [...seen.get(name), name]; + for (const next of modules[name] ?? []) { + if (!seen.has(next)) { + seen.set(next, via); + queue.push(next); + } + } + } + return seen; +} + +function describe(target, via) { + const what = kind(target) === "v0" ? "v0 module" : "antd module"; + const where = via.length > 1 ? ` through ${via.slice(1).join(" -> ")}` : ""; + return `${via[0]} (v1) reaches ${what} ${target}${where}.`; +} + +function check(report) { + const modules = report.modules ?? {}; + const v1Modules = Object.keys(modules) + .filter((name) => kind(name) === "v1") + .sort(byName); + // One line per forbidden module, from the first v1 module that reaches it. + const problems = new Map(); + for (const root of v1Modules) { + for (const [target, via] of reachable(root, modules)) { + if (["v0", "antd"].includes(kind(target)) && !problems.has(target)) { + problems.set(target, describe(target, via)); + } + } + } + return { v1Modules: v1Modules.length, problems: [...problems.values()] }; +} + +const opts = parseArgs(process.argv.slice(2)); +let report; +if (opts.report) { + try { + report = JSON.parse(fs.readFileSync(opts.report, "utf8")); + } catch (error) { + console.error( + `check-v1-bundle: cannot read ${opts.report}: ${error.message}`, + ); + process.exit(2); + } +} else { + report = await buildReport(); + if (opts.saveReport) { + fs.writeFileSync(opts.saveReport, `${JSON.stringify(report, null, 2)}\n`); + } +} + +const { v1Modules, problems } = check(report); +if (problems.length > 0) { + console.error(problems.join("\n")); + console.error( + `\ncheck-v1-bundle: ${problems.length} problem(s). v1 code may reach only v1 code and packages other than antd.`, + ); + process.exit(1); +} +console.log( + `check-v1-bundle: ok (${Object.keys(report.modules ?? {}).length} module(s), ${v1Modules} v1 module(s))`, +); diff --git a/frontend/scripts/check-v1-seam.mjs b/frontend/scripts/check-v1-seam.mjs new file mode 100755 index 0000000000..8fca1403ae --- /dev/null +++ b/frontend/scripts/check-v1-seam.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node +/* + * Seam guard for the frontend v1 epic (P0-T10). Until Phase 9, v1 work may + * change v0 only at the seam: the files in scripts/v1-seam-allowlist.json. + * + * A change set is v1 work when it touches src/v1/ or the v1 tooling + * (scripts/*v1*, test-fixtures/v1-*, and top-level files such as + * vitest.v1.config.mts). Anything else is v0 work, such as a v0 bug fix, and + * passes untouched: the seam limits the epic, not v0. + * + * "v0" here is the code that ships as v0: src/ outside src/v1/, public/, and + * index.html, v0's HTML shell. Build, test and CI configuration + * (package.json, vite.config.js, biome.json, scripts/, test-fixtures/, docs/, + * ...) sits outside both and is never guarded, and so do nginx.conf and + * default.conf: they serve v0 and v1 alike and are reviewed as deployment + * configuration. A deleted or renamed v0 file counts as a change to it. + * + * With --base, the allowlist is read from the base branch, so a PR cannot + * widen the seam and pass in the same change: a wider allowlist applies from + * the next PR, once it is reviewed and merged. Only when the base has no + * allowlist yet (the guard's own introduction) is the PR's copy used. + * + * --base Changed files are `git diff ...HEAD` (CI passes the + * PR's base branch). + * --changed Changed files are read from , one path per line, + * relative to frontend/ (used by the fixtures). + * --override Report the files, then pass. CI sets it when the PR + * carries the `v0-change` label: v1 work that genuinely + * needs a v0 change, made visible to reviewers by the + * label. + */ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const FRONTEND = path.join(import.meta.dirname, ".."); +const ALLOWLIST_REL = "scripts/v1-seam-allowlist.json"; +const GUARDED = [/^src\/(?!v1\/)/, /^public\//, /^index\.html$/]; +const V1_WORK = [ + /^src\/v1\//, + /^[^/]*v1[^/]*$/, + /^scripts\/[^/]*v1[^/]*$/, + /^test-fixtures\/v1-[^/]+\//, +]; + +function usage(message) { + console.error(`check-v1-seam: ${message}`); + console.error( + "usage: check-v1-seam.mjs (--base | --changed ) [--override]", + ); + process.exit(2); +} + +function parseArgs(argv) { + const opts = { base: null, changed: null, override: false }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--override") { + opts.override = true; + } else if (arg === "--base" || arg === "--changed") { + const value = argv[i + 1]; + if (!value || value.startsWith("--")) { + usage(`${arg} needs a value`); + } + opts[arg.slice(2)] = value; + i += 1; + } else { + usage(`unknown argument '${arg}'`); + } + } + if (Boolean(opts.base) === Boolean(opts.changed)) { + usage("pass exactly one of --base and --changed"); + } + return opts; +} + +/* `**` crosses folders, `*` and `?` stay inside one; nothing else is special. */ +function globToRegExp(glob) { + let source = ""; + for (let i = 0; i < glob.length; i += 1) { + const ch = glob[i]; + if (ch === "*" && glob[i + 1] === "*") { + // `**/` also matches no folder at all. + if (glob[i + 2] === "/") { + source += "(?:.*/)?"; + i += 2; + } else { + source += ".*"; + i += 1; + } + } else if (ch === "*") { + source += "[^/]*"; + } else if (ch === "?") { + source += "[^/]"; + } else { + source += ch.replace(/[.+^${}()|[\]\\]/g, String.raw`\$&`); + } + } + return new RegExp(`^${source}$`); +} + +/* git from PATH on purpose: a developer/CI tool with no fixed location + * across macOS and Linux (as in the boundary fixtures). */ +function git(args) { + const options = { cwd: FRONTEND, encoding: "utf8" }; + return spawnSync("git", args, options); // NOSONAR +} + +/* The allowlist's text: from the base branch with --base, else the file. */ +function allowlistSource(base) { + if (base) { + const shown = git(["show", `${base}:./${ALLOWLIST_REL}`]); + if (!shown.error && shown.status === 0) { + return { text: shown.stdout, from: `${base}:${ALLOWLIST_REL}` }; + } + } + const file = path.join(FRONTEND, ALLOWLIST_REL); + try { + return { text: fs.readFileSync(file, "utf8"), from: ALLOWLIST_REL }; + } catch (error) { + console.error(`check-v1-seam: cannot read ${file}: ${error.message}`); + process.exit(2); + } +} + +function readAllowlist(base) { + const { text, from } = allowlistSource(base); + let parsed; + try { + parsed = JSON.parse(text); + } catch (error) { + console.error(`check-v1-seam: cannot parse ${from}: ${error.message}`); + process.exit(2); + } + if ( + !Array.isArray(parsed.allow) || + parsed.allow.some((glob) => typeof glob !== "string" || glob === "") + ) { + console.error(`check-v1-seam: ${from} needs an "allow" list of globs`); + process.exit(2); + } + return parsed.allow.map((glob) => ({ glob, re: globToRegExp(glob) })); +} + +function changedFromGit(base) { + // -z: paths NUL-separated and never quoted, so a non-ASCII path such as + // src/pages/Überblick.jsx is matched as written. + const args = ["diff", "--name-only", "-z", "--no-renames", "--relative"]; + args.push(`${base}...HEAD`, "--", "."); + const result = git(args); + if (result.error || result.status !== 0) { + console.error( + `check-v1-seam: git diff against '${base}' failed: ${result.error?.message ?? result.stderr.trim()}`, + ); + process.exit(2); + } + return result.stdout.split("\0"); +} + +function changedFromFile(file) { + try { + return fs.readFileSync(file, "utf8").split("\n"); + } catch (error) { + console.error(`check-v1-seam: cannot read ${file}: ${error.message}`); + process.exit(2); + } +} + +const opts = parseArgs(process.argv.slice(2)); +const allow = readAllowlist(opts.base); +const changed = ( + opts.base ? changedFromGit(opts.base) : changedFromFile(opts.changed) +) + .map((line) => line.trim().replace(/^\.\//, "")) + .filter(Boolean); + +if (!changed.some((file) => V1_WORK.some((re) => re.test(file)))) { + console.log( + `check-v1-seam: ok (${changed.length} changed file(s), none of them v1 work)`, + ); + process.exit(0); +} + +const outside = changed.filter( + (file) => + GUARDED.some((re) => re.test(file)) && + !allow.some(({ re }) => re.test(file)), +); + +if (outside.length === 0) { + console.log( + `check-v1-seam: ok (${changed.length} changed file(s), none in v0 outside the seam)`, + ); + process.exit(0); +} + +const lines = outside.map( + (file) => + `frontend/${file}: changes v0 outside the seam allowlist (scripts/v1-seam-allowlist.json).`, +); +if (opts.override) { + console.log(lines.join("\n")); + console.log( + `\ncheck-v1-seam: ${outside.length} v0 file(s) changed; passing because the PR is labelled v0-change.`, + ); + process.exit(0); +} +console.error(lines.join("\n")); +console.error( + `\ncheck-v1-seam: ${outside.length} v0 file(s) outside the seam. v1 work changes v0 only at the seam; if this v1 work genuinely needs the v0 change, label the PR v0-change.`, +); +process.exit(1); diff --git a/frontend/scripts/check-v1-separation.mjs b/frontend/scripts/check-v1-separation.mjs new file mode 100755 index 0000000000..1926d7f70c --- /dev/null +++ b/frontend/scripts/check-v1-separation.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +/* + * Separation guard for the frontend v1 epic (P0-T10): the public repository's + * v1 tree holds no enterprise plugin code. + * + * Cloud v1 plugins live in unstract-cloud and are copied into + * src/v1/plugins// at build time (spec decision D7). The public tree keeps + * only the plugin registry's own top-level files there, so any folder under + * src/v1/plugins/ is an overlaid plugin. + * + * Inside a git work tree only tracked files count, so a developer with the + * cloud overlay copied in (ignored by .gitignore) still passes; what fails is + * a plugin that was committed. Outside git (the fixtures) the folder itself + * is read. + * + * The other half of the guard, that v1 typechecks, lints and passes its + * tests with no overlay present, is the v1 CI jobs themselves: they run on a + * clean checkout of this repository, which has no overlay. The production + * build covers v1 only once v0 imports it (the version switch, P0-T03); + * until then a v1 file nothing imports is not built. + */ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const FRONTEND = path.join(import.meta.dirname, ".."); +const PLUGINS_REL = "src/v1/plugins"; +const PLUGINS = path.join(FRONTEND, PLUGINS_REL); + +/* git from PATH on purpose: a developer/CI tool with no fixed location + * across macOS and Linux (as in the boundary fixtures). */ +function git(args) { + const options = { cwd: FRONTEND, encoding: "utf8" }; + return spawnSync("git", args, options); // NOSONAR +} + +function trackedPluginPaths() { + // Paths come back relative to the working directory, i.e. frontend/, and + // with -z unquoted, so a non-ASCII name is read as written. + const result = git(["ls-files", "-z", "--", PLUGINS_REL]); + if (result.error || result.status !== 0) { + return null; + } + return result.stdout + .split("\0") + .filter(Boolean) + .map((file) => file.slice(PLUGINS_REL.length + 1)); +} + +function diskPluginPaths() { + if (!fs.existsSync(PLUGINS)) { + return []; + } + return fs + .readdirSync(PLUGINS, { recursive: true, withFileTypes: true }) + .filter((entry) => !entry.isDirectory()) + .map((entry) => + path + .relative(PLUGINS, path.join(entry.parentPath, entry.name)) + .split(path.sep) + .join("/"), + ); +} + +const insideGit = + git(["rev-parse", "--is-inside-work-tree"]).stdout?.trim() === "true"; +const files = (insideGit && trackedPluginPaths()) || diskPluginPaths(); + +const plugins = [ + ...new Set( + files + .filter((file) => file.includes("/")) + .map((file) => file.split("/")[0]), + ), +].sort((a, b) => a.localeCompare(b)); + +if (plugins.length === 0) { + console.log("check-v1-separation: ok"); + process.exit(0); +} +console.error( + plugins + .map( + (id) => + `${PLUGINS_REL}/${id}/: enterprise plugin code in the public repository. Cloud v1 plugins live in unstract-cloud and are copied in at build time (spec D7).`, + ) + .join("\n"), +); +console.error(`\ncheck-v1-separation: ${plugins.length} plugin folder(s).`); +process.exit(1); diff --git a/frontend/scripts/v1-seam-allowlist.json b/frontend/scripts/v1-seam-allowlist.json new file mode 100644 index 0000000000..2683f45ede --- /dev/null +++ b/frontend/scripts/v1-seam-allowlist.json @@ -0,0 +1,4 @@ +{ + "$comment": "v0 files the frontend v1 epic may change until Phase 9 (the seam allowlist in the Frontend v1 spec): route definitions, the root provider mount and the global stylesheet that loads Tailwind and the v1 styles. Build, test and CI configuration outside src/, public/ and index.html is never guarded; v0's own test setup (src/setupTests.js) is v0 code, since v1 has its own. With --base the guard reads this list from the base branch, so a change to it applies from the next PR. The v0 Human Review header is cloud-only, so it is allowed by the cloud copy of this list. Read by scripts/check-v1-seam.mjs; globs are relative to frontend/, `*` stays inside one folder and `**` crosses folders.", + "allow": ["src/routes/**", "src/index.jsx", "src/App.jsx", "src/index.css"] +} diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index af0dcb2ddf..4296671648 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -112,9 +112,18 @@ Every folder may import from itself. Test files (`*.test.ts(x)`, | Script | Checks | | --- | --- | | `bun run typecheck:v1` | TypeScript-only files, styles only in `styles/`, allowed file types only, no symlinks, folder taxonomy; then `tsc` with `src/v1/tsconfig.json` | -| `bun run lint:v1` | Biome on `src/v1/`, then imports in code and in `styles/` CSS (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks, the single notification entry point, then the styling policy (S1 to S8 above) | +| `bun run lint:v1` | Biome on `src/v1/` (warnings fail), then imports in code and in `styles/` CSS (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks, the single notification entry point, then the styling policy (S1 to S8 above) | | `bun run tokens:v1` | Regenerates `styles/tokens.css` from `styles/tokens.json` (via `styles/tokens.ts`; runs under bun, which imports the TypeScript source directly) | -| `bun run test:v1` | v1 unit tests (`vitest.v1.config.mts`, then cloud plugins with `vitest.v1-plugins.config.mts`) with v1's own coverage threshold, then the guardrail fixtures in `test-fixtures/v1-boundary/` and `test-fixtures/v1-harness/`. The harness is documented in `test/README.md` | +| `bun run test:v1` | v1 unit tests (`vitest.v1.config.mts`, then cloud plugins with `vitest.v1-plugins.config.mts`) with v1's own coverage threshold, then the guardrail fixtures in `test-fixtures/v1-boundary/`, `test-fixtures/v1-harness/` and `test-fixtures/v1-unit-gate/` (the coverage threshold fails a run, and CI runs the rig group's config). The harness is documented in `test/README.md` | +| `bun run seam:v1` | Seam guard: v1 work changes v0 (`src/` outside v1, `public/`) only at the files in `scripts/v1-seam-allowlist.json`, compared with `origin/main` | +| `bun run separation:v1` | No enterprise plugin code: no folder under `plugins/` is tracked in the public repository | +| `bun run bundle:v1` | Production build, then no v1 chunk (or a chunk it loads) holds a v0 module or antd | + +In CI these run as separate `v1 · …` checks (`.github/workflows/ci-frontend-v1.yaml` +and `ci-frontend-v1-seam.yaml`), apart from v0's checks. `lint:v1` and CI +fail on any Biome warning under `src/v1/`, and the accessibility rules are +errors there. A PR that is v1 work and needs a v0 change outside the seam +carries the `v0-change` label, which turns the seam check into a report. Biome (`biome.json`) also reports the banned imports and the plain spellings of the v0 ↔ v1 boundary, so `biome ci` and editors flag them too: `@/`, `../`, diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index 59d69659c4..2ed3760fe5 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -84,7 +84,9 @@ const CASES = parseTable(` v1-notify-radix-namespace | lint:v1 | One notification entry point in v1 v1-notify-dynamic | lint:v1 | One notification entry point in v1 v1-notify-radix-dynamic | lint:v1 | One notification entry point in v1 - v1-notify-radix-bracket | lint:v1 | One notification entry point in v1 + # Biome's own warning stops lint:v1 first; check-v1 still catches it. + v1-notify-radix-bracket | lint:v1 | noDynamicNamespaceImportAccess + v1-notify-radix-bracket | node scripts/check-v1.mjs --code | One notification entry point in v1 v1-notify-radix-jsx | lint:v1 | One notification entry point in v1 v1-notify-radix-require | lint:v1 | One notification entry point in v1 v1-notify-radix-reexport | lint:v1 | One notification entry point in v1 @@ -128,7 +130,10 @@ const CASES = parseTable(` v1-raw-pixel-arbitrary-separator | lint:v1 | No raw pixel value under v1 v1-important-utility | lint:v1 | No !important under v1 v1-important-variant-utility | lint:v1 | No !important under v1 - v1-important-css | lint:v1 | No !important under v1 + # Biome now reports CSS !important first (warnings fail v1), so the styling + # policy's own CSS branch is run directly as well. + v1-important-css | lint:v1 | noImportantStyles + v1-important-css | node scripts/check-v1-styles.mjs | No !important under v1 v1-css-import-from-page | lint:v1 | CSS is imported only from app/ and styles/ v1-styles-raw-colour | lint:v1 | No raw colour under v1 v1-styles-raw-colour-named | lint:v1 | No raw colour under v1 @@ -143,6 +148,56 @@ const CASES = parseTable(` v1-cva-unprefixed-variant | lint:v1 | No unprefixed Tailwind utility under v1 v1-styles-policy-ok | lint:v1 | - v1-plain-strings-ok | lint:v1 | - + # Quality (P0-T10): accessibility rules, and every other warning, fail v1. + # Plain biome lint fails only on errors, so this proves the v1 override. + v1-a11y-button-type | biome lint src/v1/ | useButtonType + v1-warning-is-error | lint:v1 | noArrayIndexKey + # Purity (P0-T10): the antd shims are v0, so v1 cannot reach them. + v1-imports-v0-shim | lint:v1 | v1 imports nothing outside src/v1 + # Seam (P0-T10, check-v1-seam.mjs): v1 work changes v0 only at the seam. + seam-v1-only | node scripts/check-v1-seam.mjs --changed changed.txt | - + seam-v1-at-the-seam | node scripts/check-v1-seam.mjs --changed changed.txt | - + seam-v0-fix-only | node scripts/check-v1-seam.mjs --changed changed.txt | - + seam-v1-changes-v0-page | node scripts/check-v1-seam.mjs --changed changed.txt | src/pages/LogsPage.jsx: changes v0 outside the seam + seam-v1-changes-public | node scripts/check-v1-seam.mjs --changed changed.txt | public/favicon.ico: changes v0 outside the seam + seam-v1-tooling-changes-v0 | node scripts/check-v1-seam.mjs --changed changed.txt | antd-button.tsx: changes v0 outside the seam + seam-v1-changes-v0-page | node scripts/check-v1-seam.mjs --changed changed.txt --override | - + # The allowlist globs: nested matches pass, near-misses do not. + seam-glob-nested | node scripts/check-v1-seam.mjs --changed changed.txt | - + seam-glob-near-misses | node scripts/check-v1-seam.mjs --changed changed.txt | frontend/src/routes.jsx: changes v0 + seam-glob-near-misses | node scripts/check-v1-seam.mjs --changed changed.txt | frontend/src/indexXcss: changes v0 + seam-glob-near-misses | node scripts/check-v1-seam.mjs --changed changed.txt | frontend/src/pages/src/App.jsx: changes v0 + seam-glob-near-misses | node scripts/check-v1-seam.mjs --changed changed.txt | frontend/src/routesX/a.jsx: changes v0 + # A top-level v1 file (vitest.v1.config.mts) is v1 work. + seam-v1-root-config | node scripts/check-v1-seam.mjs --changed changed.txt | frontend/src/pages/a.jsx: changes v0 + # --base, as CI runs it: a real repository, root one level above frontend/. + seam-git-v1-changes-v0 | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-seam.mjs --base base | frontend/src/pages/a.jsx: changes v0 + seam-git-renames-v0 | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-seam.mjs --base base | frontend/src/pages/a.jsx: changes v0 + seam-git-deletes-v0 | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-seam.mjs --base base | frontend/src/pages/a.jsx: changes v0 + seam-git-non-ascii | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-seam.mjs --base base | frontend/src/pages/Überblick.jsx: changes v0 + seam-git-changes-index-html | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-seam.mjs --base base | frontend/index.html: changes v0 + seam-git-at-the-seam | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-seam.mjs --base base | - + # The allowlist comes from the base, so widening it in the PR does not help. + seam-git-widens-allowlist | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-seam.mjs --base base | frontend/src/pages/a.jsx: changes v0 + seam-git-v1-changes-v0 | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-seam.mjs --base nope | git diff against 'nope' failed + # Separation (P0-T10, check-v1-separation.mjs): no plugin code in OSS. + - | node scripts/check-v1-separation.mjs | - + v1-plugin-folder | node scripts/check-v1-separation.mjs | src/v1/plugins/acme/: enterprise plugin code + # In git, as CI runs it: only tracked files count. + - | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-separation.mjs | - + separation-git-overlay-ignored | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-separation.mjs | - + separation-git-plugin-committed | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-separation.mjs | src/v1/plugins/acme/: enterprise plugin code + separation-git-non-ascii-file | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-separation.mjs | - + # Bundle (P0-T10, check-v1-bundle.mjs): real builds, then a saved report. + bundle-v1-clean | node scripts/check-v1-bundle.mjs | - + # A package v0 also uses is not v0 code, wherever Rollup places it. + bundle-v1-shares-package | node scripts/check-v1-bundle.mjs | - + bundle-v1-pulls-v0 | node scripts/check-v1-bundle.mjs | reaches v0 module src/legacy.js + bundle-v1-dynamic-v0 | node scripts/check-v1-bundle.mjs | reaches v0 module src/legacy.js + # Mounted statically, so v1 is inlined into a v0 chunk and has none of its own. + bundle-v1-static-mount | node scripts/check-v1-bundle.mjs | src/v1/lib/text.ts (v1) reaches v0 module src/legacy.js + # antd reached through a package, which only this guard can see. + bundle-v1-antd-report | node scripts/check-v1-bundle.mjs --report report.json | reaches antd module /repo/frontend/node_modules/antd/es/button/index.js through /repo/frontend/node_modules/some-table/index.js `).map(([name, run, fails]) => ({ name: name === "-" ? null : name, run: run.split(" + "), @@ -163,8 +218,13 @@ function makeTree(caseName) { fs.cpSync(path.join(FRONTEND, "scripts"), path.join(dir, "scripts"), { recursive: true, }); + // Without a local copy of the cloud overlay (ignored by design, D7), so a + // developer who has one gets the same results as CI. + const plugins = path.join(FRONTEND, "src", "v1", "plugins"); fs.cpSync(path.join(FRONTEND, "src", "v1"), path.join(dir, "src", "v1"), { recursive: true, + filter: (src) => + path.dirname(src) !== plugins || !fs.statSync(src).isDirectory(), }); fs.symlinkSync( path.join(FRONTEND, "node_modules"), @@ -208,6 +268,8 @@ function runAll(dir, run) { env: { ...process.env, NO_COLOR: "1", + // For steps that run git-case.sh, which is not copied into the tree. + V1_FIXTURES: import.meta.dirname, PATH: `${path.join(dir, "node_modules", ".bin")}${path.delimiter}${process.env.PATH}`, }, }); @@ -288,6 +350,33 @@ test("cloud v1 plugins are git-ignored; the public manifest is not", () => { assert.deepEqual(sorted(ignored), sorted(cloud)); }); +// The accessibility rules the v1 README and the PR promise as errors. +const V1_A11Y_ERRORS = [ + "noLabelWithoutControl", + "noNoninteractiveElementInteractions", + "noRedundantRoles", + "noStaticElementInteractions", + "useAriaPropsSupportedByRole", + "useButtonType", + "useFocusableInteractive", + "useGenericFontNames", + "useKeyWithClickEvents", + "useSemanticElements", + "useValidAnchor", +]; + +test("the accessibility rules are errors under src/v1", () => { + const biome = JSON.parse( + fs.readFileSync(path.join(FRONTEND, "biome.json"), "utf8"), + ); + const v1 = biome.overrides.find((o) => o.includes?.includes("src/v1/**")); + assert.ok(v1, "biome.json has no src/v1/** override"); + const a11y = v1.linter?.rules?.a11y ?? {}; + for (const rule of V1_A11Y_ERRORS) { + assert.equal(a11y[rule], "error", `a11y/${rule} under src/v1`); + } +}); + test("every case folder is listed", () => { const listed = new Set(CASES.map((c) => c.name).filter(Boolean)); assert.deepEqual(sorted(fs.readdirSync(CASES_DIR)), sorted([...listed])); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-antd-report/report.json b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-antd-report/report.json new file mode 100644 index 0000000000..d7fbcaf37b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-antd-report/report.json @@ -0,0 +1,12 @@ +{ + "modules": { + "src/index.jsx": ["src/v1/pages/logs/index.tsx"], + "src/v1/pages/logs/index.tsx": [ + "/repo/frontend/node_modules/some-table/index.js" + ], + "/repo/frontend/node_modules/some-table/index.js": [ + "/repo/frontend/node_modules/antd/es/button/index.js" + ], + "/repo/frontend/node_modules/antd/es/button/index.js": [] + } +} diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/index.html b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/index.html new file mode 100644 index 0000000000..47a56ac2c5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/legacy.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/legacy.js new file mode 100644 index 0000000000..85ab98756e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/legacy.js @@ -0,0 +1 @@ +export const legacy = () => "v0"; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/main.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/main.js new file mode 100644 index 0000000000..8407b7749b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/main.js @@ -0,0 +1,4 @@ +import { legacy } from "./legacy.js"; + +legacy(); +import("@v1/pages/demo/index.ts").then((page) => page.render()); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..df95c250c7 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/v1/pages/demo/index.ts @@ -0,0 +1 @@ +export const render = () => "v1"; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/vite.config.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/vite.config.js new file mode 100644 index 0000000000..578472658b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/vite.config.js @@ -0,0 +1,6 @@ +import path from "node:path"; + +export default { + resolve: { alias: { "@v1": path.resolve(import.meta.dirname, "src/v1") } }, + build: { outDir: "dist", emptyOutDir: true }, +}; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/index.html b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/index.html new file mode 100644 index 0000000000..47a56ac2c5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/legacy.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/legacy.js new file mode 100644 index 0000000000..85ab98756e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/legacy.js @@ -0,0 +1 @@ +export const legacy = () => "v0"; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/main.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/main.js new file mode 100644 index 0000000000..dd55a7acb7 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/main.js @@ -0,0 +1 @@ +import("@v1/pages/demo/index.ts").then((page) => page.render()); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..df0e672fd5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/v1/pages/demo/index.ts @@ -0,0 +1,2 @@ +export const render = () => + import("../../../legacy.js").then(({ legacy }) => legacy()); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/vite.config.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/vite.config.js new file mode 100644 index 0000000000..578472658b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/vite.config.js @@ -0,0 +1,6 @@ +import path from "node:path"; + +export default { + resolve: { alias: { "@v1": path.resolve(import.meta.dirname, "src/v1") } }, + build: { outDir: "dist", emptyOutDir: true }, +}; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/index.html b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/index.html new file mode 100644 index 0000000000..47a56ac2c5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/legacy.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/legacy.js new file mode 100644 index 0000000000..85ab98756e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/legacy.js @@ -0,0 +1 @@ +export const legacy = () => "v0"; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/main.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/main.js new file mode 100644 index 0000000000..dd55a7acb7 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/main.js @@ -0,0 +1 @@ +import("@v1/pages/demo/index.ts").then((page) => page.render()); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..1b38b41baf --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/v1/pages/demo/index.ts @@ -0,0 +1,3 @@ +import { legacy } from "../../../legacy.js"; + +export const render = () => legacy(); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/vite.config.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/vite.config.js new file mode 100644 index 0000000000..578472658b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/vite.config.js @@ -0,0 +1,6 @@ +import path from "node:path"; + +export default { + resolve: { alias: { "@v1": path.resolve(import.meta.dirname, "src/v1") } }, + build: { outDir: "dist", emptyOutDir: true }, +}; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/index.html b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/index.html new file mode 100644 index 0000000000..47a56ac2c5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/legacy.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/legacy.js new file mode 100644 index 0000000000..85ab98756e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/legacy.js @@ -0,0 +1 @@ +export const legacy = () => "v0"; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/main.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/main.js new file mode 100644 index 0000000000..73691b351b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/main.js @@ -0,0 +1,5 @@ +import clsx from "clsx"; +import { legacy } from "./legacy.js"; + +document.body.className = clsx("v0", legacy()); +import("@v1/pages/demo/index.ts").then((page) => page.render()); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..b20ed870af --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/v1/pages/demo/index.ts @@ -0,0 +1,3 @@ +import clsx from "clsx"; + +export const render = () => clsx("v1", "page"); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/vite.config.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/vite.config.js new file mode 100644 index 0000000000..578472658b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/vite.config.js @@ -0,0 +1,6 @@ +import path from "node:path"; + +export default { + resolve: { alias: { "@v1": path.resolve(import.meta.dirname, "src/v1") } }, + build: { outDir: "dist", emptyOutDir: true }, +}; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/index.html b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/index.html new file mode 100644 index 0000000000..47a56ac2c5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/legacy.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/legacy.js new file mode 100644 index 0000000000..85ab98756e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/legacy.js @@ -0,0 +1 @@ +export const legacy = () => "v0"; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/main.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/main.js new file mode 100644 index 0000000000..7cae144575 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/main.js @@ -0,0 +1,3 @@ +import { render } from "@v1/pages/demo/index.ts"; + +render(); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/v1/lib/text.ts b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/v1/lib/text.ts new file mode 100644 index 0000000000..bf307a1fa0 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/v1/lib/text.ts @@ -0,0 +1,3 @@ +import { legacy } from "../../legacy.js"; + +export const text = () => legacy(); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..f34961e6dc --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/v1/pages/demo/index.ts @@ -0,0 +1,3 @@ +import { text } from "@v1/lib/text.ts"; + +export const render = () => text(); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/vite.config.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/vite.config.js new file mode 100644 index 0000000000..578472658b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/vite.config.js @@ -0,0 +1,6 @@ +import path from "node:path"; + +export default { + resolve: { alias: { "@v1": path.resolve(import.meta.dirname, "src/v1") } }, + build: { outDir: "dist", emptyOutDir: true }, +}; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/change.sh b/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/change.sh new file mode 100644 index 0000000000..f7d5fe1231 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/change.sh @@ -0,0 +1,2 @@ +mkdir -p src/v1/lib && echo "export const x = 1;" > src/v1/lib/x.ts +echo '// changed' >> src/routes/index.jsx diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/index.html b/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/index.html new file mode 100644 index 0000000000..c50eddd41f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/index.html @@ -0,0 +1 @@ + diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/src/pages/a.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/src/pages/a.jsx new file mode 100644 index 0000000000..38db18e938 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/src/pages/a.jsx @@ -0,0 +1 @@ +export const A = () => null; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/src/routes/index.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/src/routes/index.jsx new file mode 100644 index 0000000000..0cfe561a59 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/src/routes/index.jsx @@ -0,0 +1 @@ +export const routes = []; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/change.sh b/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/change.sh new file mode 100644 index 0000000000..c1eb18362e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/change.sh @@ -0,0 +1,2 @@ +mkdir -p src/v1/lib && echo "export const x = 1;" > src/v1/lib/x.ts +echo '' >> index.html diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/index.html b/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/index.html new file mode 100644 index 0000000000..c50eddd41f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/index.html @@ -0,0 +1 @@ + diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/src/pages/a.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/src/pages/a.jsx new file mode 100644 index 0000000000..38db18e938 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/src/pages/a.jsx @@ -0,0 +1 @@ +export const A = () => null; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/src/routes/index.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/src/routes/index.jsx new file mode 100644 index 0000000000..0cfe561a59 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/src/routes/index.jsx @@ -0,0 +1 @@ +export const routes = []; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/change.sh b/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/change.sh new file mode 100644 index 0000000000..3ce1b03bc4 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/change.sh @@ -0,0 +1,2 @@ +mkdir -p src/v1/lib && echo "export const x = 1;" > src/v1/lib/x.ts +git rm -q src/pages/a.jsx diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/index.html b/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/index.html new file mode 100644 index 0000000000..c50eddd41f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/index.html @@ -0,0 +1 @@ + diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/src/pages/a.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/src/pages/a.jsx new file mode 100644 index 0000000000..38db18e938 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/src/pages/a.jsx @@ -0,0 +1 @@ +export const A = () => null; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/src/routes/index.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/src/routes/index.jsx new file mode 100644 index 0000000000..0cfe561a59 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/src/routes/index.jsx @@ -0,0 +1 @@ +export const routes = []; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/change.sh b/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/change.sh new file mode 100644 index 0000000000..2f040fe5db --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/change.sh @@ -0,0 +1,2 @@ +mkdir -p src/v1/lib && echo "export const x = 1;" > src/v1/lib/x.ts +echo 'export const U = 1;' > 'src/pages/Überblick.jsx' diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/index.html b/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/index.html new file mode 100644 index 0000000000..c50eddd41f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/index.html @@ -0,0 +1 @@ + diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/src/pages/a.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/src/pages/a.jsx new file mode 100644 index 0000000000..38db18e938 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/src/pages/a.jsx @@ -0,0 +1 @@ +export const A = () => null; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/src/routes/index.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/src/routes/index.jsx new file mode 100644 index 0000000000..0cfe561a59 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/src/routes/index.jsx @@ -0,0 +1 @@ +export const routes = []; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/change.sh b/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/change.sh new file mode 100644 index 0000000000..1f29805bda --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/change.sh @@ -0,0 +1,2 @@ +mkdir -p src/v1/lib && echo "export const x = 1;" > src/v1/lib/x.ts +git mv src/pages/a.jsx src/pages/b.jsx diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/index.html b/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/index.html new file mode 100644 index 0000000000..c50eddd41f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/index.html @@ -0,0 +1 @@ + diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/src/pages/a.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/src/pages/a.jsx new file mode 100644 index 0000000000..38db18e938 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/src/pages/a.jsx @@ -0,0 +1 @@ +export const A = () => null; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/src/routes/index.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/src/routes/index.jsx new file mode 100644 index 0000000000..0cfe561a59 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/src/routes/index.jsx @@ -0,0 +1 @@ +export const routes = []; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/change.sh b/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/change.sh new file mode 100644 index 0000000000..a5782df989 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/change.sh @@ -0,0 +1,2 @@ +mkdir -p src/v1/lib && echo "export const x = 1;" > src/v1/lib/x.ts +echo '// changed' >> src/pages/a.jsx diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/index.html b/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/index.html new file mode 100644 index 0000000000..c50eddd41f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/index.html @@ -0,0 +1 @@ + diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/src/pages/a.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/src/pages/a.jsx new file mode 100644 index 0000000000..38db18e938 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/src/pages/a.jsx @@ -0,0 +1 @@ +export const A = () => null; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/src/routes/index.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/src/routes/index.jsx new file mode 100644 index 0000000000..0cfe561a59 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/src/routes/index.jsx @@ -0,0 +1 @@ +export const routes = []; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/change.sh b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/change.sh new file mode 100644 index 0000000000..a7ed340a2c --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/change.sh @@ -0,0 +1,11 @@ +mkdir -p src/v1/lib && echo "export const x = 1;" > src/v1/lib/x.ts +echo '// changed' >> src/pages/a.jsx +# Widen the allowlist in the same change (node, not `sed -i`, for macOS). +node -e ' +const fs = require("node:fs"); +const file = "scripts/v1-seam-allowlist.json"; +const list = JSON.parse(fs.readFileSync(file, "utf8")); +list.allow.push("src/pages/**"); +fs.writeFileSync(file, JSON.stringify(list, null, 2)); +' +grep -q 'src/pages' scripts/v1-seam-allowlist.json diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/index.html b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/index.html new file mode 100644 index 0000000000..c50eddd41f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/index.html @@ -0,0 +1 @@ + diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/src/pages/a.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/src/pages/a.jsx new file mode 100644 index 0000000000..38db18e938 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/src/pages/a.jsx @@ -0,0 +1 @@ +export const A = () => null; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/src/routes/index.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/src/routes/index.jsx new file mode 100644 index 0000000000..0cfe561a59 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/src/routes/index.jsx @@ -0,0 +1 @@ +export const routes = []; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-glob-near-misses/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-glob-near-misses/changed.txt new file mode 100644 index 0000000000..fcef155c3b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-glob-near-misses/changed.txt @@ -0,0 +1,5 @@ +src/v1/lib/x.ts +src/routes.jsx +src/indexXcss +src/pages/src/App.jsx +src/routesX/a.jsx diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-glob-nested/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-glob-nested/changed.txt new file mode 100644 index 0000000000..b1f40fcade --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-glob-nested/changed.txt @@ -0,0 +1,3 @@ +src/v1/lib/x.ts +src/routes/settings/deep/Route.jsx +src/index.css diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-v0-fix-only/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-v0-fix-only/changed.txt new file mode 100644 index 0000000000..615fba5e11 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-v0-fix-only/changed.txt @@ -0,0 +1,2 @@ +src/hooks/useLogout.js +src/hooks/useLogout.test.js diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-v1-at-the-seam/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-v1-at-the-seam/changed.txt new file mode 100644 index 0000000000..7a23f02fc5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-v1-at-the-seam/changed.txt @@ -0,0 +1,4 @@ +src/v1/index.ts +src/routes/Router.jsx +src/index.jsx +src/index.css diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-v1-changes-public/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-v1-changes-public/changed.txt new file mode 100644 index 0000000000..eead5242ba --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-v1-changes-public/changed.txt @@ -0,0 +1,2 @@ +src/v1/assets/logo.svg +public/favicon.ico diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-v1-changes-v0-page/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-v1-changes-v0-page/changed.txt new file mode 100644 index 0000000000..f8c330c4d1 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-v1-changes-v0-page/changed.txt @@ -0,0 +1,2 @@ +src/v1/pages/logs/index.tsx +src/pages/LogsPage.jsx diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-v1-only/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-v1-only/changed.txt new file mode 100644 index 0000000000..0247db891c --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-v1-only/changed.txt @@ -0,0 +1,3 @@ +src/v1/lib/format.ts +package.json +scripts/check-v1.mjs diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-v1-root-config/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-v1-root-config/changed.txt new file mode 100644 index 0000000000..3d5c0d4f2d --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-v1-root-config/changed.txt @@ -0,0 +1,2 @@ +vitest.v1.config.mts +src/pages/a.jsx diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-v1-tooling-changes-v0/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-v1-tooling-changes-v0/changed.txt new file mode 100644 index 0000000000..6874197fb3 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-v1-tooling-changes-v0/changed.txt @@ -0,0 +1,2 @@ +scripts/check-v1-styles.mjs +src/components/ui/shims/antd-button.tsx diff --git a/frontend/test-fixtures/v1-boundary/cases/separation-git-non-ascii-file/change.sh b/frontend/test-fixtures/v1-boundary/cases/separation-git-non-ascii-file/change.sh new file mode 100644 index 0000000000..125c06628b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/separation-git-non-ascii-file/change.sh @@ -0,0 +1,2 @@ +echo 'export {};' > 'src/v1/plugins/Ü.ts' +git add -f 'src/v1/plugins/Ü.ts' diff --git a/frontend/test-fixtures/v1-boundary/cases/separation-git-overlay-ignored/change.sh b/frontend/test-fixtures/v1-boundary/cases/separation-git-overlay-ignored/change.sh new file mode 100644 index 0000000000..f47bf06387 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/separation-git-overlay-ignored/change.sh @@ -0,0 +1 @@ +mkdir -p src/v1/plugins/acme && echo "export {};" > src/v1/plugins/acme/index.ts diff --git a/frontend/test-fixtures/v1-boundary/cases/separation-git-plugin-committed/change.sh b/frontend/test-fixtures/v1-boundary/cases/separation-git-plugin-committed/change.sh new file mode 100644 index 0000000000..f81d731aa6 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/separation-git-plugin-committed/change.sh @@ -0,0 +1,2 @@ +mkdir -p src/v1/plugins/acme && echo "export {};" > src/v1/plugins/acme/index.ts +git add -f src/v1/plugins/acme/index.ts diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-a11y-button-type/src/v1/components/ui/Bad.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-a11y-button-type/src/v1/components/ui/Bad.tsx new file mode 100644 index 0000000000..8f55cfd8b1 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-a11y-button-type/src/v1/components/ui/Bad.tsx @@ -0,0 +1,3 @@ +export function Bad({ onPress }: { onPress: () => void }) { + return ; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-shim/src/v1/components/ui/Button.ts b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-shim/src/v1/components/ui/Button.ts new file mode 100644 index 0000000000..c80a6ebca8 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-shim/src/v1/components/ui/Button.ts @@ -0,0 +1,3 @@ +import { Button } from "@/components/ui/shims/antd-button"; + +export { Button }; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-require/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-require/src/v1/pages/demo/index.ts index 726779d17f..ad2d51cee3 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-require/src/v1/pages/demo/index.ts +++ b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-require/src/v1/pages/demo/index.ts @@ -1,4 +1,3 @@ -// biome-ignore lint/style/noCommonJs: the fixture is the CommonJS spelling. const { Toast } = require("radix-ui"); export const Root = Toast.Root; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-plugin-folder/src/v1/plugins/acme/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-plugin-folder/src/v1/plugins/acme/index.ts new file mode 100644 index 0000000000..51f0cdda5f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-plugin-folder/src/v1/plugins/acme/index.ts @@ -0,0 +1 @@ +export const acme = true; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-warning-is-error/src/v1/components/ui/List.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-warning-is-error/src/v1/components/ui/List.tsx new file mode 100644 index 0000000000..b437a09258 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-warning-is-error/src/v1/components/ui/List.tsx @@ -0,0 +1,9 @@ +export function List({ items }: { items: string[] }) { + return ( +
    + {items.map((item, index) => ( +
  • {item}
  • + ))} +
+ ); +} diff --git a/frontend/test-fixtures/v1-boundary/git-case.sh b/frontend/test-fixtures/v1-boundary/git-case.sh new file mode 100755 index 0000000000..82fc291016 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/git-case.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Runs a guard ("$@") the way CI does: inside a real git repository whose root +# is one level above frontend/, so paths, --relative, renames, quoting and +# .gitignore all behave as on a PR. Used by boundary.fixtures.mjs. +# +# The case's tree (the current directory) is committed as the base, on +# branch `base`. Then the case's change.sh, if any, runs in frontend/ and its +# result is committed on top. change.sh itself is part of the base commit, so +# it is never part of the change. +set -eu +here=$(pwd) +repo=$(mktemp -d) +trap 'rm -rf "$repo"' EXIT +mkdir "$repo/frontend" +for entry in "$here"/* "$here"/.gitignore; do + [ "$(basename "$entry")" = node_modules ] || cp -R "$entry" "$repo/frontend/" +done +ln -s "$here/node_modules" "$repo/frontend/node_modules" +# The repository's own rule for the cloud overlay (D7). +printf '%s\n' 'frontend/src/v1/plugins/*' '!frontend/src/v1/plugins/manifest.ts' \ + >"$repo/.gitignore" +cd "$repo" +git init -q +git config user.email fixtures@example.com +git config user.name fixtures +git config core.quotePath true +git add -A +git commit -q -m base +git branch base +if [ -f frontend/change.sh ]; then + (cd frontend && sh change.sh) +fi +git add -A +git commit -q --allow-empty -m change +cd frontend +"$@" diff --git a/frontend/test-fixtures/v1-unit-gate/cases/above-threshold/src/v1/lib/grade.test.ts b/frontend/test-fixtures/v1-unit-gate/cases/above-threshold/src/v1/lib/grade.test.ts new file mode 100644 index 0000000000..a44c907f97 --- /dev/null +++ b/frontend/test-fixtures/v1-unit-gate/cases/above-threshold/src/v1/lib/grade.test.ts @@ -0,0 +1,11 @@ +import { expect, it } from "vitest"; +import { grade } from "./grade"; + +it.each([ + [95, "A"], + [85, "B"], + [75, "C"], + [65, "D"], +])("grades %i as %s", (score, letter) => { + expect(grade(score)).toBe(letter); +}); diff --git a/frontend/test-fixtures/v1-unit-gate/cases/above-threshold/src/v1/lib/grade.ts b/frontend/test-fixtures/v1-unit-gate/cases/above-threshold/src/v1/lib/grade.ts new file mode 100644 index 0000000000..bd7fe9358c --- /dev/null +++ b/frontend/test-fixtures/v1-unit-gate/cases/above-threshold/src/v1/lib/grade.ts @@ -0,0 +1,16 @@ +// One branch (the F grade) is never run: ~87% of lines, just above 80%. +export function grade(score: number): string { + if (score >= 90) { + return "A"; + } + if (score >= 80) { + return "B"; + } + if (score >= 70) { + return "C"; + } + if (score >= 60) { + return "D"; + } + return "F"; +} diff --git a/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.test.ts b/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.test.ts new file mode 100644 index 0000000000..9734321c8c --- /dev/null +++ b/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.test.ts @@ -0,0 +1,10 @@ +import { expect, it } from "vitest"; +import { grade } from "./grade"; + +it.each([ + [95, "A"], + [85, "B"], + [75, "C"], +])("grades %i as %s", (score, letter) => { + expect(grade(score)).toBe(letter); +}); diff --git a/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.ts b/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.ts new file mode 100644 index 0000000000..ca6a23fbad --- /dev/null +++ b/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.ts @@ -0,0 +1,16 @@ +// Two branches (D and F) are never run: ~67% of lines, just below 80%. +export function grade(score: number): string { + if (score >= 90) { + return "A"; + } + if (score >= 80) { + return "B"; + } + if (score >= 70) { + return "C"; + } + if (score >= 60) { + return "D"; + } + return "F"; +} diff --git a/frontend/test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs b/frontend/test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs new file mode 100644 index 0000000000..431820f527 --- /dev/null +++ b/frontend/test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs @@ -0,0 +1,133 @@ +/* + * Proves the `v1 · Unit tests` check (ci-frontend-v1.yaml) gates what it + * should: it runs the rig's `frontend-v1-unit` group config, and that config + * fails a run below v1's coverage threshold even when every test passes. + * Run by `bun run test:v1` and the `v1 · Guard fixtures` check. + */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +const FRONTEND = path.resolve(import.meta.dirname, "..", ".."); +const REPO = path.resolve(FRONTEND, ".."); +const CONFIG = path.join("test-fixtures", "v1-unit-gate", "vitest.config.mts"); +// The lockfile's vitest, run by this same node: nothing resolved from PATH. +const VITEST = path.join(FRONTEND, "node_modules", "vitest", "vitest.mjs"); + +function runCase(name) { + const reports = mkdtempSync(path.join(tmpdir(), "v1-unit-gate-")); + try { + const result = spawnSync( + process.execPath, + [VITEST, "run", "--config", CONFIG], + { + cwd: FRONTEND, + encoding: "utf8", + env: { + ...process.env, + CI: "1", + V1_GATE_CASE: name, + V1_GATE_REPORTS: reports, + }, + }, + ); + const output = `${result.stdout}${result.stderr}`.replace( + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes + /\u001b\[[0-9;]*m/g, + "", + ); + return { status: result.status, output }; + } finally { + rmSync(reports, { recursive: true, force: true }); + } +} + +test("passing tests below the coverage threshold fail the gate", () => { + const { status, output } = runCase("below-threshold"); + assert.notEqual(status, 0, output); + assert.match(output, /Tests\s+3 passed/); + assert.match( + output, + /Coverage for lines \([\d.]+%\) does not meet global threshold \(80%\)/, + ); +}); + +test("passing tests just above the coverage threshold pass the gate", () => { + const { status, output } = runCase("above-threshold"); + assert.equal(status, 0, output); +}); + +// The `frontend-v1-unit` block of tests/groups.yaml, up to the next group. +function groupBlock(yaml, name) { + const start = yaml.indexOf(`\n ${name}:\n`); + assert.notEqual(start, -1, `tests/groups.yaml has no ${name} group`); + const rest = yaml.slice(start + name.length + 5); + const end = rest.search(/\n {2}[a-z0-9-]+:\n/); + return end === -1 ? rest : rest.slice(0, end); +} + +test("the CI unit check runs the rig group's vitest config", () => { + const groups = readFileSync(path.join(REPO, "tests", "groups.yaml"), "utf8"); + const config = groupBlock(groups, "frontend-v1-unit").match( + /^\s+vitest_config:\s*(\S+)\s*$/m, + )?.[1]; + assert.ok(config, "frontend-v1-unit sets no vitest_config"); + + const workflow = readFileSync( + path.join(REPO, ".github", "workflows", "ci-frontend-v1.yaml"), + "utf8", + ); + const job = workflow.slice(workflow.indexOf("\n unit:\n")); + assert.ok(job.length > 1, "ci-frontend-v1.yaml has no `unit` job"); + const run = job.match(/^\s+run: (.*vitest run.*)$/m)?.[1]; + assert.ok(run, "the `unit` job runs no vitest"); + assert.ok( + run.includes(`--config ${config}`), + `the \`unit\` job runs \`${run}\`, not the group's config ${config}`, + ); +}); + +// Each v1 job's commands, exactly: a job that runs something weaker (no +// --error-on-warnings, `|| true`, a guard swapped for `true`) fails here. +const JOB_COMMANDS = { + biome: ["biome ci --error-on-warnings src/v1/"], + typecheck: ["bun run typecheck:v1"], + purity: [ + "node scripts/check-v1.mjs --code", + "node scripts/check-v1-styles.mjs", + ], + unit: ["./node_modules/.bin/vitest run --config vitest.v1.config.mts"], + build: [ + "node scripts/check-v1-bundle.mjs", + "node scripts/check-v1-separation.mjs", + ], + "guard-fixtures": [ + "node --test test-fixtures/v1-boundary/boundary.fixtures.mjs test-fixtures/v1-harness/harness.fixtures.mjs test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs", + ], +}; + +// A job's block: from ` :` to the next top-level job. +function jobBlock(workflow, name) { + const start = workflow.indexOf(`\n ${name}:\n`); + assert.notEqual(start, -1, `ci-frontend-v1.yaml has no \`${name}\` job`); + const rest = workflow.slice(start + name.length + 5); + const end = rest.search(/\n {2}[a-z0-9-]+:\n/); + return end === -1 ? rest : rest.slice(0, end); +} + +test("each v1 CI job runs its guard's exact commands", () => { + const workflow = readFileSync( + path.join(REPO, ".github", "workflows", "ci-frontend-v1.yaml"), + "utf8", + ); + assert.doesNotMatch(workflow, /continue-on-error/); + for (const [name, commands] of Object.entries(JOB_COMMANDS)) { + const runs = [...jobBlock(workflow, name).matchAll(/^\s+run: (.*)$/gm)] + .map((match) => match[1].trim()) + .filter((run) => !run.startsWith("bun install")); + assert.deepEqual(runs, commands, `the \`${name}\` job's run steps`); + } +}); diff --git a/frontend/test-fixtures/v1-unit-gate/vitest.config.mts b/frontend/test-fixtures/v1-unit-gate/vitest.config.mts new file mode 100644 index 0000000000..db1ebf87b3 --- /dev/null +++ b/frontend/test-fixtures/v1-unit-gate/vitest.config.mts @@ -0,0 +1,28 @@ +import path from "node:path"; +import { defineV1Config } from "../../vitest.v1.config.mts"; + +/* + * The real v1 config, coverage threshold included, rooted at one case in + * ./cases (named by V1_GATE_CASE), whose own src/v1 tree stands in for the + * real one. Only the setup file is re-pointed, at the real harness, and the + * report and Vite's cache go to V1_GATE_REPORTS so a run never writes into + * the tree. Used only by unit-gate.fixtures.mjs. + */ +const FRONTEND = path.resolve(import.meta.dirname, "..", ".."); +const CASE = process.env.V1_GATE_CASE ?? ""; +const REPORTS = process.env.V1_GATE_REPORTS ?? ""; +if (!CASE || !REPORTS) { + throw new Error("Set V1_GATE_CASE and V1_GATE_REPORTS"); +} +const base = defineV1Config("core"); + +export default { + ...base, + root: path.join(import.meta.dirname, "cases", CASE), + cacheDir: path.join(REPORTS, "vite"), + test: { + ...base.test, + setupFiles: [path.join(FRONTEND, "src", "v1", "test", "setup.ts")], + coverage: { ...base.test?.coverage, reportsDirectory: REPORTS }, + }, +};