Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cloudflare/artifact-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export async function artifactRoute(request: Request, env: Env): Promise<Respons
const preview = await service.preview(snapshot);
if (!preview.ok || !preview._meta) return new Response(preview.check, { status: 400 });
const payload = preview._meta.artifact;
const frame = `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; connect-src ${publicOrigin(request.url, env.ARTIFACTS_PUBLIC_ORIGIN)}/api/artifact/files/transfer/ ${publicOrigin(request.url, env.ARTIFACTS_PUBLIC_ORIGIN)}/api/canvas/files/transfer/; form-action 'none'; base-uri 'none'"><style>body{margin:0;background:#181818;color:#f0f0f0;font-family:system-ui,sans-serif}#root{padding:24px}</style></head><body><div id="root"></div><script>window.__herdrCanvas=window.__artifacts=${scriptJson({ artifactId: payload.name, canvasId: payload.name, state: payload.state, theme: { kind: "dark" }, plugins: payload.plugins, ...(payload.files ? { filesVersionId: payload.versionId } : {}), route: { path: (url.pathname.slice(slug.length + 1) || "/") + url.search, basePath: `/${slug}`, external: true }, ...(payload.server ? { serverVersionId: payload.versionId } : {}) })};${galleryBridge.replace(/<\/script/gi, "<\\/script")}</script><script type="module">${payload.js.replace(/<\/script/gi, "<\\/script")}</script></body></html>`;
const frame = `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; connect-src ${publicOrigin(request.url, env.ARTIFACTS_PUBLIC_ORIGIN)}/api/artifact/files/transfer/ ${publicOrigin(request.url, env.ARTIFACTS_PUBLIC_ORIGIN)}/api/canvas/files/transfer/; form-action 'none'; base-uri 'none'"><style>body{margin:0;background:#181818;color:#f0f0f0;font-family:system-ui,sans-serif}</style></head><body><div id="root"></div><script>window.__herdrCanvas=window.__artifacts=${scriptJson({ artifactId: payload.name, canvasId: payload.name, state: payload.state, theme: { kind: "dark" }, plugins: payload.plugins, ...(payload.files ? { filesVersionId: payload.versionId } : {}), route: { path: (url.pathname.slice(slug.length + 1) || "/") + url.search, basePath: `/${slug}`, external: true }, ...(payload.server ? { serverVersionId: payload.versionId } : {}) })};${galleryBridge.replace(/<\/script/gi, "<\\/script")}</script><script type="module">${payload.js.replace(/<\/script/gi, "<\\/script")}</script></body></html>`;
return html(`<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>Artifact</title><style>html,body,iframe{width:100%;height:100%;margin:0;border:0;display:block;background:#181818}</style></head><body><iframe title="Artifact" sandbox="allow-scripts"></iframe><script>
const frame=document.querySelector('iframe');frame.srcdoc=${scriptJson(frame)};
window.__artifactNavigationHost={frame,basePath:${scriptJson(`/${slug}`)}};
Expand Down
18 changes: 18 additions & 0 deletions cloudflare/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { chromium } from "playwright";
import { ARTIFACTS_APP_URI } from "../src/mcp/app-contract";
import { ROUTING_ARTIFACT } from "../src/test/routing";
import { openGallerySubscription } from "../src/test/gallery-subscription";
import { VIEWPORT_ARTIFACT, checkArtifactLayout } from "../src/test/artifact-layout";

let runtime: Miniflare;
let runtimeOptions: ConstructorParameters<typeof Miniflare>[0];
Expand Down Expand Up @@ -636,6 +637,23 @@ test("standalone artifact APIs preserve HTTP semantics and never fall back to th
for (const path of ["/_artifact", "/_artifact/unknown", "/_artifact/request/extra", "/_artifact/plugins/extra"]) expect((await fetch(`${origin}/pages-only${path}`)).status).toBe(404);
}, 60000);

test("hosted standalone artifacts and gallery previews fill the viewport without host padding", async () => {
const name = "viewport-presentation";
const created = await client.callTool({ name: "artifact_write", arguments: { name, slug: name, access: "public", contents: VIEWPORT_ARTIFACT, preview: false } });
expect(created.isError).not.toBe(true);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
for (const viewport of [{ width: 1440, height: 960 }, { width: 390, height: 844 }]) {
await page.setViewportSize(viewport);
expect((await page.goto(`${origin}/${name}`))!.status()).toBe(200);
await checkArtifactLayout(page.frameLocator("iframe"));
expect((await page.goto(`${origin}/gallery/preview?workspace=test&name=${name}`))!.status()).toBe(200);
await checkArtifactLayout(page);
}
} finally { await browser.close(); }
}, 60_000);

test("standalone nested artifact pages restore browser routes on direct load and refresh", async () => {
expect((await client.callTool({ name: "artifact_write", arguments: { name: "linked-routes", slug: "linked-routes", access: "public", contents: ROUTING_ARTIFACT } })).isError).not.toBe(true);
const browser = await chromium.launch({ headless: true });
Expand Down
2 changes: 1 addition & 1 deletion cloudflare/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ app.get("/gallery/preview", async c => {
// Enforce isolation even if somebody opens the preview URL directly instead
// of through the gallery's sandboxed iframe. Preview state is page-local.
c.header("Content-Security-Policy", `sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; connect-src ${publicOrigin(c.req.url, c.env.ARTIFACTS_PUBLIC_ORIGIN)}/api/artifact/files/transfer/ ${publicOrigin(c.req.url, c.env.ARTIFACTS_PUBLIC_ORIGIN)}/api/canvas/files/transfer/; form-action 'none'; base-uri 'none'; frame-ancestors 'self'`);
return c.html(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sidequery Artifacts preview</title><style>body{margin:0;background:#181818;color:#f0f0f0;font-family:system-ui,sans-serif}#root{padding:24px}</style></head><body><div id="root"></div><script>window.__herdrCanvas=window.__artifacts=${JSON.stringify({ artifactId: payload.name, canvasId: payload.name, state: payload.state, theme: { kind: "dark" }, plugins: payload.plugins, ...(payload.files ? { filesVersionId: payload.versionId } : {}), ...(payload.server ? { serverVersionId: payload.versionId } : {}) }).replaceAll("<", "\\u003c")};${galleryBridge.replace(/<\/script/gi, "<\\/script")}</script><script type="module">${payload.js.replace(/<\/script/gi, "<\\/script")}</script></body></html>`);
return c.html(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sidequery Artifacts preview</title><style>body{margin:0;background:#181818;color:#f0f0f0;font-family:system-ui,sans-serif}</style></head><body><div id="root"></div><script>window.__herdrCanvas=window.__artifacts=${JSON.stringify({ artifactId: payload.name, canvasId: payload.name, state: payload.state, theme: { kind: "dark" }, plugins: payload.plugins, ...(payload.files ? { filesVersionId: payload.versionId } : {}), ...(payload.server ? { serverVersionId: payload.versionId } : {}) }).replaceAll("<", "\\u003c")};${galleryBridge.replace(/<\/script/gi, "<\\/script")}</script><script type="module">${payload.js.replace(/<\/script/gi, "<\\/script")}</script></body></html>`);
});
app.get("/", c => c.env.ASSETS.fetch(new Request(new URL("/index.html", c.req.url))));
app.get("/gallery", c => c.env.ASSETS.fetch(new Request(new URL("/index.html", c.req.url))));
Expand Down
28 changes: 28 additions & 0 deletions e2e/artifact-layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { expect, test } from "bun:test";
import { rmSync } from "node:fs";
import { join } from "node:path";
import { chromium } from "playwright";
import { createArtifactServer } from "../src/serve";
import { VIEWPORT_ARTIFACT, checkArtifactLayout } from "../src/test/artifact-layout";
import { tempDir, writeArtifact } from "../src/test/fixtures";

