// 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...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 (

Release Readiness

${marker}

{Array.from({ length: ${ARTIFACT_ROW_COUNT} }, (_, i) => (

Check {i + 1}: ${marker}

))}
); } `; /** 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 => ((result.raw as { structuredContent?: Record }).structuredContent ?? {}) as Record; /** * 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 `