854 lines · 38.9 KB · TypeScript
RawDownload
// 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.