// Cross-target: the artifacts journey, end to end.//// An agent on a client that cannot display MCP Apps calls `create-artifact`. The// product promise under test is vision.md's delivery negotiation: the model// behaves identically either way, and only DELIVERY changes — a client without// MCP Apps gets a deep link into the web app instead of an embedded widget.// Persistence is what makes that possible, so the same artifact must then be// reachable three ways: the deep link, the Artifacts tab, and back through MCP// by title.//// Two scenarios, split by what they prove:// 1. create-artifact saves + delivers a working deep link, and the page renders// the component live (the fallback path a non-Apps client actually walks).// 2. Rename and delete in the console are what MCP reads back afterwards// (the console and the agent share one store, not two caches).import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest";import { Effect } from "effect";import type { Page } from "playwright";import { composePluginApi } from "@executor-js/api/server";import { AccountHttpApi } from "@executor-js/api";import type { ArtifactId } from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario";import { Api, Browser, Mcp, Target } from "../src/services";import { visit } from "../src/surfaces/browser"; const api = composePluginApi([] as const); /** * The component `create-artifact` persists. * * It must declare none of the ~280 globals the shell puts in scope (`Card`, * `useState`, …) or the server's guard rejects it before it is ever saved, and * its rendered text must be distinctive enough to assert on inside the shell's * nested sandbox iframe. * * It is purely declarative, which is now the whole contract rather than a * stylistic choice: there is no `run()` in scope, and code that calls one is * rejected before it is saved. Rows come from `Array.from`, a display constant — * an artifact that needed live data would reach for * `useQuery(tools.<integration>.<tool>.queryOptions(...))`. * * It is also deliberately TALL — 40 rows at 28px, far more than fits on the * detail page — and laid out the way the `artifact-style` skill teaches: a * bounded flex column whose header stays put while one region scrolls. That is * what the detail page's fill mode exists to support, and what the steps below * assert: the frame is the viewport, the page itself does not scroll, and the * heading is still on screen after the rows have been scrolled. */const ARTIFACT_ROW_COUNT = 40; const artifactSource = (marker: string) => `function App() { return ( <div className="flex h-full flex-col gap-4"> <div data-testid="artifact-header" className="shrink-0"> <h2>Release Readiness</h2> <p data-testid="artifact-marker">${marker}</p> </div> <div data-testid="artifact-scroll" className="min-h-0 flex-1 overflow-auto"> {Array.from({ length: ${ARTIFACT_ROW_COUNT} }, (_, i) => ( <p key={i} data-testid={"artifact-row-" + i} style={{ height: 28 }}> Check {i + 1}: ${marker} </p> ))} </div> </div> );}`; /** Selfhost shares one workspace across scenarios, so every title is unique to * this run and assertions look for "mine", never "the only one". */const uniqueSuffix = () => randomBytes(4).toString("hex"); const structuredOf = (result: { readonly raw: unknown }): Record<string, unknown> => ((result.raw as { structuredContent?: Record<string, unknown> }).structuredContent ?? {}) as Record<string, unknown>; /** * The generated component, two frames down. * * The artifact page hosts the shell DOCUMENT in a sandboxed iframe and speaks * the MCP-Apps protocol to it — the same way any MCP-Apps client loads * `ui://executor/shell.html`. The shell then compiles the stored JSX into its * own nested sandbox. Both hops are load-bearing, so tests descend through both. */const artifactContent = (page: Page) => page.frameLocator('[data-testid="artifact-shell-frame"]').frameLocator("iframe"); /** * A fingerprint of the CONSOLE document's styling. * * The shell ships its own Tailwind build and its own palette; the console's is * strictly grayscale. Sampling a token, a real rendered control, and the * stylesheet count catches the leak whether it arrives as a `<style>` element, * a variable override, or the runtime Tailwind JIT mutating the page. *//** * Record, for every `window.addEventListener("message", …)` the CONSOLE document * makes, what the shell frame's document was at that instant. * * This is the handshake's ordering invariant made observable. `ui/initialize` is * posted once by the app with no retry, ext-apps buffers nothing, and the host * only starts listening inside `AppBridge.connect()` — so if the host registers * after the shell's scripts have run, the message is lost and both sides wait * forever on "Connecting". * * The host must therefore be listening while the frame is still its initial * `about:blank` document, before the shell document exists to run anything. That * is a property of WHERE the host connects, not of how fast the machine is: * connecting on the frame's `load` event cannot satisfy it (load fires only once * the document is complete), while connecting on the element's ref always does. * * Must be installed before any navigation. */declare global { // eslint-disable-next-line no-var var __handshakeOrder: Array<string> | undefined;} const recordHandshakeOrdering = async (page: Page): Promise<void> => { await page.addInitScript(() => { globalThis.__handshakeOrder = []; const original = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function (type, listener, options) { if (type === "message" && this === window) { const frame = document.querySelector<HTMLIFrameElement>( '[data-testid="artifact-shell-frame"]', ); // No frame yet is even earlier than about:blank, so it satisfies the // invariant too; "(none)" keeps that case distinguishable in a failure. globalThis.__handshakeOrder?.push( frame ? (frame.contentDocument?.URL ?? "(no document)") : "(none)", ); } return original.call(this, type, listener, options as never); }; });}; const readHandshakeOrdering = (page: Page): Promise<ReadonlyArray<string>> => page.evaluate(() => globalThis.__handshakeOrder ?? []); const readConsoleStyle = (page: Page): Promise<{ primary: string; buttonBg: string }> => page.evaluate(() => { const button = document.querySelector("button"); return { primary: getComputedStyle(document.documentElement).getPropertyValue("--primary").trim(), buttonBg: button ? getComputedStyle(button).backgroundColor : "", }; }); // The shell's compiled stylesheet declares `--mcp-apps-shell-stylesheet: 1`// on `:root` as a provenance marker (see the shell's globals.css): the shell's// tokens deliberately mirror the console's, so this marker is the only// declaration that identifies the sheet. Reading it as a computed value on a// document's root element answers "did the shell's stylesheet land in THIS// document?" — unlike counting document.styleSheets, which moves on its own in// dev (TanStack Start swaps its route-styles <link> as matches settle, and a// swapped-in link only counts once loaded), which made an equality-of-counts// assertion flaky.const readShellStylesheetMarker = (page: Page): Promise<string> => page.evaluate(() => getComputedStyle(document.documentElement) .getPropertyValue("--mcp-apps-shell-stylesheet") .trim(), ); scenario( "Artifacts · create-artifact hands a non-Apps client a deep link that renders the live component", { timeout: 180_000 }, Effect.gen(function* () { const target = yield* Target; const mcp = yield* Mcp; const browser = yield* Browser; const { client: apiClient } = yield* Api; const identity = yield* target.newIdentity(); const client = yield* apiClient(api, identity); // Artifacts are on by default; the plain session carries the surface // this scenario exercises. const session = mcp.session(identity); const suffix = uniqueSuffix(); const title = `Release Readiness ${suffix}`; const marker = `artifact-ok-${suffix}`; // Tracked so cleanup runs even when an assertion below fails. let artifactId: ArtifactId | undefined; yield* Effect.gen(function* () { // `mcp.session` advertises no MCP-Apps capability — which is exactly the // client under test here, so no capability juggling is needed. const tools = yield* session.listTools(); expect(tools, "create-artifact is advertised regardless of MCP-Apps support").toContain( "create-artifact", ); // Artifact code is purely declarative `tools.*`. The `run()` escape hatch // is gone from the shell scope, so a model reaching for it — which is // exactly what one did to hand-roll a pagination loop — is turned around // here, with the declarative replacement named, rather than saving an // artifact that would die with a ReferenceError inside the iframe. const refused = yield* session.call("create-artifact", { code: `function App(){ const q = useQuery({ queryKey: ["x"], queryFn: () => run("return await tools.a.b({})") }); return null; }`, title: `Rejected ${suffix}`, description: "Should never be saved", }); expect(refused.ok, "create-artifact refuses code that calls run()").toBe(false); expect(refused.text, "the model is told what to use instead").toContain( "infiniteQueryOptions", ); const rendered = yield* session.call("create-artifact", { code: artifactSource(marker), title, description: "Whether the current release is ready to ship", }); expect(rendered.ok, `create-artifact succeeded: ${rendered.text}`).toBe(true); const structured = structuredOf(rendered); // `fallback_unavailable` here would mean the host has no webBaseUrl // wired — a mis-wired deployment, not a passing test. expect( structured.status, `a non-Apps client gets a deep link, not an embedded widget: ${JSON.stringify(structured)}`, ).toBe("fallback_url"); artifactId = structured.artifactId as ArtifactId; expect(artifactId, "the artifact was persisted and its id returned").toBeTruthy(); const url = String(structured.url); expect(rendered.text, "the model is handed the URL to relay to the user").toContain(url); // The org this identity is bound to. Org-scoped hosts (cloud, self-host) // advertise a slug; local is unscoped and advertises none. const accountClient = yield* apiClient(AccountHttpApi, identity); const me = yield* accountClient.account.me(); const orgSlug = me.organization?.slug; // The link must point at THIS deployment, at the artifact's own page, and // — this is the part that bit us — inside the right org. A bare // `/artifacts/:id` on an org-scoped host is resolved against whatever org // the browser was last in, so the emitted URL names the org itself. const parsed = new URL(url); expect(parsed.origin, `the deep link targets this deployment (${url})`).toBe( new URL(target.baseUrl).origin, ); expect(parsed.pathname, `the deep link addresses the artifact (${url})`).toBe( orgSlug ? `/${orgSlug}/artifacts/${artifactId}` : `/artifacts/${artifactId}`, ); yield* browser.session(identity, async ({ page, step }) => { // The console's own styling, sampled BEFORE any artifact is opened. // The shell ships its own Tailwind build; if its stylesheet ever // reaches the top-level document again, its base/utility layers move // these computed values (and its provenance marker appears, asserted // below). let consoleStyleBefore: { primary: string; buttonBg: string }; await step("Open the artifact link the agent handed over", async () => { await recordHandshakeOrdering(page); await visit(page, url); consoleStyleBefore = await readConsoleStyle(page); }); await step("The artifact page is titled with the artifact's name", async () => { await page.getByRole("heading", { name: title }).waitFor({ timeout: 20_000 }); if (orgSlug) { // The link arrives already org-scoped and must LAND in that scope: // the console neither strips the segment nor rewrites it onto some // other org, and the artifact survives the trip through auth. await page.waitForURL(`**/${orgSlug}/artifacts/${artifactId}`, { timeout: 20_000 }); } // Landing straight on the deep link (no list visit first) must still // offer the management affordances and the way back to the list. await page.getByRole("button", { name: "Rename" }).waitFor(); await page.getByRole("button", { name: "Delete" }).waitFor(); await page.getByRole("button", { name: "Artifacts" }).waitFor(); }); await step("The saved component renders live inside the shell", async () => { // TWO frames deep, deliberately. The page hosts the shell DOCUMENT in // a sandboxed iframe and speaks the MCP-Apps protocol to it; the shell // in turn compiles the stored JSX into its own nested sandbox. Finding // the marker at the bottom of that chain proves the whole path — the // host bridge delivered the code, and the sandbox compiled and ran it. // An unscoped lookup would still pass if either frame collapsed. const rendered = artifactContent(page).getByTestId("artifact-marker"); await rendered.waitFor({ timeout: 30_000 }); expect(await rendered.textContent()).toContain(marker); // Stuck on "Connecting" must fail loudly rather than time out at some // later, more confusing assertion. The shell shows that word only // while `ui/initialize` is unanswered. const shellBody = page .frameLocator('[data-testid="artifact-shell-frame"]') .locator("body"); await expect .poll(async () => await shellBody.innerText(), { timeout: 10_000, message: "the shell completed the handshake rather than sitting on Connecting", }) .not.toContain("Connecting"); }); await step("The host was listening before the shell could speak", async () => { // The regression guard for the handshake race. Asserting only that the // artifact rendered is not enough: the previous implementation // connected on the frame's `load` event and still rendered on a warm, // fast machine, winning by ~5ms — while the user's browser lost the // same race and sat on "Connecting" forever. // // So assert the ORDERING rather than the outcome. Every listener the // console registers must be registered while the shell frame is still // `about:blank` (or before the frame exists at all) — never once the // shell document has been navigated to, which is precisely what // connecting on `load` would show here. // `about:blank` specifically, rather than "every listener was early": // it is the only value that pins the BRIDGE's registration. A listener // recorded before the frame exists at all could belong to any unrelated // console code, and one recorded afterwards may legitimately be some // later feature's — but a registration made while the shell frame is // mounted and still unnavigated can only be the host attaching to it. // // Mutation-tested against the previous implementation, which connected // on `load` and recorded the shell document's URL here instead. const order = await readHandshakeOrdering(page); expect( order, `the host attached to the shell frame before it was navigated (saw ${JSON.stringify(order)})`, ).toContain("about:blank"); }); // ------------------------------------------------------------------ // Regression guards for the two symptoms of mounting the shell inline. // ------------------------------------------------------------------ await step("The shell's styles stay inside its own document", async () => { const after = await readConsoleStyle(page); // The console's design system is strictly grayscale. When the shell // was mounted inline, its stylesheet redefined `--primary` to a teal // and every button and avatar in the console picked it up — for the // rest of the session, on every page. expect(after.primary, "the console's --primary is untouched by the shell").toBe( consoleStyleBefore.primary, ); expect(after.buttonBg, "a console button keeps its own background").toBe( consoleStyleBefore.buttonBg, ); // And positively: the shell's stylesheet IS present, one document // down. Without this the assertions above would also pass if the // shell had simply failed to load. const shellHasOwnStyles = await page .frameLocator('[data-testid="artifact-shell-frame"]') .locator("html") .evaluate((html) => ({ marker: getComputedStyle(html).getPropertyValue("--mcp-apps-shell-stylesheet").trim(), sheets: html.ownerDocument.styleSheets.length, })); expect( shellHasOwnStyles.sheets, "the shell document carries its own stylesheets", ).toBeGreaterThan(0); expect( shellHasOwnStyles.marker, "the shell document carries the shell's own compiled stylesheet", ).toBe("1"); // The marker is the injection fingerprint: even a shell sheet that // lost the cascade race (so the computed values above stayed put) // would still surface it on the console's root element. expect( await readShellStylesheetMarker(page), "the shell injected no stylesheet into the console document", ).toBe(""); }); await step("The artifact fills the page and scrolls inside itself", async () => { // A tall artifact on its own page is an APP, not a document: the page // hands it the viewport below the title bar and holds it there, and // the artifact scrolls its own rows under its own header. // // Before fill mode the shell only ever grew to content, so this page // scrolled instead — and a user filtering a long table watched its // heading and search box disappear off the top of the screen. const lastRow = artifactContent(page).getByTestId( `artifact-row-${ARTIFACT_ROW_COUNT - 1}`, ); await lastRow.waitFor({ timeout: 30_000 }); const frame = page.locator('[data-testid="artifact-shell-frame"]'); const frameBox = await frame.boundingBox(); expect(frameBox, "the artifact frame is laid out").not.toBeNull(); // The frame is the viewport below the title bar, not the content's // height. 40 rows at 28px is over 1100px of content, so a frame that // still matched its content would be far taller than the window. const viewport = page.viewportSize(); expect(viewport, "the browser reports a viewport").not.toBeNull(); expect( frameBox?.height ?? 0, "the frame is bounded by the window, not by its content", ).toBeLessThanOrEqual(viewport?.height ?? 0); expect(frameBox?.height ?? 0, "the frame fills the space it was given").toBeGreaterThan( 200, ); // The CONSOLE page does not scroll — that is the whole difference. const pageScrolls = await page.evaluate( () => document.documentElement.scrollHeight > document.documentElement.clientHeight + 1, ); expect(pageScrolls, "the console page itself does not scroll").toBe(false); // The artifact's own region does, and its header stays put while it // happens. This is the user-visible symptom, asserted directly. const headerTopBefore = await artifactContent(page) .getByTestId("artifact-header") .evaluate((el) => el.getBoundingClientRect().top); const scrolled = await artifactContent(page) .getByTestId("artifact-scroll") .evaluate((el) => { el.scrollTop = 300; return { scrollTop: el.scrollTop, overflowing: el.scrollHeight > el.clientHeight }; }); expect(scrolled.overflowing, "the artifact's row region is a real scroll box").toBe(true); expect(scrolled.scrollTop, "the row region actually scrolled").toBeGreaterThan(0); const headerTopAfter = await artifactContent(page) .getByTestId("artifact-header") .evaluate((el) => el.getBoundingClientRect().top); expect( Math.abs(headerTopAfter - headerTopBefore), "the artifact's header stayed put while its rows scrolled", ).toBeLessThanOrEqual(1); }); await step("The artifact is listed on the Artifacts tab", async () => { await page.getByRole("link", { name: "Artifacts" }).first().click(); await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); await page.getByRole("link", { name: `Open artifact ${title}` }).waitFor({ timeout: 20_000, }); // The list is a preview gallery, not a row list: the card leads with a // preview that has real layout extent, and the grid lays cards out in // columns rather than stacking them full-width. Asserting the computed // column count (rather than a class name) is what actually proves the // user-visible shape at this viewport. const card = page.locator('[data-slot="artifact-card"]').filter({ hasText: title }); const preview = card.locator('[data-slot="artifact-preview"]'); await preview.waitFor({ timeout: 20_000 }); const previewBox = await preview.boundingBox(); expect(previewBox?.height ?? 0, "the card's preview has layout extent").toBeGreaterThan( 80, ); // And it is the artifact's REAL layout, not the id-seeded schematic. // The smoke render that validated this code at create time also // produced its loading-state markup, which is what the card draws — // so the artifact's own heading is present in the card itself. expect( await preview.getAttribute("data-preview-kind"), "the card shows the captured layout rather than the fallback schematic", ).toBe("layout"); await expect .poll(async () => await preview.innerText(), { timeout: 10_000, message: "the preview carries the artifact's own rendered content", }) .toContain("Release Readiness"); // The preview is inert by construction: nothing in it may be clicked, // focused or read out, because it is a picture of a UI and not the UI. const inert = await preview.evaluate((node) => ({ pointerEvents: globalThis.getComputedStyle(node).pointerEvents, hidden: node.getAttribute("aria-hidden"), scripts: node.querySelectorAll("script, iframe, img, form, button, a").length, })); expect(inert.pointerEvents, "the preview cannot be interacted with").toBe("none"); expect(inert.hidden, "the preview is hidden from assistive technology").toBe("true"); expect( inert.scripts, "the sanitizer left no script, frame, image, form or interactive element", ).toBe(0); const columns = await page.locator('[data-slot="artifact-grid"]').evaluate( (grid) => globalThis .getComputedStyle(grid) .gridTemplateColumns.split(" ") .filter((track) => track.length > 0).length, );Lines 1–500 of 854. Load more
// Cross-target: the artifacts journey, end to end. // // An agent on a client that cannot display MCP Apps calls `create-artifact`. The // product promise under test is vision.md's delivery negotiation: the model // behaves identically either way, and only DELIVERY changes — a client without // MCP Apps gets a deep link into the web app instead of an embedded widget. // Persistence is what makes that possible, so the same artifact must then be // reachable three ways: the deep link, the Artifacts tab, and back through MCP // by title. // // Two scenarios, split by what they prove: // 1. create-artifact saves + delivers a working deep link, and the page renders // the component live (the fallback path a non-Apps client actually walks). // 2. Rename and delete in the console are what MCP reads back afterwards // (the console and the agent share one store, not two caches). import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; import { Effect } from "effect"; import type { Page } from "playwright"; import { composePluginApi } from "@executor-js/api/server"; import { AccountHttpApi } from "@executor-js/api"; import type { ArtifactId } from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario"; import { Api, Browser, Mcp, Target } from "../src/services"; import { visit } from "../src/surfaces/browser"; const api = composePluginApi([] as const); /** * The component `create-artifact` persists. * * It must declare none of the ~280 globals the shell puts in scope (`Card`, * `useState`, …) or the server's guard rejects it before it is ever saved, and * its rendered text must be distinctive enough to assert on inside the shell's * nested sandbox iframe. * * It is purely declarative, which is now the whole contract rather than a * stylistic choice: there is no `run()` in scope, and code that calls one is * rejected before it is saved. Rows come from `Array.from`, a display constant — * an artifact that needed live data would reach for * `useQuery(tools.<integration>.<tool>.queryOptions(...))`. * * It is also deliberately TALL — 40 rows at 28px, far more than fits on the * detail page — and laid out the way the `artifact-style` skill teaches: a * bounded flex column whose header stays put while one region scrolls. That is * what the detail page's fill mode exists to support, and what the steps below * assert: the frame is the viewport, the page itself does not scroll, and the * heading is still on screen after the rows have been scrolled. */ const ARTIFACT_ROW_COUNT = 40; const artifactSource = (marker: string) => ` function App() { return ( <div className="flex h-full flex-col gap-4"> <div data-testid="artifact-header" className="shrink-0"> <h2>Release Readiness</h2> <p data-testid="artifact-marker">${marker}</p> </div> <div data-testid="artifact-scroll" className="min-h-0 flex-1 overflow-auto"> {Array.from({ length: ${ARTIFACT_ROW_COUNT} }, (_, i) => ( <p key={i} data-testid={"artifact-row-" + i} style={{ height: 28 }}> Check {i + 1}: ${marker} </p> ))} </div> </div> ); } `; /** Selfhost shares one workspace across scenarios, so every title is unique to * this run and assertions look for "mine", never "the only one". */ const uniqueSuffix = () => randomBytes(4).toString("hex"); const structuredOf = (result: { readonly raw: unknown }): Record<string, unknown> => ((result.raw as { structuredContent?: Record<string, unknown> }).structuredContent ?? {}) as Record<string, unknown>; /** * The generated component, two frames down. * * The artifact page hosts the shell DOCUMENT in a sandboxed iframe and speaks * the MCP-Apps protocol to it — the same way any MCP-Apps client loads * `ui://executor/shell.html`. The shell then compiles the stored JSX into its * own nested sandbox. Both hops are load-bearing, so tests descend through both. */ const artifactContent = (page: Page) => page.frameLocator('[data-testid="artifact-shell-frame"]').frameLocator("iframe"); /** * A fingerprint of the CONSOLE document's styling. * * The shell ships its own Tailwind build and its own palette; the console's is * strictly grayscale. Sampling a token, a real rendered control, and the * stylesheet count catches the leak whether it arrives as a `<style>` element, * a variable override, or the runtime Tailwind JIT mutating the page. */ /** * Record, for every `window.addEventListener("message", …)` the CONSOLE document * makes, what the shell frame's document was at that instant. * * This is the handshake's ordering invariant made observable. `ui/initialize` is * posted once by the app with no retry, ext-apps buffers nothing, and the host * only starts listening inside `AppBridge.connect()` — so if the host registers * after the shell's scripts have run, the message is lost and both sides wait * forever on "Connecting". * * The host must therefore be listening while the frame is still its initial * `about:blank` document, before the shell document exists to run anything. That * is a property of WHERE the host connects, not of how fast the machine is: * connecting on the frame's `load` event cannot satisfy it (load fires only once * the document is complete), while connecting on the element's ref always does. * * Must be installed before any navigation. */ declare global { // eslint-disable-next-line no-var var __handshakeOrder: Array<string> | undefined; } const recordHandshakeOrdering = async (page: Page): Promise<void> => { await page.addInitScript(() => { globalThis.__handshakeOrder = []; const original = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function (type, listener, options) { if (type === "message" && this === window) { const frame = document.querySelector<HTMLIFrameElement>( '[data-testid="artifact-shell-frame"]', ); // No frame yet is even earlier than about:blank, so it satisfies the // invariant too; "(none)" keeps that case distinguishable in a failure. globalThis.__handshakeOrder?.push( frame ? (frame.contentDocument?.URL ?? "(no document)") : "(none)", ); } return original.call(this, type, listener, options as never); }; }); }; const readHandshakeOrdering = (page: Page): Promise<ReadonlyArray<string>> => page.evaluate(() => globalThis.__handshakeOrder ?? []); const readConsoleStyle = (page: Page): Promise<{ primary: string; buttonBg: string }> => page.evaluate(() => { const button = document.querySelector("button"); return { primary: getComputedStyle(document.documentElement).getPropertyValue("--primary").trim(), buttonBg: button ? getComputedStyle(button).backgroundColor : "", }; }); // The shell's compiled stylesheet declares `--mcp-apps-shell-stylesheet: 1` // on `:root` as a provenance marker (see the shell's globals.css): the shell's // tokens deliberately mirror the console's, so this marker is the only // declaration that identifies the sheet. Reading it as a computed value on a // document's root element answers "did the shell's stylesheet land in THIS // document?" — unlike counting document.styleSheets, which moves on its own in // dev (TanStack Start swaps its route-styles <link> as matches settle, and a // swapped-in link only counts once loaded), which made an equality-of-counts // assertion flaky. const readShellStylesheetMarker = (page: Page): Promise<string> => page.evaluate(() => getComputedStyle(document.documentElement) .getPropertyValue("--mcp-apps-shell-stylesheet") .trim(), ); scenario( "Artifacts · create-artifact hands a non-Apps client a deep link that renders the live component", { timeout: 180_000 }, Effect.gen(function* () { const target = yield* Target; const mcp = yield* Mcp; const browser = yield* Browser; const { client: apiClient } = yield* Api; const identity = yield* target.newIdentity(); const client = yield* apiClient(api, identity); // Artifacts are on by default; the plain session carries the surface // this scenario exercises. const session = mcp.session(identity); const suffix = uniqueSuffix(); const title = `Release Readiness ${suffix}`; const marker = `artifact-ok-${suffix}`; // Tracked so cleanup runs even when an assertion below fails. let artifactId: ArtifactId | undefined; yield* Effect.gen(function* () { // `mcp.session` advertises no MCP-Apps capability — which is exactly the // client under test here, so no capability juggling is needed. const tools = yield* session.listTools(); expect(tools, "create-artifact is advertised regardless of MCP-Apps support").toContain( "create-artifact", ); // Artifact code is purely declarative `tools.*`. The `run()` escape hatch // is gone from the shell scope, so a model reaching for it — which is // exactly what one did to hand-roll a pagination loop — is turned around // here, with the declarative replacement named, rather than saving an // artifact that would die with a ReferenceError inside the iframe. const refused = yield* session.call("create-artifact", { code: `function App(){ const q = useQuery({ queryKey: ["x"], queryFn: () => run("return await tools.a.b({})") }); return null; }`, title: `Rejected ${suffix}`, description: "Should never be saved", }); expect(refused.ok, "create-artifact refuses code that calls run()").toBe(false); expect(refused.text, "the model is told what to use instead").toContain( "infiniteQueryOptions", ); const rendered = yield* session.call("create-artifact", { code: artifactSource(marker), title, description: "Whether the current release is ready to ship", }); expect(rendered.ok, `create-artifact succeeded: ${rendered.text}`).toBe(true); const structured = structuredOf(rendered); // `fallback_unavailable` here would mean the host has no webBaseUrl // wired — a mis-wired deployment, not a passing test. expect( structured.status, `a non-Apps client gets a deep link, not an embedded widget: ${JSON.stringify(structured)}`, ).toBe("fallback_url"); artifactId = structured.artifactId as ArtifactId; expect(artifactId, "the artifact was persisted and its id returned").toBeTruthy(); const url = String(structured.url); expect(rendered.text, "the model is handed the URL to relay to the user").toContain(url); // The org this identity is bound to. Org-scoped hosts (cloud, self-host) // advertise a slug; local is unscoped and advertises none. const accountClient = yield* apiClient(AccountHttpApi, identity); const me = yield* accountClient.account.me(); const orgSlug = me.organization?.slug; // The link must point at THIS deployment, at the artifact's own page, and // — this is the part that bit us — inside the right org. A bare // `/artifacts/:id` on an org-scoped host is resolved against whatever org // the browser was last in, so the emitted URL names the org itself. const parsed = new URL(url); expect(parsed.origin, `the deep link targets this deployment (${url})`).toBe( new URL(target.baseUrl).origin, ); expect(parsed.pathname, `the deep link addresses the artifact (${url})`).toBe( orgSlug ? `/${orgSlug}/artifacts/${artifactId}` : `/artifacts/${artifactId}`, ); yield* browser.session(identity, async ({ page, step }) => { // The console's own styling, sampled BEFORE any artifact is opened. // The shell ships its own Tailwind build; if its stylesheet ever // reaches the top-level document again, its base/utility layers move // these computed values (and its provenance marker appears, asserted // below). let consoleStyleBefore: { primary: string; buttonBg: string }; await step("Open the artifact link the agent handed over", async () => { await recordHandshakeOrdering(page); await visit(page, url); consoleStyleBefore = await readConsoleStyle(page); }); await step("The artifact page is titled with the artifact's name", async () => { await page.getByRole("heading", { name: title }).waitFor({ timeout: 20_000 }); if (orgSlug) { // The link arrives already org-scoped and must LAND in that scope: // the console neither strips the segment nor rewrites it onto some // other org, and the artifact survives the trip through auth. await page.waitForURL(`**/${orgSlug}/artifacts/${artifactId}`, { timeout: 20_000 }); } // Landing straight on the deep link (no list visit first) must still // offer the management affordances and the way back to the list. await page.getByRole("button", { name: "Rename" }).waitFor(); await page.getByRole("button", { name: "Delete" }).waitFor(); await page.getByRole("button", { name: "Artifacts" }).waitFor(); }); await step("The saved component renders live inside the shell", async () => { // TWO frames deep, deliberately. The page hosts the shell DOCUMENT in // a sandboxed iframe and speaks the MCP-Apps protocol to it; the shell // in turn compiles the stored JSX into its own nested sandbox. Finding // the marker at the bottom of that chain proves the whole path — the // host bridge delivered the code, and the sandbox compiled and ran it. // An unscoped lookup would still pass if either frame collapsed. const rendered = artifactContent(page).getByTestId("artifact-marker"); await rendered.waitFor({ timeout: 30_000 }); expect(await rendered.textContent()).toContain(marker); // Stuck on "Connecting" must fail loudly rather than time out at some // later, more confusing assertion. The shell shows that word only // while `ui/initialize` is unanswered. const shellBody = page .frameLocator('[data-testid="artifact-shell-frame"]') .locator("body"); await expect .poll(async () => await shellBody.innerText(), { timeout: 10_000, message: "the shell completed the handshake rather than sitting on Connecting", }) .not.toContain("Connecting"); }); await step("The host was listening before the shell could speak", async () => { // The regression guard for the handshake race. Asserting only that the // artifact rendered is not enough: the previous implementation // connected on the frame's `load` event and still rendered on a warm, // fast machine, winning by ~5ms — while the user's browser lost the // same race and sat on "Connecting" forever. // // So assert the ORDERING rather than the outcome. Every listener the // console registers must be registered while the shell frame is still // `about:blank` (or before the frame exists at all) — never once the // shell document has been navigated to, which is precisely what // connecting on `load` would show here. // `about:blank` specifically, rather than "every listener was early": // it is the only value that pins the BRIDGE's registration. A listener // recorded before the frame exists at all could belong to any unrelated // console code, and one recorded afterwards may legitimately be some // later feature's — but a registration made while the shell frame is // mounted and still unnavigated can only be the host attaching to it. // // Mutation-tested against the previous implementation, which connected // on `load` and recorded the shell document's URL here instead. const order = await readHandshakeOrdering(page); expect( order, `the host attached to the shell frame before it was navigated (saw ${JSON.stringify(order)})`, ).toContain("about:blank"); }); // ------------------------------------------------------------------ // Regression guards for the two symptoms of mounting the shell inline. // ------------------------------------------------------------------ await step("The shell's styles stay inside its own document", async () => { const after = await readConsoleStyle(page); // The console's design system is strictly grayscale. When the shell // was mounted inline, its stylesheet redefined `--primary` to a teal // and every button and avatar in the console picked it up — for the // rest of the session, on every page. expect(after.primary, "the console's --primary is untouched by the shell").toBe( consoleStyleBefore.primary, ); expect(after.buttonBg, "a console button keeps its own background").toBe( consoleStyleBefore.buttonBg, ); // And positively: the shell's stylesheet IS present, one document // down. Without this the assertions above would also pass if the // shell had simply failed to load. const shellHasOwnStyles = await page .frameLocator('[data-testid="artifact-shell-frame"]') .locator("html") .evaluate((html) => ({ marker: getComputedStyle(html).getPropertyValue("--mcp-apps-shell-stylesheet").trim(), sheets: html.ownerDocument.styleSheets.length, })); expect( shellHasOwnStyles.sheets, "the shell document carries its own stylesheets", ).toBeGreaterThan(0); expect( shellHasOwnStyles.marker, "the shell document carries the shell's own compiled stylesheet", ).toBe("1"); // The marker is the injection fingerprint: even a shell sheet that // lost the cascade race (so the computed values above stayed put) // would still surface it on the console's root element. expect( await readShellStylesheetMarker(page), "the shell injected no stylesheet into the console document", ).toBe(""); }); await step("The artifact fills the page and scrolls inside itself", async () => { // A tall artifact on its own page is an APP, not a document: the page // hands it the viewport below the title bar and holds it there, and // the artifact scrolls its own rows under its own header. // // Before fill mode the shell only ever grew to content, so this page // scrolled instead — and a user filtering a long table watched its // heading and search box disappear off the top of the screen. const lastRow = artifactContent(page).getByTestId( `artifact-row-${ARTIFACT_ROW_COUNT - 1}`, ); await lastRow.waitFor({ timeout: 30_000 }); const frame = page.locator('[data-testid="artifact-shell-frame"]'); const frameBox = await frame.boundingBox(); expect(frameBox, "the artifact frame is laid out").not.toBeNull(); // The frame is the viewport below the title bar, not the content's // height. 40 rows at 28px is over 1100px of content, so a frame that // still matched its content would be far taller than the window. const viewport = page.viewportSize(); expect(viewport, "the browser reports a viewport").not.toBeNull(); expect( frameBox?.height ?? 0, "the frame is bounded by the window, not by its content", ).toBeLessThanOrEqual(viewport?.height ?? 0); expect(frameBox?.height ?? 0, "the frame fills the space it was given").toBeGreaterThan( 200, ); // The CONSOLE page does not scroll — that is the whole difference. const pageScrolls = await page.evaluate( () => document.documentElement.scrollHeight > document.documentElement.clientHeight + 1, ); expect(pageScrolls, "the console page itself does not scroll").toBe(false); // The artifact's own region does, and its header stays put while it // happens. This is the user-visible symptom, asserted directly. const headerTopBefore = await artifactContent(page) .getByTestId("artifact-header") .evaluate((el) => el.getBoundingClientRect().top); const scrolled = await artifactContent(page) .getByTestId("artifact-scroll") .evaluate((el) => { el.scrollTop = 300; return { scrollTop: el.scrollTop, overflowing: el.scrollHeight > el.clientHeight }; }); expect(scrolled.overflowing, "the artifact's row region is a real scroll box").toBe(true); expect(scrolled.scrollTop, "the row region actually scrolled").toBeGreaterThan(0); const headerTopAfter = await artifactContent(page) .getByTestId("artifact-header") .evaluate((el) => el.getBoundingClientRect().top); expect( Math.abs(headerTopAfter - headerTopBefore), "the artifact's header stayed put while its rows scrolled", ).toBeLessThanOrEqual(1); }); await step("The artifact is listed on the Artifacts tab", async () => { await page.getByRole("link", { name: "Artifacts" }).first().click(); await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); await page.getByRole("link", { name: `Open artifact ${title}` }).waitFor({ timeout: 20_000, }); // The list is a preview gallery, not a row list: the card leads with a // preview that has real layout extent, and the grid lays cards out in // columns rather than stacking them full-width. Asserting the computed // column count (rather than a class name) is what actually proves the // user-visible shape at this viewport. const card = page.locator('[data-slot="artifact-card"]').filter({ hasText: title }); const preview = card.locator('[data-slot="artifact-preview"]'); await preview.waitFor({ timeout: 20_000 }); const previewBox = await preview.boundingBox(); expect(previewBox?.height ?? 0, "the card's preview has layout extent").toBeGreaterThan( 80, ); // And it is the artifact's REAL layout, not the id-seeded schematic. // The smoke render that validated this code at create time also // produced its loading-state markup, which is what the card draws — // so the artifact's own heading is present in the card itself. expect( await preview.getAttribute("data-preview-kind"), "the card shows the captured layout rather than the fallback schematic", ).toBe("layout"); await expect .poll(async () => await preview.innerText(), { timeout: 10_000, message: "the preview carries the artifact's own rendered content", }) .toContain("Release Readiness"); // The preview is inert by construction: nothing in it may be clicked, // focused or read out, because it is a picture of a UI and not the UI. const inert = await preview.evaluate((node) => ({ pointerEvents: globalThis.getComputedStyle(node).pointerEvents, hidden: node.getAttribute("aria-hidden"), scripts: node.querySelectorAll("script, iframe, img, form, button, a").length, })); expect(inert.pointerEvents, "the preview cannot be interacted with").toBe("none"); expect(inert.hidden, "the preview is hidden from assistive technology").toBe("true"); expect( inert.scripts, "the sanitizer left no script, frame, image, form or interactive element", ).toBe(0); const columns = await page.locator('[data-slot="artifact-grid"]').evaluate( (grid) => globalThis .getComputedStyle(grid) .gridTemplateColumns.split(" ") .filter((track) => track.length > 0).length, );