diff --git a/.github/workflows/ci-frontend-lint.yaml b/.github/workflows/ci-frontend-lint.yaml index 1c87375a96..cc65d2c073 100644 --- a/.github/workflows/ci-frontend-lint.yaml +++ b/.github/workflows/ci-frontend-lint.yaml @@ -50,7 +50,7 @@ jobs: uses: actions/checkout@v6 - name: Setup Biome - uses: biomejs/setup-biome@v2 + uses: biomejs/setup-biome@4c91541eaada48f67d7dbd7833600ce162b68f51 # v2.7.1 with: version: "2.3.13" @@ -121,6 +121,31 @@ jobs: 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/.gitignore b/.gitignore index cfd13e388f..b820925e1e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,8 +41,12 @@ eggs/ lib/ lib64/ # The Python `lib/` rule above also matches the frontend's shadcn/ui helper -# directory (`@/lib/utils` per components.json). Keep that tracked. +# directory (`@/lib/utils` per components.json), the v1 `lib/` folder and the +# v1 guardrail fixtures. Keep those tracked, or their files are silently left +# out of commits. !frontend/src/lib/ +!frontend/src/v1/**/lib/ +!frontend/test-fixtures/**/lib/ parts/ sdist/ var/ @@ -635,6 +639,10 @@ workers/pluggable_worker/* # FE Plugins frontend/src/plugins/* frontend/public/llm-whisperer/ +# v1 plugins come from the cloud overlay; only the placeholder and the loader are OSS. +frontend/src/v1/plugins/* +!frontend/src/v1/plugins/.gitkeep +!frontend/src/v1/plugins/index.ts # TODO: Ensure its made generic to abstract subfolder and file names diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e279326d6f..fe06ede0a0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,9 +22,9 @@ repos: - id: check-docstring-first - id: check-ast - id: check-json - # Both files are JSONC — their tooling (VS Code, tsc) allows comments, + # These files are JSONC — their tooling (VS Code, tsc) allows comments, # but this hook parses with strict `json.loads`, which does not. - exclude: "(\\.vscode/launch\\.json|frontend/tsconfig\\.json)$" + exclude: "(\\.vscode/launch\\.json|frontend/(src/v1/)?tsconfig\\.json)$" - id: check-executables-have-shebangs - id: check-shebang-scripts-are-executable - id: check-toml diff --git a/docker/dockerfiles/frontend.Dockerfile b/docker/dockerfiles/frontend.Dockerfile index 0c3fdcc92a..54843defba 100644 --- a/docker/dockerfiles/frontend.Dockerfile +++ b/docker/dockerfiles/frontend.Dockerfile @@ -46,6 +46,12 @@ RUN bun install --frozen-lockfile --ignore-scripts # Copy the rest of the application files COPY ${BUILD_CONTEXT_PATH}/ . +# Which v1 plugins the build offers (oss, cloud or onprem); the cloud +# workflows set it. Declared after install so a different value keeps the +# dependency layer cached. See frontend/src/v1/README.md. +ARG VITE_DISTRIBUTION=oss +ENV VITE_DISTRIBUTION=${VITE_DISTRIBUTION} + # Build with Vite RUN bun run build diff --git a/frontend/biome.json b/frontend/biome.json index eb98923c0c..8d9f1d1ff3 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -129,5 +129,82 @@ "assist": { "enabled": true, "actions": { "source": { "organizeImports": "on" } } - } + }, + "overrides": [ + { + "includes": ["src/v1/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "paths": { + "prop-types": "v1 is typed with TypeScript; PropTypes are not used under src/v1.", + "antd": "antd is not used under src/v1." + }, + "patterns": [ + { + "group": [ + "../**", + "..", + "./../**", + "/**", + "@/**", + "src/**" + ], + "message": "v1 imports nothing outside src/v1, and never with '../'. Use './' within a folder and '@v1/...' across folders." + }, + { + "group": ["@ant-design/**", "antd/**"], + "message": "antd is not used under src/v1." + }, + { + "group": ["prop-types/**"], + "message": "v1 is typed with TypeScript; PropTypes are not used under src/v1." + }, + { + "group": ["**/*.css", "!@v1/styles/**"], + "message": "No component .css under src/v1; style with tokens and Tailwind. Global v1 styles live in @v1/styles/." + } + ] + } + } + }, + "suspicious": { + "noExplicitAny": "error" + } + } + } + }, + { + "includes": ["src/**", "!src/v1/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "@v1/**", + "@/v1", + "@/v1/**", + "src/v1", + "src/v1/**", + "/src/v1", + "/src/v1/**", + "!@v1" + ], + "message": "v0 may import only the v1 public entry, as '@v1'." + } + ] + } + } + } + } + } + } + ] } diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json index 082b33982c..d2e095ab6b 100644 --- a/frontend/jsconfig.json +++ b/frontend/jsconfig.json @@ -2,9 +2,11 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@v1": ["./src/v1/index.ts"], + "@v1/*": ["./src/v1/*"] } }, "include": ["src/**/*"], - "exclude": ["node_modules", "build"] + "exclude": ["node_modules", "build", "src/v1"] } diff --git a/frontend/package.json b/frontend/package.json index b4ab1e1bd5..cc01b5c9db 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -77,7 +77,10 @@ "check:fix": "biome check --write src/", "lint:all": "biome check --write src/", "lint:changed": "(git diff --name-only --diff-filter=ACMR; git ls-files --others --exclude-standard) | grep -E '\\.[jt]sx?$' | sed 's|^frontend/||' | sort -u | xargs -r biome check --write", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "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", + "test:v1": "vitest run --dir src/v1 --passWithNoTests && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs scripts/v1-plugins.fixtures.mjs" }, "browserslist": { "production": [ diff --git a/frontend/scripts/check-v1.mjs b/frontend/scripts/check-v1.mjs new file mode 100755 index 0000000000..9f786e55a7 --- /dev/null +++ b/frontend/scripts/check-v1.mjs @@ -0,0 +1,705 @@ +#!/usr/bin/env node +/* + * Guard for the v1 frontend (src/v1/): the rules Biome cannot express. + * + * --files TypeScript only (a .js under v1 is skipped by tsc, not failed, + * because allowJs is off), style files only in styles/, no + * symlinks, and every file inside the v1 folder taxonomy. Run by + * `typecheck:v1`. + * --code Imports: nothing outside src/v1 and the folder import contract + * (which v1 folder may import which). No PropTypes, no antd `ant-` + * class names. Also scans v0 (the rest of src/) for imports that + * reach into v1 other than through `@v1`. Run by `lint:v1`. + * + * With neither flag, both run. It checks the src/ beside this script; the + * fixtures in test-fixtures/v1-boundary/ copy it into each throwaway tree for + * that reason. + * + * Imports are read from static import/export statements and from the calls + * Vite resolves: import(), require(), import.meta.glob() and + * new URL(..., import.meta.url). A template argument is checked by its fixed + * prefix. In v1, an argument that is neither (a variable, say) is reported as + * unanalysable rather than passed. + * + * The v0 <-> v1 boundary is also a Biome rule (biome.json `overrides`), so CI + * and editors report the plain forms. Biome overrides replace rule options + * rather than merge them, which is why the per-folder contract lives here and + * not as one override per folder. + * + * The `ant-` check exists only while antd markup survives in v0; drop it + * once the shadcn migration is complete. + */ +import fs from "node:fs"; +import path from "node:path"; + +const SRC = path.join(import.meta.dirname, "..", "src"); +const ROOT = path.join(SRC, "v1"); +const args = new Set(process.argv.slice(2)); +const runFiles = args.has("--files") || !args.has("--code"); +const runCode = args.has("--code") || !args.has("--files"); + +const ALL = "*"; +/* + * Folder -> folders it may import from, besides itself. `entry` is + * src/v1/index.ts. Pages may not import other pages, app/, test/ (except + * from test files) or the entry. + * Mirrors the table in src/v1/README.md; change both together. + */ +const CONTRACT = { + entry: ALL, + app: ALL, + plugins: ALL, + test: ALL, + pages: [ + "components/ui", + "components/patterns", + "shell", + "services", + "stores", + "hooks", + "lib", + "styles", + "assets", + "plugins", + ], + "components/ui": ["lib", "styles", "assets"], + "components/patterns": [ + "components/ui", + "lib", + "services", + "stores", + "hooks", + "assets", + ], + shell: [ + "components/ui", + "components/patterns", + "services", + "stores", + "hooks", + "lib", + "assets", + ], + services: ["lib"], + stores: ["lib"], + hooks: ["lib"], + lib: [], + styles: ["lib", "assets"], + // Static files (icons, logos, images); a generated icon map may live here. + assets: [], +}; +const TOP_LEVEL = new Set([ + "app", + "shell", + "services", + "stores", + "hooks", + "lib", + "styles", + "assets", + "plugins", + "test", +]); +const ROOT_FILES = new Set(["index.ts", "tsconfig.json", "README.md"]); + +const CODE_EXT = /\.(ts|tsx|mts|cts)$/; +const JS_EXT = /\.(js|jsx|mjs|cjs)$/; +const STYLE_EXT = /\.(css|scss|sass|less)$/; +const TEST_FILE = /\.(test|spec)\.(ts|tsx|mts|cts)$/; +// Specifiers that leave src/v1: root-absolute, parent, the v0 alias, src/. +const OUTSIDE = /^(\/|\.\.(\/|$)|@\/|src\/)/; +// The v1 public entry, in the spellings that resolve to src/v1/index.ts. +const ENTRY_SPEC = /^@v1(\/index(\.ts)?)?$/; +// antd packages, which v1 styles may not pull in either. +const ANTD = /^(antd|@ant-design)(\/|$)/; + +/* + * The v1 folder a path (relative to src/v1, `/`-separated) belongs to, e.g. + * "components/ui" or "pages/settings"; null when it is outside the taxonomy. + * `isDir` is for import targets like "@v1/lib", which name a folder. + */ +function folderOf(rel, isDir = false) { + const segs = rel.split("/").filter(Boolean); + const depth = isDir ? segs.length + 1 : segs.length; + if (segs.length === 0 || (depth === 1 && segs[0] === "index.ts")) { + return "entry"; + } + if (depth === 1) { + return null; + } + const [top, sub] = segs; + if (top === "components") { + return (sub === "ui" || sub === "patterns") && depth >= 3 + ? `components/${sub}` + : null; + } + if (top === "pages") { + return sub && depth >= 3 ? `pages/${sub}` : null; + } + return TOP_LEVEL.has(top) ? top : null; +} + +function contractKey(folder) { + return folder.startsWith("pages/") ? "pages" : folder; +} + +function mayImport(from, to, isTestFile) { + if (from === to) { + return true; + } + if (isTestFile && to === "test") { + return true; + } + if (from.startsWith("pages/") && to.startsWith("pages/")) { + return false; + } + const allowed = CONTRACT[contractKey(from)]; + return allowed === ALL || allowed.includes(to); +} + +/* + * Blanks comments, keeping line numbers so matches can be reported. Tracks + * strings, template literals (including `${}` expressions) and regex + * literals, so a `/*` or `//` inside any of them does not swallow the code + * after it. A regex literal is told from division, and a string from a JSX + * apostrophe, by the token before it; a string must also close on its line. + */ +function blankComments(src) { + let out = ""; + // The same text with string, template and regex bodies blanked too, so + // import statements and calls are found only in executable code. + let code = ""; + let i = 0; + // "`" = inside a template literal; "{" = inside a `${}` or a brace in one. + const stack = []; + while (i < src.length) { + if (stack.at(-1) === "`") { + const opensOrCloses = + src[i] === "`" || (src[i] === "$" && src[i + 1] === "{"); + const end = i + templateStep(src, i, stack); + const chunk = src.slice(i, end); + out += chunk; + code += opensOrCloses ? chunk : blank(chunk); + i = end; + continue; + } + const end = tokenEnd(src, i, out, stack); + const token = src.slice(i, end); + if (isComment(src, i)) { + out += blank(token); + code += blank(token); + } else { + out += token; + // A string or regex literal keeps only its opening delimiter. + const literal = /^["'/]/.test(token) && token.length > 1; + code += literal ? token[0] + blank(token.slice(1)) : token; + } + i = end; + } + return { text: out, code }; +} + +const blank = (s) => s.replace(/[^\n]/g, " "); + +/* CSS has block comments only; a `//` there is part of a URL. */ +function blankCssComments(src) { + return src.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")); +} + +function isComment(src, i) { + return src[i] === "/" && (src[i + 1] === "/" || src[i + 1] === "*"); +} + +/* Index just past the code token that starts at i. */ +function tokenEnd(src, i, out, stack) { + if (isComment(src, i)) { + return commentEnd(src, i); + } + if ((src[i] === '"' || src[i] === "'") && stringMayStart(out)) { + const end = stringEnd(src, i); + if (end !== -1) { + return end; + } + } + if (src[i] === "/" && regexMayStart(out)) { + const end = regexEnd(src, i); + if (end !== -1) { + return end; + } + } + codeStep(src[i], stack); + return i + 1; +} + +// A `/` after one of these (or a keyword below) starts a regex, not division. +// `<` is left out so JSX closing tags (``) stay code. +const REGEX_AFTER = new Set("(,=:[!&|?{};+-*%>~^"); +// A quote after one of these (or a keyword) starts a string. Anywhere else it +// is JSX text: the apostrophe in `It's` or `{name}'s` follows a letter or a +// `}`, and reading it as a string would hide the code after it. +const STRING_AFTER = new Set("(,=:[!&|?{;+-*%>~^"); +const LITERAL_KEYWORDS = new Set([ + "import", + "from", + "export", + "throw", + "return", + "typeof", + "case", + "do", + "else", + "in", + "of", + "void", + "yield", + "await", + "delete", + "new", +]); + +function regexMayStart(out) { + return literalMayStart(out, REGEX_AFTER); +} + +function stringMayStart(out) { + return literalMayStart(out, STRING_AFTER); +} + +/* Whether a literal may start here, judged by the code token before it. */ +function literalMayStart(out, after) { + const before = out.trimEnd(); + if (before === "") { + return true; + } + if (after.has(before.at(-1))) { + return true; + } + const word = /[\w$]+$/.exec(before); + return word !== null && LITERAL_KEYWORDS.has(word[0]); +} + +/* Index just past the regex literal at i, or -1 if it does not close on its + * line (then it was division after all). */ +function regexEnd(src, i) { + let inClass = false; + for (let j = i + 1; j < src.length; j += 1) { + const c = src[j]; + if (c === "\n") { + return -1; + } + if (c === "\\") { + j += 1; + } else if (c === "[") { + inClass = true; + } else if (c === "]") { + inClass = false; + } else if (c === "/" && !inClass) { + return j + 1 + (/^[a-z]*/i.exec(src.slice(j + 1))?.[0].length ?? 0); + } + } + return -1; +} + +/* Index just past the line or block comment that starts at i. */ +function commentEnd(src, i) { + if (src[i + 1] === "/") { + const newline = src.indexOf("\n", i); + return newline === -1 ? src.length : newline; + } + const close = src.indexOf("*/", i + 2); + return close === -1 ? src.length : close + 2; +} + +/* Index just past the '...' or "..." string that starts at i, or -1 if it + * does not close on its line (then the quote was text, not a string). */ +function stringEnd(src, i) { + let j = i + 1; + while (j < src.length && src[j] !== src[i] && src[j] !== "\n") { + j += src[j] === "\\" ? 2 : 1; + } + return src[j] === src[i] ? j + 1 : -1; +} + +/* One step inside a template literal: how many characters to copy. Closing + * the literal or opening `${` updates the stack. */ +function templateStep(src, i, stack) { + if (src[i] === "\\") { + return 2; + } + if (src[i] === "`") { + stack.pop(); + return 1; + } + if (src[i] === "$" && src[i + 1] === "{") { + stack.push("{"); + return 2; + } + return 1; +} + +/* Tracks template literals and the braces inside their `${}` expressions. */ +function codeStep(c, stack) { + if (c === "`") { + stack.push("`"); + } else if (c === "{" && stack.length > 0) { + stack.push("{"); + } else if (c === "}" && stack.length > 0) { + stack.pop(); + } +} + +// Static import/export statements, up to the quote of their string literal. +const STATIC_RE = + /\b(?:import|export)\s+(?:type\s+)?(?:[\w$*{}\s,]+?\s+from\s+)?(?=["'])/g; +// Calls whose first argument Vite resolves as a module or asset. +const CALL_RE = + /\b(import\.meta\.glob|import|require|new\s+URL)\s*(?:<[^>]*>)?\(\s*/g; +// CSS `@import` and `url()` targets. +const CSS_IMPORT_RE = /(?:@import\s+(?:url\(\s*)?|url\(\s*)["']?([^"')\s;]+)/g; + +/* + * The argument at j: { kind: "literal", spec } for a plain string; + * { kind: "prefix", spec } for a template with `${}`, spec being the fixed + * part before it; { kind: "opaque" } for anything else. `end` is the index + * just past a string argument. + */ +function readArg(text, j) { + const quote = text[j]; + if (quote !== '"' && quote !== "'" && quote !== "`") { + return { kind: "opaque" }; + } + const end = text.indexOf(quote, j + 1); + if (end === -1) { + return { kind: "opaque" }; + } + const body = text.slice(j + 1, end); + const hole = quote === "`" ? body.indexOf("${") : -1; + return hole === -1 + ? { kind: "literal", spec: body, end: end + 1 } + : { kind: "prefix", spec: body.slice(0, hole), end: end + 1 }; +} + +/* + * Every import, as { kind, spec, index }. Statements and calls are found in + * `code` (no string contents), and their argument is read from `text` at the + * same offset. + */ +function* importSpecs({ text, code }) { + for (const m of code.matchAll(STATIC_RE)) { + const arg = readArg(text, m.index + m[0].length); + if (arg.kind === "literal") { + yield { ...arg, index: m.index }; + } + } + for (const m of code.matchAll(CALL_RE)) { + yield* callSpecs(text, m); + } +} + +function* callSpecs(text, m) { + const j = m.index + m[0].length; + if (m[1] === "import.meta.glob" && text[j] === "[") { + yield* globArraySpecs(text, j + 1); + return; + } + const arg = readArg(text, j); + if (m[1].startsWith("new")) { + // Only `new URL(x, import.meta.url)` is a module reference. + const base = arg.end === undefined ? "" : text.slice(arg.end, arg.end + 40); + if (!/^\s*,\s*import\.meta\.url\b/.test(base)) { + return; + } + } + yield { ...arg, index: m.index }; +} + +/* Every pattern of an `import.meta.glob([...])` array; `!` patterns exclude + * files rather than import them, so they are skipped. */ +function* globArraySpecs(text, j) { + let k = j; + while (k < text.length) { + k += /^[\s,]*/.exec(text.slice(k))[0].length; + if (text[k] === "]") { + return; + } + const arg = readArg(text, k); + if (arg.kind !== "literal" || !arg.spec.startsWith("!")) { + yield { ...arg, index: k }; + } + if (arg.end === undefined) { + return; + } + k = arg.end; + } +} + +/* + * Classifies a specifier: "outside" when it leaves src/v1; the v1 folder it + * names; null for a v1 path outside the taxonomy; undefined for a package. + */ +function targetFolder(spec, fileRel) { + if (OUTSIDE.test(spec)) { + return "outside"; + } + if (ENTRY_SPEC.test(spec)) { + return "entry"; + } + let rel; + if (spec.startsWith("@v1/")) { + rel = path.posix.normalize(spec.slice(4)); + } else if (spec.startsWith("./")) { + rel = path.posix.join(path.posix.dirname(fileRel), spec); + } else { + return undefined; + } + if (rel.startsWith("..")) { + return "outside"; + } + // No extension on the last segment -> it may name a folder ("@v1/lib"). + const isDir = !path.posix.extname(rel); + return folderOf(rel, isDir) ?? folderOf(rel); +} + +/* + * Classifies a template's fixed prefix like targetFolder, by the directory it + * fixes (up to its last "/"). "any" when that leaves the v1 folder open, as in + * `@v1/pages/${name}`; only folders that may import everything may do that. + */ +function prefixFolder(prefix, fileRel) { + if (OUTSIDE.test(prefix)) { + return "outside"; + } + if (!prefix.startsWith("@v1") && !prefix.startsWith("./")) { + return undefined; + } + const dir = prefix.slice(0, prefix.lastIndexOf("/") + 1); + const rel = dir.startsWith("@v1/") + ? path.posix.normalize(dir.slice(4)) + : path.posix.join(path.posix.dirname(fileRel), dir); + if (rel.startsWith("..")) { + return "outside"; + } + const isRoot = rel === "" || rel === "." || rel === "./"; + return (isRoot ? null : folderOf(rel, true)) ?? "any"; +} + +/* The problem with one v1 import, or null. */ +function v1ImportProblem(folder, rel, arg, isTestFile) { + if (arg.kind === "opaque" || (arg.kind === "prefix" && arg.spec === "")) { + return "unanalysable import; use a string literal, or a template whose fixed prefix names the folder."; + } + const { spec } = arg; + const to = + arg.kind === "prefix" ? prefixFolder(spec, rel) : targetFolder(spec, rel); + if (to === undefined) { + return null; + } + if (to === "outside") { + return `'${spec}' is outside src/v1; v1 imports nothing outside it.`; + } + if (to === null) { + return `'${spec}' is outside the v1 folder taxonomy.`; + } + if (to === "any") { + return CONTRACT[contractKey(folder)] === ALL + ? null + : `${folder}/ may not import a computed v1 path ('${spec}...'); only app/, plugins/, test/ and the entry may.`; + } + return mayImport(folder, to, isTestFile) + ? null + : `${folder}/ may not import from ${to}/ ('${spec}'); see the folder contract in src/v1/README.md.`; +} + +function lineAt(text, index) { + return text.slice(0, index).split("\n").length; +} + +// Finder litter; never committed (.gitignore), so not worth reporting. +const IGNORED = new Set([".DS_Store"]); + +/* Every file under dir, plus symlinks, which are listed rather than + * followed; callers decide what to do with them. */ +function walk(dir) { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((d) => (d.isFile() || d.isSymbolicLink()) && !IGNORED.has(d.name)) + .map((d) => + path + .relative(dir, path.join(d.parentPath, d.name)) + .replaceAll(path.sep, "/"), + ) + .toSorted((a, b) => a.localeCompare(b)); +} + +const problems = []; +const report = (rel, line, msg) => { + const where = line ? `${rel}:${line}` : rel; + problems.push(`src/v1/${where}: ${msg}`); +}; + +if (!fs.existsSync(ROOT)) { + console.error(`check-v1: ${ROOT} does not exist`); + process.exit(2); +} + +for (const rel of walk(ROOT)) { + const name = path.posix.basename(rel); + const isRootFile = !rel.includes("/"); + const folder = folderOf(rel); + + if (fs.lstatSync(path.join(ROOT, rel)).isSymbolicLink()) { + if (runFiles) { + report(rel, 0, "No symlinks under v1; they bypass these checks."); + } + continue; + } + + if (runFiles) { + if (JS_EXT.test(name)) { + report(rel, 0, "v1 is TypeScript only; use .ts or .tsx."); + } + if (STYLE_EXT.test(name) && folder !== "styles") { + report(rel, 0, "No component style files; only styles/ holds CSS."); + } + if ( + isRootFile + ? !ROOT_FILES.has(name) + : folder === null && name !== ".gitkeep" + ) { + report(rel, 0, "Outside the v1 folder taxonomy (see src/v1/README.md)."); + } + } + + if (runCode && STYLE_EXT.test(name) && folder === "styles") { + const css = blankCssComments(fs.readFileSync(path.join(ROOT, rel), "utf8")); + for (const m of css.matchAll(CSS_IMPORT_RE)) { + if (ANTD.test(m[1])) { + report(rel, lineAt(css, m.index), "antd is not used under src/v1."); + } else if (targetFolder(m[1], rel) === "outside") { + report( + rel, + lineAt(css, m.index), + `'${m[1]}' is outside src/v1; v1 styles import nothing outside it.`, + ); + } + } + for (const m of css.matchAll(/(? v1 half: v0 (everything in src/ outside v1, including the cloud + * src/plugins/ overlay) reaches v1 only through the `@v1` entry. + */ +const V0_CODE_EXT = /\.(js|jsx|mjs|cjs|ts|tsx|mts|cts)$/; + +/* A v0 specifier as a path from the project root, or undefined for a + * package. fileRel is relative to src/. */ +function v0Target(spec, fileRel) { + if (spec.startsWith("@v1/")) { + return `src/v1/${spec.slice(4)}`; + } + if (spec.startsWith("@/")) { + return `src/${spec.slice(2)}`; + } + if (spec.startsWith("/")) { + return spec.slice(1); + } + if (spec.startsWith("src/")) { + return spec; + } + if (spec.startsWith(".")) { + return path.posix.join("src", path.posix.dirname(fileRel), spec); + } + return undefined; +} + +/* Whether a v0 import reaches into v1 other than through the `@v1` entry. + * A template is judged by the directory its fixed prefix names. */ +function intoV1(arg, fileRel) { + if (arg.kind === "opaque") { + return false; + } + if (arg.kind === "literal" && ENTRY_SPEC.test(arg.spec)) { + return false; + } + const target = v0Target(arg.spec, fileRel); + if (target === undefined) { + return false; + } + const norm = path.posix.normalize(target); + const fixed = + arg.kind === "prefix" ? norm.slice(0, norm.lastIndexOf("/") + 1) : norm; + return fixed === "src/v1" || fixed.startsWith("src/v1/"); +} + +function v0Specs(rel, raw) { + if (!STYLE_EXT.test(rel)) { + const scanned = blankComments(raw); + return { text: scanned.text, specs: importSpecs(scanned) }; + } + const text = blankCssComments(raw); + const specs = [...text.matchAll(CSS_IMPORT_RE)].map((m) => ({ + kind: "literal", + spec: m[1], + index: m.index, + })); + return { text, specs }; +} + +function checkV0() { + for (const rel of walk(SRC)) { + const full = path.join(SRC, rel); + const inV1 = rel.startsWith("v1/"); + const scanned = STYLE_EXT.test(rel) || V0_CODE_EXT.test(rel); + if (inV1 || !scanned || fs.lstatSync(full).isSymbolicLink()) { + continue; + } + const { text, specs } = v0Specs(rel, fs.readFileSync(full, "utf8")); + for (const arg of specs) { + if (intoV1(arg, rel)) { + problems.push( + `src/${rel}:${lineAt(text, arg.index)}: '${arg.spec}' reaches into src/v1; v0 may import only the v1 public entry, as '@v1'.`, + ); + } + } + } +} + +if (runCode) { + checkV0(); +} + +if (problems.length > 0) { + console.error(problems.join("\n")); + console.error(`\ncheck-v1: ${problems.length} problem(s).`); + process.exit(1); +} +console.log("check-v1: ok"); diff --git a/frontend/scripts/v1-plugins.fixtures.mjs b/frontend/scripts/v1-plugins.fixtures.mjs new file mode 100644 index 0000000000..b58812f5ff --- /dev/null +++ b/frontend/scripts/v1-plugins.fixtures.mjs @@ -0,0 +1,156 @@ +// Fixtures for v1-plugins.mjs. Run with `node --test` (bun run test:v1). +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { check } from "./v1-plugins.mjs"; + +function plugins(manifests) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "v1-plugins-")); + for (const [id, body] of Object.entries(manifests)) { + fs.mkdirSync(path.join(dir, id)); + fs.writeFileSync(path.join(dir, id, "manifest.ts"), body); + } + return dir; +} + +const manifest = (id, distributions, extra = "") => ` +import type { PluginManifest } from "@v1/lib/plugins/types"; +export const manifest = { + id: "${id}", + distributions: ${JSON.stringify(distributions)}, + ${extra} +} satisfies PluginManifest; +`; + +test("the matrix lists each plugin per distribution", async () => { + const dir = plugins({ + billing: manifest( + "billing", + ["cloud"], + `slots: { "account-menu.plan-billing": () => import("./Billing") }, + routes: [{ path: "/billing", load: () => import("./Billing") }],`, + ), + switcher: manifest("switcher", ["cloud", "onprem"]), + }); + assert.deepEqual(await check(dir), { + distributions: { + oss: [], + cloud: ["billing", "switcher"], + onprem: ["switcher"], + }, + plugins: [ + { + id: "billing", + distributions: ["cloud"], + slots: ["account-menu.plan-billing"], + routes: ["/billing"], + }, + { + id: "switcher", + distributions: ["cloud", "onprem"], + slots: [], + routes: [], + }, + ], + }); +}); + +test("no plugins directory gives an empty matrix", async () => { + assert.deepEqual(await check(path.join(os.tmpdir(), "v1-plugins-none")), { + distributions: { oss: [], cloud: [], onprem: [] }, + plugins: [], + }); +}); + +test("a slot filled twice fails, naming both plugins", async () => { + const slot = `slots: { "shell.product-switcher": () => import("./X") },`; + const dir = plugins({ + a: manifest("a", ["cloud"], slot), + b: manifest("b", ["cloud"], slot), + }); + await assert.rejects(check(dir), /shell\.product-switcher.*\ba\b.*\bb\b/); +}); + +test("a manifest whose id differs from its folder fails", async () => { + const dir = plugins({ billing: manifest("payments", ["cloud"]) }); + await assert.rejects( + check(dir), + /billing\/manifest\.ts declares id payments/, + ); +}); + +test("a manifest with a runtime import fails with a clear message", async () => { + const dir = plugins({ + bad: `import { x } from "@v1/lib/plugins/registry";\nexport const manifest = x;\n`, + }); + await assert.rejects(check(dir), /bad\/manifest\.ts.*import type/); +}); + +test("a slot id the contract does not define fails, naming the plugin", async () => { + const dir = plugins({ + typo: manifest( + "typo", + ["cloud"], + `slots: { "shell.product-swicher": () => import("./X") },`, + ), + }); + await assert.rejects(check(dir), /typo.*shell\.product-swicher/); +}); + +test("a distribution no build offers fails, naming the plugin", async () => { + const dir = plugins({ stray: manifest("stray", ["cloud", "onprem-x"]) }); + await assert.rejects(check(dir), /stray.*onprem-x/); +}); + +test("a folder without a manifest fails, but dotfiles are ignored", async () => { + const dir = plugins({ billing: manifest("billing", ["cloud"]) }); + fs.mkdirSync(path.join(dir, ".cache")); + fs.writeFileSync(path.join(dir, "index.ts"), "export {};\n"); + assert.deepEqual((await check(dir)).distributions.cloud, ["billing"]); + fs.mkdirSync(path.join(dir, "empty")); + await assert.rejects(check(dir), /empty\/ has no manifest\.ts/); +}); + +const script = path.join(import.meta.dirname, "v1-plugins.mjs"); +const cli = (dir) => + spawnSync(process.execPath, [script, "--dir", dir], { encoding: "utf8" }); + +test("the CLI exits 1 on a conflict, naming both plugins", () => { + const slot = `slots: { "shell.product-switcher": () => import("./X") },`; + const result = cli( + plugins({ + a: manifest("a", ["cloud"], slot), + b: manifest("b", ["cloud"], slot), + }), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /shell\.product-switcher.*\ba\b.*\bb\b/); +}); + +test("the CLI exits 0 and prints the matrix on a clean directory", () => { + const result = cli(plugins({ billing: manifest("billing", ["cloud"]) })); + assert.equal(result.status, 0); + assert.deepEqual(JSON.parse(result.stdout).distributions.cloud, ["billing"]); +}); + +test("the CLI writes the matrix to the file argument, with or without --dir", () => { + const out = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "v1-out-")), + "m.json", + ); + const bare = spawnSync(process.execPath, [script, out], { encoding: "utf8" }); + assert.equal(bare.status, 0, bare.stderr); + assert.equal(bare.stdout, ""); + assert.ok(JSON.parse(fs.readFileSync(out, "utf8")).distributions); + + const dir = plugins({ billing: manifest("billing", ["cloud"]) }); + const before = spawnSync(process.execPath, [script, out, "--dir", dir], { + encoding: "utf8", + }); + assert.equal(before.status, 0, before.stderr); + const matrix = JSON.parse(fs.readFileSync(out, "utf8")); + assert.deepEqual(matrix.distributions.cloud, ["billing"]); +}); diff --git a/frontend/scripts/v1-plugins.mjs b/frontend/scripts/v1-plugins.mjs new file mode 100755 index 0000000000..7009b4bcdc --- /dev/null +++ b/frontend/scripts/v1-plugins.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +/* + * Build check for v1 plugins (src/v1/plugins//manifest.ts, copied in by + * the cloud overlay). Under Bun it imports the TypeScript directly; under Node + * it transpiles with esbuild, loaded only then, because the cloud overlay runs + * this with Bun before any dependency is installed. Runs the registry's + * conflict checks for every distribution and returns the distribution matrix. vite.config.js runs it in every build and publishes + * the matrix as plugin-matrix.json. + * + * node scripts/v1-plugins.mjs [out.json] [--dir plugins] check, print or write the matrix + */ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const DEFAULT_DIR = path.join(import.meta.dirname, "../src/v1/plugins"); +const REGISTRY = path.join( + import.meta.dirname, + "../src/v1/lib/plugins/registry.ts", +); + +const dataUrl = (code) => + `data:text/javascript;base64,${Buffer.from(code).toString("base64")}`; + +const BUN = Boolean(process.versions.bun); + +let registry; +function loadRegistry() { + if (BUN) { + registry ??= import(pathToFileURL(REGISTRY).href); + return registry; + } + registry ??= import("esbuild") + .then(({ build }) => + build({ + entryPoints: [REGISTRY], + bundle: true, + write: false, + format: "esm", + platform: "node", + }), + ) + .then((r) => import(dataUrl(r.outputFiles[0].text))); + return registry; +} + +async function importManifest(file) { + if (BUN) { + return import(pathToFileURL(file).href); + } + const { transform } = await import("esbuild"); + const { code } = await transform(fs.readFileSync(file, "utf8"), { + loader: "ts", + format: "esm", + sourcefile: file, + }); + return import(dataUrl(code)); +} + +async function load(dir, id) { + const file = path.join(dir, id, "manifest.ts"); + const rel = `${id}/manifest.ts`; + let mod; + try { + mod = await importManifest(file); + } catch (err) { + throw new Error( + `${rel} could not be loaded (${err.message}). A manifest may only ` + + '`import type`; load components with `() => import("./X")`.', + ); + } + const { manifest } = mod; + if (!manifest) { + throw new Error(`${rel} does not export \`manifest\``); + } + if (manifest.id !== id) { + throw new Error( + `${rel} declares id ${manifest.id}; it must match its folder`, + ); + } + return manifest; +} + +export async function check(dir = DEFAULT_DIR) { + const ids = fs.existsSync(dir) + ? fs + .readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && !e.name.startsWith(".")) + .map((e) => e.name) + .sort() + : []; + for (const id of ids) { + if (!fs.existsSync(path.join(dir, id, "manifest.ts"))) { + throw new Error(`${id}/ has no manifest.ts`); + } + } + const manifests = await Promise.all(ids.map((id) => load(dir, id))); + const { buildRegistry } = await loadRegistry(); + // Throws on any conflict in any distribution. + const offered = (d) => [...buildRegistry(manifests, d).plugins]; + return { + distributions: { + oss: offered("oss"), + cloud: offered("cloud"), + onprem: offered("onprem"), + }, + plugins: manifests.map((m) => ({ + id: m.id, + distributions: [...m.distributions], + slots: Object.keys(m.slots ?? {}), + routes: (m.routes ?? []).map((r) => r.path), + })), + }; +} + +// No top-level await: vite.config.js imports this file and Vite bundles the +// config as CommonJS. +const isMain = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href; + +if (isMain) { + const args = process.argv.slice(2); + const flag = args.indexOf("--dir"); + const dir = flag === -1 ? DEFAULT_DIR : path.resolve(args[flag + 1]); + const out = args.find( + (a, i) => !a.startsWith("--") && (flag === -1 || i !== flag + 1), + ); + check(dir) + .then((matrix) => { + const json = `${JSON.stringify(matrix, null, 2)}\n`; + if (out) { + fs.writeFileSync(out, json); + } else { + process.stdout.write(json); + } + }) + .catch((err) => { + console.error(`v1-plugins: ${err.message}`); + process.exit(1); + }); +} diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md new file mode 100644 index 0000000000..817802f7e7 --- /dev/null +++ b/frontend/src/v1/README.md @@ -0,0 +1,70 @@ +# Frontend v1 + +The v1 frontend, built alongside v0 (everything else under `src/`) until v0 is +retired. The full guide is the Frontend Developer Guide for v1 (UN-4378). The +rules and the "May import from" column below are enforced by tooling; the +"Holds" column is convention. + +## Rules + +- **TypeScript only.** `.ts` / `.tsx`, `strict`, unused locals and parameters + are errors, no explicit `any`. +- **Nothing outside `src/v1`.** No v0 component, store, hook, helper, CSS or + shim, and no symlinks. Import with `./` within a folder and `@v1/...` across + folders; `../`, `/src/...` and `src/...` are never used. +- **v0 imports only the public entry**, `@v1` (`src/v1/index.ts`). +- **Imports must be readable.** Use a string literal, or a template whose fixed + prefix names the folder (`` `@v1/pages/${name}/index.tsx` ``, allowed only in + folders that may import everything). An import of a variable is rejected. +- **No PropTypes, no component `.css`, no antd** (package, shim or `ant-` + class). Global v1 styles live in `styles/` and are imported as + `@v1/styles/...`, including from inside `styles/`. +- **Static assets live in `assets/`**, imported as `@v1/assets/...` (e.g. + `@v1/assets/logo.svg?react`). v1 never uses v0's `src/assets/`. + +## Folders and what each may import + +Every folder may import from itself. Test files (`*.test.ts(x)`, +`*.spec.ts(x)`) may also import `test/`. + +| Folder | Holds | May import from | +| --- | --- | --- | +| `index.ts` | Public entry; the version switch (UN-4271) will mount v1 through it | everything | +| `app/` | Entry, providers, router, route registry | everything | +| `components/ui/` | Core components | `lib/`, `styles/`, `assets/` | +| `components/patterns/` | Composites built from core components | `components/ui/`, `lib/`, `services/`, `stores/`, `hooks/`, `assets/` | +| `shell/` | Top bar, rail, context bar, tab bar | `components/ui/`, `components/patterns/`, `services/`, `stores/`, `hooks/`, `lib/`, `assets/` | +| `pages//` | One folder per target page | `components/ui/`, `components/patterns/`, `shell/`, `services/`, `stores/`, `hooks/`, `lib/`, `styles/`, `assets/`, `plugins/` — not other pages, `app/`, `test/` or the entry | +| `services/` | Typed service modules; the only place a URL appears | `lib/` | +| `stores/` | Zustand stores | `lib/` | +| `hooks/` | Shared hooks | `lib/` | +| `lib/` | Pure helpers | — | +| `styles/` | Tokens and global v1 styles | `lib/`, `assets/` | +| `assets/` | Static files: icons, logos, images (and a generated icon map) | — | +| `plugins/` | Cloud overlay target; only a placeholder in the public repository | everything | +| `test/` | Render helpers, fixtures, network mocks | everything | + +## Plugins + +`lib/plugins/` is the extension-point contract. Pages and the shell import `Slot` from `@v1/lib/plugins`; they never import a plugin. `plugins/index.ts` builds the registry from the manifests and exports `PluginSlot` and `pluginRoutes()`; `app/` passes `PluginSlot` to `PluginProvider` and the routes to the router. With no provider, every slot shows its fallback. + +- `types.ts` names every slot and its props. Adding a slot is one line there. +- A plugin is `plugins//manifest.ts` exporting `manifest` (`satisfies PluginManifest`), with `distributions` (`cloud`, `onprem`) and lazy `slots` and `routes`. The folder name is the id. A manifest may only `import type`: the build check loads it without bundling. +- `` shows the fallback when no plugin fills the slot, silently. A filler that fails to load or render shows the fallback and is reported; each slot has its own error boundary. +- `VITE_DISTRIBUTION` (`oss`, the default, `cloud` or `onprem`) picks the plugins the build offers. The frontend Dockerfile takes it as a build arg. +- Every build runs `scripts/v1-plugins.mjs`. It fails on a slot filled twice or a route defined twice in any distribution, or a plugin with no distribution, and writes `plugin-matrix.json` to the build output. `node scripts/v1-plugins.mjs` prints the matrix. +- `plugin-matrix.json` lists, for `oss`, `cloud` and `onprem`, the plugin ids each build offers, and for every plugin its distributions, slots and routes. Release tooling reads it to see which plugins ship where. +- The check also fails on a slot id or distribution the contract does not define, and on a `plugins/` folder with no `manifest.ts`. +- v0's optional-plugin stub skips `src/v1`, so a missing module in v1 fails the build. + +## Scripts + +| Script | Checks | +| --- | --- | +| `bun run typecheck:v1` | TypeScript-only files, CSS only in `styles/`, no symlinks, folder taxonomy; then `tsc` with `src/v1/tsconfig.json` | +| `bun run lint:v1` | Biome on `src/v1/`, then imports (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks | +| `bun run test:v1` | v1 unit tests, then the guardrail fixtures in `test-fixtures/v1-boundary/` | + +The v0 ↔ v1 boundary and the banned imports are Biome rules in `biome.json`, +so `biome ci src/` (CI) and editors report them too. The remaining checks are +`src/v1/tsconfig.json` and `scripts/check-v1.mjs`. diff --git a/frontend/src/v1/app/.gitkeep b/frontend/src/v1/app/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/assets/.gitkeep b/frontend/src/v1/assets/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/components/patterns/.gitkeep b/frontend/src/v1/components/patterns/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/components/ui/.gitkeep b/frontend/src/v1/components/ui/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/hooks/.gitkeep b/frontend/src/v1/hooks/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/index.ts b/frontend/src/v1/index.ts new file mode 100644 index 0000000000..48afca1c00 --- /dev/null +++ b/frontend/src/v1/index.ts @@ -0,0 +1,7 @@ +/* + * Public entry of the v1 frontend — the only v1 module v0 may import, as + * `@v1`. Lint rejects any other v0 → v1 import (see src/v1/README.md). + * + * Empty until the version switch (UN-4271) mounts v1 through it. + */ +export {}; diff --git a/frontend/src/v1/lib/.gitkeep b/frontend/src/v1/lib/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/lib/plugins/Slot.test.tsx b/frontend/src/v1/lib/plugins/Slot.test.tsx new file mode 100644 index 0000000000..b5f3bf94fb --- /dev/null +++ b/frontend/src/v1/lib/plugins/Slot.test.tsx @@ -0,0 +1,151 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildRegistry } from "./registry"; +import { makeSlot } from "./Slot"; +import type { PluginManifest } from "./types"; + +const fallback = fallback; + +function slotFor(manifests: PluginManifest[]) { + const report = vi.fn(); + const Slot = makeSlot(buildRegistry(manifests, "cloud"), report); + return { Slot, report }; +} + +describe("Slot", () => { + beforeEach(() => { + // React logs caught render errors; the report spy is the assertion. + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + afterEach(() => vi.restoreAllMocks()); + + it("renders the fallback silently when no plugin fills it", () => { + const { Slot, report } = slotFor([]); + render(); + expect(screen.getByText("fallback")).toBeInTheDocument(); + expect(report).not.toHaveBeenCalled(); + }); + + it("renders the filler when a plugin fills it", async () => { + const { Slot, report } = slotFor([ + { + id: "switcher", + distributions: ["cloud"], + slots: { + "shell.product-switcher": async () => ({ + default: () => switcher, + }), + }, + }, + ]); + render(); + expect(await screen.findByText("switcher")).toBeInTheDocument(); + expect(report).not.toHaveBeenCalled(); + }); + + it("reports a filler that fails to load and shows the fallback", async () => { + const { Slot, report } = slotFor([ + { + id: "broken", + distributions: ["cloud"], + slots: { + "shell.product-switcher": () => Promise.reject(new Error("chunk")), + }, + }, + ]); + render(); + expect(await screen.findByText("fallback")).toBeInTheDocument(); + await vi.waitFor(() => + expect(report).toHaveBeenCalledWith( + "broken", + "shell.product-switcher", + expect.objectContaining({ message: "chunk" }), + ), + ); + }); + + it("keeps a failing plugin from taking down another slot", async () => { + const { Slot, report } = slotFor([ + { + id: "broken", + distributions: ["cloud"], + slots: { + "account-menu.plan-billing": async () => ({ + default: () => { + throw new Error("render"); + }, + }), + }, + }, + { + id: "switcher", + distributions: ["cloud"], + slots: { + "shell.product-switcher": async () => ({ + default: () => switcher, + }), + }, + }, + ]); + render( + <> + + + , + ); + expect(await screen.findByText("switcher")).toBeInTheDocument(); + expect(await screen.findByText("fallback")).toBeInTheDocument(); + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith( + "broken", + "account-menu.plan-billing", + expect.objectContaining({ message: "render" }), + ); + }); + + it("loads again on a later mount after a failed load", async () => { + const load = vi + .fn() + .mockRejectedValueOnce(new Error("chunk")) + .mockResolvedValue({ default: () => switcher }); + const { Slot, report } = slotFor([ + { + id: "flaky", + distributions: ["cloud"], + slots: { "shell.product-switcher": load }, + }, + ]); + const first = render( + , + ); + await vi.waitFor(() => expect(report).toHaveBeenCalledTimes(1)); + first.unmount(); + render(); + expect(await screen.findByText("switcher")).toBeInTheDocument(); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("recovers in place on the next render after a failed load", async () => { + const load = vi + .fn() + .mockRejectedValueOnce(new Error("chunk")) + .mockResolvedValue({ default: () => switcher }); + const { Slot, report } = slotFor([ + { + id: "flaky", + distributions: ["cloud"], + slots: { "shell.product-switcher": load }, + }, + ]); + // A fresh element each time, in the same position: React re-renders the + // mounted slot and its boundary instead of mounting new ones. + const tree = () => ( + + ); + const { rerender } = render(tree()); + await vi.waitFor(() => expect(report).toHaveBeenCalledTimes(1)); + rerender(tree()); + expect(await screen.findByText("switcher")).toBeInTheDocument(); + expect(load).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/v1/lib/plugins/Slot.tsx b/frontend/src/v1/lib/plugins/Slot.tsx new file mode 100644 index 0000000000..367f7ef91f --- /dev/null +++ b/frontend/src/v1/lib/plugins/Slot.tsx @@ -0,0 +1,120 @@ +import { + Component, + type ComponentType, + type ErrorInfo, + lazy, + type ReactNode, + Suspense, +} from "react"; +import type { PluginRegistry } from "./registry"; +import type { SlotId, SlotProps } from "./types"; + +export type PluginErrorReporter = ( + owner: string, + slot: SlotId, + error: unknown, +) => void; + +export interface SlotComponentProps { + id: K; + props: SlotProps[K]; + /** Shown when no plugin fills the slot, while it loads, and if it fails. */ + fallback: ReactNode; +} + +interface BoundaryProps { + /** A new value clears an earlier failure, so a fresh filler gets a try. */ + resetKey: unknown; + fallback: ReactNode; + onError: (error: unknown) => void; + children: ReactNode; +} + +interface BoundaryState { + failed: boolean; + resetKey: unknown; +} + +class SlotBoundary extends Component { + override state: BoundaryState = { + failed: false, + resetKey: this.props.resetKey, + }; + + static getDerivedStateFromError(): Partial { + return { failed: true }; + } + + static getDerivedStateFromProps( + props: BoundaryProps, + state: BoundaryState, + ): Partial | null { + return props.resetKey === state.resetKey + ? null + : { failed: false, resetKey: props.resetKey }; + } + + override componentDidCatch(error: unknown, _info: ErrorInfo): void { + this.props.onError(error); + } + + override render(): ReactNode { + return this.state.failed ? this.props.fallback : this.props.children; + } +} + +/** + * The `` component for one registry. Each slot gets its own error + * boundary, so a plugin that fails to load or render shows that slot's + * fallback and is reported, and every other slot keeps working. + */ +export function makeSlot( + registry: PluginRegistry, + report: PluginErrorReporter, +): (p: SlotComponentProps) => ReactNode { + const fillers = new Map>(); + + function filler(id: K): ComponentType | null { + const entry = registry.slot(id); + if (!entry) { + return null; + } + if (!fillers.has(id)) { + // React caches a rejected lazy promise, so a failed load drops the + // filler and the next render imports the chunk again. + const lazyFiller = lazy(() => + entry.load().catch((err: unknown) => { + if (fillers.get(id) === lazyFiller) { + fillers.delete(id); + } + throw err; + }), + ) as ComponentType; + fillers.set(id, lazyFiller); + } + return fillers.get(id) as ComponentType; + } + + return function Slot({ + id, + props, + fallback, + }: SlotComponentProps): ReactNode { + const Filler = filler(id); + if (!Filler) { + return fallback; + } + const owner = registry.slot(id)?.owner ?? "unknown"; + return ( + report(owner, id, e)} + > + + + + + ); + }; +} diff --git a/frontend/src/v1/lib/plugins/context.test.tsx b/frontend/src/v1/lib/plugins/context.test.tsx new file mode 100644 index 0000000000..cd1bdd8c77 --- /dev/null +++ b/frontend/src/v1/lib/plugins/context.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import { expect, it } from "vitest"; +import { buildRegistry, makeSlot, PluginProvider, Slot } from "./index"; + +const fallback = fallback; + +it("a slot with no provider shows its fallback", () => { + render(); + expect(screen.getByText("fallback")).toBeInTheDocument(); +}); + +it("a slot renders the provider's filler", async () => { + const registry = buildRegistry( + [ + { + id: "switcher", + distributions: ["cloud"], + slots: { + "shell.product-switcher": async () => ({ + default: () => switcher, + }), + }, + }, + ], + "cloud", + ); + render( + undefined)}> + + , + ); + expect(await screen.findByText("switcher")).toBeInTheDocument(); +}); diff --git a/frontend/src/v1/lib/plugins/index.tsx b/frontend/src/v1/lib/plugins/index.tsx new file mode 100644 index 0000000000..85c6f2c0ca --- /dev/null +++ b/frontend/src/v1/lib/plugins/index.tsx @@ -0,0 +1,41 @@ +/* + * v1's extension-point contract. Shell and pages render `` from here; + * they never import a plugin. The registry itself is built in + * `@v1/plugins` (the only folder that may see plugin manifests) and handed + * down by `app/` through `PluginProvider`. With no provider, as in OSS tests, + * every slot shows its fallback. + */ +import { createContext, type ReactNode, useContext } from "react"; +import type { makeSlot, SlotComponentProps } from "./Slot"; +import type { SlotId } from "./types"; + +export { + buildRegistry, + PluginConflictError, + type RouteEntry, +} from "./registry"; +export { + makeSlot, + type PluginErrorReporter, + type SlotComponentProps, +} from "./Slot"; +export type * from "./types"; + +type SlotRenderer = ReturnType; + +const SlotContext = createContext(null); + +export function PluginProvider({ + slot, + children, +}: { + slot: SlotRenderer; + children: ReactNode; +}): ReactNode { + return {children}; +} + +export function Slot(p: SlotComponentProps): ReactNode { + const Render = useContext(SlotContext); + return Render ? : p.fallback; +} diff --git a/frontend/src/v1/lib/plugins/missing-import.test.ts b/frontend/src/v1/lib/plugins/missing-import.test.ts new file mode 100644 index 0000000000..3d0af968fa --- /dev/null +++ b/frontend/src/v1/lib/plugins/missing-import.test.ts @@ -0,0 +1,14 @@ +import { expect, it } from "vitest"; + +/* + * v0's optionalPluginImports (vite.config.js, vitest.config.mjs) turns any + * missing import under a `/plugins/` path into a stub. v1 has no optional + * imports: an absent plugin has no manifest, so a missing module is a bug. + * The specifier is a template so the failure surfaces at run time, where the + * test can see it; a literal one fails the transform, which is the same rule. + * Its fixed prefix names this folder, so the v1 import guard can still read it. + */ +it("a missing module under v1 fails to import instead of being stubbed", async () => { + const name = "does-not-exist"; + await expect(import(/* @vite-ignore */ `./${name}`)).rejects.toThrow(); +}); diff --git a/frontend/src/v1/lib/plugins/registry.test.ts b/frontend/src/v1/lib/plugins/registry.test.ts new file mode 100644 index 0000000000..9b0babd318 --- /dev/null +++ b/frontend/src/v1/lib/plugins/registry.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { buildRegistry, PluginConflictError } from "./registry"; +import type { PluginManifest } from "./types"; + +const load = () => Promise.resolve({ default: () => null }); + +const billing: PluginManifest = { + id: "billing", + distributions: ["cloud"], + slots: { "account-menu.plan-billing": load }, + routes: [{ path: "/billing", load }], +}; +const switcher: PluginManifest = { + id: "switcher", + distributions: ["cloud", "onprem"], + slots: { "shell.product-switcher": load }, +}; + +describe("buildRegistry", () => { + it("keeps only plugins that list the current distribution", () => { + const onprem = buildRegistry([billing, switcher], "onprem"); + expect(onprem.plugins).toEqual(["switcher"]); + expect(onprem.slot("account-menu.plan-billing")).toBeUndefined(); + expect(onprem.slot("shell.product-switcher")?.owner).toBe("switcher"); + expect(onprem.routes).toEqual([]); + + const cloud = buildRegistry([billing, switcher], "cloud"); + expect(cloud.plugins).toEqual(["billing", "switcher"]); + expect(cloud.routes.map((r) => r.path)).toEqual(["/billing"]); + }); + + it("offers nothing in the OSS build", () => { + const oss = buildRegistry([billing, switcher], "oss"); + expect(oss.plugins).toEqual([]); + expect(oss.slot("shell.product-switcher")).toBeUndefined(); + }); + + it("fails on a slot filled twice, naming both plugins", () => { + const twin = { ...switcher, id: "twin" }; + expect(() => buildRegistry([switcher, twin], "cloud")).toThrow( + PluginConflictError, + ); + expect(() => buildRegistry([switcher, twin], "cloud")).toThrow( + /shell\.product-switcher.*switcher.*twin/, + ); + }); + + it("fails on a route two plugins contribute, naming both", () => { + const other: PluginManifest = { + id: "other", + distributions: ["cloud"], + routes: [{ path: "/billing", load }], + }; + expect(() => buildRegistry([billing, other], "cloud")).toThrow( + /\/billing.*billing.*other/, + ); + }); + + it("fails on a route OSS also defines, naming both owners", () => { + expect(() => buildRegistry([billing], "cloud", ["/billing"])).toThrow( + /\/billing.*OSS.*billing/, + ); + }); + + it("checks conflicts in every distribution, not only the current one", () => { + const twin = { ...billing, id: "twin", routes: [] }; + expect(() => buildRegistry([billing, twin], "oss")).toThrow( + /account-menu\.plan-billing/, + ); + }); + + it("lets plugins in disjoint distributions fill the same slot", () => { + const cloudOnly = { ...switcher, distributions: ["cloud"] as const }; + const onpremOnly = { + ...switcher, + id: "switcher-onprem", + distributions: ["onprem"] as const, + }; + const onprem = buildRegistry([cloudOnly, onpremOnly], "onprem"); + expect(onprem.slot("shell.product-switcher")?.owner).toBe( + "switcher-onprem", + ); + }); + + it("fails on a plugin that declares no distribution", () => { + const undeclared = { ...switcher, distributions: [] }; + expect(() => buildRegistry([undeclared], "cloud")).toThrow( + /switcher declares no distributions/, + ); + }); + + it("fails on two plugins with the same id", () => { + expect(() => buildRegistry([switcher, switcher], "cloud")).toThrow( + /plugin id switcher is used twice/, + ); + }); + + it("fails on a slot id no plugin contract defines, naming the plugin", () => { + const typo = { + id: "typo", + distributions: ["cloud"], + slots: { "shell.product-swicher": load }, + } as unknown as PluginManifest; + expect(() => buildRegistry([typo], "cloud")).toThrow(PluginConflictError); + expect(() => buildRegistry([typo], "cloud")).toThrow( + /typo.*shell\.product-swicher/, + ); + }); + + it("fails on a distribution no build offers, naming the plugin", () => { + const stray = { + id: "stray", + distributions: ["cloud", "onprm"], + } as unknown as PluginManifest; + expect(() => buildRegistry([stray], "cloud")).toThrow(PluginConflictError); + expect(() => buildRegistry([stray], "cloud")).toThrow(/stray.*onprm/); + }); +}); diff --git a/frontend/src/v1/lib/plugins/registry.ts b/frontend/src/v1/lib/plugins/registry.ts new file mode 100644 index 0000000000..fc7f0c5e46 --- /dev/null +++ b/frontend/src/v1/lib/plugins/registry.ts @@ -0,0 +1,122 @@ +import { + type ComponentLoader, + type Distribution, + PLUGIN_DISTRIBUTIONS, + type PluginManifest, + type PluginRoute, + SLOT_IDS, + type SlotId, + type SlotProps, +} from "./types"; + +export class PluginConflictError extends Error { + override name = "PluginConflictError"; +} + +export interface SlotEntry { + owner: string; + load: ComponentLoader; +} + +export interface RouteEntry extends PluginRoute { + owner: string; +} + +export interface PluginRegistry { + distribution: Distribution; + plugins: readonly string[]; + routes: readonly RouteEntry[]; + slot(id: K): SlotEntry | undefined; +} + +function assertDeclared(manifests: readonly PluginManifest[]): void { + const seen = new Set(); + for (const m of manifests) { + if (seen.has(m.id)) { + throw new PluginConflictError(`plugin id ${m.id} is used twice`); + } + seen.add(m.id); + if (m.distributions.length === 0) { + throw new PluginConflictError(`${m.id} declares no distributions`); + } + for (const d of m.distributions as readonly string[]) { + if (!(PLUGIN_DISTRIBUTIONS as readonly string[]).includes(d)) { + throw new PluginConflictError( + `${m.id} declares unknown distribution ${d}`, + ); + } + } + for (const id of Object.keys(m.slots ?? {})) { + if (!(SLOT_IDS as readonly string[]).includes(id)) { + throw new PluginConflictError(`${m.id} fills unknown slot ${id}`); + } + } + } +} + +function collect( + manifests: readonly PluginManifest[], + ossRoutes: readonly string[], +): { slots: Map>; routes: RouteEntry[] } { + const slots = new Map>(); + const routeOwners = new Map( + ossRoutes.map((path) => [path, "OSS"]), + ); + const routes: RouteEntry[] = []; + for (const m of manifests) { + for (const [id, load] of Object.entries(m.slots ?? {})) { + const slotId = id as SlotId; + const taken = slots.get(slotId); + if (taken) { + throw new PluginConflictError( + `slot ${slotId} is filled by both ${taken.owner} and ${m.id}`, + ); + } + slots.set(slotId, { + owner: m.id, + load: load as ComponentLoader, + }); + } + for (const route of m.routes ?? []) { + const taken = routeOwners.get(route.path); + if (taken) { + throw new PluginConflictError( + `route ${route.path} is defined by both ${taken} and ${m.id}`, + ); + } + routeOwners.set(route.path, m.id); + routes.push({ ...route, owner: m.id }); + } + } + return { slots, routes }; +} + +/** + * The plugins, slots and routes one distribution offers. Throws + * PluginConflictError when any distribution would have a slot filled twice, + * a route defined twice, a plugin that declares nowhere to ship, or a + * manifest naming a slot or distribution that does not exist. + */ +export function buildRegistry( + manifests: readonly PluginManifest[], + distribution: Distribution, + ossRoutes: readonly string[] = [], +): PluginRegistry { + assertDeclared(manifests); + const inDistribution = (d: Distribution) => + manifests.filter((m) => + (m.distributions as readonly Distribution[]).includes(d), + ); + for (const d of PLUGIN_DISTRIBUTIONS) { + collect(inDistribution(d), ossRoutes); + } + const offered = inDistribution(distribution); + const { slots, routes } = collect(offered, ossRoutes); + return { + distribution, + plugins: offered.map((m) => m.id), + routes, + slot: (id: K) => + slots.get(id) as SlotEntry | undefined, + }; +} diff --git a/frontend/src/v1/lib/plugins/types.ts b/frontend/src/v1/lib/plugins/types.ts new file mode 100644 index 0000000000..0181973ae3 --- /dev/null +++ b/frontend/src/v1/lib/plugins/types.ts @@ -0,0 +1,47 @@ +import type { ComponentType } from "react"; + +/* + * Plugin manifests import from here with `import type`, so the build check in + * scripts/v1-plugins.mjs can load a manifest without bundling it. The runtime + * lists below let the registry reject a value tsc never saw. + */ + +/** Builds a cloud plugin can ship in. The public OSS build has no plugins. */ +export const PLUGIN_DISTRIBUTIONS = ["cloud", "onprem"] as const; +export type PluginDistribution = (typeof PLUGIN_DISTRIBUTIONS)[number]; +export type Distribution = "oss" | PluginDistribution; + +/** Every extension point. Adding a slot is one line here. */ +export const SLOT_IDS = [ + "prompt-studio.lookups", + "prompt-studio.cloud-tools", + "workflows.human-review", + "account-menu.plan-billing", + "shell.product-switcher", + "platform.hitl-keys", + "platform.hitl-settings", + "dashboard.subscription-usage", + "dashboard.attention-items", +] as const; +export type SlotId = (typeof SLOT_IDS)[number]; + +/* + * The props a filler receives; the host passes them. + * ponytail: props are empty until the host page that renders each slot is + * built; that task adds the fields it passes. + */ +export type SlotProps = { [K in SlotId]: Record }; + +export type ComponentLoader