test("local artifacts and gallery previews fill the viewport without host padding", async () => {
const directory = tempDir("artifact-layout-");
writeArtifact(directory, "presentation", VIEWPORT_ARTIFACT);
const server = await createArtifactServer({ artifactsDir: directory, historyPath: join(directory, "history.sqlite"), gallery: true });
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
for (const viewport of [{ width: 1440, height: 960 }, { width: 390, height: 844 }]) {
await page.setViewportSize(viewport);
for (const path of ["/c/presentation", "/gallery/preview?name=presentation"]) {
expect((await page.goto(server.url + path))!.status()).toBe(200);
await checkArtifactLayout(page);
}
}
} finally {
await browser.close();
server.stop();
rmSync(directory, { recursive: true, force: true });
}
}, 30_000);
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@
"test:native-worker": "tsc -p scripts/native-worker/tsconfig.json --noEmit && bun test scripts/native-worker --timeout 30000",
"native-worker:operator": "bun run scripts/native-worker/operator.ts",
"test:e2e": "bun test e2e/herdr.e2e.test.ts",
"test:mcp-ui": "bun test e2e/gallery-transfer.test.ts e2e/gallery-creation.test.ts e2e/gallery-onboarding.test.ts e2e/gallery-drafts.test.ts e2e/gallery-move.test.ts e2e/project-editor.test.ts e2e/mcp-app.test.ts e2e/mcp-file-editor.test.ts e2e/gallery-scripts.test.ts e2e/gallery-secrets.test.ts e2e/gallery-native-apps.test.ts e2e/gallery-navigation.test.ts e2e/gallery-live.test.ts e2e/gallery-connection.test.ts e2e/execution-controls.test.ts e2e/browser-plugins.test.ts e2e/plugin-bridge.test.ts e2e/routing.test.ts e2e/runner-status.test.ts --timeout 30000",
"test:mcp-ui": "bun test e2e/gallery-transfer.test.ts e2e/gallery-creation.test.ts e2e/gallery-onboarding.test.ts e2e/gallery-drafts.test.ts e2e/gallery-move.test.ts e2e/project-editor.test.ts e2e/mcp-app.test.ts e2e/mcp-file-editor.test.ts e2e/gallery-scripts.test.ts e2e/gallery-secrets.test.ts e2e/gallery-native-apps.test.ts e2e/gallery-navigation.test.ts e2e/gallery-live.test.ts e2e/gallery-connection.test.ts e2e/execution-controls.test.ts e2e/browser-plugins.test.ts e2e/plugin-bridge.test.ts e2e/routing.test.ts e2e/artifact-layout.test.ts e2e/runner-status.test.ts --timeout 30000",
"typecheck": "tsc --noEmit && tsc -p examples/runner-status/tsconfig.json --noEmit && tsc -p examples/runner-status/tsconfig.local.json --noEmit",
"dev:cloudflare": "wrangler dev --local --var ENVIRONMENT:local",
"prepare:celld": "bun run scripts/prepare-celld.ts",
Expand Down
2 changes: 1 addition & 1 deletion src/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function artifactHtml(opts: {
color: #f0f0f0;
font-family: ui-sans-serif, system-ui, sans-serif;
}
#root { padding: 24px; box-sizing: border-box; }
#root { box-sizing: border-box; }
</style>
</head>
<body>
Expand Down
44 changes: 44 additions & 0 deletions src/test/artifact-layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { expect } from "bun:test";
import type { FrameLocator, Page } from "playwright";

export const VIEWPORT_ARTIFACT = `import { useState } from "sidequery/artifacts";
export default function Presentation() {
const [expanded, setExpanded] = useState(false);
return <main style={{ minHeight: expanded ? "150vh" : "100vh", boxSizing: "border-box", padding: 32, background: "white", color: "black", display: "flex", flexDirection: "column", justifyContent: "space-between" }}>
<header>Presentation</header>
<footer><button onClick={() => setExpanded(value => !value)}>{expanded ? "Show less" : "Show more"}</button></footer>
</main>;
}`;

export async function checkArtifactLayout(view: Page | FrameLocator) {
const presentation = view.getByRole("main");
await presentation.waitFor();
const layout = await presentation.evaluate(element => {
const bounds = element.getBoundingClientRect();
const header = element.querySelector("header")!.getBoundingClientRect();
const footer = element.querySelector("footer")!.getBoundingClientRect();
return {
x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height,
viewportWidth: innerWidth, viewportHeight: innerHeight,
scrollWidth: document.documentElement.scrollWidth,
scrollHeight: document.documentElement.scrollHeight,
headerX: header.x, headerY: header.y, footerBottom: footer.bottom,
};
});
expect(layout.x).toBe(0);
expect(layout.y).toBe(0);
expect(layout.width).toBe(layout.viewportWidth);
expect(layout.height).toBe(layout.viewportHeight);
expect(layout.scrollWidth).toBe(layout.viewportWidth);
expect(layout.scrollHeight).toBe(layout.viewportHeight);
expect(layout.headerX).toBe(32);
expect(layout.headerY).toBe(32);
expect(layout.footerBottom).toBe(layout.viewportHeight - 32);

await view.getByRole("button", { name: "Show more", exact: true }).click();
await view.getByRole("button", { name: "Show less", exact: true }).waitFor();
expect(await presentation.evaluate(() => document.documentElement.scrollHeight > innerHeight)).toBe(true);
// The footer remains reachable when the artifact intentionally exceeds the viewport.
await view.getByRole("button", { name: "Show less", exact: true }).click();
await view.getByRole("button", { name: "Show more", exact: true }).waitFor();
}
Loading