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
96 changes: 59 additions & 37 deletions e2e/execution-controls.test.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,64 @@
import {mkdtemp,rm} from "node:fs/promises";
import {tmpdir} from "node:os";
import {join} from "node:path";
import {expect,test} from "bun:test";
import {chromium} from "playwright";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "bun:test";
import { chromium } from "playwright";

test("schedule controls ignore unfinished request fields except when saving",async()=>{
const directory=await mkdtemp(join(tmpdir(),"execution-controls-"));
const entry=join(directory,"fixture.tsx");
await Bun.write(entry,`import {createElement} from ${JSON.stringify(Bun.resolveSync("react",import.meta.dir))};import {createRoot} from ${JSON.stringify(Bun.resolveSync("react-dom/client",import.meta.dir))};import {ExecutionControls} from ${JSON.stringify(new URL("../src/gallery/execution-controls.tsx",import.meta.url).pathname)};createRoot(document.getElementById("root")).render(createElement(ExecutionControls,{workspace:"default",name:"example"}));`);
test("schedule loads before editing, preserves request headers and ignores unfinished fields for controls", async () => {
const directory = await mkdtemp(join(tmpdir(), "execution-controls-"));
const entry = join(directory, "fixture.tsx");
await Bun.write(entry, `import {createElement} from ${JSON.stringify(Bun.resolveSync("react", import.meta.dir))};import {createRoot} from ${JSON.stringify(Bun.resolveSync("react-dom/client", import.meta.dir))};import {ExecutionControls} from ${JSON.stringify(new URL("../src/gallery/execution-controls.tsx", import.meta.url).pathname)};createRoot(document.getElementById("root")).render(createElement(ExecutionControls,{workspace:"default",name:"example"}));`);
let build;
try { build=await Bun.build({entrypoints:[entry],target:"browser"}); } finally { await rm(directory,{recursive:true,force:true}); }
expect(build.success).toBe(true);
const js=await build.outputs[0]!.text();
const actions:string[]=[];
let schedule={interval_seconds:3600,paused:false,next_run_at:Date.now()+3600000,request:{path:"/",method:"GET",headers:[]}};
const server=Bun.serve({hostname:"127.0.0.1",port:0,async fetch(request){
if(new URL(request.url).pathname==="/client.js")return new Response(js,{headers:{"content-type":"text/javascript"}});
if(new URL(request.url).pathname==="/api/tools"){
const call=await request.json() as {arguments:{action:string}};actions.push(call.arguments.action);
if(call.arguments.action==="pause")schedule={...schedule,paused:true};
if(call.arguments.action==="resume")schedule={...schedule,paused:false};
return Response.json({structuredContent:{schedule}});
try { build = await Bun.build({ entrypoints: [entry], target: "browser" }); }
finally { await rm(directory, { recursive: true, force: true }); }
if (!build.success) throw new Error(build.logs.join("\n"));
const js = await build.outputs[0]!.text();
type Arguments = { action: string; interval_seconds?: number; request?: { path: string; method: string; headers: [string, string][]; body?: string } };
const calls: Arguments[] = [];
let schedule = { interval_seconds: 7200, paused: false, next_run_at: Date.now() + 7200000, request: { path: "/existing", method: "POST", headers: [["x-example", "first"], ["x-example", "second"]] as [string, string][], body: Buffer.from("prior body").toString("base64") } };
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) {
if (new URL(request.url).pathname === "/client.js") return new Response(js, { headers: { "content-type": "text/javascript" } });
if (new URL(request.url).pathname === "/api/tools") {
const call = await request.json() as { arguments: Arguments }; calls.push(call.arguments);
if (call.arguments.action === "get") await gate;
if (call.arguments.action === "pause") schedule = { ...schedule, paused: true };
if (call.arguments.action === "resume") schedule = { ...schedule, paused: false };
if (call.arguments.action === "set") schedule = { ...schedule, interval_seconds: call.arguments.interval_seconds!, request: { ...call.arguments.request!, body: call.arguments.request!.body ?? "" } };
return Response.json({ structuredContent: { schedule } });
}
return new Response('<div id="root"></div><script type="module" src="/client.js"></script>',{headers:{"content-type":"text/html"}});
}});
const browser=await chromium.launch({headless:true});
try{
const page=await browser.newPage();await page.goto(server.url.href);
await page.getByText("Schedule",{exact:true}).click();
return new Response('<div id="root"></div><script type="module" src="/client.js"></script>', { headers: { "content-type": "text/html" } });
} });
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage(); await page.goto(server.url.href);
await page.getByText("Schedule", { exact: true }).click();
await page.getByText("Loading schedule…", { exact: true }).waitFor();
expect(await page.getByRole("button", { name: "Save schedule", exact: true }).isEnabled()).toBe(false);
expect(await page.getByLabel("Schedule path").isEnabled()).toBe(false);
release();
await page.getByText("Next run:", { exact: false }).waitFor();
expect(await page.getByLabel("Schedule path").inputValue()).toBe("/existing");
expect(await page.getByLabel("Schedule interval", { exact: true }).inputValue()).toBe("2");
expect(await page.getByLabel("Schedule interval unit").inputValue()).toBe("hours");
expect(await page.getByLabel("Schedule body").inputValue()).toBe("prior body");
await page.getByLabel("Schedule interval unit").selectOption("minutes");
expect(await page.getByLabel("Schedule interval", { exact: true }).inputValue()).toBe("120");
await page.getByLabel("Schedule interval", { exact: true }).fill("30");
expect(await page.getByText("Next run if saved now:", { exact: false }).innerText()).toContain("(UTC)");
await page.getByRole("button", { name: "Save schedule", exact: true }).click();
await page.getByRole("button", { name: "Pause schedule", exact: true }).click();
expect(calls.find(call => call.action === "set")).toMatchObject({ interval_seconds: 1800, request: { path: "/existing", headers: [["x-example", "first"], ["x-example", "second"]] } });
await page.getByLabel("Schedule headers").fill("{");
await page.getByRole("button",{name:"Load schedule",exact:true}).click();
await page.getByText("Next run:",{exact:false}).waitFor();
await page.getByLabel("Schedule headers").fill("{");
await page.getByRole("button",{name:"Pause schedule",exact:true}).click();
await page.getByRole("button",{name:"Resume schedule",exact:true}).click();
await page.getByRole("button",{name:"Run schedule now",exact:true}).click();
await page.getByRole("button",{name:"Save schedule",exact:true}).click();
await page.getByRole("button", { name: "Resume schedule", exact: true }).click();
await page.getByRole("button", { name: "Run schedule now", exact: true }).click();
await page.getByRole("button", { name: "Save schedule", exact: true }).click();
await page.getByRole("alert").waitFor();
expect(actions).toEqual(["get","pause","resume","run_now"]);
}finally{await browser.close();server.stop(true);}
},30000);
expect(calls.map(call => call.action)).toEqual(["get", "set", "pause", "resume", "run_now"]);
await page.getByLabel("Schedule headers").fill('{"x-new":"value"}');
await page.getByRole("button", { name: "Save schedule", exact: true }).click();
await page.getByRole("button", { name: "Pause schedule", exact: true }).click();
expect(calls.filter(call => call.action === "set").at(-1)!.request!.headers).toEqual([["x-new", "value"]]);
} finally { release(); await browser.close(); server.stop(true); }
}, 30000);
1 change: 1 addition & 0 deletions e2e/gallery-drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ test("gallery retains complete unsaved projects and new scripts and offers confl
await page.getByRole("combobox", { name: "Version", exact: true }).selectOption("old");
await page.getByRole("button", { name: "Restore and deploy", exact: true }).waitFor();
expect(await client.innerText()).toBe("historical client");
expect(await page.locator(".live-data-note").innerText()).toContain("Historical code uses the current database and files and can change them");
await page.getByRole("combobox", { name: "Version", exact: true }).selectOption("working");
expect(await client.innerText()).toBe("unsaved client");
await page.getByRole("button", { name: "Save and deploy", exact: true }).click();
Expand Down
32 changes: 32 additions & 0 deletions e2e/gallery-onboarding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expect, test } from "bun:test";
import { chromium } from "playwright";

