diff --git a/runner/docs/guide/developers.md b/runner/docs/guide/developers.md index 28b4cc7a..47e19ae6 100644 --- a/runner/docs/guide/developers.md +++ b/runner/docs/guide/developers.md @@ -21,7 +21,7 @@ Or skip the clicking: ``` https://demos.handsontable.com/?example=javascript&v=13191 -https://demos.handsontable.com/?docs=guides/rows/row-sorting/react/example1.tsx&v=13191 +https://demos.handsontable.com/?docs=guides/rows/rows-sorting/react/exampleSortingDemo.tsx&v=13191 https://demos.handsontable.com/?example=react&v=https://pkg.pr.new/handsontable@13191 ``` @@ -52,26 +52,30 @@ majors are **15–19**; anything outside that is refused rather than half-workin Two edges worth knowing: -- **Integers below 1000 are read as a version, not a PR.** `18` means 18.0.0. Real - Handsontable PR numbers are five digits, so this only bites on toy input. +- **A bare integer is only a PR ref from 1000 up.** `15`–`19` are read as majors — + `18` means 18.0.0 — and `20`–`999` are refused outright rather than guessed at. + Real Handsontable PR numbers are five digits, so this only bites on toy input. - **The build has to exist.** If CI has not published yet, or the PR comes from a fork that cannot publish, the install fails and the preview says so. `curl -sI https://pkg.pr.new/handsontable@` answering `200` is the check. ## Where a demo runs, and why some are slow -Two tiers, and which one you get is decided by the starter: +Two engines, and which one you get is decided by the starter: -- **In-browser (tier 1)** — the blank templates, JavaScript, TypeScript, React, Vue. +- **In-browser** — the blank templates, JavaScript, TypeScript, React, Vue. Bundled in the page by Sandpack. Fast, cheap, no container, and unaffected by the monthly budget. -- **Container (tier 2)** — Angular, Next.js, Nuxt, Astro, Remix, and the UI-library +- **Container** — Angular, Next.js, Nuxt, Astro, Remix, and the UI-library starters (MUI, Ant Design, Fluent UI, Base Web). A real dev server in a Cloudflare Sandbox, with a real `install`. The first load is slow by construction, and these are - what the budget ceiling pauses. + what the budget ceiling pauses. One surprise in the list: React (Vite, JS) runs in a + container too, despite being a React starter — the instant React is the TypeScript + one. -If you are demonstrating grid behaviour rather than framework integration, pick a tier-1 -starter: it costs nothing, starts instantly, and a PR build resolves in it just as well. +If you are demonstrating grid behaviour rather than framework integration, pick an +in-browser starter: it costs nothing, starts instantly, and a PR build resolves in it +just as well. ## Publish an example from your own machine @@ -122,7 +126,8 @@ the [Everyone track](/guide/everyone); it applies to you too. ## What the runner accepts -The same limits on every path — file drop, plugin, MCP: +The same rules on every path — file drop, plugin, MCP — with the per-path differences +called out: - **Text files only.** Source (`.js .jsx .mjs .cjs .ts .tsx .vue .svelte .astro`), markup and styles (`.html .css .scss .sass .less .svg`), data and config @@ -131,8 +136,10 @@ The same limits on every path — file drop, plugin, MCP: URL, or inline a data URI. - **`.env` and `.env.*` are never accepted**, on any path, and that is deliberate rather than incidental. -- **`node_modules`, build output and lockfiles are refused, not dropped** — you find out - instead of wondering why the tree looks odd. +- **`node_modules`, build output and lockfiles never make it in**, but the two paths + say so differently. The MCP refuses the whole payload, with the reason. A file drop + skips them quietly — nobody means to drop `node_modules` — and stops at 50 files, + saying where it stopped. - **About 50 files and 256 KB of source** on the MCP path. If an example is bigger than that, it is a project: trim it to the grid, its data and its configuration. @@ -155,9 +162,10 @@ bridge back to a local reproduction: unzip, install, run. version does not have. That is a real answer to a compatibility question, not a broken demo. - **A PR build fails to install.** See above: the build is probably not published yet. -- **"Live editing is paused…"** is the monthly budget on the container tier. Tier-1 - starters keep working, and saving, forking, client links and embeds are unaffected — - they are static builds. +- **"Live editing is paused…"** is the monthly budget on the container starters. + In-browser starters keep working, and existing links and embeds are unaffected — + they are static builds. A Save or a Fork boots a build container, though, so the + top budget tiers refuse those too. - **An import was refused as not-Handsontable.** The guard wants a `handsontable` dependency, an import of it, or a CDN tag. A project that uses it only through your own wrapper package will trip this; add the dependency, or drop the files in instead. diff --git a/runner/docs/guide/devrel.md b/runner/docs/guide/devrel.md index 39baac6c..c165e245 100644 --- a/runner/docs/guide/devrel.md +++ b/runner/docs/guide/devrel.md @@ -55,8 +55,9 @@ changes and download a `.zip`, but cannot save over yours or change its version. article, that is the link for "open this in the playground" under an embed — the embed shows the result, the playground shows how. -The **client link** (`/d//`) cannot be framed at all, on any site. Use it in prose, -never in an `iframe`. +The bare demo page (`/d//`) cannot be framed on any *other* site — it answers with +`frame-ancestors 'self'`, which is exactly how `/share/` and the full-window view +display it here. Use it in prose, never in an `iframe` of your own. ## Full-window preview, for screenshots and recordings @@ -65,7 +66,7 @@ you give it. It works on any starting point, which makes it the tool for a clean screenshot or a screen recording: ``` -https://demos.handsontable.com/?docs=guides/rows/row-sorting/react/example1.tsx&v=18.0.0&mode=full +https://demos.handsontable.com/?docs=guides/rows/rows-sorting/react/exampleSortingDemo.tsx&v=18.0.0&mode=full https://demos.handsontable.com/share/ab12cd34 ``` @@ -87,8 +88,8 @@ live on an older docs branch. Any example from the guides opens here directly: ``` -https://demos.handsontable.com/?docs=guides/columns/column-adding/react/example1.tsx -https://demos.handsontable.com/?docs=…&v=17.1.0 +https://demos.handsontable.com/?docs=guides/rows/rows-sorting/react/exampleSortingDemo.tsx +https://demos.handsontable.com/?docs=guides/rows/rows-sorting/react/exampleSortingDemo.tsx&v=18.0.0 ``` Fork it, extend it into something that carries the article's story — realistic columns, diff --git a/runner/docs/guide/everyone.md b/runner/docs/guide/everyone.md index 2fa6c729..b35dad3d 100644 --- a/runner/docs/guide/everyone.md +++ b/runner/docs/guide/everyone.md @@ -112,9 +112,11 @@ Plain language is enough. What actually changes the result: a real container. - **The Handsontable version.** Say nothing and you get the current release. Name a version — "on 17.1.0" — if the conversation is about that one. -- **The description.** Claude writes one, because whoever opens the link later was - not in your conversation. Tell it what to say if the demo is going into a ticket or - a customer thread — a link back to that thread is usually the useful part. +- **The description.** Required on this route: the runner refuses a demo without + one, because whoever opens the link later was not in your conversation. (The + browser editor treats it as optional; here it is not.) Claude writes it — tell it + what to say if the demo is going into a ticket or a customer thread, because a link + back to that thread is usually the useful part. ## Open the link before you send it @@ -145,7 +147,7 @@ So open the client link and look for the grid: at startup. Paste the link back to Claude, say the grid does not render, and ask it to fix and re-save. The link stays the same, so anything you already sent starts working once it is fixed. -- **"This demo is not available" or a 404?** The build failed. Ask Claude to try again. +- **"This demo is unavailable." or a 404?** The build failed. Ask Claude to try again. Claude often **cannot open the link itself** — the sandbox it runs in is not allowed to reach demos.handsontable.com. If it says it could not verify the demo, that is not @@ -206,6 +208,9 @@ link to explain. Worth knowing: +- **It only works on demos the MCP created.** A demo you built in the browser answers + with a refusal — "this demo was not created through the MCP; edit it at `/edit/` + in the browser" — which is where you change those anyway. - **It only touches demos you created.** Somebody else's demo comes back refused, not rewritten — the same rule as the browser, where their demo opens read-only for you. - **A deleted demo cannot be updated.** Deleting revokes the link for good; there is diff --git a/runner/docs/guide/overview.md b/runner/docs/guide/overview.md index c61acfeb..d9fd06a2 100644 --- a/runner/docs/guide/overview.md +++ b/runner/docs/guide/overview.md @@ -25,8 +25,8 @@ A saved demo is reachable four ways, and picking the right one is most of what | Link | What it is | Send it to | | --- | --- | --- | -| `/d//` | the **client link** — a permanent static page of the demo alone | a customer | -| `/share/` | the **read-only playground** — the code, editable in place, not savable | someone who asked "how is it done?" | +| `/share/` | the **public client link** — the read-only playground: the running demo plus its code, editable in place, not savable | a customer, or someone who asked "how is it done?" | +| `/d//` | the **bare demo page** — the permanent static build alone, which the playground displays inside itself | a link in prose, when only the grid should show | | `/embed//` | the **docs embed** — renders only inside `handsontable.com` | an `iframe` in the docs or a blog post | | `/edit/` | the **editor** — yours to change, if you own it | nobody; it is your own bookmark | @@ -38,15 +38,15 @@ Anything you can reach by clicking, you can also link to directly: |-----|-------| | `/?example=blank` | a blank template (also `blank-ts`, `blank-react`) | | `/?example=react` | a framework starter (`javascript`, `typescript`, `vue`, `angular`, `next.js`, `nuxt`, `astro`, `remix`, `mui`, `ant-design`, `fluent-ui`, `base-web`) | -| `/?docs=guides/columns/column-adding/react/example1.tsx` | a documentation example | +| `/?docs=guides/rows/rows-sorting/react/exampleSortingDemo.tsx` | a documentation example | | `/?v=17.1.0` | the same page at a chosen version (combines with the above) | | `/?v=13191` | the same page built from pull request 13191 | | `/?import=` | the import flow for a JSFiddle or StackBlitz URL | | `/?payload=` | a project handed over from the Theme Builder (24 hours) | | `/?mode=full` | the preview alone, no editor chrome | | `/edit/` | your saved demo, editable | -| `/share/` | the read-only playground for a demo | -| `/d//` | the built client page | +| `/share/` | the read-only playground for a demo — the public client link | +| `/d//` | the bare static page a demo builds to | | `/embed//` | the docs embed | | `/my-demos`, `/all-demos` | your demos; everyone's (`?owner=` filters) | | `/settings`, `/guide` | your profile; this guide | @@ -60,9 +60,9 @@ The same four rules whichever route you take: `/share/`. Regenerate the data first, and say in the description that you did. - **Never a licence key, a token, or a `.env` file.** The runner refuses `.env*` outright, but that is a backstop, not your check. -- **Handsontable demos only.** Imports and file drops with no Handsontable in them - are refused by design; this playground is not general hosting. An application with - a backend, a login or its own storage belongs behind the `publish-app` flow instead. +- **Handsontable demos only.** An import with no Handsontable in it is refused by + design; this playground is not general hosting. An application with a backend, a + login or its own storage belongs behind the `publish-app` flow instead. - **Pin the version that matters.** A demo runs at one Handsontable version. If you are reproducing a bug, pin the version it was reported against — that is the whole point of the demo. @@ -78,12 +78,14 @@ back is the fix. **"Live editing is paused until the monthly budget resets…"** The live containers and the AI features cost money per use, so they stop when the month's ceiling is reached -rather than running up a bill. Saved demos, client links and embeds are unaffected — -they are static builds, and saving or forking still works. The in-browser examples -(the blank templates, JavaScript, TypeScript, React, Vue) keep working too. What -pauses is everything that runs in a container: Angular, Next, Nuxt, Astro and Remix, -and also the UI-library starters — MUI, Ant Design, Fluent UI, Base Web — which look -instant but are running a real dev server. +rather than running up a bill. Reading is unaffected: saved demos, their links and +embeds are static builds and keep serving. The in-browser examples (the blank +templates, JavaScript, TypeScript, React, Vue) keep working too. What pauses is +everything that runs in a container: Angular, Next, Nuxt, Astro and Remix, and also +the UI-library starters — MUI, Ant Design, Fluent UI, Base Web — which look instant +but are running a real dev server. And because a Save or a Fork builds in a container +too, the top budget tiers refuse those as well: existing links keep working, new ones +wait for the reset. **"This example is unavailable for Handsontable *x.y.z*."** The example does not exist for the version you picked. Documentation examples travel with their version: diff --git a/runner/docs/guide/support.md b/runner/docs/guide/support.md index 2df704db..8197d565 100644 --- a/runner/docs/guide/support.md +++ b/runner/docs/guide/support.md @@ -61,7 +61,9 @@ Signed in, Files also lets you: in; drop on a folder row to go inside it. Existing paths ask before they are replaced. - **Rename or delete** a file from the row's own controls. -- **Download** the whole workspace, including your edits, as a `.zip`. + +**Download** needs no sign-in at all: the button in the top bar hands anyone the whole +workspace, including unsaved edits, as a `.zip`. Two things the drop will refuse, on purpose. **Binaries** — images, fonts, videos, archives — because a demo's files are text all the way through to the build, so there @@ -111,7 +113,7 @@ the demo's own URL is the one to keep. **All demos** lists everything the team has published, and the **Owner** dropdown filters it to one person — the filter is in the URL -(`/all-demos?owner=marek-martuszewski`), so you can paste the filtered view to +(`/all-demos?owner=marek.martuszewski`), so you can paste the filtered view to somebody. ![All demos with the Owner dropdown open, listing each teammate and their demo count](/guide/all-demos-owner-filter.jpg) @@ -120,7 +122,7 @@ somebody. - **Open** takes you to the read-only playground: read the code, try changes, download a `.zip`. -- **Copy link** gives you the client link, ready to send. +- **Copy link** gives you the public client link (`/share/`), ready to send. - **Fork** gives you your own editable copy. Rename, save and delete stay with the owner, and an `/edit/` link to a demo that is @@ -179,10 +181,11 @@ to send. - **Save** (on a demo you own) writes your edits and rebuilds the shared page. - **Fork** takes whatever is open — a starter, a docs example, someone else's demo, an import — and makes it a new demo owned by you. -- **Share** gives you the links. For a customer, that is the **client link** - (`/d//`): a permanent static page, safe to send, and it costs nothing to keep - online because it is built once rather than run live. If they want to read the code, - send `/share/` instead. +- **Share** gives you the links. For a customer, copy the **Public client link** + (`/share/`): the read-only playground, safe to send — they see the running demo + and can read the code, but cannot save over yours. The dialog's other fields are + the full-window view (`/share/?mode=full`) and the docs embed. The bare static + page behind them all is `/d//`, which the playground displays inside itself. ![The Share this demo dialog listing the client link, the full-window link and the docs embed URL](/guide/share-dialog.jpg) diff --git a/runner/pipeline/guide-tracks.test.mjs b/runner/pipeline/guide-tracks.test.mjs index 4a5fd190..5be6df66 100644 --- a/runner/pipeline/guide-tracks.test.mjs +++ b/runner/pipeline/guide-tracks.test.mjs @@ -1,10 +1,13 @@ -// The guide's routing and anchor slugs (DEV-2522). +// The guide's routing and anchor slugs (DEV-2522), and its parity with the +// product it describes (DEV-2203). // -// Two things are worth pinning here. Routing, because a stale `/guide/` -// link must land on the overview rather than a blank page. And the anchors, because +// Three things are worth pinning here. Routing, because a stale `/guide/` +// link must land on the overview rather than a blank page. The anchors, because // the page's contents list and the rendered headings get their ids from the same // function by position — if that ordering or the de-duplication drifts, every -// deeplink in the guide silently scrolls to the wrong section. +// deeplink in the guide silently scrolls to the wrong section. And the facts, +// because the guide prints URLs, limits and starter names that live in the +// product — a number that drifts from its constant is a lie with a byline. import test from "node:test"; import assert from "node:assert/strict"; @@ -23,10 +26,29 @@ import { isGuideTrackSlug, parseGuideRoute, } from "../apps/authoring/src/guideTracks.ts"; +import { MAX_MCP_BYTES, MAX_MCP_FILES } from "../workers/api/src/mcp-create.ts"; +import { MAX_DESCRIPTION } from "../workers/api/src/demo-info.ts"; +// Built output, the way version.test.mjs imports it: version.ts pulls in `semver` +// via `./types.js` specifiers that --experimental-strip-types cannot resolve. +import { DEFAULT_MAX_MAJOR, DEFAULT_MIN_MAJOR } from "../packages/runtime/dist/version.js"; const here = path.dirname(fileURLToPath(import.meta.url)); const docs = path.join(here, "../docs/guide"); +/** Every track plus the overview, read once — the parity tests below scan them all. */ +const DOC_NAMES = [...GUIDE_TRACKS.map((t) => t.slug), "overview"]; +const readDoc = (name) => fs.readFileSync(path.join(docs, `${name}.md`), "utf8"); + +/** A constant that is not exported (or lives in a module the test runner cannot + * import, like the worker entry) is read out of the source instead — the + * arrangement theme-tokens.test.mjs uses for its generated-file guards. */ +function sourceConst(relPath, name) { + const source = fs.readFileSync(path.join(here, relPath), "utf8"); + const m = source.match(new RegExp(`const ${name} = ([^;]+);`)); + assert.ok(m, `${relPath} no longer declares ${name} — update the guide and this test`); + return m[1]; +} + test("the four tracks are the four routes, least technical first", () => { assert.deepEqual( GUIDE_TRACKS.map((t) => t.slug), @@ -222,13 +244,167 @@ test("no HTML entities in the guide's markdown", () => { // The renderer prints text verbatim — it builds React elements and never touches // innerHTML, which is what makes it safe for model output. So `—` reaches the // reader as "—". Write the character. - for (const name of [...GUIDE_TRACKS.map((t) => t.slug), "overview"]) { - const md = fs.readFileSync(path.join(docs, `${name}.md`), "utf8"); + for (const name of DOC_NAMES) { + const md = readDoc(name); const found = [...md.matchAll(/&[a-zA-Z]+;/g)].map((m) => m[0]); assert.deepEqual(found, [], `${name}.md contains HTML entities: ${found.join(", ")}`); } }); +// ---- Parity with the product (DEV-2203) -------------------------------------- +// +// The guide asserts facts about the runner: which URLs open, which limits apply, +// which starters run where. Those facts live in the product's own files, so the +// guide is tested against them — a reader pasting an example URL out of the guide +// must land on a page that exists. + +test("every ?docs= URL the guide prints exists in the release bucket", () => { + // The bucket the guide's unversioned URLs resolve to: the current release line. + const manifest = JSON.parse( + fs.readFileSync( + path.join(here, "../apps/authoring/public/docs-examples/18.0/manifest.json"), + "utf8", + ), + ); + const known = new Set(manifest.examples.map((e) => e.docsPath)); + + let seen = 0; + for (const name of DOC_NAMES) { + // The path ends at the next query parameter (`&v=`, `&mode=`), closing + // backtick (the URL tables), pipe, or whitespace (the code fences). + for (const m of readDoc(name).matchAll(/[?&]docs=([^&\s)`|]+)/g)) { + seen += 1; + const docsPath = decodeURIComponent(m[1]); + assert.ok( + known.has(docsPath), + `${name}.md prints ?docs=${docsPath}, which is not in the 18.0 bucket`, + ); + } + } + // The guide leans on ?docs= URLs as the way in — them all vanishing would mean + // the extraction regex broke, not that the guide went quiet. + assert.ok(seen >= 4, `expected the guide to print ?docs= URLs, found ${seen}`); +}); + +test("the guide's numbers are the product's numbers", () => { + const all = DOC_NAMES.map(readDoc).join("\n"); + + // The MCP caps (everyone.md's "what it will not do", developers.md's limits). + assert.match(all, new RegExp(`\\b${MAX_MCP_FILES} (?:text )?files\\b`)); + assert.match(all, new RegExp(`\\b${MAX_MCP_BYTES / 1024} KB\\b`)); + + // The drop ceiling is a different constant than the MCP's (Bugbot, #188): + // developers.md says the file drop "stops at N files", and that N is + // dropFiles.ts's own MAX_DROP_FILES — the two ceilings can diverge. + const maxDrop = Number(sourceConst("../packages/editor-shell/src/dropFiles.ts", "MAX_DROP_FILES")); + assert.match(all, new RegExp(`stops at ${maxDrop} files`)); + + // The description field's ceiling (support.md's title-and-description section). + assert.match(all, new RegExp(`\\b${MAX_DESCRIPTION.toLocaleString("en-US")} characters\\b`)); + + // The version range and the bare-integer rule (developers.md). The guide commits + // to the exact split: majors read as versions, the refused gap, the PR-ref floor. + const minBare = Number(sourceConst("../packages/runtime/src/version.ts", "MIN_BARE_NUMERIC_PKG_PR_NEW_REF")); + assert.match(all, new RegExp(`\\*\\*${DEFAULT_MIN_MAJOR}–${DEFAULT_MAX_MAJOR}\\*\\*`)); + assert.match(all, new RegExp(`≥ ${minBare}\\b`)); + assert.match(all, new RegExp(`from ${minBare} up`)); + assert.match(all, new RegExp(`\`${DEFAULT_MAX_MAJOR + 1}\`–\`${minBare - 1}\``)); + + // The Theme Builder handover TTL (overview.md's URL table, support.md). The worker + // entry cannot be imported here, so the declaration is pinned instead: if the TTL + // stops being 24 hours, this fails and the guide gets rewritten with it. + assert.equal(sourceConst("../workers/api/src/index.ts", "PAYLOAD_TTL_SECONDS"), "24 * 60 * 60"); + assert.match(all, /\b24 hours\b/); +}); + +test("the guide's starter keys match the catalog", () => { + const catalog = JSON.parse(fs.readFileSync(path.join(here, "../catalog.json"), "utf8")); + const keys = new Set(catalog.examples.map((e) => e.framework)); + + let seen = 0; + for (const name of DOC_NAMES) { + const md = readDoc(name); + // Every ?example= the guide prints must open something. + for (const m of md.matchAll(/[?&]example=([A-Za-z0-9.-]+)/g)) { + seen += 1; + assert.ok(keys.has(m[1]), `${name}.md prints ?example=${m[1]}, which is not a catalog key`); + } + // The URL table lists alternates as bare code spans next to the ?example= rows + // ("also `blank-ts`, `blank-react`") — those are keys too, and rename with them. + for (const line of md.split("\n")) { + if (!line.includes("?example=")) continue; + for (const m of line.matchAll(/`([a-z][a-z0-9.-]*)`/g)) { + seen += 1; + assert.ok(keys.has(m[1]), `${name}.md lists \`${m[1]}\` as a starter, which is not a catalog key`); + } + } + } + assert.ok(seen >= 5, `expected the guide to name starters, found ${seen}`); +}); + +test("the guide's container claims match the catalog engines", () => { + const catalog = JSON.parse(fs.readFileSync(path.join(here, "../catalog.json"), "utf8")); + const byKey = new Map(catalog.examples.map((e) => [e.framework, e])); + + // The names the prose uses, mapped to catalog keys — the display names carry + // qualifiers ("React (Vite, JS)") that the slugs do not. + const NAME_TO_KEY = [ + ["Angular", "angular"], + ["Next.js", "next.js"], + ["Nuxt", "nuxt"], + ["Astro", "astro"], + ["Remix", "remix"], + ["MUI", "mui"], + ["Ant Design", "ant-design"], + ["Fluent UI", "fluent-ui"], + ["Base Web", "base-web"], + ["React (Vite, JS)", "react-js"], + ]; + const IN_BROWSER_NAMES = [ + ["JavaScript", "javascript"], + ["TypeScript", "typescript"], + ["React", "react"], + ["Vue", "vue"], + ]; + + const md = readDoc("developers"); + const start = md.indexOf("## Where a demo runs"); + assert.ok(start >= 0, "developers.md lost its 'Where a demo runs' section"); + const nextHeading = md.indexOf("\n## ", start + 1); + const section = md.slice(start, nextHeading === -1 ? undefined : nextHeading); + + // The catalog's `tier` field does not track the engine split (the UI-library + // starters are tier 1 *and* engine "container"), which is exactly how the guide + // once drifted. The section speaks in engines; tiers stay out of it. + assert.ok(!/tier[- ]?\d/i.test(section), "developers.md claims catalog tiers; speak in engines"); + + const split = section.indexOf("**Container**"); + assert.ok(split >= 0, "developers.md lost its container bullet"); + const inBrowserPart = section.slice(0, split); + const containerPart = section.slice(split); + + let found = 0; + for (const [displayName, key] of NAME_TO_KEY) { + if (!containerPart.includes(displayName)) continue; + found += 1; + assert.equal( + byKey.get(key)?.engine, + "container", + `developers.md lists ${displayName} as a container starter, but catalog.json says engine=${byKey.get(key)?.engine}`, + ); + } + assert.ok(found >= 9, `expected the container list to name the container starters, found ${found}`); + + for (const [displayName, key] of IN_BROWSER_NAMES) { + assert.ok(inBrowserPart.includes(displayName), `the in-browser bullet lost ${displayName}`); + assert.equal( + byKey.get(key)?.engine, + "sandpack", + `developers.md lists ${displayName} as in-browser, but catalog.json says engine=${byKey.get(key)?.engine}`, + ); + } +}); + test("every MCP prompt in the guide names the tool it needs", () => { // Claude fetches tools on demand: a prompt that does not start with `Load // create_demo` / `Load update_demo` is the one that comes back "I do not have that