diff --git a/runner/pipeline/demo-routes-version.test.mjs b/runner/pipeline/demo-routes-version.test.mjs new file mode 100644 index 00000000..638a98ff --- /dev/null +++ b/runner/pipeline/demo-routes-version.test.mjs @@ -0,0 +1,279 @@ +// Route-level proof of Handsontable version resolution on the browser share +// routes (DEV-2565): POST /api/demos and PATCH /api/demos/:id, driven through +// the REAL router — the default export of workers/api/src/index.ts. The bug +// class: both create routes used to store `body.htVersion ?? "latest"` +// verbatim, and that dist-tag sentinel — a string the validator rejects — +// broke /edit (boot refusal) and Save (a bare PR ref reached pnpm as a +// registry range). The fix derives a concrete ref (payload pin → tag → +// previous row → catalog) and pins the files server-side; until this spec, +// only the resolver had tests — no spec proved these two handlers wire it. +// +// Bindings are the shared in-memory fakes from fixtures/worker-harness.mjs. +// Two stubs specific to this file: +// - the login broker: `authenticate()` live-fetches LOGIN_BROKER_URL, so a +// global fetch stub answers "Bearer test-token" with the team identity — +// the real production auth path, not the DEV_AUTH_EMAIL loopback bypass; +// - the same stub THROWS on every other URL, doubling as a no-network +// tripwire: an un-seeded fallthrough to npm surfaces as a 502 (inside +// fetchVersionCatalog) instead of silently passing against live `latest`. +// +// Build prerequisite: `npm --prefix packages/runtime run build` — the worker +// imports @handsontable/demo-runtime from dist/, and a stale dist fails the +// whole file with ERR_MODULE_NOT_FOUND (the root `npm test` script builds it). +// +// Run: node --experimental-strip-types --test pipeline/*.test.mjs + +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import { register } from "node:module"; +// The harness imports nothing from the worker's source tree, so its (hoisted) +// evaluation before register() below is safe — see its own header comment. +import { + AUTHOR, + ctx, + demoRow, + makeEnv, + seedCatalog, + sourceSnapshot, +} from "./fixtures/worker-harness.mjs"; + +register("./fixtures/worker-hooks.mjs", import.meta.url); + +const { default: worker } = await import("../workers/api/src/index.ts"); + +// ---- the broker stub / network tripwire ---------------------------------------- + +/** Captured once, before the stub is installed. */ +const REAL_FETCH = globalThis.fetch; + +globalThis.fetch = async (input, init) => { + const url = typeof input === "string" ? input : input.url; + // authenticate() forwards the caller's Authorization header to the broker + // and trusts only the returned email — answer exactly that exchange. + if (url.startsWith("https://login.invalid") && init?.headers?.Authorization === "Bearer test-token") { + return Response.json({ email: AUTHOR, sub: "u1" }); + } + // Anything else is a test escaping its sandbox. A throw inside + // authenticate's try/catch reads as 401, inside fetchVersionCatalog as 502 — + // both fail the asserting test loudly instead of reaching a live registry. + throw new Error(`unexpected network fetch in demo-routes-version.test.mjs: ${url}`); +}; + +// node --test runs each file in its own process, so nothing leaks either way — +// restored anyway, out of hygiene. +after(() => { + globalThis.fetch = REAL_FETCH; +}); + +// ---- fixtures ------------------------------------------------------------------ + +const PR_URL = "https://pkg.pr.new/handsontable@13106"; + +/** + * A minimal workspace whose /package.json pins Handsontable to `dep`. Kept to + * two tiny files so the pin's re-serialisation can never trip a size cap and + * turn a version test into something else. + */ +const filesWith = (dep) => ({ + "/package.json": JSON.stringify({ name: "demo", dependencies: { handsontable: dep } }), + "/index.js": "console.log(1)", +}); + +/** A workspace with no Handsontable dependency at all — nothing to derive. */ +const FILES_NO_DEP = { "/package.json": '{"name":"demo"}', "/index.js": "console.log(1)" }; + +const authHeaders = { + "Content-Type": "application/json", + Authorization: "Bearer test-token", +}; + +const createRequest = (body) => + new Request("https://demos.handsontable.com/api/demos", { + method: "POST", + headers: authHeaders, + body: JSON.stringify(body), + }); + +const patchRequest = (id, body) => + new Request(`https://demos.handsontable.com/api/demos/${id}`, { + method: "PATCH", + headers: authHeaders, + body: JSON.stringify(body), + }); + +/** The parsed handsontable dependency the stored source snapshot pins. */ +const snapshotDep = (artifacts, id) => + JSON.parse(sourceSnapshot(artifacts, id).files["/package.json"]).dependencies.handsontable; + +/** The rebuild handler's D1 oracle: updateDemo's column-by-column UPDATE. + * parseDemosInsert only reads INSERTs, so the write log is read directly — + * bind order is ht_version, files_hash, updated_at, ..., id (share.ts). */ +function findVersionUpdate(writes) { + return writes.find((w) => /UPDATE demos SET ht_version=/.test(w.sql)); +} + +// ---- POST /api/demos ------------------------------------------------------------- + +test("a browser create derives the version from the payload's own pin when htVersion is absent", async () => { + // The editor's Save sends only files for a demo whose package.json already + // pins a release; the route must derive that pin, never default to the + // "latest" sentinel (the DEV-2565 bug) or resolve npm behind the caller's + // back. The catalog is deliberately NOT seeded: with the throwing fetch + // stub, any registry fallthrough fails as 502, proving derivation was local. + const { env, demos, artifacts } = makeEnv(); + const res = await worker.fetch( + createRequest({ framework: "react", title: "Grid", files: filesWith("16.0.2") }), + env, + ctx, + ); + assert.equal(res.status, 201); + const body = await res.json(); + assert.equal(body.htVersion, "16.0.2"); + assert.equal(demos.get(body.id).ht_version, "16.0.2", "the derived ref is what the row stores"); + // The stored snapshot is the pin's only artifact-side observable (built + // output never exists under the fake build-cache hit). + assert.equal(snapshotDep(artifacts, body.id), "16.0.2"); +}); + +test("a browser create resolves an explicit 'latest' to the catalog's concrete release", async () => { + // "latest" names a moving target; the column has to hold a ref the editor + // can validate, so the tag must leave as a release. '16.2.0' exists only in + // the seeded catalog — an exact-equality assertion on it cannot be satisfied + // by a live registry, unlike a /\d+\.\d+\.\d+/ match. + const { env, demos, artifacts } = makeEnv(); + await seedCatalog(env, "16.2.0"); + const res = await worker.fetch( + // The dep is the 'latest' range too, so handsontableDependencyRef yields + // null and the tag is genuinely what answers. + createRequest({ framework: "react", title: "Grid", htVersion: "latest", files: filesWith("latest") }), + env, + ctx, + ); + assert.equal(res.status, 201); + const body = await res.json(); + assert.equal(body.htVersion, "16.2.0"); + assert.equal(demos.get(body.id).ht_version, "16.2.0", "never the sentinel"); + assert.equal(snapshotDep(artifacts, body.id), "16.2.0", "the 'latest' range was rewritten to the release"); +}); + +test("a browser create refuses an invalid explicit ref with the validator's message and writes nothing", async () => { + // The 400 carries the shared validator's own message and lands before the + // budget gate, the usage event, and createDemo — a bad ref costs nothing. + // Empty write log = no usage event either; deepEqual([]) is the right oracle + // on refusal paths only (success paths log usage/build_cache writes too). + const { env, writes, artifacts } = makeEnv(); + const res = await worker.fetch( + createRequest({ framework: "react", title: "Grid", htVersion: "not-a-version", files: filesWith("16.0.2") }), + env, + ctx, + ); + assert.equal(res.status, 400); + assert.match((await res.json()).error, /semver-valid or a pkg\.pr\.new id\/URL/); + assert.deepEqual(writes, [], "no D1 write may happen for a refused create"); + assert.deepEqual(artifacts.puts, [], "no artifact may be stored for a refused create"); +}); + +test("a browser create is a fixed point for files already pinned to a pkg.pr.new build", async () => { + // A caller pinning a PR build asks for that build; re-pinning it to npm + // latest would rebuild the demo against a different core — worse than the + // bug being fixed. Stored: the BARE ref (what the validator accepts), while + // the snapshot keeps the exact URL. Compared as the parsed dependency value: + // the pin re-serialises package.json (2-space indent + newline), so byte + // equality is a cannot-pass, and "some URL present" a cannot-fail. + const { env, demos, artifacts } = makeEnv(); + const res = await worker.fetch( + createRequest({ framework: "react", title: "Grid", files: filesWith(PR_URL) }), + env, + ctx, + ); + assert.equal(res.status, 201); + const body = await res.json(); + assert.equal(body.htVersion, "13106", "the bare ref, never the URL"); + assert.equal(demos.get(body.id).ht_version, "13106"); + assert.equal(snapshotDep(artifacts, body.id), PR_URL, "the submitted URL survives the re-pin exactly"); +}); + +// ---- PATCH /api/demos/:id (rebuild branch) --------------------------------------- + +test("a browser rebuild derives from the payload pin and replaces a stale sentinel row", async () => { + // The self-repair path: a row saved before DEV-2565 holds the "latest" + // sentinel, and the owner's next Save must move it onto the ref the files + // actually pin — not re-store the sentinel, not consult npm (no catalog is + // seeded; a fallthrough would 502 on the throwing stub). + const { env, writes, artifacts } = makeEnv([demoRow({ ht_version: "latest" })]); + const res = await worker.fetch(patchRequest("abc123", { files: filesWith("16.0.2") }), env, ctx); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { ok: true, htVersion: "16.0.2" }); + const update = findVersionUpdate(writes); + assert.ok(update, "the rebuild must update the demos row"); + assert.equal(update.binds[0], "16.0.2", "the sentinel row is repaired to the derived ref"); + assert.equal(update.binds.at(-1), "abc123"); + assert.equal(snapshotDep(artifacts, "abc123"), "16.0.2"); +}); + +test("a browser rebuild resolves an explicit 'latest' through the catalog when the files pin nothing", async () => { + // On the browser path a dist-tag is demoted below the payload's own pin + // (MyDemos' fork forwards legacy sentinels) — so here the files carry only + // the 'latest' range, and the demoted tag is what genuinely answers. The + // inverse wiring — tag OUTRANKING a pin — is the MCP path's contract, + // asserted in mcp-routes.test.mjs. + const { env, writes, artifacts } = makeEnv([demoRow({ ht_version: "latest" })]); + await seedCatalog(env, "16.2.0"); + const res = await worker.fetch( + patchRequest("abc123", { htVersion: "latest", files: filesWith("latest") }), + env, + ctx, + ); + assert.equal(res.status, 200); + assert.equal((await res.json()).htVersion, "16.2.0"); + const update = findVersionUpdate(writes); + assert.ok(update, "the rebuild must update the demos row"); + assert.equal(update.binds[0], "16.2.0", "changed off the seeded sentinel"); + assert.equal(snapshotDep(artifacts, "abc123"), "16.2.0"); +}); + +test("a browser rebuild refuses an invalid explicit ref and leaves the demo untouched", async () => { + // getDemo's SELECT is a read (first(), never logged), so an empty write log + // really does mean the demo was left alone — no UPDATE, no usage event, no + // artifacts, and the 400 is the validator's own message. + const { env, writes, artifacts } = makeEnv([demoRow({ ht_version: "latest" })]); + const res = await worker.fetch( + patchRequest("abc123", { htVersion: "not-a-version", files: filesWith("16.0.2") }), + env, + ctx, + ); + assert.equal(res.status, 400); + assert.match((await res.json()).error, /semver-valid or a pkg\.pr\.new id\/URL/); + assert.deepEqual(writes, [], "a refused rebuild must not write"); + assert.deepEqual(artifacts.puts, [], "a refused rebuild must not store artifacts"); +}); + +test("a browser rebuild is a fixed point for already-pinned pkg.pr.new files and stores the bare ref", async () => { + // Same promise as the create fixed point, on the path that broke in + // production: Save re-sends the loaded files verbatim, so the server's + // re-pin must not clobber the PR URL, and the row must move off the + // sentinel onto the bare ref (URL in the column = the next /edit refusal). + const { env, writes, artifacts } = makeEnv([demoRow({ ht_version: "latest" })]); + const res = await worker.fetch(patchRequest("abc123", { files: filesWith(PR_URL) }), env, ctx); + assert.equal(res.status, 200); + assert.equal((await res.json()).htVersion, "13106"); + const update = findVersionUpdate(writes); + assert.ok(update, "the rebuild must update the demos row"); + assert.equal(update.binds[0], "13106", "bare ref stored, sentinel gone"); + assert.equal(snapshotDep(artifacts, "abc123"), PR_URL, "the pinned URL survives the re-pin exactly"); +}); + +test("a browser rebuild falls back to the row's previous concrete ref when the payload is silent", async () => { + // Route-only wiring: `previousRef: row.ht_version` at the PATCH handler. The + // files pin nothing and no htVersion is sent, so the row's own ref is all + // that answers. If the route ever dropped previousRef, resolution would fall + // through to npm latest — and with no catalog seeded, that regression fails + // here as a 502 on the throwing stub instead of passing by luck. + const { env, writes } = makeEnv([demoRow({ ht_version: "16.0.2" })]); + const res = await worker.fetch(patchRequest("abc123", { files: FILES_NO_DEP }), env, ctx); + assert.equal(res.status, 200); + assert.equal((await res.json()).htVersion, "16.0.2"); + const update = findVersionUpdate(writes); + assert.ok(update, "the rebuild must update the demos row"); + assert.equal(update.binds[0], "16.0.2", "the demo stays on the core it was built against"); +}); diff --git a/runner/pipeline/fixtures/worker-harness.mjs b/runner/pipeline/fixtures/worker-harness.mjs new file mode 100644 index 00000000..bb85531e --- /dev/null +++ b/runner/pipeline/fixtures/worker-harness.mjs @@ -0,0 +1,187 @@ +// Shared in-memory bindings for route-level worker specs — extracted from +// pipeline/mcp-routes.test.mjs when demo-routes-version.test.mjs (DEV-2565) +// needed the same fakes for the browser routes. Imports nothing from the +// worker's source tree, so a spec may load it before `module.register()` +// installs the .js→.ts hooks; `node --test` runs each spec file in its own +// process, so state never crosses files. +// +// The fakes cover exactly what the share routes touch: D1 (with a recorded +// write log and a live `demos` map), KV, and R2 (with recorded {key, value} +// puts — the value is the only observable of the server-side version pin, via +// the `demos//__source.json` snapshot). The build_cache read always hits, +// steering createDemo()/updateDemo() through their cached-artifact branch so +// no route under test ever asks for a container. + +import assert from "node:assert/strict"; + +/** + * Rebuild the row a `INSERT OR REPLACE INTO demos (...) VALUES (...)` wrote: + * zip the column list with the placeholders, `?` consuming a bind and a bare + * literal (the hardcoded `revoked` 0) standing as itself. + */ +export function parseDemosInsert(sql, binds) { + const m = /INSERT OR REPLACE INTO demos\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)/s.exec(sql); + if (!m) return null; + const cols = m[1].split(",").map((s) => s.trim()); + const placeholders = m[2].split(",").map((s) => s.trim()); + let next = 0; + const row = {}; + cols.forEach((col, i) => { + row[col] = placeholders[i] === "?" ? binds[next++] : Number(placeholders[i]); + }); + return row; +} + +/** + * D1 fake: seeded demo rows, a recorded write log, and a build_cache that + * always hits so createDemo() takes its cached-artifact branch. Unmatched + * reads answer empty, which the budget code treats as "no spend yet". + */ +export function fakeD1(seedRows = []) { + const writes = []; + const demos = new Map(seedRows.map((row) => [row.id, row])); + const prepare = (sql) => { + const bound = (binds) => ({ + async first() { + if (/FROM demos WHERE id = \?/.test(sql)) return demos.get(binds[0]) ?? null; + if (/FROM build_cache/.test(sql)) return { r2_prefix: "demos/_prior-identical-build/" }; + return null; + }, + async run() { + writes.push({ sql, binds }); + const inserted = parseDemosInsert(sql, binds); + if (inserted) demos.set(inserted.id, inserted); + return { success: true, meta: {} }; + }, + async all() { + return { success: true, results: [] }; + }, + }); + return { bind: (...binds) => bound(binds), ...bound([]) }; + }; + return { db: { prepare }, writes, demos }; +} + +/** + * KV fake — a Map, ignoring TTL options (nothing under test outlives one). + */ +export function fakeKV() { + const store = new Map(); + return { + async get(key, type) { + const value = store.get(key); + if (value === undefined) return null; + return type === "json" ? JSON.parse(value) : value; + }, + async put(key, value) { + store.set(key, String(value)); + }, + async delete(key) { + store.delete(key); + }, + }; +} + +/** + * R2 fake recording every put as {key, value}. The value matters: the version + * pin's only artifact-side observable is the `__source.json` snapshot body — + * built output never exists here (the build-cache branch copies an empty + * listing), so asserting on put *keys* alone could not tell a pinned file map + * from the raw submitted one. + */ +export function fakeR2() { + const puts = []; + return { + puts, + async put(key, value) { + puts.push({ key, value }); + }, + async get() { + return null; + }, + async list() { + return { objects: [] }; + }, + }; +} + +export const SECRET = "test-secret"; +export const AUTHOR = "dev@handsontable.com"; + +/** + * A worker env wired to fresh fakes. Extra keys (e.g. DEV_AUTH_EMAIL) can be + * layered by the caller; none are set here so the broker path stays the one + * under test on the browser routes. + */ +export function makeEnv(seedRows = []) { + const { db, writes, demos } = fakeD1(seedRows); + const artifacts = fakeR2(); + const env = { + Sandbox: {}, + SANDBOX_BUILDER: {}, + DB: db, + CACHE: fakeKV(), + ARTIFACTS: artifacts, + MCP_SHARED_SECRET: SECRET, + LOGIN_BROKER_URL: "https://login.invalid", + EMBED_ALLOWED_ANCESTORS: "https://handsontable.com", + ERROR_REPORTING_DSN: "", + CF_VERSION_METADATA: { id: "test", tag: "test" }, + // Not the production host, so the Sentry gate in index.ts stays inert. + PREVIEW_HOST: "localhost:8787", + }; + return { env, writes, demos, artifacts }; +} + +export const ctx = { + waitUntil(promise) { + Promise.resolve(promise).catch(() => {}); + }, + passThroughOnException() {}, +}; + +/** + * A stored demo row as D1 would return it (see DemoRow in share.ts). + */ +export const demoRow = (overrides = {}) => ({ + id: "abc123", + title: "A demo", + description: "words", + framework: "react", + tier: 1, + ht_version: "latest", + files_hash: "hash", + r2_prefix: "demos/abc123/", + forked_from: "mcp:react", + visibility: "unlisted", + revoked: 0, + created_by: AUTHOR, + created_at: "2026-08-17T00:00:00.000Z", + updated_at: "2026-08-17T00:00:00.000Z", + revoked_at: null, + ...overrides, +}); + +/** + * Seed the KV version catalog (ht-version.ts CATALOG_KEY) so a dist-tag + * resolves without touching npm. "16.2.0" is deliberately not any real + * `latest`: an assertion on it can only pass through this seeded document, + * never by a live registry fetch answering the same thing. The guard at + * fetchVersionCatalog requires a truthy `.latest` and the validator caps + * majors at 15–19; this document satisfies both. + */ +export async function seedCatalog(env, latest = "16.2.0") { + await env.CACHE.put("versions", JSON.stringify({ latest, next: null, versions: [latest] })); + return latest; +} + +/** + * The parsed `demos//__source.json` snapshot a route stored — the single + * observable of the server-side pin (share.ts writes it on both create and + * update). Fails loudly when the route stored none. + */ +export function sourceSnapshot(artifacts, id) { + const put = artifacts.puts.find((p) => p.key === `demos/${id}/__source.json`); + assert.ok(put, `expected an R2 put of demos/${id}/__source.json`); + return JSON.parse(put.value); +} diff --git a/runner/pipeline/mcp-routes.test.mjs b/runner/pipeline/mcp-routes.test.mjs index 6f960f2b..78fd169b 100644 --- a/runner/pipeline/mcp-routes.test.mjs +++ b/runner/pipeline/mcp-routes.test.mjs @@ -7,7 +7,8 @@ // The worker loads under plain `node --test` via the module hooks in // fixtures/worker-hooks.mjs (registered below, before the import). Bindings are // in-memory fakes covering exactly what these two routes touch: D1 (with a -// recorded write log), KV, and R2. No route under test may reach a container — +// recorded write log), KV, and R2 — shared with demo-routes-version.test.mjs +// via fixtures/worker-harness.mjs. No route under test may reach a container — // the create path is steered through createDemo()'s build-cache-hit branch // (itself production code) by a fake build_cache row, and the sandbox stub // throws if anything asks for a container anyway. @@ -17,6 +18,17 @@ import test from "node:test"; import assert from "node:assert/strict"; import { register } from "node:module"; +// The harness imports nothing from the worker's source tree, so its (hoisted) +// evaluation before register() below is safe — see its own header comment. +import { + AUTHOR, + SECRET, + ctx, + demoRow, + makeEnv, + seedCatalog, + sourceSnapshot, +} from "./fixtures/worker-harness.mjs"; // register() is synchronous by contract: it blocks until the hooks module's // initialize has completed and returns void — nothing to await (node:module @@ -27,116 +39,15 @@ register("./fixtures/worker-hooks.mjs", import.meta.url); const { default: worker } = await import("../workers/api/src/index.ts"); const { demoListQuery } = await import("../workers/api/src/demos-list.ts"); -// ---- in-memory bindings ------------------------------------------------------ - -/** Rebuild the row a `INSERT OR REPLACE INTO demos (...) VALUES (...)` wrote: - * zip the column list with the placeholders, `?` consuming a bind and a bare - * literal (the hardcoded `revoked` 0) standing as itself. */ -function parseDemosInsert(sql, binds) { - const m = /INSERT OR REPLACE INTO demos\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)/s.exec(sql); - if (!m) return null; - const cols = m[1].split(",").map((s) => s.trim()); - const placeholders = m[2].split(",").map((s) => s.trim()); - let next = 0; - const row = {}; - cols.forEach((col, i) => { - row[col] = placeholders[i] === "?" ? binds[next++] : Number(placeholders[i]); - }); - return row; -} - -/** D1 fake: seeded demo rows, a recorded write log, and a build_cache that - * always hits so createDemo() takes its cached-artifact branch. Unmatched - * reads answer empty, which the budget code treats as "no spend yet". */ -function fakeD1(seedRows = []) { - const writes = []; - const demos = new Map(seedRows.map((row) => [row.id, row])); - const prepare = (sql) => { - const bound = (binds) => ({ - async first() { - if (/FROM demos WHERE id = \?/.test(sql)) return demos.get(binds[0]) ?? null; - if (/FROM build_cache/.test(sql)) return { r2_prefix: "demos/_prior-identical-build/" }; - return null; - }, - async run() { - writes.push({ sql, binds }); - const inserted = parseDemosInsert(sql, binds); - if (inserted) demos.set(inserted.id, inserted); - return { success: true, meta: {} }; - }, - async all() { - return { success: true, results: [] }; - }, - }); - return { bind: (...binds) => bound(binds), ...bound([]) }; - }; - return { db: { prepare }, writes, demos }; -} - -function fakeKV() { - const store = new Map(); - return { - async get(key, type) { - const value = store.get(key); - if (value === undefined) return null; - return type === "json" ? JSON.parse(value) : value; - }, - async put(key, value) { - store.set(key, String(value)); - }, - async delete(key) { - store.delete(key); - }, - }; -} - -function fakeR2() { - const puts = []; - return { - puts, - async put(key) { - puts.push(key); - }, - async get() { - return null; - }, - async list() { - return { objects: [] }; - }, - }; -} - -const SECRET = "test-secret"; -const AUTHOR = "dev@handsontable.com"; - -function makeEnv(seedRows = []) { - const { db, writes, demos } = fakeD1(seedRows); - const artifacts = fakeR2(); - const env = { - Sandbox: {}, - SANDBOX_BUILDER: {}, - DB: db, - CACHE: fakeKV(), - ARTIFACTS: artifacts, - MCP_SHARED_SECRET: SECRET, - LOGIN_BROKER_URL: "https://login.invalid", - EMBED_ALLOWED_ANCESTORS: "https://handsontable.com", - ERROR_REPORTING_DSN: "", - CF_VERSION_METADATA: { id: "test", tag: "test" }, - // Not the production host, so the Sentry gate in index.ts stays inert. - PREVIEW_HOST: "localhost:8787", - }; - return { env, writes, demos, artifacts }; -} +const FILES = { "/package.json": '{"name":"demo"}', "/index.js": "console.log(1)" }; -const ctx = { - waitUntil(promise) { - Promise.resolve(promise).catch(() => {}); - }, - passThroughOnException() {}, -}; +const PR_URL = "https://pkg.pr.new/handsontable@13106"; -const FILES = { "/package.json": '{"name":"demo"}', "/index.js": "console.log(1)" }; +/** A minimal workspace whose /package.json pins Handsontable to `dep`. */ +const filesWith = (dep) => ({ + "/package.json": JSON.stringify({ name: "demo", dependencies: { handsontable: dep } }), + "/index.js": "console.log(1)", +}); const mcpHeaders = { "Content-Type": "application/json", @@ -158,26 +69,6 @@ const patchRequest = (id, body = { files: FILES }) => body: JSON.stringify(body), }); -/** A stored demo row as D1 would return it (see DemoRow in share.ts). */ -const demoRow = (overrides = {}) => ({ - id: "abc123", - title: "A demo", - description: "words", - framework: "react", - tier: 1, - ht_version: "latest", - files_hash: "hash", - r2_prefix: "demos/abc123/", - forked_from: "mcp:react", - visibility: "unlisted", - revoked: 0, - created_by: AUTHOR, - created_at: "2026-08-17T00:00:00.000Z", - updated_at: "2026-08-17T00:00:00.000Z", - revoked_at: null, - ...overrides, -}); - // ---- create ------------------------------------------------------------------ test("an MCP demo without a description is refused before it is built", async () => { @@ -196,6 +87,9 @@ test("an MCP demo without a description is refused before it is built", async () test("a created demo answers with the four links and its owner", async () => { const { env } = makeEnv(); + // FILES pins nothing, so the route resolves npm `latest`; seeded so this + // spec never depends on a live registry (it used to — fetch-spy proven). + await seedCatalog(env); const res = await worker.fetch( createRequest({ framework: "react", title: "Grid", description: "A sortable grid", files: FILES }), env, @@ -218,11 +112,15 @@ test("a created demo answers with the four links and its owner", async () => { assert.equal(body.shareUrl, `/share/${body.id}`); assert.equal(body.createdBy, AUTHOR); // Concrete, not a dist-tag: the agent pins its follow-up update to this. - assert.match(body.htVersion, /^\d+\.\d+\.\d+/); + // The exact seeded value, not a shape regex — a live-npm `latest` would also + // match /^\d+\.\d+\.\d+/, so a shape match could pass with the seeded catalog + // silently ignored (a drifted CACHE key) and the registry dependency back. + assert.equal(body.htVersion, "16.2.0"); }); test("a created demo is written with the caller as its owner, and its owner's listing finds it", async () => { const { env, writes, demos } = makeEnv(); + await seedCatalog(env); // see the create test above — no live npm const res = await worker.fetch( createRequest({ framework: "react", title: "Grid", description: "A sortable grid", files: FILES }), env, @@ -286,3 +184,82 @@ test("an unknown demo is 404", async () => { assert.equal(res.status, 404); assert.equal((await res.json()).error, "not found"); }); + +// ---- update: version resolution on the rebuild path (DEV-2565) ---------------- +// +// The bug class: both create routes used to store `body.htVersion ?? "latest"` +// verbatim — a dist-tag sentinel the validator rejects — so /edit refused to +// boot and Save re-sent a ref pnpm reads as a registry range. The fix derives a +// concrete ref (payload pin → trusted tag → previous row → catalog) before +// updateDemo() runs; these specs prove the MCP rebuild handler actually wires +// that resolution, which until now had zero version assertions. + +test("an MCP rebuild derives the bare ref from a pkg.pr.new-pinned payload and repairs a sentinel row", async () => { + // A legacy row holding the "latest" sentinel — the exact shape DEV-2565 left + // behind — whose owner now re-saves files pinned to a PR build, saying + // nothing about htVersion. The promise: the demo stays on the build its own + // package.json asks for (bare ref in D1, exact URL in the snapshot), and the + // sentinel is repaired rather than re-stored. + const { env, writes, artifacts } = makeEnv([demoRow({ ht_version: "latest" })]); + const res = await worker.fetch(patchRequest("abc123", { files: filesWith(PR_URL) }), env, ctx); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.rebuilt, true); + // The bare ref, never the URL: the column must hold what the validator + // accepts, or the next /edit boot refuses the demo all over again. + assert.equal(body.htVersion, "13106"); + // updateDemo's UPDATE is the write oracle (parseDemosInsert only reads + // INSERTs): bind order is ht_version, files_hash, updated_at, ..., id. + const update = writes.find((w) => /UPDATE demos SET ht_version=/.test(w.sql)); + assert.ok(update, "the rebuild must update the demos row"); + assert.equal(update.binds[0], "13106", "the sentinel row is repaired to the derived ref"); + assert.equal(update.binds.at(-1), "abc123"); + // Fixed point: the server-side re-pin must hand the install the same URL the + // caller pinned — compared as the parsed dependency value, because the pin + // re-serialises package.json (2-space indent) and byte equality cannot pass. + const snapshot = sourceSnapshot(artifacts, "abc123"); + assert.equal(JSON.parse(snapshot.files["/package.json"]).dependencies.handsontable, PR_URL); +}); + +test("an MCP rebuild lets an explicit 'latest' outrank the payload's own pin (trustDistTag)", async () => { + // The service path's one lever for moving a demo OFF a PR build: hot-mcp + // forwards the model's own request, so an explicit tag there must beat the + // pin the files still carry (trustDistTag at index.ts's MCP PATCH handler). + // If the route dropped the flag, the pin would win and '13106' would land + // here instead — the deliberate inverse of the browser-PATCH tag test in + // demo-routes-version.test.mjs. The seeded catalog value is the only place + // '16.2.0' exists, so the assertion cannot pass via a live registry. + const { env, writes, artifacts } = makeEnv([demoRow({ ht_version: "latest" })]); + await seedCatalog(env, "16.2.0"); + const res = await worker.fetch( + patchRequest("abc123", { htVersion: "latest", files: filesWith(PR_URL) }), + env, + ctx, + ); + assert.equal(res.status, 200); + assert.equal((await res.json()).htVersion, "16.2.0"); + const update = writes.find((w) => /UPDATE demos SET ht_version=/.test(w.sql)); + assert.ok(update, "the rebuild must update the demos row"); + assert.equal(update.binds[0], "16.2.0"); + // The pin follows the winning ref: the PR URL is rewritten to the release. + const snapshot = sourceSnapshot(artifacts, "abc123"); + assert.equal(JSON.parse(snapshot.files["/package.json"]).dependencies.handsontable, "16.2.0"); +}); + +test("an MCP rebuild refuses an invalid explicit ref with the validator's message and writes nothing", async () => { + // The 400 must come from the shared validator, before the budget gate, the + // usage event, and updateDemo — a bad ref costs the caller nothing, not a + // container boot on a doomed install. Empty write log = no usage event + // either, which is why deepEqual([], ...) is the right oracle here and only + // on refusal paths (success paths log usage/build_cache writes too). + const { env, writes, artifacts } = makeEnv([demoRow()]); + const res = await worker.fetch( + patchRequest("abc123", { htVersion: "not-a-version", files: filesWith("16.0.2") }), + env, + ctx, + ); + assert.equal(res.status, 400); + assert.match((await res.json()).error, /semver-valid or a pkg\.pr\.new id\/URL/); + assert.deepEqual(writes, [], "a refused rebuild must not write"); + assert.deepEqual(artifacts.puts, [], "a refused rebuild must not store artifacts"); +});