= () => Promise<{ default: ComponentType

}>; + +export interface PluginRoute { + path: string; + load: ComponentLoader>; +} + +export interface PluginManifest { + id: string; + distributions: readonly PluginDistribution[]; + slots?: { [K in SlotId]?: ComponentLoader }; + routes?: readonly PluginRoute[]; +} diff --git a/frontend/src/v1/pages/.gitkeep b/frontend/src/v1/pages/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/plugins/.gitkeep b/frontend/src/v1/plugins/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/plugins/index.ts b/frontend/src/v1/plugins/index.ts new file mode 100644 index 0000000000..f8e7e25a75 --- /dev/null +++ b/frontend/src/v1/plugins/index.ts @@ -0,0 +1,48 @@ +/* + * Builds the plugin registry from the manifests the cloud overlay copies in + * as `plugins//manifest.ts`. An absent plugin has no manifest, so its + * slots show their fallbacks; every import error left is a real failure and + * is reported. `app/` passes `PluginSlot` to `PluginProvider` and + * `pluginRoutes()` to the router. + */ +import { + buildRegistry, + type Distribution, + makeSlot, + type PluginErrorReporter, + type PluginManifest, + type RouteEntry, +} from "@v1/lib/plugins"; + +const DISTRIBUTIONS: readonly Distribution[] = ["oss", "cloud", "onprem"]; + +function currentDistribution(): Distribution { + const value = import.meta.env.VITE_DISTRIBUTION ?? "oss"; + if (!DISTRIBUTIONS.includes(value)) { + throw new Error( + `VITE_DISTRIBUTION must be one of ${DISTRIBUTIONS.join(", ")}; got ${value}`, + ); + } + return value; +} + +const manifests = Object.values( + import.meta.glob("./*/manifest.ts", { + eager: true, + import: "manifest", + }), +); + +export const registry = buildRegistry(manifests, currentDistribution()); + +// ponytail: logs only; the v1 notification contract adds the toast here. +const report: PluginErrorReporter = (owner, slot, error) => { + console.error(`v1 plugin ${owner} failed in slot ${slot}`, error); +}; + +export const PluginSlot = makeSlot(registry, report); + +/** Routes contributed by plugins in this distribution, for the v1 router. */ +export function pluginRoutes(): readonly RouteEntry[] { + return registry.routes; +} diff --git a/frontend/src/v1/services/.gitkeep b/frontend/src/v1/services/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/shell/.gitkeep b/frontend/src/v1/shell/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/stores/.gitkeep b/frontend/src/v1/stores/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/styles/.gitkeep b/frontend/src/v1/styles/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/test/.gitkeep b/frontend/src/v1/test/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/test/jest-dom.d.ts b/frontend/src/v1/test/jest-dom.d.ts new file mode 100644 index 0000000000..0d0fff9d8f --- /dev/null +++ b/frontend/src/v1/test/jest-dom.d.ts @@ -0,0 +1,2 @@ +// jest-dom matchers for v1 tests; src/setupTests.js registers them at run time. +/// diff --git a/frontend/src/v1/tsconfig.json b/frontend/src/v1/tsconfig.json new file mode 100644 index 0000000000..89bfe6e386 --- /dev/null +++ b/frontend/src/v1/tsconfig.json @@ -0,0 +1,44 @@ +{ + /* + * TypeScript config for the v1 frontend, checked by `bun run typecheck:v1`. + * It lives here rather than beside tsconfig.json so editors pick it up for + * files under src/v1; tsconfig.json and jsconfig.json exclude this folder. + * + * `allowJs: false` makes tsc skip a .js file rather than fail on it, so the + * "TypeScript only" rule is enforced by scripts/check-v1.mjs, which + * typecheck:v1 runs first. + */ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + + "allowJs": false, + "noEmit": true, + /* On, as in tsconfig.json. Off also type-checks third-party typings, and + * those conflict: @types/chai 5.2.3 and vitest 3.2.6 both declare + * `containSubset`, so any v1 test importing vitest failed. The cost is + * that hand-written .d.ts files under v1 are not checked either. */ + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Only `@v1` and `@v1/*` — deliberately no `@/*`, so an `@/` import + * fails typecheck as well as lint (`../` imports are caught by lint). The + * alias is also declared in vite.config.js and vitest.config.mjs. */ + "paths": { + "@v1": ["./index.ts"], + "@v1/*": ["./*"] + } + }, + "include": ["./**/*", "../../vite-env.d.ts"] +} diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs new file mode 100644 index 0000000000..c449cea8ee --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -0,0 +1,510 @@ +/* + * Proves the v1 guardrails fail when they should and pass when they should. + * Run by `bun run test:v1` with `node --test`: it spawns the real tools, so it + * is not a vitest test (vitest.config.mjs excludes test-fixtures/). + * + * Each case builds a minimal throwaway copy of the frontend (biome.json, + * package.json, tsconfig.json, vite-env.d.ts, scripts/, the real src/v1 + * scaffold, a node_modules symlink and a .gitignore), overlays the files under + * cases// and runs the real package.json scripts, or a raw command, + * inside it. The known-bad files live here, outside src/, so CI lint, the + * pre-commit hook and typecheck never see them. + */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +const FRONTEND = path.resolve(import.meta.dirname, "..", ".."); +const CASES_DIR = path.join(import.meta.dirname, "cases"); +const { scripts } = JSON.parse( + fs.readFileSync(path.join(FRONTEND, "package.json"), "utf8"), +); + +/* Rows of "a | b | c" cells; blank lines and "#" comments are skipped. */ +function parseTable(text) { + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")) + .map((line) => line.split("|").map((cell) => cell.trim())); +} + +const sorted = (items) => items.toSorted((a, b) => a.localeCompare(b)); + +/* + * One case per row: the folder under cases/ ("-" for the empty scaffold); + * the steps, joined by "+", each a package.json script name or a raw command + * (the v0 side has no v1 script); and a substring the tool output must + * contain, or "-" when the steps must pass. + */ +const CASES = parseTable(` + # The empty scaffold passes everything. + - | typecheck:v1 + lint:v1 | - + # Import boundary, v1 -> v0 (Biome). + v1-imports-v0-alias | lint:v1 | v1 imports nothing outside src/v1 + v1-imports-v0-relative | lint:v1 | v1 imports nothing outside src/v1 + v1-imports-root-absolute | lint:v1 | v1 imports nothing outside src/v1 + # Import boundary, v0 -> v1: Biome for aliases, check-v1 for the rest. + v0-imports-v1-internal | biome lint src/ | v0 may import only the v1 public entry + v0-imports-v1-relative | lint:v1 | v0 may import only the v1 public entry + v0-imports-v1-template | lint:v1 | v0 may import only the v1 public entry + v0-imports-v1-entry | biome lint src/ | - + # A v1-looking path that is not src/v1 (a package subpath, a v0 folder). + v0-imports-v1-lookalike | biome lint src/ + lint:v1 | - + # v0 typecheck follows the entry into v1, where @v1/... must resolve. + v0-tsx-imports-entry | typecheck + typecheck:v1 + lint:v1 | - + # Banned under v1. + v1-prop-types | lint:v1 | PropTypes are not used + v1-prop-types-subpath | lint:v1 | PropTypes are not used + v1-prop-types-assignment | lint:v1 | No PropTypes under v1 + v1-antd-import | lint:v1 | antd is not used under src/v1 + v1-antd-subpath | lint:v1 | antd is not used under src/v1 + v1-styles-import-outside | lint:v1 | v1 styles import nothing outside it + v1-styles-ant-class | lint:v1 | No antd 'ant-' class names + v1-styles-import-antd | lint:v1 | antd is not used under src/v1 + v1-styles-ant-after-url | lint:v1 | No antd 'ant-' class names + v1-unanalysable-import | lint:v1 | unanalysable import + v1-component-css-import | lint:v1 | No component .css under src/v1 + v1-component-css-file | typecheck:v1 | No component style files + v1-ant-class-prefix | lint:v1 | No antd 'ant-' class names + v1-ant-built-class | lint:v1 | No antd 'ant-' class names + v1-explicit-any | lint:v1 | noExplicitAny + # The v1 override must add to the shared rules, not replace them. + v1-base-rules-still-apply | lint:v1 | noDoubleEquals + # TypeScript only, strict. + v1-js-file | typecheck:v1 | TypeScript only + v1-implicit-any | typecheck:v1 | TS7006 + v1-strict-null | typecheck:v1 | TS18048 + v1-unused-local | typecheck:v1 | TS6133 + v1-unused-parameter | typecheck:v1 | TS6133 + # Folder taxonomy and contract (the full contract is the matrix below). + v1-outside-taxonomy | typecheck:v1 | Outside the v1 folder taxonomy + v1-root-file | typecheck:v1 | Outside the v1 folder taxonomy + v1-ui-imports-services | lint:v1 | components/ui/ may not import from services/ + v1-page-imports-other-page | lint:v1 | pages/projects/ may not import from pages/settings/ + # Only test files may use test/, and being a test file allows nothing else. + v1-non-test-imports-test | lint:v1 | components/ui/ may not import from test/ + v1-test-file-imports-services | lint:v1 | components/ui/ may not import from services/ + v1-allowed-imports | typecheck:v1 + lint:v1 | - +`).map(([name, run, fails]) => ({ + name: name === "-" ? null : name, + run: run.split(" + "), + fails: fails === "-" ? null : fails, +})); + +function makeTree(caseName) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "v1-boundary-")); + for (const file of [ + "biome.json", + "package.json", + "tsconfig.json", + "vite-env.d.ts", + ]) { + fs.copyFileSync(path.join(FRONTEND, file), path.join(dir, file)); + } + // Copied, not linked: check-v1.mjs finds src/v1 relative to itself. + fs.cpSync(path.join(FRONTEND, "scripts"), path.join(dir, "scripts"), { + recursive: true, + }); + fs.cpSync(path.join(FRONTEND, "src", "v1"), path.join(dir, "src", "v1"), { + recursive: true, + }); + fs.symlinkSync( + path.join(FRONTEND, "node_modules"), + path.join(dir, "node_modules"), + "dir", + ); + if (caseName) { + fs.cpSync(path.join(CASES_DIR, caseName), dir, { recursive: true }); + } + // biome.json enables VCS integration with `useIgnoreFile`, which needs an + // ignore file at the root. + fs.writeFileSync(path.join(dir, ".gitignore"), "node_modules\n"); + return dir; +} + +function removeTree(dir) { + // Unlink first so the recursive delete can never walk into node_modules. + fs.unlinkSync(path.join(dir, "node_modules")); + fs.rmSync(dir, { recursive: true, force: true }); +} + +function writeFile(dir, rel, content) { + const file = path.join(dir, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +/* + * Runs the steps in order, stopping at the first failure. `output` holds only + * what the tools printed, so an expected message can never match the command + * text. A command that could not start at all is an error, not a "failure". + */ +function runAll(dir, run) { + let output = ""; + let log = ""; + for (const step of run) { + const command = scripts[step] ?? step; + const result = spawnSync("/bin/sh", ["-c", command], { + cwd: dir, + encoding: "utf8", + env: { + ...process.env, + NO_COLOR: "1", + PATH: `${path.join(dir, "node_modules", ".bin")}${path.delimiter}${process.env.PATH}`, + }, + }); + if (result.error || result.status === null) { + throw new Error( + `could not run "${command}": ${result.error ?? result.signal}`, + ); + } + // 127: sh could not find the command, e.g. a tool missing from PATH. + assert.notEqual(result.status, 127, `command not found: ${command}`); + output += result.stdout + result.stderr; + log += `$ ${command}\n${result.stdout}${result.stderr}`; + if (result.status !== 0) { + return { ok: false, output, log }; + } + } + return { ok: true, output, log }; +} + +/* Runs `check-v1.mjs --code` over generated files; returns the reported lines. */ +function checkCode(files) { + const dir = makeTree(null); + try { + for (const [rel, content] of Object.entries(files)) { + writeFile(dir, rel, content); + } + const { output } = runAll(dir, ["node scripts/check-v1.mjs --code"]); + return output.split("\n").filter((l) => l.startsWith("src/")); + } finally { + removeTree(dir); + } +} + +/* + * The repository's Python `lib/` ignore rule also matches src/v1/lib/ and the + * fixtures' lib/ folders; .gitignore re-includes them. `--no-index` checks the + * rules even for files already tracked. + */ +test("no v1 or fixture file is git-ignored", () => { + const files = ["src/v1", "test-fixtures"].flatMap((top) => + fs + .readdirSync(path.join(FRONTEND, top), { + recursive: true, + withFileTypes: true, + }) + .filter((d) => d.isFile() && d.name !== ".DS_Store") + .map((d) => path.relative(FRONTEND, path.join(d.parentPath, d.name))) + // Cloud plugins copied into src/v1/plugins// are git-ignored on + // purpose; they belong to the cloud repository. + .filter((rel) => !/^src\/v1\/plugins\/[^/]+\//.test(rel)), + ); + // A lib/ folder anywhere under v1, not only the top-level one. + files.push("src/v1/pages/projects/lib/x.ts", "src/v1/lib/sub/lib/x.ts"); + const gitArgs = ["check-ignore", "--no-index", "--stdin"]; + const options = { cwd: FRONTEND, encoding: "utf8", input: files.join("\n") }; + // git from PATH on purpose: this is a developer/CI test, and git has no + // fixed location across macOS and Linux. + const result = spawnSync("git", gitArgs, options); // NOSONAR + // Exit 1 means "nothing matched"; 0 means some file is ignored. + assert.equal(result.status, 1, `git-ignored:\n${result.stdout}`); +}); + +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])); +}); + +for (const { name, run, fails } of CASES) { + const label = `${name ?? "empty scaffold"}: ${fails ? "fails" : "passes"} ${run.join(", ")}`; + test(label, () => { + const dir = makeTree(name); + try { + const { ok, output, log } = runAll(dir, run); + if (fails) { + assert.equal(ok, false, `expected a failure, got:\n${log}`); + assert.ok(output.includes(fails), `expected "${fails}" in:\n${log}`); + } else { + assert.equal(ok, true, log); + } + } finally { + removeTree(dir); + } + }); +} + +/* + * The folder contract, written out independently of check-v1.mjs so a typo + * in either shows up here. Source: the table in src/v1/README.md. Folders + * may always import themselves; pages/a and pages/b stand for two pages. + */ +const FOLDERS = { + entry: "", + app: "app", + "components/ui": "components/ui", + "components/patterns": "components/patterns", + shell: "shell", + "pages/a": "pages/a", + "pages/b": "pages/b", + services: "services", + stores: "stores", + hooks: "hooks", + lib: "lib", + styles: "styles", + assets: "assets", + plugins: "plugins", + test: "test", +}; +const EVERYTHING = Object.keys(FOLDERS); +const MAY_IMPORT = Object.fromEntries( + parseTable(` + app | * + plugins | * + test | * + pages/a | components/ui components/patterns shell services stores hooks lib styles assets plugins + components/ui | lib styles assets + components/patterns | components/ui lib services stores hooks assets + shell | components/ui components/patterns services stores hooks lib assets + services | lib + stores | lib + hooks | lib + lib | + styles | lib assets + assets | + `).map(([from, to]) => [from, to === "*" ? EVERYTHING : to.split(/\s+/)]), +); + +/* The file for one folder pair: it imports the target by file and by folder. + * The entry is a file, so it has only the one form. */ +function pairFile(to) { + const target = FOLDERS[to]; + return to === "entry" + ? ['import "@v1";'] + : [`import "@v1/${target}/target";`, `import "@v1/${target}";`]; +} + +/* One file per folder pair, and the problems check-v1 must report for them. */ +function contractMatrix() { + const files = {}; + const expected = []; + for (const [from, allowed] of Object.entries(MAY_IMPORT)) { + for (const to of EVERYTHING) { + const rel = `src/v1/${FOLDERS[from]}/m-${to.replace("/", "-")}.ts`; + const imports = pairFile(to); + files[rel] = `${imports.join("\n")}\n`; + const denied = from !== to && !allowed.includes(to); + expected.push( + ...(denied ? imports : []).map( + (_, i) => `${rel}:${i + 1}: ${from}/ may not import from ${to}/`, + ), + ); + } + } + return { files, expected }; +} + +test("folder contract: every folder pair, by file and by folder", () => { + const { files, expected } = contractMatrix(); + const reported = checkCode(files).map((l) => l.replace(/ \(.*$/, "")); + assert.deepEqual(sorted(reported), sorted(expected)); +}); + +/* Reported lines for one generated file. */ +const linesFor = (reported, file) => + reported.filter((l) => l.startsWith(`${file}:`)); + +test("folder contract: every import form is read", () => { + // Each file breaks the contract exactly once (components/ui -> services). + const forms = { + static: 'import { a } from "@v1/services/a";', + multiline: 'import {\n a,\n b,\n} from "@v1/services/a";', + type: 'import type { A } from "@v1/services/a";', + inlineType: 'import { type A } from "@v1/services/a";', + sideEffect: 'import "@v1/services/a";', + namespace: 'import * as a from "@v1/services/a";', + exportFrom: 'export { a } from "@v1/services/a";', + exportStar: 'export * from "@v1/services/a";', + exportStarAs: 'export * as a from "@v1/services/a";', + dynamic: 'export const a = () => import("@v1/services/a");', + dynamicTemplate: "export const a = () => import(`@v1/services/a`);", + templatePrefix: + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "export const a = (n: string) => import(`@v1/services/${n}`);", + require: 'export const a = require("@v1/services/a");', + newUrl: 'export const a = new URL("@v1/services/a.ts", import.meta.url);', + relative: 'import { a } from "./../../services/a";', + afterSlashStarInString: + 'export const route = "/projects/*";\nimport { a } from "@v1/services/a";\n/** doc */', + afterSlashStarInComment: + '// matches /api/*\nimport { a } from "@v1/services/a";\n/** doc */', + afterSlashStarInRegex: + 'export const strip = (p: string) => p.replace(/\\/*$/, "");\nimport { a } from "@v1/services/a";\n/** doc */', + afterDoubleSlashInString: + 'const u = "a//b"; export { a } from "@v1/services/a";', + glob: 'export const a = import.meta.glob("@v1/services/*.ts");', + // An apostrophe in JSX text is not a string opener. + afterJsxContraction: + 'export const A = () => It\'s ;', + afterJsxBraceApostrophe: + 'export const B = (n: string) =>

