diff --git a/.gitignore b/.gitignore index ab73808bed..2ec679b724 100644 --- a/.gitignore +++ b/.gitignore @@ -641,10 +641,10 @@ workers/pluggable_worker/* # FE Plugins frontend/src/plugins/* frontend/public/llm-whisperer/ -# v1 cloud plugins are copied in at build time; only the placeholder manifest -# is public. +# v1 plugins come from the cloud overlay; only the placeholder and the loader are OSS. frontend/src/v1/plugins/* -!frontend/src/v1/plugins/manifest.ts +!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/docker/dockerfiles/frontend.Dockerfile b/docker/dockerfiles/frontend.Dockerfile index 4c03dfa1f0..b4626b4899 100644 --- a/docker/dockerfiles/frontend.Dockerfile +++ b/docker/dockerfiles/frontend.Dockerfile @@ -24,6 +24,12 @@ COPY ${BUILD_CONTEXT_PATH}/ /app/ COPY ${BUILD_CONTEXT_PATH}/generate-runtime-config.sh /app/generate-runtime-config.sh RUN chmod +x /app/generate-runtime-config.sh +# VITE_DISTRIBUTION: as in the builder stage below. The dev server reads it +# from the environment when it starts, so the dev-live image of a cloud or +# on-prem overlay needs it here too. +ARG VITE_DISTRIBUTION="" +ENV VITE_DISTRIBUTION=${VITE_DISTRIBUTION} + # Set dev server to run on port 80 to match production nginx ENV PORT=80 EXPOSE 80 @@ -52,10 +58,9 @@ COPY ${BUILD_CONTEXT_PATH}/ . # VITE_V1_DEV_ROUTES: "true" only for the E2E image (ci-test.yaml); registers # the v1 demo pages that prove the version switch and honours the v1 preview # override. Release builds leave it empty. -# VITE_DISTRIBUTION: oss (default when empty), cloud or onprem; the -# v1 route registry ignores pages the distribution lacks. The same name as -# P0-T07's plugin manifest (UN-4286). The cloud and on-prem builds do not set -# it yet, so every image builds as oss until P0-T07 wires them. +# VITE_DISTRIBUTION: oss (default when empty), cloud or onprem. It picks the +# v1 plugins the build offers (P0-T07) and the pages the v1 route registry +# keeps; the cloud workflows set it. See frontend/src/v1/README.md. ARG VITE_V1_DEV_ROUTES="" ARG VITE_DISTRIBUTION="" diff --git a/frontend/bun.lock b/frontend/bun.lock index 2e28eba374..7e66d10ae6 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -87,6 +87,7 @@ "autoprefixer": "^10.5.4", "axe-core": "4.14.0", "baseline-browser-mapping": "^2.9.19", + "esbuild": "^0.27.0", "happy-dom": "^20.10.2", "msw": "2.15.0", "postcss": "^8.5.23", diff --git a/frontend/package.json b/frontend/package.json index f91327c514..f3e602de9c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -85,7 +85,7 @@ "tokens:v1": "bun scripts/build-v1-tokens.mjs", "typecheck:v1": "node scripts/check-v1.mjs --files && tsc -p src/v1/tsconfig.json", "lint:v1": "biome lint --error-on-warnings src/v1/ && node scripts/check-v1.mjs --code && node scripts/check-v1-styles.mjs && node scripts/check-v1-icons.mjs", - "test:v1": "vitest run --config vitest.v1.config.mts --coverage && vitest run --config vitest.v1-plugins.config.mts --coverage && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs test-fixtures/v1-harness/harness.fixtures.mjs test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs", + "test:v1": "vitest run --config vitest.v1.config.mts --coverage && vitest run --config vitest.v1-plugins.config.mts --coverage && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs test-fixtures/v1-harness/harness.fixtures.mjs test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs scripts/v1-plugins.fixtures.mjs", "seam:v1": "node scripts/check-v1-seam.mjs --base origin/main", "separation:v1": "node scripts/check-v1-separation.mjs", "bundle:v1": "node scripts/check-v1-bundle.mjs" @@ -120,6 +120,7 @@ "autoprefixer": "^10.5.4", "axe-core": "4.14.0", "baseline-browser-mapping": "^2.9.19", + "esbuild": "^0.27.0", "happy-dom": "^20.10.2", "msw": "2.15.0", "postcss": "^8.5.23", diff --git a/frontend/scripts/v1-plugins.fixtures.mjs b/frontend/scripts/v1-plugins.fixtures.mjs new file mode 100644 index 0000000000..454572c731 --- /dev/null +++ b/frontend/scripts/v1-plugins.fixtures.mjs @@ -0,0 +1,344 @@ +// 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 { after, test } from "node:test"; +import { check } from "./v1-plugins.mjs"; + +const temps = []; +after(() => { + for (const dir of temps) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function tempDir(prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + temps.push(dir); + return dir; +} + +function plugins(manifests) { + const dir = tempDir("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 () => { + const none = path.join(tempDir("v1-plugins-"), "none"); + assert.deepEqual(await check(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 manifest with a syntax error fails with its cause and no import hint", async () => { + const dir = plugins({ broken: "export const manifest = {\n" }); + await assert.rejects(check(dir), (err) => { + assert.match(err.message, /broken\/manifest\.ts could not be loaded/); + assert.doesNotMatch(err.message, /import type/); + assert.ok(err.cause, "keeps the original error as its cause"); + return true; + }); +}); + +test("a manifest whose slot or route is not a loader fails, naming the plugin", async () => { + for (const [extra, why] of [ + [ + `slots: { "shell.product-switcher": undefined },`, + /odd fills slot shell\.product-switcher with undefined/, + ], + [`routes: [{ path: "/odd", load: "./Odd" }],`, /odd route \/odd has no load/], + [`routes: [{ load: () => import("./X") }],`, /odd route 0 has no string path/], + ]) { + const dir = plugins({ odd: manifest("odd", ["cloud"], extra) }); + await assert.rejects(check(dir), why); + } +}); + +for (const [route, why] of [ + ["/:orgName/v1-demo/:id?", /segment ':id\?'/], + ["billing", /must start with \//], +]) { + test(`a plugin route ${route} the route registry's pattern rule refuses fails`, async () => { + const dir = plugins({ + odd: manifest( + "odd", + ["cloud"], + `routes: [{ path: "${route}", load: () => import("./X") }],`, + ), + }); + await assert.rejects(check(dir), /odd route .* is not accepted/); + await assert.rejects(check(dir), why); + }); +} + +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/); +}); + +// DEMO_PAGES in app/registry/entries.ts declares /:orgName/v1-demo. A route +// the router would match for the same URLs clashes too. +for (const route of [ + "/:orgName/v1-demo", + "/:org/v1-demo", + "/:orgName/V1-Demo", + "/:orgName/*", +]) { + test(`a plugin route ${route} on a page OSS declares fails, naming OSS and the plugin`, async () => { + const dir = plugins({ + clash: manifest( + "clash", + ["cloud"], + `routes: [{ path: "${route}", load: () => import("./X") }],`, + ), + }); + await assert.rejects(check(dir), /\/:orgName\/v1-demo.*\bOSS\b.*\bclash\b/); + }); +} + +test("a plugin route that only shares a prefix with an OSS page passes", async () => { + const dir = plugins({ + near: manifest( + "near", + ["cloud"], + `routes: [{ path: "/:orgName/v1-demo-x", load: () => import("./X") }],`, + ), + }); + assert.deepEqual((await check(dir)).distributions.cloud, ["near"]); +}); + +test("the Vite plugin fails the build on a conflict", async () => { + const slot = `slots: { "shell.product-switcher": () => import("./X") },`; + const dir = plugins({ + a: manifest("a", ["cloud"], slot), + b: manifest("b", ["cloud"], slot), + }); + const { v1PluginMatrix } = await import("../vite.config.js"); + await assert.rejects( + v1PluginMatrix({ dir, distribution: "cloud" }).buildStart(), + /shell\.product-switcher.*\ba\b.*\bb\b/, + ); +}); + +test("the Vite plugin emits plugin-matrix.json naming the build's distribution", async () => { + const dir = plugins({ billing: manifest("billing", ["cloud"]) }); + const { v1PluginMatrix } = await import("../vite.config.js"); + const plugin = v1PluginMatrix({ dir, distribution: "cloud" }); + await plugin.buildStart(); + const emitted = []; + plugin.generateBundle.call({ emitFile: (file) => emitted.push(file) }); + assert.equal(emitted.length, 1); + assert.equal(emitted[0].fileName, "plugin-matrix.json"); + const matrix = JSON.parse(emitted[0].source); + assert.equal(matrix.distribution, "cloud"); + assert.deepEqual(matrix.distributions.cloud, ["billing"]); +}); + +test("the Vite config accepts every distribution the route registry declares", async () => { + const { distributions } = await check(tempDir("v1-plugins-")); + const { default: config } = await import("../vite.config.js"); + const saved = process.env.VITE_DISTRIBUTION; + try { + for (const d of Object.keys(distributions)) { + process.env.VITE_DISTRIBUTION = d; + assert.doesNotThrow(() => config({ mode: "production" }), d); + } + } finally { + if (saved === undefined) { + delete process.env.VITE_DISTRIBUTION; + } else { + process.env.VITE_DISTRIBUTION = saved; + } + } +}); + +test("the Vite config runs the plugin matrix in every build", async () => { + const { default: config } = await import("../vite.config.js"); + const names = config({ mode: "production" }).plugins.map((p) => p.name); + assert.ok(names.includes("v1-plugin-matrix"), names.join(", ")); +}); + +test("vite build fails on a VITE_DISTRIBUTION it does not know", async () => { + const { default: config } = await import("../vite.config.js"); + const saved = process.env.VITE_DISTRIBUTION; + process.env.VITE_DISTRIBUTION = "on-prem"; + try { + assert.throws(() => config({ mode: "production" }), /on-prem/); + } finally { + if (saved === undefined) { + delete process.env.VITE_DISTRIBUTION; + } else { + process.env.VITE_DISTRIBUTION = saved; + } + } +}); + +test("both frontend image stages that run Vite take VITE_DISTRIBUTION", () => { + const dockerfile = fs.readFileSync( + path.join( + import.meta.dirname, + "../../docker/dockerfiles/frontend.Dockerfile", + ), + "utf8", + ); + const stages = Object.fromEntries( + dockerfile + .split(/^FROM /m) + .slice(1) + .map((stage) => [stage.match(/ AS (\S+)/)?.[1], stage]), + ); + for (const name of ["development", "builder"]) { + assert.match(stages[name], /^ARG VITE_DISTRIBUTION=/m, `${name} stage`); + } + assert.match( + stages.development, + /^ENV VITE_DISTRIBUTION=\$\{VITE_DISTRIBUTION\}/m, + ); +}); + +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 prints the cause of a manifest that fails to load", () => { + const result = cli(plugins({ broken: "export const manifest = {\n" })); + assert.equal(result.status, 1); + assert.match(result.stderr, /broken\/manifest\.ts could not be loaded/); + // The cause follows the message, with its stack. + assert.match(result.stderr, /^\s+at /m); +}); + +test("the CLI writes the matrix to the file argument, with or without --dir", () => { + const out = path.join(tempDir("v1-out-"), "m.json"); + // Without --dir it reads V1_PLUGINS_DIR, not the repository's plugins/. + const bare = spawnSync(process.execPath, [script, out], { + encoding: "utf8", + env: { + ...process.env, + V1_PLUGINS_DIR: plugins({ switcher: manifest("switcher", ["onprem"]) }), + }, + }); + assert.equal(bare.status, 0, bare.stderr); + assert.equal(bare.stdout, ""); + const fromEnv = JSON.parse(fs.readFileSync(out, "utf8")); + assert.deepEqual(fromEnv.distributions.onprem, ["switcher"]); + + 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..b3a3a4c720 --- /dev/null +++ b/frontend/scripts/v1-plugins.mjs @@ -0,0 +1,179 @@ +#!/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 + * shape and conflict checks for every distribution, including plugin routes + * on pages the v1 route registry declares and route paths its pattern rule + * rejects, 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 + * + * V1_PLUGINS_DIR replaces the default plugins folder (the fixtures use it). + */ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const DEFAULT_DIR = process.env.V1_PLUGINS_DIR + ? path.resolve(process.env.V1_PLUGINS_DIR) + : path.join(import.meta.dirname, "../src/v1/plugins"); +const SRC = path.join(import.meta.dirname, "../src/v1"); +const REGISTRY = path.join(SRC, "lib/plugins/registry.ts"); +// The v1 route registry's pages, its pattern rules and its distributions. +const ROUTE_ENTRIES = path.join(SRC, "app/registry/entries.ts"); +const ROUTE_PATTERNS = path.join(SRC, "app/registry/patterns.ts"); +const ROUTE_TYPES = path.join(SRC, "app/registry/types.ts"); + +const dataUrl = (code) => + `data:text/javascript;base64,${Buffer.from(code).toString("base64")}`; + +const BUN = Boolean(process.versions.bun); + +/** Imports a source module with its own imports; under Node, bundled by esbuild. */ +function loadSource(file) { + if (BUN) { + return import(pathToFileURL(file).href); + } + return import("esbuild") + .then(({ build }) => + build({ + entryPoints: [file], + bundle: true, + write: false, + format: "esm", + platform: "node", + }), + ) + .then((r) => import(dataUrl(r.outputFiles[0].text))); +} + +/** A function that imports a manifest; under Node it loads esbuild first. */ +async function manifestImporter() { + if (BUN) { + return (file) => import(pathToFileURL(file).href); + } + const { transform } = await import("esbuild"); + return async (file) => { + const { code } = await transform(fs.readFileSync(file, "utf8"), { + loader: "ts", + format: "esm", + sourcefile: file, + }); + return import(dataUrl(code)); + }; +} + +// Node from a data: URL, Node from a file, Bun, esbuild. +const UNRESOLVED = + /ERR_UNSUPPORTED_RESOLVE_REQUEST|ERR_MODULE_NOT_FOUND|Cannot find module|Could not resolve/; + +const unresolved = (err) => + UNRESOLVED.test(`${err?.code ?? ""} ${err?.message ?? ""}`); + +async function load(importManifest, dir, id) { + const file = path.join(dir, id, "manifest.ts"); + const rel = `${id}/manifest.ts`; + let mod; + try { + mod = await importManifest(file); + } catch (err) { + const hint = unresolved(err) + ? ' A manifest may only `import type`; load components with `() => import("./X")`.' + : ""; + throw new Error(`${rel} could not be loaded (${err?.message}).${hint}`, { + cause: err, + }); + } + 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 importManifest = await manifestImporter(); + const manifests = await Promise.all( + ids.map((id) => load(importManifest, dir, id)), + ); + const [ + { buildRegistry }, + { PAGES, DEMO_PAGES }, + { patternProblem, patternsOverlap }, + { DISTRIBUTIONS }, + ] = await Promise.all([ + loadSource(REGISTRY), + loadSource(ROUTE_ENTRIES), + loadSource(ROUTE_PATTERNS), + loadSource(ROUTE_TYPES), + ]); + const rules = { + // Demo pages count: an E2E build registers them. + ossRoutes: [...PAGES, ...DEMO_PAGES].flatMap((p) => p.v1Routes), + overlaps: patternsOverlap, + patternProblem, + }; + // Throws on any conflict in any distribution. + const offered = (d) => [...buildRegistry(manifests, d, rules).plugins]; + return { + distributions: Object.fromEntries(DISTRIBUTIONS.map((d) => [d, offered(d)])), + 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}`); + if (err.cause) { + console.error(err.cause); + } + process.exit(1); + }); +} diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 405387cbda..058159ff64 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -173,9 +173,22 @@ Every folder may import from itself. Test files (`*.test.ts(x)`, | `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 | +| `plugins/` | The plugin loader (`index.ts`, which builds the registry); the cloud overlay copies plugin folders in beside it | 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`. `plugins/index.ts` builds the registry from the manifests and exports `PluginSlot` and `pluginRoutes()`. Nothing renders them yet: the v1 shell (P2-T01) is to wrap the app in `PluginProvider` with `PluginSlot` and add `pluginRoutes()` to the v1 router. Until then, and 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, is logged and raises an error notification; each slot has its own error boundary. A failed load is reported once per slot until a load succeeds, and the slot waits 30 seconds (`SLOT_RETRY_MS`) before fetching the chunk again. A render error is reported each time a boundary catches one. +- `VITE_DISTRIBUTION` (`oss`, the default, `cloud` or `onprem`) picks the plugins the build offers. The frontend Dockerfile takes it as a build arg in both the `development` and `builder` stages, and `vite build` fails on any other value. +- Every build runs `scripts/v1-plugins.mjs`. It fails on a slot filled twice, two routes the router would match for the same URL (by two plugins, or by a plugin and a page in the v1 route registry, demo pages included; params, case and a final `*` count, via the registry's `patternsOverlap`) or a plugin with no distribution in any distribution, naming both owners. It writes `plugin-matrix.json` to the build output: the build's `distribution` and the plugins this build carries. The cloud overlay removes plugins outside the target distribution first, so the matrix of an OSS image is empty. `node scripts/v1-plugins.mjs` prints the matrix. +- `plugin-matrix.json` also has `distributions` (for `oss`, `cloud` and `onprem`, the ids among this build's manifests) and `plugins` (each one's distributions, slots and routes). In a built image only `distributions[distribution]` is meaningful: the overlay has already removed the other distributions' plugins. +- The check also fails on a slot id or distribution the contract does not define, a slot value or route `load` that is not a function, a route without a string `path`, a route path the route registry's `patternProblem` rejects (for example `:id?`, or no leading `/`), and a `plugins/` folder with no `manifest.ts`. tsc never sees an overlaid manifest, so these run-time checks stand in for `satisfies PluginManifest`. A manifest that fails to resolve an import is told it may only `import type`. +- v0's optional-plugin stub skips `src/v1`, so a missing module in v1 fails the build. + ## Scripts | Script | Checks | diff --git a/frontend/src/v1/app/plugin-distribution.test.ts b/frontend/src/v1/app/plugin-distribution.test.ts new file mode 100644 index 0000000000..0595b0198e --- /dev/null +++ b/frontend/src/v1/app/plugin-distribution.test.ts @@ -0,0 +1,89 @@ +import { expect, it, vi } from "vitest"; + +// The frontend image passes VITE_DISTRIBUTION through even when unset, as "". +it.each([ + "", + " OSS ", + "cloud", +])("VITE_DISTRIBUTION=%j loads the plugin registry", async (value) => { + vi.stubEnv("VITE_DISTRIBUTION", value); + vi.resetModules(); + await expect(import("@v1/plugins")).resolves.toHaveProperty("registry"); +}); + +it("rejects a distribution it does not know", async () => { + vi.stubEnv("VITE_DISTRIBUTION", "cluod"); + vi.resetModules(); + await expect(import("@v1/plugins")).rejects.toThrow(/must be one of/); +}); + +it.each([ + "/:orgName/v1-demo", + "/:org/V1-Demo", + "/:orgName/*", +])("rejects a plugin route %s on a page the OSS route registry declares", async (path) => { + vi.resetModules(); + const { pluginRegistry } = await import("@v1/plugins"); + expect(() => + pluginRegistry( + [ + { + id: "clash", + distributions: ["cloud"], + routes: [ + { + path, + load: async () => ({ default: () => null }), + }, + ], + }, + ], + "cloud", + ), + ).toThrow(/\/:orgName\/v1-demo.*\bOSS\b.*\bclash\b/); +}); + +it.each([ + ["/:orgName/v1-demo/:id?", /segment ':id\?'/], + ["billing", /must start with \//], +])("rejects a plugin route %s the route registry's pattern rule refuses", async (path, why) => { + vi.resetModules(); + const { pluginRegistry } = await import("@v1/plugins"); + const manifest = { + id: "odd", + distributions: ["cloud"] as const, + routes: [{ path, load: async () => ({ default: () => null }) }], + }; + expect(() => pluginRegistry([manifest], "cloud")).toThrow( + /odd route .* is not accepted/, + ); + expect(() => pluginRegistry([manifest], "cloud")).toThrow(why); +}); + +it("logs a plugin failure and raises one error notification per slot", async () => { + vi.resetModules(); + const { reportPluginError } = await import("@v1/plugins"); + const { setNotifier } = await import("@v1/lib/notify"); + const shown: unknown[] = []; + const unregister = setNotifier({ + show: (input) => { + shown.push(input); + return input.id ?? ""; + }, + dismiss: () => undefined, + }); + const logged = vi.spyOn(console, "error").mockImplementation(() => undefined); + const error = new Error("chunk"); + reportPluginError("billing", "account-menu.plan-billing", error); + unregister(); + expect(logged).toHaveBeenCalledWith( + "v1 plugin billing failed in slot account-menu.plan-billing", + error, + ); + expect(shown).toEqual([ + expect.objectContaining({ + kind: "error", + id: "plugin-billing-account-menu.plan-billing", + }), + ]); +}); 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..553e9e687e --- /dev/null +++ b/frontend/src/v1/lib/plugins/Slot.test.tsx @@ -0,0 +1,230 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildRegistry } from "./registry"; +import { makeSlot, SLOT_RETRY_MS } from "./Slot"; +import type { PluginManifest } from "./types"; + +const fallback = fallback; + +function slotFor(manifests: PluginManifest[]) { + const report = vi.fn(); + const clock = { now: 0 }; + const Slot = makeSlot( + buildRegistry(manifests, "cloud"), + report, + () => clock.now, + ); + return { Slot, report, clock }; +} + +function flaky(load: () => Promise) { + return slotFor([ + { + id: "flaky", + distributions: ["cloud"], + slots: { "shell.product-switcher": load as never }, + }, + ]); +} + +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 once the retry delay has passed", async () => { + const load = vi + .fn() + .mockRejectedValueOnce(new Error("chunk")) + .mockResolvedValue({ default: () => switcher }); + const { Slot, report, clock } = flaky(load); + const first = render( + , + ); + await vi.waitFor(() => expect(report).toHaveBeenCalledTimes(1)); + first.unmount(); + clock.now = SLOT_RETRY_MS; + render(); + expect(await screen.findByText("switcher")).toBeInTheDocument(); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("neither fetches nor reports again on re-renders and remounts within the retry delay", async () => { + const load = vi.fn().mockRejectedValue(new Error("chunk")); + const { Slot, report, clock } = flaky(load); + const tree = () => ( + + ); + const { rerender, unmount } = render(tree()); + await vi.waitFor(() => expect(report).toHaveBeenCalledTimes(1)); + clock.now = SLOT_RETRY_MS - 1; + rerender(tree()); + rerender(tree()); + unmount(); + render(tree()); + await new Promise((r) => setTimeout(r, 20)); + expect(screen.getByText("fallback")).toBeInTheDocument(); + expect(load).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledTimes(1); + }); + + it("recovers in place on a render after the retry delay", async () => { + const load = vi + .fn() + .mockRejectedValueOnce(new Error("chunk")) + .mockResolvedValue({ default: () => switcher }); + const { Slot, report, clock } = flaky(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)); + clock.now = SLOT_RETRY_MS; + rerender(tree()); + expect(await screen.findByText("switcher")).toBeInTheDocument(); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("reports a slot that keeps failing once, until a load succeeds", async () => { + const load = vi + .fn() + .mockRejectedValueOnce(new Error("chunk")) + .mockRejectedValueOnce(new Error("chunk")) + .mockResolvedValueOnce({ + default: () => { + throw new Error("render"); + }, + }); + const { Slot, report, clock } = flaky(load); + const tree = () => ( + + ); + const { rerender } = render(tree()); + await vi.waitFor(() => expect(report).toHaveBeenCalledTimes(1)); + clock.now = SLOT_RETRY_MS; + rerender(tree()); + await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(2)); + await new Promise((r) => setTimeout(r, 20)); + expect(report).toHaveBeenCalledTimes(1); + // The third load succeeds, so the render error after it is new and reported. + clock.now = 2 * SLOT_RETRY_MS; + rerender(tree()); + await vi.waitFor(() => expect(report).toHaveBeenCalledTimes(2)); + expect(report).toHaveBeenLastCalledWith( + "flaky", + "shell.product-switcher", + expect.objectContaining({ message: "render" }), + ); + }); + + it("reports a render error on every mount that hits it", async () => { + const { Slot, report } = flaky(() => + Promise.resolve({ + default: () => { + throw new Error("render"); + }, + }), + ); + const tree = () => ( + + ); + const first = render(tree()); + await vi.waitFor(() => expect(report).toHaveBeenCalledTimes(1)); + first.unmount(); + render(tree()); + await vi.waitFor(() => expect(report).toHaveBeenCalledTimes(2)); + expect(report).toHaveBeenLastCalledWith( + "flaky", + "shell.product-switcher", + expect.objectContaining({ message: "render" }), + ); + }); +}); diff --git a/frontend/src/v1/lib/plugins/Slot.tsx b/frontend/src/v1/lib/plugins/Slot.tsx new file mode 100644 index 0000000000..5274c9fc40 --- /dev/null +++ b/frontend/src/v1/lib/plugins/Slot.tsx @@ -0,0 +1,152 @@ +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; + } +} + +/** + * How long a slot whose filler failed to load waits before fetching it again. + * Until then every render and mount shows the fallback without a request, so + * a chunk that is gone for good (a tab left open across a deploy) is not + * fetched on every re-render of its host. + */ +export const SLOT_RETRY_MS = 30_000; + +/** + * 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. A failed + * load is reported once until a load of that slot succeeds; a render error + * is reported each time a boundary catches one. + */ +export function makeSlot( + registry: PluginRegistry, + report: PluginErrorReporter, + now: () => number = Date.now, +): (p: SlotComponentProps) => ReactNode { + const fillers = new Map>(); + const failedAt = new Map(); + const reported = new Set(); + + function filler(id: K): ComponentType | null { + const entry = registry.slot(id); + if (!entry) { + return null; + } + const failed = failedAt.get(id); + if (failed !== undefined && now() - failed >= SLOT_RETRY_MS) { + // React caches a rejected lazy promise, so a retry needs a new lazy. + fillers.delete(id); + failedAt.delete(id); + } + if (!fillers.has(id)) { + const lazyFiller = lazy(() => + entry.load().then( + (mod) => { + reported.delete(id); + return mod; + }, + (err: unknown) => { + failedAt.set(id, now()); + throw err; + }, + ), + ) as ComponentType; + fillers.set(id, lazyFiller); + } + return fillers.get(id) as ComponentType; + } + + function onError(owner: string, id: SlotId, error: unknown): void { + // failedAt is set before React rethrows a rejected load, so it marks a load failure. + if (failedAt.has(id)) { + if (reported.has(id)) { + return; + } + reported.add(id); + } + report(owner, id, error); + } + + return function Slot({ + id, + props, + fallback, + }: SlotComponentProps): ReactNode { + const Filler = filler(id); + if (!Filler) { + return fallback; + } + const owner = registry.slot(id)?.owner ?? "unknown"; + return ( + onError(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..732c34ae97 --- /dev/null +++ b/frontend/src/v1/lib/plugins/index.tsx @@ -0,0 +1,44 @@ +/* + * v1's extension-point contract. Shell and pages render `` from here. + * The registry itself is built in `@v1/plugins`. The v1 shell (P2-T01) is to + * hand it down through `PluginProvider`; nothing does yet. 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, + type PatternProblem, + PluginConflictError, + type PluginRegistry, + type RouteEntry, + type RouteOverlap, + type RouteRules, +} 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/registry.test.ts b/frontend/src/v1/lib/plugins/registry.test.ts new file mode 100644 index 0000000000..dc882f2722 --- /dev/null +++ b/frontend/src/v1/lib/plugins/registry.test.ts @@ -0,0 +1,196 @@ +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", { ossRoutes: ["/billing"] }), + ).toThrow(/\/billing.*OSS.*billing/); + }); + + it("fails on overlapping routes when given an overlap rule, naming both paths", () => { + // Stands in for patternsOverlap: every /billing route overlaps. + const prefix = (a: string, b: string) => + a.startsWith("/billing") && b.startsWith("/billing"); + const other = { + ...billing, + id: "other", + slots: {}, + routes: [{ path: "/billing/*", load }], + }; + expect(() => + buildRegistry([billing, other], "cloud", { overlaps: prefix }), + ).toThrow(/\/billing\/\* \(overlaps \/billing\).*billing.*other/); + expect(() => buildRegistry([billing, other], "cloud")).not.toThrow(); + }); + + 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/); + }); + + it.each([ + ["an undefined slot", { slots: { "shell.product-switcher": undefined } }], + ["a string slot", { slots: { "shell.product-switcher": "./X" } }], + ])("fails on %s, naming the plugin and the slot", (_, extra) => { + const bad = { + id: "bad", + distributions: ["cloud"], + ...extra, + } as unknown as PluginManifest; + expect(() => buildRegistry([bad], "cloud")).toThrow(PluginConflictError); + expect(() => buildRegistry([bad], "cloud")).toThrow( + /bad fills slot shell\.product-switcher with (undefined|string), not a loader function/, + ); + }); + + it("fails on a route whose load is not a function, naming the plugin and the path", () => { + const bad = { + id: "bad", + distributions: ["cloud"], + routes: [{ path: "/bad", load: "./Bad" }], + } as unknown as PluginManifest; + expect(() => buildRegistry([bad], "cloud")).toThrow( + /bad route \/bad has no load function/, + ); + }); + + it.each([ + [{ load }], + [{ path: 42, load }], + ])("fails on a route without a string path, naming the plugin (%o)", (route) => { + const bad = { + id: "bad", + distributions: ["cloud"], + routes: [route], + } as unknown as PluginManifest; + expect(() => buildRegistry([bad], "cloud")).toThrow( + /bad route 0 has no string path/, + ); + }); + + it("checks route paths with the given pattern rule, naming the plugin", () => { + const optional = { + ...billing, + routes: [{ path: "/billing/:id?", load }], + }; + const noOptional = (p: string) => + p.includes("?") ? "optional segments are not accepted" : null; + expect(() => + buildRegistry([optional], "cloud", { patternProblem: noOptional }), + ).toThrow( + /billing route \/billing\/:id\? is not accepted: optional segments/, + ); + expect(() => buildRegistry([optional], "cloud")).not.toThrow(); + }); + + it("rejects a relative route path by default", () => { + const relative = { ...billing, routes: [{ path: "billing", load }] }; + expect(() => buildRegistry([relative], "cloud")).toThrow( + /billing route billing is not accepted: must start with \//, + ); + }); +}); diff --git a/frontend/src/v1/lib/plugins/registry.ts b/frontend/src/v1/lib/plugins/registry.ts new file mode 100644 index 0000000000..4b9e8a2d05 --- /dev/null +++ b/frontend/src/v1/lib/plugins/registry.ts @@ -0,0 +1,189 @@ +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; +} + +/** True when some URL matches both route patterns. */ +export type RouteOverlap = (a: string, b: string) => boolean; + +/** Why a route pattern is not accepted, or null. */ +export type PatternProblem = (pattern: string) => string | null; + +/** How plugin routes are checked; the app passes the route registry's rules. */ +export interface RouteRules { + /** Route patterns of OSS pages; a plugin route may not overlap one. */ + ossRoutes?: readonly string[]; + /** Default: two paths overlap only when they are equal. */ + overlaps?: RouteOverlap; + /** Default: a path must start with `/`. */ + patternProblem?: PatternProblem; +} + +const samePath: RouteOverlap = (a, b) => a === b; + +const absolute: PatternProblem = (pattern) => + pattern.startsWith("/") ? null : "must start with /"; + +function fail(message: string): never { + throw new PluginConflictError(message); +} + +/* + * tsc never sees a manifest the cloud overlay copies in, so its shape is + * checked here at run time. + */ +function assertShape(m: PluginManifest, patternProblem: PatternProblem): void { + for (const [id, load] of Object.entries(m.slots ?? {})) { + if (!(SLOT_IDS as readonly string[]).includes(id)) { + fail(`${m.id} fills unknown slot ${id}`); + } + if (typeof load !== "function") { + fail( + `${m.id} fills slot ${id} with ${typeof load}, not a loader function`, + ); + } + } + for (const [i, route] of (m.routes ?? []).entries()) { + const path: unknown = route?.path; + if (typeof path !== "string") { + fail(`${m.id} route ${i} has no string path`); + } + if (typeof route.load !== "function") { + fail(`${m.id} route ${path} has no load function`); + } + const problem = patternProblem(path); + if (problem) { + fail(`${m.id} route ${path} is not accepted: ${problem}`); + } + } +} + +function assertDeclared( + manifests: readonly PluginManifest[], + patternProblem: PatternProblem, +): void { + const seen = new Set(); + for (const m of manifests) { + if (seen.has(m.id)) { + fail(`plugin id ${m.id} is used twice`); + } + seen.add(m.id); + if (m.distributions.length === 0) { + fail(`${m.id} declares no distributions`); + } + for (const d of m.distributions as readonly string[]) { + if (!(PLUGIN_DISTRIBUTIONS as readonly string[]).includes(d)) { + fail(`${m.id} declares unknown distribution ${d}`); + } + } + assertShape(m, patternProblem); + } +} + +function collect( + manifests: readonly PluginManifest[], + ossRoutes: readonly string[], + overlaps: RouteOverlap, +): { slots: Map>; routes: RouteEntry[] } { + const slots = new Map>(); + const owned: [path: string, owner: string][] = 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 = owned.find(([path]) => overlaps(path, route.path)); + if (taken) { + const [path, owner] = taken; + const what = + path === route.path ? route.path : `${route.path} (overlaps ${path})`; + throw new PluginConflictError( + `route ${what} is defined by both ${owner} and ${m.id}`, + ); + } + owned.push([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, a manifest + * naming a slot or distribution that does not exist, a slot or route whose + * loader is not a function, or a route path `patternProblem` rejects. + * + * Two routes clash when `overlaps` says some URL matches both. The app passes + * the route registry's `patternsOverlap` and `patternProblem` (lib/ cannot + * import app/), so params, case and splats count, and only patterns that + * check can stay exact are let in. + */ +export function buildRegistry( + manifests: readonly PluginManifest[], + distribution: Distribution, + { + ossRoutes = [], + overlaps = samePath, + patternProblem = absolute, + }: RouteRules = {}, +): PluginRegistry { + assertDeclared(manifests, patternProblem); + const inDistribution = (d: Distribution) => + manifests.filter((m) => + (m.distributions as readonly Distribution[]).includes(d), + ); + for (const d of PLUGIN_DISTRIBUTIONS) { + collect(inDistribution(d), ossRoutes, overlaps); + } + const offered = inDistribution(distribution); + const { slots, routes } = collect(offered, ossRoutes, overlaps); + 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/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..dc20e2515a --- /dev/null +++ b/frontend/src/v1/plugins/index.ts @@ -0,0 +1,80 @@ +/* + * 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; a filler that fails to load or render is + * reported. Nothing renders `PluginSlot` or `pluginRoutes()` yet: the v1 + * shell (P2-T01) wraps the app in `PluginProvider` with `PluginSlot` and adds + * the plugin routes to the v1 router. + */ +import { DEMO_PAGES, PAGES } from "@v1/app/registry/entries"; +import { patternProblem, patternsOverlap } from "@v1/app/registry/patterns"; +import { DISTRIBUTIONS } from "@v1/app/registry/types"; +import { notify } from "@v1/lib/notify"; +import { + buildRegistry, + type Distribution, + makeSlot, + type PluginErrorReporter, + type PluginManifest, + type PluginRegistry, + type RouteEntry, +} from "@v1/lib/plugins"; + +function currentDistribution(): Distribution { + // Empty means oss: the frontend image passes the build arg through even when unset. + const value = + import.meta.env.VITE_DISTRIBUTION?.trim().toLowerCase() || "oss"; + const distribution = DISTRIBUTIONS.find((d) => d === value); + if (!distribution) { + throw new Error( + `VITE_DISTRIBUTION must be one of ${DISTRIBUTIONS.join(", ")}; got ${value}`, + ); + } + return distribution; +} + +const manifests = Object.values( + import.meta.glob("./*/manifest.ts", { + eager: true, + import: "manifest", + }), +); + +// Demo pages count: an E2E build registers them. +const OSS_ROUTES = [...PAGES, ...DEMO_PAGES].flatMap((page) => page.v1Routes); + +/** + * The registry for these manifests. A plugin route on an OSS page, or one the + * route registry's pattern rule rejects, throws. + */ +export function pluginRegistry( + list: readonly PluginManifest[], + distribution: Distribution, +): PluginRegistry { + return buildRegistry(list, distribution, { + ossRoutes: OSS_ROUTES, + overlaps: patternsOverlap, + patternProblem, + }); +} + +export const registry = pluginRegistry(manifests, currentDistribution()); + +/** Logs the failure and raises one error notification per plugin slot. */ +export const reportPluginError: PluginErrorReporter = (owner, slot, error) => { + console.error(`v1 plugin ${owner} failed in slot ${slot}`, error); + notify({ + kind: "error", + title: "Part of this page could not load.", + description: "Reload the page to try again.", + // Reusing the id replaces an open notification instead of stacking. + id: `plugin-${owner}-${slot}`, + }); +}; + +export const PluginSlot = makeSlot(registry, reportPluginError); + +/** 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/plugins/manifest.ts b/frontend/src/v1/plugins/manifest.ts deleted file mode 100644 index c139d37fbf..0000000000 --- a/frontend/src/v1/plugins/manifest.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* - * Cloud overlay target. Empty in the public repository: the cloud build - * copies its v1 plugins into this folder. The manifest's shape and contents - * belong to UN-4285 and UN-4286. - */ -export const pluginManifest = [] as const; 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..a8329892ea --- /dev/null +++ b/frontend/src/v1/test/jest-dom.d.ts @@ -0,0 +1,2 @@ +// jest-dom matchers for v1 tests; src/v1/test/setup.ts registers them at run time. +/// diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index fcc736bba7..116df2f4a7 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -364,13 +364,17 @@ test("no v1 or fixture file is git-ignored", () => { assert.deepEqual(gitIgnored(files), []); }); -test("cloud v1 plugins are git-ignored; the public manifest is not", () => { +test("cloud v1 plugins are git-ignored; the public loader and placeholder are not", () => { const cloud = [ "src/v1/plugins/billing/index.ts", "src/v1/plugins/billing/lib/x.ts", "src/v1/plugins/stray.ts", ]; - const ignored = gitIgnored([...cloud, "src/v1/plugins/manifest.ts"]); + const ignored = gitIgnored([ + ...cloud, + "src/v1/plugins/index.ts", + "src/v1/plugins/.gitkeep", + ]); assert.deepEqual(sorted(ignored), sorted(cloud)); }); @@ -936,15 +940,15 @@ test("v0 symlinks: a link into v1 is reported, a file link is scanned", () => { writeFile(dir, "elsewhere/bad.jsx", 'import "@v1/lib/x";\n'); fs.mkdirSync(path.join(dir, "src", "pages"), { recursive: true }); fs.symlinkSync( - path.join(dir, "src", "v1", "plugins", "manifest.ts"), - path.join(dir, "src", "pages", "manifest.ts"), + path.join(dir, "src", "v1", "plugins", "index.ts"), + path.join(dir, "src", "pages", "index.ts"), ); fs.symlinkSync( path.join(dir, "elsewhere", "bad.jsx"), path.join(dir, "src", "pages", "bad.jsx"), ); const { output, log } = runAll(dir, ["node scripts/check-v1.mjs --code"]); - assert.ok(output.includes("src/pages/manifest.ts: links into src/v1"), log); + assert.ok(output.includes("src/pages/index.ts: links into src/v1"), log); assert.ok(output.includes("src/pages/bad.jsx:1: '@v1/lib/x' reaches"), log); } finally { removeTree(dir); @@ -976,3 +980,28 @@ test("v1 --code reads unknown file types and style files outside styles/", () => assert.ok(badge[0].includes("is outside src/v1"), badge[0]); assert.ok(badge[1].includes("No antd 'ant-' class names"), badge[1]); }); + +/* + * v0's optionalPluginImports turns a missing import under `/plugins/` into a + * stub. v1 has no optional imports, so both copies must leave a v1 importer to + * fail. The v1 vitest config never loads this resolver, so only a direct call + * proves the guard. + */ +test("optionalPluginImports stubs missing v0 plugin imports but never v1 ones", async () => { + const v1 = path.join(FRONTEND, "src/v1/lib/plugins/registry.ts"); + const v0 = path.join(FRONTEND, "src/components/app.jsx"); + for (const config of ["vite.config.js", "vitest.config.mjs"]) { + const { optionalPluginImports } = await import(path.join(FRONTEND, config)); + const { resolveId } = optionalPluginImports(); + assert.equal( + resolveId("./does-not-exist", v1), + null, + `${config}: v1 stubbed`, + ); + assert.notEqual( + resolveId("../plugins/does-not-exist", v0), + null, + `${config}: v0 not stubbed`, + ); + } +}); diff --git a/frontend/test-fixtures/v1-boundary/git-case.sh b/frontend/test-fixtures/v1-boundary/git-case.sh index 82fc291016..371b1664d5 100755 --- a/frontend/test-fixtures/v1-boundary/git-case.sh +++ b/frontend/test-fixtures/v1-boundary/git-case.sh @@ -17,7 +17,7 @@ for entry in "$here"/* "$here"/.gitignore; do done ln -s "$here/node_modules" "$repo/frontend/node_modules" # The repository's own rule for the cloud overlay (D7). -printf '%s\n' 'frontend/src/v1/plugins/*' '!frontend/src/v1/plugins/manifest.ts' \ +printf '%s\n' 'frontend/src/v1/plugins/*' '!frontend/src/v1/plugins/index.ts' \ >"$repo/.gitignore" cd "$repo" git init -q diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 92da3927ce..3f3fd11c56 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -29,12 +29,16 @@ const ASSET_EXTENSIONS = new Set([ // Asset imports (images, SVGs, etc.) are resolved to a module that exports // an empty string as default, so static `import logo from "..."` statements // don't break the build. -function optionalPluginImports() { +export function optionalPluginImports() { return { name: "optional-plugin-imports", resolveId(source, importer) { if (!importer) return null; + // v1 has no optional imports: an absent v1 plugin has no manifest, so a + // missing module under src/v1 is a bug and must fail the build. + if (importer.includes("/src/v1/")) return null; + // Only handle relative imports if (!source.startsWith(".")) return null; @@ -134,6 +138,30 @@ function jsxInJs() { }; } +/* + * v1 plugin check (scripts/v1-plugins.mjs): fails the build on a slot filled + * twice, a route defined twice (by two plugins, or by a plugin and an OSS + * page) or a plugin with no distribution, and publishes the plugins this + * build carries as plugin-matrix.json, tagged with its distribution. + */ +export function v1PluginMatrix({ dir, distribution = "oss" } = {}) { + let matrix; + return { + name: "v1-plugin-matrix", + async buildStart() { + const { check } = await import("./scripts/v1-plugins.mjs"); + matrix = await check(dir); + }, + generateBundle() { + this.emitFile({ + type: "asset", + fileName: "plugin-matrix.json", + source: `${JSON.stringify({ distribution, ...matrix }, null, 2)}\n`, + }); + }, + }; +} + // https://vite.dev/config/ export default defineConfig(({ mode }) => { // Load env file based on `mode` in the current working directory. @@ -165,6 +193,7 @@ export default defineConfig(({ mode }) => { }), // SVG as React component support (for `import Logo from './logo.svg?react'`) svgr(), + v1PluginMatrix({ distribution: distribution || "oss" }), ], /* diff --git a/frontend/vitest.config.mjs b/frontend/vitest.config.mjs index 5d6b2a8f0c..0333338986 100644 --- a/frontend/vitest.config.mjs +++ b/frontend/vitest.config.mjs @@ -47,11 +47,16 @@ function jsxInJs() { * reported as a failed file, not a failed assertion, and easy to read as * unrelated infrastructure noise. */ -function optionalPluginImports() { +export function optionalPluginImports() { return { name: "optional-plugin-imports", resolveId(source, importer) { - if (!importer || !source.startsWith(".")) { + // v1 has no optional imports; a missing module there must fail. + if ( + !importer || + !source.startsWith(".") || + importer.includes("/src/v1/") + ) { return null; } const sourcePath = source.split("?")[0].split("#")[0]; diff --git a/frontend/vitest.v1.config.mts b/frontend/vitest.v1.config.mts index 52e9adf06f..3410ecf184 100644 --- a/frontend/vitest.v1.config.mts +++ b/frontend/vitest.v1.config.mts @@ -31,7 +31,8 @@ type ScopeSpec = { }; const SCOPES: Record = { - // Everything under src/v1 except plugins/, which the cloud overlay fills. + // Everything under src/v1 except the plugin folders the cloud overlay + // copies in; the public loader plugins/index.ts is core. core: { root: V1_ROOT, name: "frontend-v1", @@ -40,8 +41,7 @@ const SCOPES: Record = { }, // Cloud v1 plugins only (`frontend-v1-unit-cloud`), so a cloud plugin // failure never reddens the OSS group. Coverage counts the plugin folders - // (plugins//), not OSS's placeholder plugins/manifest.ts, which no - // plugin test exercises. + // (plugins//), not the public loader plugins/index.ts, which core covers. plugins: { root: PLUGINS_ROOT, name: "frontend-v1-plugins", @@ -60,7 +60,7 @@ const COVERAGE_THRESHOLDS = { export function defineV1Config(scope: Scope) { const { root, name, reports, covers } = SCOPES[scope]; - const scopeExclude = scope === "core" ? [`${PLUGINS_ROOT}/**`] : []; + const scopeExclude = scope === "core" ? [`${PLUGINS_ROOT}/*/**`] : []; return defineConfig({ plugins: [react(), svgr()], resolve: {