test("empty hosted library supplies copyable deployment MCP URL and starter prompt", async () => {
const build = await Bun.build({ entrypoints: [new URL("../src/gallery/client.tsx", import.meta.url).pathname], target: "browser" });
if (!build.success) throw new Error(build.logs.join("\n"));
const js = await build.outputs[0]!.text();
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch(request) {
const path = new URL(request.url).pathname;
if (path === "/client.js") return new Response(js, { headers: { "content-type": "text/javascript" } });
if (path === "/api/session") return Response.json({});
if (path === "/api/gallery") return Response.json({ workspace: "my-project", artifacts: [], capabilities: { links: true, scripts: true } });
return new Response('<div id="root"></div><script type="module" src="/client.js"></script>', { headers: { "content-type": "text/html" } });
} });
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({ permissions: ["clipboard-read", "clipboard-write"] });
const page = await context.newPage();
await page.goto(new URL("?library=team", server.url).href);
const url = await page.getByLabel("Deployment MCP URL").inputValue();
expect(url).toBe(new URL("/mcp?workspace=my-project&library=team", server.url).href);
await page.getByRole("button", { name: "Copy MCP URL", exact: true }).click();
await page.getByText("MCP URL copied", { exact: true }).waitFor();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(url);
const prompt = await page.getByLabel("Starter prompt").inputValue();
expect(prompt).toContain("artifact_guide");
expect(prompt).toContain("private access");
await page.getByRole("button", { name: "Copy starter prompt", exact: true }).click();
await page.getByText("Starter prompt copied", { exact: true }).waitFor();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(prompt);
} finally { await browser.close(); server.stop(true); }
}, 30000);
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
"src/viewer.ts",
"src/localProject.ts",
"cloudflare/project.ts",
"cloudflare/schedule.ts",
"src/gallery/execution-controls.tsx",
"src/gallery/project-editor.tsx",
"src/gallery/source-editor.tsx",
Expand All @@ -106,7 +107,7 @@
"mcp": "bun run src/cli.ts mcp",
"test": "bun test src scripts/prepare-plugins.test.ts examples/runner-status --timeout 30000",
"test:e2e": "bun test e2e/herdr.e2e.test.ts",
"test:mcp-ui": "bun test e2e/gallery-drafts.test.ts e2e/gallery-move.test.ts e2e/project-editor.test.ts e2e/mcp-app.test.ts e2e/gallery-scripts.test.ts e2e/gallery-navigation.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-onboarding.test.ts e2e/gallery-drafts.test.ts e2e/gallery-move.test.ts e2e/project-editor.test.ts e2e/mcp-app.test.ts e2e/gallery-scripts.test.ts e2e/gallery-navigation.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",
"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
10 changes: 9 additions & 1 deletion src/gallery/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client";
import { authClient, signInUrl } from "../auth/client-api";
import type { GalleryArtifact, GalleryData } from "./types";
import { ExecutionControls } from "./execution-controls";
import { ArtifactSourcePanel, LinkSettings, ScriptPanel, liveRevisionLabel, type ScriptView } from "./hosted";
import { AgentOnboarding, ArtifactSourcePanel, LinkSettings, ScriptPanel, liveRevisionLabel, type ScriptView } from "./hosted";
import { artifactFileTransferUrl } from "../sdk/files";
import { RemixPanel } from "./remix";
import { Select } from "./select";
Expand Down Expand Up @@ -130,6 +130,12 @@ const styles = `
.save-feedback p { margin: 0 0 6px; }
.save-feedback button { height: auto; text-align: left; text-decoration: underline; }
.save-feedback pre { white-space: pre-wrap; overflow-wrap: anywhere; }
.agent-onboarding { max-width: 680px; padding: 32px; }
.agent-onboarding label { display: block; margin-top: 16px; }
.agent-onboarding input, .agent-onboarding textarea { display: block; width: 100%; background: var(--raised); }
.agent-onboarding textarea { min-height: 100px; }
.live-data-note { padding: 8px 16px; margin: 0; border-bottom: 1px solid var(--line); color: var(--muted); }
.artifact-execution fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
.source-actions .source-readonly { margin-right: auto; }
.save-conflict { flex-shrink: 0; padding: 8px 16px; max-height: 35vh; overflow: auto; }
.save-conflict button { border-color: var(--line); margin-right: 8px; }
Expand Down Expand Up @@ -693,12 +699,14 @@ function App() {
{sortedVersions.map(version => <option key={version.id} value={version.id}>Revision {version.revision} · {formatDate(version.createdAt)}</option>)}
</Select></label>
</div> : null}
{!creatingScript && selectedArtifact?.kind !== "script" && selectedArtifact && resolvedVersion !== "working" && gallery?.capabilities?.links ? <p className="live-data-note"><strong>Live data</strong> · Historical code uses the current database and files and can change them. Restore deploys this code while keeping current data.</p> : null}
{showMove && !creatingScript && selectedArtifact && gallery?.libraryScope && gallery.capabilities?.moves ? <div id="library-move-panel" className="detail-disclosure"><MovePanel key={selectedArtifact.key} artifact={selectedArtifact} library={gallery.libraryScope} onCancel={() => setShowMove(false)} /></div> : null}
{remixing && !creatingScript && selectedArtifact && resolvedVersion ? <div className="detail-disclosure"><RemixPanel key={selectedArtifact.key + resolvedVersion} artifact={selectedArtifact} version={resolvedVersion} onCancel={() => setRemixing(false)} onSaved={async name => { createdRemix.current = {name, workspace:selectedArtifact.workspace, kind:selectedArtifact.kind ?? "artifact"}; await loadGallery(); setSelectedVersion("working"); setQuery(""); setKindFilter("all"); setRemixing(false); }} /></div> : null}
{!creatingScript && selectedArtifact && gallery?.capabilities?.links ? <div id="link-settings-panel" className="detail-disclosure" hidden={!showLinks}><LinkSettings key={selectedArtifact.key} artifact={selectedArtifact} onSaved={loadGallery} /></div> : null}
<section id="artifact-panel" className="artifact-stage" aria-label={selectedArtifact?.kind === "script" || creatingScript ? "Script editor" : activeTab === "preview" ? "Artifact preview" : activeTab === "activity" ? "Artifact activity" : "Artifact source"}>
{creatingScript ? <ScriptPanel key="new-script" workspace={gallery?.workspace ?? "default"} onCancel={() => setCreatingScript(false)} onSaved={async name => { createdScriptName.current = name; await loadGallery(); setCreatingScript(false); setSelectedVersion("working"); setTab("source"); setQuery(""); setKindFilter("all"); }} />
: selectedArtifact?.kind === "script" ? <ScriptPanel key={`${selectedArtifact.key}:${resolvedVersion}`} artifact={selectedArtifact} workspace={selectedArtifact.workspace} version={resolvedVersion ?? undefined} view={activeTab as ScriptView} sourceUrl={resolvedVersion ? artifactUrl("/api/source", selectedArtifact, resolvedVersion) : undefined} onSaved={async () => { setSelectedVersion("working"); await loadGallery(); }} />
: !selectedArtifact && !loading && gallery?.capabilities?.links ? <AgentOnboarding workspace={gallery.workspace} />
: !selectedArtifact ? <div className="empty-detail"><h2>{loading ? "Loading your library…" : "Your library"}</h2><p>{loading ? "Your saved artifacts will appear shortly." : "Select an artifact or script to open it."}</p></div>
: !resolvedVersion ? <div className="empty-detail"><h2>No readable version</h2><p>This artifact has no saved source available to preview.</p></div>
: <>
Expand Down
Loading
Loading