{n}\'s import("@v1/services/a")}>x

;', + globArrayLater: + 'export const a = import.meta.glob(["@v1/components/ui/*.tsx", "@v1/services/*.ts"]);', + afterTemplateExpression: + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + 'const t = `${"}"}/*`;\nimport { a } from "@v1/services/a";\n/** doc */', + }; + // None of these may be reported: comments, JSX, look-alike class names. + const quiet = { + "commentedOut.ts": + '// import "@v1/services/a";\n/* import("@/helpers/x"); */\nexport const v = 1;', + "lookalikeClasses.ts": 'export const c = "variant-outline tenant-id";', + // Example source text in strings, templates and regexes is not an import. + "importsInStrings.ts": + 'export const a = "import(name)";\nexport const b = \'import x from "@/helpers/x"\';\nexport const c = `require(n) or import.meta.glob(x)`;\nexport const d = /import\\(x\\)/;', + "jsxQuotes.tsx": + "export const C = () =>

Don't 'quote' me

;", + "jsx.tsx": + 'export const T = () =>
a / b
;\nexport const n = 2 / 1;', + }; + const files = {}; + for (const [form, code] of Object.entries(forms)) { + files[`src/v1/components/ui/${form}.ts`] = `${code}\n`; + } + for (const [name, code] of Object.entries(quiet)) { + files[`src/v1/components/ui/${name}`] = `${code}\n`; + } + const reported = checkCode(files); + for (const form of Object.keys(forms)) { + const lines = linesFor(reported, `src/v1/components/ui/${form}.ts`); + assert.equal(lines.length, 1, `${form}:\n${reported.join("\n")}`); + assert.ok( + lines[0].includes("components/ui/ may not import from services/"), + `${form}: ${lines[0]}`, + ); + } + for (const name of Object.keys(quiet)) { + assert.deepEqual(linesFor(reported, `src/v1/components/ui/${name}`), []); + } +}); + +test("a symlinked file under v1 fails typecheck:v1", () => { + const dir = makeTree(null); + try { + fs.symlinkSync( + path.join(dir, "vite-env.d.ts"), + path.join(dir, "src", "v1", "lib", "linked.d.ts"), + ); + const { ok, output, log } = runAll(dir, ["typecheck:v1"]); + assert.equal(ok, false, log); + assert.ok(output.includes("No symlinks under v1"), log); + } finally { + removeTree(dir); + } +}); + +test("v1 imports nothing outside src/v1, in any spelling", () => { + const imports = [ + "import(`@/helpers/x`)", + "import(`../helpers/x`)", + "import(`..`)", + "import(`./../../helpers/x`)", + "import(`/src/assets/logo.svg`)", + "import(`src/helpers/x`)", + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "import(`../../helpers/${name}`)", + 'new URL("../../assets/logo.svg", import.meta.url)', + ]; + const files = {}; + imports.forEach((code, i) => { + files[`src/v1/lib/outside-${i}.ts`] = + `export const a = (name: string) => ${code};\n`; + }); + const reported = checkCode(files); + imports.forEach((code, i) => { + const lines = linesFor(reported, `src/v1/lib/outside-${i}.ts`); + assert.equal(lines.length, 1, `${code}:\n${reported.join("\n")}`); + assert.ok(lines[0].includes("is outside src/v1"), `${code}: ${lines[0]}`); + }); +}); + +test("v1: an import the guard cannot read fails closed", () => { + const files = { + "src/v1/lib/variable.ts": "export const a = (n: string) => import(n);\n", + "src/v1/lib/fullyComputed.ts": + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "export const a = (n: string) => import(`${n}`);\n", + "src/v1/components/ui/computedPage.ts": + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "export const a = (n: string) => import(`@v1/pages/${n}/index.tsx`);\n", + // The lazy route registry: app/ may import any v1 folder. + "src/v1/app/routes.ts": + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "export const a = (n: string) => import(`@v1/pages/${n}/index.tsx`);\n", + // A non-module `new URL` is not an import. + "src/v1/lib/link.ts": + 'export const u = (p: string) => new URL(p, "https://example.com");\n', + }; + const reported = checkCode(files); + for (const file of ["variable", "fullyComputed"]) { + const lines = linesFor(reported, `src/v1/lib/${file}.ts`); + assert.equal(lines.length, 1, reported.join("\n")); + assert.ok(lines[0].includes("unanalysable import"), lines[0]); + } + const page = linesFor(reported, "src/v1/components/ui/computedPage.ts"); + assert.equal(page.length, 1, reported.join("\n")); + assert.ok(page[0].includes("may not import a computed v1 path"), page[0]); + assert.deepEqual(linesFor(reported, "src/v1/app/routes.ts"), []); + assert.deepEqual(linesFor(reported, "src/v1/lib/link.ts"), []); +}); + +test("v0 reaches v1 only through @v1, in any form", () => { + const denied = { + "static.jsx": 'import x from "@v1/lib/x";', + "template.jsx": "export const a = () => import(`@v1/plugins/manifest`);", + "templatePrefix.jsx": + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "export const a = (n) => import(`../v1/lib/${n}`);", + "glob.jsx": 'export const a = import.meta.glob("@v1/pages/*/index.ts");', + "globRootAbsolute.jsx": + 'export const a = import.meta.glob("/src/v1/lib/*.ts");', + "globArrayLater.jsx": + 'export const a = import.meta.glob(["./*.jsx", "../v1/lib/*.ts"]);', + "require.jsx": 'export const a = require("../v1/lib/x");', + "newUrl.jsx": + 'export const a = new URL("../v1/lib/x.ts", import.meta.url);', + "aliasV0.jsx": 'export const a = () => import("@/v1/lib/x");', + "aliasV0Dot.jsx": "export const a = () => import(`@/./v1/lib/x`);", + "srcPath.jsx": 'export const a = () => import("src/v1/lib/x");', + "relativeEntry.jsx": 'import "../v1";', + "typed.tsx": 'import x from "../v1/lib/x";\nexport const y = x;', + "styles.css": '@import "../v1/styles/tokens.css";', + "stylesRootAbsolute.css": ".a { background: url(/src/v1/styles/a.png); }", + }; + const allowed = { + "entry.jsx": 'import "@v1";', + "entryTemplate.jsx": "export const a = () => import(`@v1`);", + "entryIndex.jsx": 'import "@v1/index";', + "v0Only.jsx": 'import x from "../helpers/x";\nexport const y = x;', + "lookalike.jsx": + 'import a from "@/api/v1";\nimport { v1 } from "uuid/v1";\nexport const b = [a, v1];', + }; + const files = {}; + for (const [name, code] of Object.entries({ ...denied, ...allowed })) { + files[`src/pages/${name}`] = `${code}\n`; + } + const reported = checkCode(files); + for (const name of Object.keys(denied)) { + const lines = linesFor(reported, `src/pages/${name}`); + assert.equal(lines.length, 1, `${name}:\n${reported.join("\n")}`); + assert.ok(lines[0].includes("reaches into src/v1"), `${name}: ${lines[0]}`); + } + for (const name of Object.keys(allowed)) { + assert.deepEqual(linesFor(reported, `src/pages/${name}`), [], name); + } +}); diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-entry/src/pages/Ok.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-entry/src/pages/Ok.jsx new file mode 100644 index 0000000000..8e96182e95 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-entry/src/pages/Ok.jsx @@ -0,0 +1 @@ +import "@v1"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx new file mode 100644 index 0000000000..ecddfc6910 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx @@ -0,0 +1,3 @@ +import { pluginManifest } from "@v1/plugins/manifest"; + +export const manifestSize = () => pluginManifest.length; diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-lookalike/src/pages/Lookalike.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-lookalike/src/pages/Lookalike.jsx new file mode 100644 index 0000000000..04f214831c --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-lookalike/src/pages/Lookalike.jsx @@ -0,0 +1,4 @@ +import api from "@/api/v1"; +import { v1 as uuid } from "uuid/v1"; + +export const ids = () => [api, uuid]; diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx new file mode 100644 index 0000000000..953cb550c6 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx @@ -0,0 +1,3 @@ +import { pluginManifest } from "../v1/plugins/manifest"; + +export const manifestSize = () => pluginManifest.length; diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-template/src/pages/Bad.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-template/src/pages/Bad.jsx new file mode 100644 index 0000000000..278db55314 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-template/src/pages/Bad.jsx @@ -0,0 +1 @@ +export const loadManifest = () => import(`@v1/plugins/manifest`); diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/V1Switch.tsx b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/V1Switch.tsx new file mode 100644 index 0000000000..33ec55123a --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/V1Switch.tsx @@ -0,0 +1,3 @@ +import { mountV1 } from "@v1"; + +export const mounted: string = mountV1(); diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/app/mount.ts b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/app/mount.ts new file mode 100644 index 0000000000..4487c798fb --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/app/mount.ts @@ -0,0 +1 @@ +export const mountV1 = (): string => "v1"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/index.ts b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/index.ts new file mode 100644 index 0000000000..f54562d67c --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/index.ts @@ -0,0 +1 @@ +export { mountV1 } from "@v1/app/mount"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/assets/logo.svg b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/assets/logo.svg new file mode 100644 index 0000000000..625fd367af --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/assets/logo.svg @@ -0,0 +1 @@ + diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.test.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.test.ts new file mode 100644 index 0000000000..feb52d7f4f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.test.ts @@ -0,0 +1,8 @@ +import { renderLabel } from "@v1/test/render"; +import { expect, it } from "vitest"; +import { label } from "./Label"; + +// Importing vitest here keeps typecheck:v1 honest about test typings. +it("renders", () => { + expect(renderLabel(label("ok"))).toBe("OK"); +}); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.ts new file mode 100644 index 0000000000..ef8a7487be --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.ts @@ -0,0 +1,3 @@ +import { upper } from "@v1/lib/format"; + +export const label = (text: string): string => upper(text); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Logo.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Logo.tsx new file mode 100644 index 0000000000..9ca41d3948 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Logo.tsx @@ -0,0 +1,5 @@ +import LogoIcon from "@v1/assets/logo.svg?react"; + +export function Logo() { + return