From 7c989d78e658d56f794cfce8c86294fdae32baf2 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Sat, 3 Oct 2026 19:26:52 -0700 Subject: [PATCH] Simplify gallery navigation and Worker app management --- bun.lock | 19 + cloudflare/service.ts | 3 +- cloudflare/worker.test.ts | 5 +- e2e/execution-controls.test.ts | 15 +- e2e/gallery-drafts.test.ts | 10 +- e2e/gallery-live.test.ts | 6 +- e2e/gallery-move.test.ts | 14 +- e2e/gallery-native-apps.test.ts | 317 ++++++++-- e2e/gallery-navigation.test.ts | 66 +- e2e/gallery-scripts.test.ts | 16 +- e2e/gallery-secrets.test.ts | 5 +- e2e/gallery-transfer.test.ts | 7 +- package.json | 1 + src/gallery/client.tsx | 165 +++-- src/gallery/execution-controls.tsx | 58 +- src/gallery/hosted.tsx | 8 +- src/gallery/native-apps.tsx | 927 ++++++++++++++++++++++++++--- 17 files changed, 1410 insertions(+), 232 deletions(-) diff --git a/bun.lock b/bun.lock index 3aeaaeb..af14bac 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "workspaces": { "": { "dependencies": { + "@base-ui/react": "^1.8.0", "@better-auth/oauth-provider": "1.7.3", "@codemirror/commands": "^6.11.0", "@codemirror/lang-javascript": "^6.2.5", @@ -45,6 +46,12 @@ "@cloudflare/worker-bundler@0.2.3": "patches/@cloudflare%2Fworker-bundler@0.2.3.patch", }, "packages": { + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@base-ui/react": ["@base-ui/react@1.8.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@base-ui/utils": "0.4.0", "@floating-ui/react-dom": "^2.1.9", "@floating-ui/utils": "^0.2.12", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-P0/1sxo6SBVZOklKMIedvTWqw2s2IQzi9x5bIVsXu980cuSOD4NeuRSs+/L7LZQfDkZP/uRZyGPyfFl/B1oH+Q=="], + + "@base-ui/utils": ["@base-ui/utils@0.4.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@floating-ui/utils": "^0.2.12", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-bO9fz25kKtPf+aZVyfQrC0PDmJdmVni31W2hCS5/Owb+inwdIL3XU26pCPRPlt4LSxZrBgLwubXQXQlKaFEZzw=="], + "@better-auth/core": ["@better-auth/core@1.7.3", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", "@standard-schema/spec": "^1.1.0", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-JdP7lOkyE83jgjn7RilJj1XvZ7n2JjRsErKJuaXchjyuNo6cf1iVd3GtbhAtiUyJJkWdk8yL+LaUBKT80H0zLA=="], "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.3", "", { "peerDependencies": { "@better-auth/core": "^1.7.3", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-S+nQRlxbUhkR43LrSv8c98ZvOvmv3nrtOnHkiZXkdDkr60PWp7maC2cqgzZ2C9exCC1a+4TugJlx2jRL+r+/9A=="], @@ -159,6 +166,14 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], @@ -473,6 +488,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "reselect": ["reselect@5.3.0", "", {}, "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg=="], + "resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="], "rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="], @@ -541,6 +558,8 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], diff --git a/cloudflare/service.ts b/cloudflare/service.ts index 22f9755..1aa96d6 100644 --- a/cloudflare/service.ts +++ b/cloudflare/service.ts @@ -168,7 +168,8 @@ export class CloudArtifactService { } } const { name: _name, ...input } = args; - const payload = { schedule: await backend.schedule(input as ArtifactScheduleInput) }; + const active = await backend.activeRevision(); + const payload = { schedule: await backend.schedule(input as ArtifactScheduleInput), has_server: active?.has_server ?? snapshot.server_source !== null }; return { ...text(payload), structuredContent: payload }; } case "artifact_request": { diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts index 09c4adb..1966e56 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -761,6 +761,7 @@ test("artifact schedules retain validated revisions, keep host history isolated, }; const saved = await tool("artifact_schedule", { action: "set", interval_seconds: 3600, request: { path: "/counter", method: "POST" } }); expect(saved.schedule).toMatchObject({ paused: false, interval_seconds: 3600 }); + expect(saved.has_server).toBe(true); await tool("artifact_schedule", { action: "pause" }); const invalid = await client.callTool({ name: "artifact_write", arguments: { name, contents: "export default function {" } }); expect(invalid.isError).toBe(true); @@ -776,7 +777,9 @@ export class ArtifactServer extends DurableObject { fetch() { return Response.js const inspected = await tool("artifact_request", { request: { path: "/" } }); expect(JSON.parse(atob(inspected.response.body))).toEqual([]); expect((await client.callTool({ name: "artifact_write", arguments: { name, contents: counterClient, server: null } })).isError).not.toBe(true); - expect((await tool("artifact_schedule")).schedule).toMatchObject({ paused: true, next_run_at: null }); + const unavailable = await tool("artifact_schedule"); + expect(unavailable.schedule).toMatchObject({ paused: true, next_run_at: null }); + expect(unavailable.has_server).toBe(false); expect((await client.callTool({ name: "artifact_schedule", arguments: { name, action: "resume" } })).isError).toBe(true); }, 120000); diff --git a/e2e/execution-controls.test.ts b/e2e/execution-controls.test.ts index b722355..8e1f864 100644 --- a/e2e/execution-controls.test.ts +++ b/e2e/execution-controls.test.ts @@ -7,7 +7,7 @@ import { chromium } from "playwright"; 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"}));`); + 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",kind:new URLSearchParams(location.search).has("unsupported")?"artifact":"script"}));`); let build; try { build = await Bun.build({ entrypoints: [entry], target: "browser" }); } finally { await rm(directory, { recursive: true, force: true }); } @@ -16,12 +16,16 @@ test("schedule loads before editing, preserves request headers and ignores unfin 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 unsupported = false; let release!: () => void; const gate = new Promise(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); + const call = await request.json() as { name: string; arguments: Arguments }; + if (call.name.endsWith("_runs")) return Response.json({ structuredContent: { runs: [] } }); + if (unsupported) return Response.json({ structuredContent: { schedule: null, has_server: false } }); + 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 }; @@ -33,7 +37,6 @@ test("schedule loads before editing, preserves request headers and ignores unfin 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); @@ -42,6 +45,7 @@ test("schedule loads before editing, preserves request headers and ignores unfin 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"); + await page.getByText("Advanced request settings", { exact: true }).click(); 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"); @@ -60,5 +64,10 @@ test("schedule loads before editing, preserves request headers and ignores unfin 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"]]); + unsupported = true; + await page.goto(`${server.url}?unsupported=1`); + await page.getByText("This artifact has no validated server.", { exact: false }).waitFor(); + expect(await page.getByRole("button", { name: "Save schedule", exact: true }).count()).toBe(0); + await page.getByText("No runs recorded yet.", { exact: true }).waitFor(); } finally { release(); await browser.close(); server.stop(true); } }, 30000); diff --git a/e2e/gallery-drafts.test.ts b/e2e/gallery-drafts.test.ts index 3236477..3b072e6 100644 --- a/e2e/gallery-drafts.test.ts +++ b/e2e/gallery-drafts.test.ts @@ -36,7 +36,7 @@ test("gallery retains complete unsaved projects and new scripts and offers confl page.setDefaultTimeout(5000); await page.goto(server.url.href); await page.getByTitle("default/report", { exact: true }).click(); - await page.getByRole("group", { name: "Artifact view" }).getByRole("button", { name: "Source", exact: true }).click(); + await page.getByRole("tablist", { name: "Artifact view" }).getByRole("tab", { name: "Source", exact: true }).click(); const client = page.getByRole("textbox", { name: "report.artifact.tsx", exact: true }); await client.fill("unsaved client"); await page.getByRole("button", { name: "report.artifact.server.ts", exact: true }).click(); @@ -65,11 +65,15 @@ test("gallery retains complete unsaved projects and new scripts and offers confl expect(await page.getByRole("textbox", { name: "Project dependencies" }).inputValue()).toBe('{"example":"latest"}'); expect(await page.getByRole("button", { name: "Save and deploy", exact: true }).isEnabled()).toBe(false); await page.getByRole("textbox", { name: "Project dependencies" }).fill("{}"); - await page.getByRole("combobox", { name: "Version", exact: true }).selectOption("old"); + await page.getByRole("button", { name: "More actions", exact: true }).click(); + await page.getByRole("menuitem", { name: "Versions", exact: true }).click(); + await page.getByRole("menuitem", { name: /^Revision 1 ·/ }).click(); 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"); + await page.getByRole("button", { name: "More actions", exact: true }).click(); + await page.getByRole("menuitem", { name: "Versions", exact: true }).click(); + await page.getByRole("menuitem", { name: "Working copy", exact: true }).click(); expect(await client.innerText()).toBe("unsaved client"); await page.getByRole("button", { name: "Save and deploy", exact: true }).click(); await page.getByRole("button", { name: "Compare saved project" }).waitFor(); diff --git a/e2e/gallery-live.test.ts b/e2e/gallery-live.test.ts index 9c70e62..9a57c39 100644 --- a/e2e/gallery-live.test.ts +++ b/e2e/gallery-live.test.ts @@ -70,7 +70,7 @@ test("live changes update clean editors, preserve dirty drafts, and reconcile af const connected = page.waitForEvent("websocket").then(socket => socket.waitForEvent("framereceived", { predicate: event => event.payload === "ready" })); await page.goto(server.url.href); await connected; - await page.getByRole("button", { name: "Source", exact: true }).click(); + await page.getByRole("tab", { name: "Source", exact: true }).click(); const editor = page.getByRole("textbox", { name: "report.artifact.tsx", exact: true }); await editor.waitFor(); source = "agent update"; revision = "two"; @@ -100,10 +100,10 @@ test("live changes update clean editors, preserve dirty drafts, and reconcile af expect(await reports.getAttribute("aria-expanded")).toBe("false"); await reports.click(); await nested.click(); - await page.getByRole("button", { name: "Source", exact: true }).click(); + await page.getByRole("tab", { name: "Source", exact: true }).click(); await page.getByRole("textbox", { name: "missed.artifact.tsx", exact: true }).waitFor(); await page.getByTitle("default/report", { exact: true }).click(); - await page.getByRole("button", { name: "Source", exact: true }).click(); + await page.getByRole("tab", { name: "Source", exact: true }).click(); expect(await editor.innerText()).toBe("my unsaved changes"); } finally { await browser.close(); server.stop(true); } }, 30_000); diff --git a/e2e/gallery-move.test.ts b/e2e/gallery-move.test.ts index 3c776a1..c7e401a 100644 --- a/e2e/gallery-move.test.ts +++ b/e2e/gallery-move.test.ts @@ -62,7 +62,7 @@ test("gallery moves artifacts and scripts between libraries, preserves selection expect(await selected.getAttribute("title")).toBe(`${workspace}/${name}`); expect(await selected.innerText()).toBe(kind === "script" ? `${name}\nScript` : name); expect(await page.getByRole("combobox", { name: "Library", exact: true }).inputValue()).toBe("private"); - await page.getByRole("button", { name: "Move", exact: true }).click(); + await page.getByRole("button", { name: "More actions", exact: true }).click(); await page.getByRole("menuitem", { name: "Move", exact: true }).click(); const panel = page.getByRole("form", { name: "Move between libraries" }); await panel.waitFor(); expect(await panel.innerText()).toContain("including its data and secrets"); @@ -72,18 +72,18 @@ test("gallery moves artifacts and scripts between libraries, preserves selection await panel.getByRole("button", { name: "Cancel", exact: true }).click(); expect(await panel.count()).toBe(0); expect(moves).toHaveLength(beforeCancel); - if (kind === "artifact") await page.getByRole("group", { name: "Artifact view" }).getByRole("button", { name: "Source", exact: true }).click(); + if (kind === "artifact") await page.getByRole("tablist", { name: "Artifact view" }).getByRole("tab", { name: "Source", exact: true }).click(); const editor = page.getByRole("textbox", { name: kind === "script" ? "script.ts" : `${name}.artifact.tsx`, exact: true }); const savedSource = await editor.innerText(); await editor.fill("unsaved project source"); - await page.getByRole("button", { name: "Move", exact: true }).click(); + await page.getByRole("button", { name: "More actions", exact: true }).click(); await page.getByRole("menuitem", { name: "Move", exact: true }).click(); page.once("dialog", dialog => void dialog.dismiss()); await panel.getByRole("button", { name: "Move to team library", exact: true }).click(); expect(moves).toHaveLength(beforeCancel); expect(await editor.innerText()).toBe("unsaved project source"); await editor.fill(savedSource); await panel.getByRole("button", { name: "Cancel", exact: true }).click(); - await page.getByRole("button", { name: "Move", exact: true }).click(); + await page.getByRole("button", { name: "More actions", exact: true }).click(); await page.getByRole("menuitem", { name: "Move", exact: true }).click(); collision = true; await panel.getByRole("button", { name: "Move to team library", exact: true }).click(); await panel.getByRole("alert").waitFor(); @@ -93,13 +93,13 @@ test("gallery moves artifacts and scripts between libraries, preserves selection expect(libraries.private.some(artifact => artifact.kind === kind && artifact.workspace === workspace)).toBe(true); collision = false; for (const [from, destination, action] of [["private", "team", "Move to team library"], ["team", "private", "Move to my personal library"]] as const) { - if (from === "team") await page.getByRole("button", { name: "Move", exact: true }).click(); + if (from === "team") { await page.getByRole("button", { name: "More actions", exact: true }).click(); await page.getByRole("menuitem", { name: "Move", exact: true }).click(); } await panel.getByRole("button", { name: action, exact: true }).click(); await page.waitForURL(url => url.searchParams.get("library") === destination); await page.getByRole("combobox", { name: "Library", exact: true }).waitFor(); await selected.waitFor(); expect(await page.getByRole("combobox", { name: "Library", exact: true }).inputValue()).toBe(destination); - expect(Object.fromEntries(new URL(page.url()).searchParams)).toEqual({ library: destination, workspace, name, kind }); + expect(Object.fromEntries(new URL(page.url()).searchParams)).toEqual({ library: destination, workspace, name, kind, tab: "source" }); expect(await selected.getAttribute("title")).toBe(`${workspace}/${name}`); expect(await selected.innerText()).toBe(kind === "script" ? `${name}\nScript` : name); expect(moves.at(-1)).toEqual({ from, workspace, kind, name, library: destination }); @@ -112,7 +112,7 @@ test("gallery moves artifacts and scripts between libraries, preserves selection await mobile.goto(`${server.url}?${new URLSearchParams({ library: "private", workspace, name, kind: "artifact" })}`); const mobileItem = mobile.locator('.artifact-row[aria-current="true"]'); await mobileItem.waitFor(); await mobileItem.click(); - await mobile.getByRole("button", { name: "Move", exact: true }).click(); + await mobile.getByRole("button", { name: "More actions", exact: true }).click(); await mobile.getByRole("menuitem", { name: "Move", exact: true }).click(); await mobile.getByRole("form", { name: "Move between libraries" }).waitFor(); expect(await mobile.getByRole("combobox", { name: "Library", exact: true }).isVisible()).toBe(true); expect(await mobile.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); diff --git a/e2e/gallery-native-apps.test.ts b/e2e/gallery-native-apps.test.ts index 383bb50..90bf238 100644 --- a/e2e/gallery-native-apps.test.ts +++ b/e2e/gallery-native-apps.test.ts @@ -3,93 +3,314 @@ import { chromium } from "playwright"; import { galleryHtml } from "../src/gallery/server"; test("Worker gallery deploys drafts, manages revisions/secrets and preserves edit base across status refresh", async () => { - const build = await Bun.build({ entrypoints: [new URL("../src/gallery/client.tsx", import.meta.url).pathname], target: "browser" }); + 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 calls: { name: string; arguments: Record }[] = []; - const firstToken = "a".repeat(64), newerToken = "b".repeat(64), revision = "c".repeat(64); + const firstToken = "a".repeat(64), + newerToken = "b".repeat(64), + revision = "c".repeat(64); const restoredToken = "d".repeat(64); let saved: Record | null = null; let failFirst = true; let failRestore = true; - const server = Bun.serve({ port: 0, hostname: "127.0.0.1", async fetch(request) { - const url = new URL(request.url); - if (url.pathname === "/gallery.js") return new Response(js, { headers: { "content-type": "text/javascript" } }); - if (url.pathname === "/api/session") return Response.json({}); - if (url.pathname === "/api/gallery") return Response.json({ workspace: "test", artifacts: [], libraryScope: "private", capabilities: { links: true, nativeApps: true } }); - if (url.pathname === "/api/tools") { - const call = await request.json() as typeof calls[number]; calls.push(call); - const result = (structuredContent: unknown, isError = false) => Response.json({ structuredContent, isError }); - switch (call.name) { - case "app_list": return result({ apps: saved ? [saved] : [], providers: ["cloudflare"], next_offset: null }); - case "app_write": { - saved = { name: call.arguments.name, provider: "cloudflare", status: "active", desired_revision: revision, active_revision: revision, - revision_token: firstToken, source: call.arguments.source, manifest: call.arguments.manifest, project: call.arguments.project }; - if (failFirst) { failFirst = false; saved.status = "draft"; return result({ ...saved, applied: true, ok: false, diagnostics: [{ severity: "error", message: "Invalid fixture draft" }] }, true); } - return result({ ...saved, applied: true, ok: true }); - } - case "app_read": return result(saved); - case "app_history": return result({ revisions: [{ id: revision, created_at: "2026-10-03T00:00:00Z" }], next_offset: null }); - case "app_reconcile": return result({ ...saved, ok: true }); - case "app_secrets": return result({ names: ["TOKEN"] }); - case "app_restore": { - if (failRestore) { - failRestore = false; - saved = { ...saved, source: 'export default {fetch(){return new Response("restored draft");}};', revision_token: restoredToken, status: "recovery-required" }; - return result({ ...saved, applied: true, ok: false, diagnostics: [{ severity: "error", message: "Missing restored secret" }] }, true); + let failReconcile = true; + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/gallery.js") return new Response(js, { headers: { "content-type": "text/javascript" } }); + if (url.pathname === "/api/session") return Response.json({}); + if (url.pathname === "/api/gallery") + return Response.json({ + workspace: "test", + artifacts: [], + libraryScope: "private", + capabilities: { links: true, nativeApps: true }, + }); + if (url.pathname === "/api/tools") { + const call = (await request.json()) as (typeof calls)[number]; + calls.push(call); + const result = (structuredContent: unknown, isError = false) => Response.json({ structuredContent, isError }); + switch (call.name) { + case "app_list": + return result({ apps: saved ? [saved] : [], providers: ["cloudflare"], next_offset: null }); + case "app_write": { + if (saved && call.arguments.expected_revision !== saved.revision_token) + return result( + { ok: false, diagnostics: [{ severity: "error", message: "Revision conflict: reload before saving" }] }, + true, + ); + saved = { + name: call.arguments.name, + provider: "cloudflare", + status: "active", + desired_revision: revision, + active_revision: revision, + revision_token: firstToken, + source: call.arguments.source, + manifest: call.arguments.manifest, + project: call.arguments.project, + }; + if (failFirst) { + failFirst = false; + saved.status = "draft"; + return result( + { + ...saved, + applied: true, + ok: false, + diagnostics: [{ severity: "error", message: "Invalid fixture draft" }], + }, + true, + ); + } + return result({ ...saved, applied: true, ok: true }); + } + case "app_read": + return result(saved); + case "app_history": + return result({ revisions: [{ id: revision, created_at: "2026-10-03T00:00:00Z" }], next_offset: null }); + case "app_reconcile": + if (failReconcile) { + failReconcile = false; + saved!.status = "recovery-required"; + return result( + { + ...saved, + applied: true, + ok: false, + diagnostics: [{ severity: "error", message: "Deployment temporarily unavailable" }], + }, + true, + ); + } + saved!.status = "active"; + return result({ ...saved, ok: true }); + case "app_secrets": + return result({ names: ["TOKEN"] }); + case "app_restore": { + if (failRestore) { + failRestore = false; + saved = { + ...saved, + source: 'export default {fetch(){return new Response("restored draft");}};', + revision_token: restoredToken, + status: "recovery-required", + }; + return result( + { + ...saved, + applied: true, + ok: false, + diagnostics: [{ severity: "error", message: "Missing restored secret" }], + }, + true, + ); + } + return result({ ...saved, ok: true }); } - return result({ ...saved, ok: true }); + default: + throw new Error(`Unexpected gallery tool ${call.name}`); } - default: throw new Error(`Unexpected gallery tool ${call.name}`); } - } - return new Response(galleryHtml(), { headers: { "content-type": "text/html" } }); - } }); + return new Response(galleryHtml(), { headers: { "content-type": "text/html" } }); + }, + }); const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); page.setDefaultTimeout(5000); - const errors: string[] = []; page.on("pageerror", error => errors.push(error.message)); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); await page.goto(server.url.href); await page.getByRole("button", { name: "Worker apps", exact: true }).click(); - const panel = page.getByRole("dialog", { name: "Worker apps" }); + const panel = page.getByRole("region", { name: "Worker apps", exact: true }); await panel.getByText("No Worker apps saved.").waitFor(); + await panel.getByRole("button", { name: "New Worker app", exact: true }).click(); await panel.getByLabel("Worker app name").fill("example"); + await panel.getByRole("button", { name: "Continue to editor" }).click(); + await panel.getByRole("tab", { name: "Settings", exact: true }).click(); + await panel.getByText("Advanced manifest (JSON)", { exact: true }).click(); + const fullManifest = { + main: "worker.ts", + compatibility_date: "2026-09-06", + compatibility_flags: ["nodejs_compat"], + vars: { STAGE: "dev" }, + secrets: ["TOKEN"], + bindings: { + STORE: { type: "kv", resource: "store" }, + ROOM: { type: "durable-object", resource: "rooms", class_name: "Room" }, + JOBS: { type: "queue", resource: "jobs" }, + }, + triggers: { crons: ["0 * * * *"], queues: ["jobs"] }, + }; + await panel.getByLabel("Worker app manifest").fill(JSON.stringify(fullManifest)); + await panel.getByLabel("Variable value 1", { exact: true }).fill("production"); + await panel.getByRole("tab", { name: "Resources", exact: true }).click(); + await panel.getByLabel("Resource name 1", { exact: true }).fill("stable_store"); + await panel.getByRole("tab", { name: "Triggers", exact: true }).click(); + await panel.getByLabel("Cron schedule 1", { exact: true }).fill("30 * * * *"); + await panel.getByRole("tab", { name: "Source", exact: true }).click(); await panel.getByRole("button", { name: "Save and deploy Worker", exact: true }).click(); await panel.getByRole("alert").filter({ hasText: "Invalid fixture draft" }).waitFor(); - expect(calls.find(call => call.name === "app_write")!.arguments.expected_revision).toBeNull(); + expect(calls.find((call) => call.name === "app_write")!.arguments.expected_revision).toBeNull(); + expect(calls.find((call) => call.name === "app_write")!.arguments.manifest).toEqual({ + ...fullManifest, + vars: { STAGE: "production" }, + bindings: { ...fullManifest.bindings, STORE: { type: "kv", resource: "stable_store" } }, + triggers: { crons: ["30 * * * *"], queues: ["jobs"] }, + }); await panel.getByRole("button", { name: "Save and deploy Worker", exact: true }).click(); await panel.getByText("Worker app deployed", { exact: true }).waitFor(); - expect(calls.filter(call => call.name === "app_write").at(-1)!.arguments.expected_revision).toBe(firstToken); - expect(await panel.getByRole("link", { name: "Open private app" }).getAttribute("href")).toBe("/apps/example/?workspace=test"); + expect(calls.filter((call) => call.name === "app_write").at(-1)!.arguments.expected_revision).toBe(firstToken); + expect(await panel.getByRole("link", { name: "Open private app" }).getAttribute("href")).toBe( + "/apps/example/?workspace=test", + ); // Another editor saves a newer project after this panel loaded the first token. saved!.revision_token = newerToken; await panel.locator(".cm-content").fill('export default {fetch(){return new Response("local edit");}};'); + await panel.getByText("Unsaved changes", { exact: true }).waitFor(); + await page.evaluate(() => { + const workerUrl = window.location.href; + window.history.replaceState(null, "", "/?workspace=test"); + window.history.pushState(null, "", workerUrl); + }); + const declinedBack = page.waitForEvent("dialog").then((dialog) => dialog.dismiss()); + await page.evaluate(() => window.history.back()); + await declinedBack; + await panel.getByRole("tab", { name: "Source", exact: true }).waitFor(); + expect(new URL(page.url()).searchParams.get("view")).toBe("workers"); + expect(await panel.locator(".cm-content").innerText()).toContain("local edit"); + await panel.getByRole("tab", { name: "Deployments", exact: true }).click(); await panel.getByRole("button", { name: "Reconcile deployment", exact: true }).click(); + await panel.getByRole("alert").filter({ hasText: "Deployment temporarily unavailable" }).waitFor(); + expect(await panel.locator(".cm-content").innerText()).toContain("local edit"); + expect(await panel.getByText("Unsaved changes", { exact: true }).count()).toBe(1); + await panel.getByRole("button", { name: "Retry deployment", exact: true }).click(); await panel.getByText("Deployment reconciled", { exact: true }).waitFor(); expect(await panel.locator(".cm-content").innerText()).toContain("local edit"); + expect(new URL(page.url()).searchParams.get("app")).toBe("example"); + expect(new URL(page.url()).searchParams.get("appTab")).toBe("deployments"); + await panel.getByRole("tab", { name: "Settings", exact: true }).click(); await panel.getByLabel("Secret name", { exact: true }).fill("TOKEN"); await panel.getByLabel("Secret value", { exact: true }).fill("secret-fixture-value"); + const writesBeforeSecret = calls.filter((call) => call.name === "app_write").length; await panel.getByRole("button", { name: "Save secret", exact: true }).click(); await panel.getByText("Secret saved", { exact: true }).waitFor(); expect(await panel.getByLabel("Secret value", { exact: true }).inputValue()).toBe(""); + expect(calls.filter((call) => call.name === "app_write").length).toBe(writesBeforeSecret); expect(await panel.locator(".cm-content").innerText()).toContain("local edit"); await panel.getByRole("button", { name: "Save and deploy Worker", exact: true }).click(); - await panel.getByText("Worker app deployed", { exact: true }).waitFor(); - expect(calls.filter(call => call.name === "app_write").at(-1)!.arguments.expected_revision).toBe(firstToken); - await panel.getByRole("button", { name: "Restore Worker revision", exact: true }).click(); + await panel.getByRole("alert").filter({ hasText: "Revision conflict" }).waitFor(); + expect(calls.filter((call) => call.name === "app_write").at(-1)!.arguments.expected_revision).toBe(firstToken); + expect(await panel.locator(".cm-content").innerText()).toContain("local edit"); + expect(await panel.getByText("Unsaved changes", { exact: true }).count()).toBe(1); + page.once("dialog", (dialog) => dialog.accept()); + await panel.getByRole("tab", { name: "Deployments", exact: true }).click(); + await panel.getByRole("button", { name: "Restore and deploy", exact: true }).click(); await panel.getByRole("alert").filter({ hasText: "Missing restored secret" }).waitFor(); expect(await panel.locator(".cm-content").innerText()).toContain("restored draft"); expect(await panel.getByText("Unsaved changes", { exact: true }).count()).toBe(0); await panel.getByRole("button", { name: "Save and deploy Worker", exact: true }).click(); await panel.getByText("Worker app deployed", { exact: true }).waitFor(); - expect(calls.filter(call => call.name === "app_write").at(-1)!.arguments.expected_revision).toBe(restoredToken); - expect(calls.filter(call => call.name === "app_write").at(-1)!.arguments.source).toContain("restored draft"); - await panel.getByRole("button", { name: "Restore Worker revision", exact: true }).click(); + expect(calls.filter((call) => call.name === "app_write").at(-1)!.arguments.expected_revision).toBe(restoredToken); + expect(calls.filter((call) => call.name === "app_write").at(-1)!.arguments.source).toContain("restored draft"); + await panel.getByRole("tab", { name: "Deployments", exact: true }).click(); + await panel.getByRole("button", { name: "Restore and deploy", exact: true }).click(); await panel.getByText("Source restored and deployed; data retained", { exact: true }).waitFor(); - expect(calls.find(call => call.name === "app_restore")!.arguments.revision_id).toBe(revision); - if (process.env.GALLERY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.GALLERY_SCREENSHOT_DIR}/native-apps.png`, fullPage: true }); - await panel.getByRole("button", { name: "Close Worker apps", exact: true }).click(); - expect(await panel.count()).toBe(0); expect(errors).toEqual([]); - } finally { await browser.close(); server.stop(true); } + expect(calls.find((call) => call.name === "app_restore")!.arguments.revision_id).toBe(revision); + if (process.env.GALLERY_SCREENSHOT_DIR) + await page.screenshot({ + path: `${process.env.GALLERY_SCREENSHOT_DIR}/native-apps-deployments-wide.png`, + fullPage: true, + }); + await page.setViewportSize({ width: 390, height: 844 }); + await panel.getByRole("tab", { name: "Settings", exact: true }).click(); + await panel.getByRole("button", { name: "Save secret", exact: true }).scrollIntoViewIfNeeded(); + expect(await panel.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); + await panel.getByRole("tab", { name: "Deployments", exact: true }).click(); + await panel.getByRole("button", { name: "Restore and deploy", exact: true }).scrollIntoViewIfNeeded(); + await panel.getByRole("tab", { name: "Source", exact: true }).click(); + await panel.getByRole("button", { name: "Save and deploy Worker", exact: true }).scrollIntoViewIfNeeded(); + expect(await panel.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); + if (process.env.GALLERY_SCREENSHOT_DIR) + await page.screenshot({ path: `${process.env.GALLERY_SCREENSHOT_DIR}/native-apps.png`, fullPage: true }); + await panel.getByRole("button", { name: "Back to gallery", exact: true }).click(); + expect(await panel.count()).toBe(0); + expect(errors).toEqual([]); + } finally { + await browser.close(); + server.stop(true); + } +}, 30000); + +test("Worker provider empty state has documentation and no creation editor", 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", + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/gallery.js") return new Response(js, { headers: { "content-type": "text/javascript" } }); + if (url.pathname === "/api/session") return Response.json({}); + if (url.pathname === "/api/gallery") + return Response.json({ + workspace: "test", + artifacts: [], + libraryScope: "private", + capabilities: { nativeApps: true }, + }); + if (url.pathname === "/api/tools") { + const call = (await request.json()) as { name: string }; + const saved = { + name: "unavailable-app", + provider: "cloudflare", + status: "active", + revision_token: "a".repeat(64), + source: "export default {};", + manifest: { main: "worker.ts", compatibility_date: "2026-09-06" }, + project: { files: {}, dependencies: {} }, + }; + if (call.name === "app_read") return Response.json({ structuredContent: saved }); + if (call.name === "app_history") + return Response.json({ structuredContent: { revisions: [], next_offset: null } }); + return Response.json({ structuredContent: { apps: [saved], providers: [], next_offset: null } }); + } + return new Response(galleryHtml(), { headers: { "content-type": "text/html" } }); + }, + }); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); + await page.goto(`${server.url.href}?workspace=test&library=team&view=workers`); + const panel = page.getByRole("region", { name: "Worker apps", exact: true }); + await panel.getByRole("heading", { name: "Worker apps are unavailable" }).waitFor(); + expect(await panel.getByRole("link", { name: "Worker setup documentation" }).getAttribute("href")).toBe( + "https://github.com/sidequery/artifacts/blob/main/docs/native-workers.md", + ); + expect(await panel.getByRole("button", { name: "New Worker app", exact: true }).isDisabled()).toBe(true); + expect(await panel.getByLabel("Worker app name").count()).toBe(0); + expect(await panel.locator(".cm-content").count()).toBe(0); + await page.goto(`${server.url.href}?workspace=test&library=team&view=workers&app=unavailable-app&appTab=source`); + await panel + .getByText( + "unavailable-app uses cloudflare, which is not available on this deployment. Configure that provider to edit or deploy this app.", + ) + .waitFor(); + expect(await panel.locator(".cm-content").count()).toBe(0); + expect(await panel.getByRole("button", { name: "Save and deploy Worker", exact: true }).count()).toBe(0); + } finally { + await browser.close(); + server.stop(true); + } }, 30000); diff --git a/e2e/gallery-navigation.test.ts b/e2e/gallery-navigation.test.ts index 64e0cde..5c95ed1 100644 --- a/e2e/gallery-navigation.test.ts +++ b/e2e/gallery-navigation.test.ts @@ -74,6 +74,19 @@ test("gallery filters, previews revisions, and preserves mobile library navigati expect(await campaign.evaluate(el => getComputedStyle(el).borderLeftWidth)).toBe("0px"); const desktopFilters = (await desktop.getByRole("group", { name: "Filter library" }).boundingBox())!; expect((await campaign.boundingBox())!.y).toBe(desktopFilters.y + desktopFilters.height); + const assertSingleRow = async (page: Page) => { + const header = page.locator(".detail-header"); + const bounds = (await header.boundingBox())!; + for (const control of [header.getByRole("heading", { level: 1 }), header.getByRole("tablist"), header.getByRole("button", { name: "Share", exact: true }), header.getByRole("button", { name: "More actions", exact: true })]) { + const item = (await control.boundingBox())!; + expect(item.y).toBeLessThan(bounds.y + bounds.height / 2); + expect(item.y + item.height).toBeLessThanOrEqual(bounds.y + bounds.height); + } + expect(await header.locator(".detail-title p").count()).toBe(0); + expect(await header.getByRole("tab", { name: "Schedule", exact: true }).count()).toBe(0); + expect(await header.getByRole("tab", { name: "Settings", exact: true }).count()).toBe(0); + }; + await assertSingleRow(desktop); await screenshot(desktop, "gallery-desktop-preview"); await desktop.getByRole("button", { name: "Switch to dark theme" }).click(); await desktop.reload(); @@ -112,7 +125,10 @@ test("gallery filters, previews revisions, and preserves mobile library navigati expect(await campaign.getAttribute("aria-current")).toBe("true"); expect(await script.getAttribute("aria-current")).toBeNull(); - await desktop.getByRole("group", { name: "Artifact view" }).getByRole("button", { name: "Source", exact: true }).click(); + const previewTab = desktop.getByRole("tab", { name: "Preview", exact: true }); + await previewTab.focus(); + await previewTab.press("ArrowRight"); + expect(await desktop.getByRole("tab", { name: "Source", exact: true }).getAttribute("aria-selected")).toBe("true"); const source = desktop.getByRole("textbox", { name: "Campaign overview.artifact.tsx", exact: true }); await source.waitFor(); expect(await source.innerText()).toBe(sourceFor(false)); @@ -126,19 +142,28 @@ test("gallery filters, previews revisions, and preserves mobile library navigati expect(await source.locator("span").evaluateAll(spans => new Set(spans.map(el => getComputedStyle(el).color)).size)).toBeGreaterThan(1); const draft = sourceFor(false).replace("Current campaign", "Unsaved campaign"); await source.fill(draft); - await desktop.getByRole("group", { name: "Artifact view" }).getByRole("button", { name: "Preview", exact: true }).click(); - await desktop.getByRole("group", { name: "Artifact view" }).getByRole("button", { name: "Source", exact: true }).click(); + await desktop.getByRole("tablist", { name: "Artifact view" }).getByRole("tab", { name: "Preview", exact: true }).click(); + await desktop.getByRole("tablist", { name: "Artifact view" }).getByRole("tab", { name: "Source", exact: true }).click(); expect(await source.innerText()).toBe(draft); + await desktop.getByRole("button", { name: "More actions" }).click(); + const menu = desktop.getByRole("menu"); await menu.waitFor(); + await menu.press("Escape"); + await menu.waitFor({ state: "hidden" }); + expect(await desktop.getByRole("button", { name: "More actions" }).evaluate(el => el === document.activeElement)).toBe(true); await noHorizontalOverflow(desktop); await screenshot(desktop, "gallery-desktop-source"); - await desktop.getByRole("combobox", { name: "Version", exact: true }).selectOption("campaign-revision-1"); + await desktop.getByRole("button", { name: "More actions", exact: true }).click(); + await desktop.getByRole("menuitem", { name: "Versions", exact: true }).click(); + await desktop.getByRole("menuitem", { name: /^Revision 1 ·/ }).click(); await desktop.getByRole("button", { name: "Restore and deploy", exact: true }).waitFor(); await desktop.waitForFunction(() => document.querySelector('[role="textbox"][aria-label="Campaign overview.artifact.tsx"]')?.textContent?.includes("Campaign revision 1")); expect(await source.getAttribute("aria-readonly")).toBe("true"); - await desktop.getByRole("group", { name: "Artifact view" }).getByRole("button", { name: "Preview", exact: true }).click(); + await desktop.getByRole("tablist", { name: "Artifact view" }).getByRole("tab", { name: "Preview", exact: true }).click(); await desktop.frameLocator('iframe[title="Preview of Campaign overview"]').getByRole("heading", { name: "Campaign revision 1" }).waitFor(); expect(new URL(await desktop.getByTitle("Preview of Campaign overview").getAttribute("src") ?? "", server.url).searchParams.get("version")).toBe("campaign-revision-1"); - await desktop.getByRole("combobox", { name: "Version", exact: true }).selectOption("working"); + await desktop.getByRole("button", { name: "More actions", exact: true }).click(); + await desktop.getByRole("menuitem", { name: "Versions", exact: true }).click(); + await desktop.getByRole("menuitem", { name: "Working copy", exact: true }).click(); await desktop.frameLocator('iframe[title="Preview of Campaign overview"]').getByRole("heading", { name: "Campaign performance" }).waitFor(); await desktop.getByRole("button", { name: "Share", exact: true }).click(); await desktop.getByRole("textbox", { name: "URL slug", exact: true }).fill("campaign-report"); @@ -166,15 +191,17 @@ test("gallery filters, previews revisions, and preserves mobile library navigati await mobile.frameLocator('iframe[title="Preview of Campaign overview"]').getByRole("heading", { name: "Campaign performance" }).waitFor(); await noHorizontalOverflow(mobile); await mobile.getByLabel("More actions", { exact: true }).click(); - expect(await mobile.getByRole("link", { name: "Download", exact: true }).isVisible()).toBe(true); - await mobile.getByLabel("More actions", { exact: true }).click(); + await mobile.getByRole("menuitem", { name: "Download", exact: true }).waitFor(); + await mobile.getByRole("menu").press("Escape"); + await mobile.getByRole("menu").waitFor({ state: "hidden" }); + await assertSingleRow(mobile); await screenshot(mobile, "gallery-mobile-preview"); - await mobile.getByRole("group", { name: "Artifact view" }).getByRole("button", { name: "Source", exact: true }).click(); + await mobile.getByRole("tablist", { name: "Artifact view" }).getByRole("tab", { name: "Source", exact: true }).click(); const mobileSource = mobile.getByRole("textbox", { name: "Campaign overview.artifact.tsx", exact: true }); await mobileSource.waitFor(); await mobileSource.fill(sourceFor(false).replace("Current campaign", "Mobile unsaved campaign")); await mobileSource.press("ControlOrMeta+Home"); - for (const control of [mobile.getByRole("combobox", { name: "Version", exact: true }), mobile.getByRole("button", { name: "Save and deploy", exact: true }), mobile.getByRole("button", { name: "Find", exact: true }), mobile.getByText("Manage helper files", { exact: true }), mobile.getByText("Dependencies (0)", { exact: true })]) { + for (const control of [mobile.getByRole("button", { name: "Save and deploy", exact: true }), mobile.getByRole("button", { name: "Find", exact: true }), mobile.getByText("Manage helper files", { exact: true }), mobile.getByText("Dependencies (0)", { exact: true })]) { await control.scrollIntoViewIfNeeded(); const bounds = (await control.boundingBox())!; expect(bounds.x).toBeGreaterThanOrEqual(0); @@ -196,6 +223,25 @@ test("gallery filters, previews revisions, and preserves mobile library navigati expect(await mobileLibrary.getByTitle("default/sync-customers", { exact: true }).count()).toBe(0); await noHorizontalOverflow(mobile); expect(calls.map(call => call.name)).toEqual(["artifact_link"]); + await desktop.route("**/api/tools?*", async route => { + const call = route.request().postDataJSON(); + await route.fulfill({ json: { structuredContent: call.name === "artifact_schedule" ? { + has_server: true, + schedule: { interval_seconds: 3600, paused: false, next_run_at: Date.now() + 3600000, request: { path: "/tick", method: "POST", headers: [] } }, + } : { runs: [{ id: "last-run", revision: "validated-revision", trigger: "schedule", started_at: "2026-10-03T18:00:00Z", duration_ms: 24, status: "succeeded", http_status: 200 }] } } }); + }); + await desktop.getByRole("button", { name: "Share", exact: true }).click(); + await desktop.getByRole("button", { name: "More actions", exact: true }).click(); + await desktop.getByRole("menuitem", { name: "Schedule", exact: true }).click(); + await desktop.getByRole("status").filter({ hasText: /^Enabled$/ }).waitFor(); + await desktop.getByText("Last result: succeeded", { exact: false }).waitFor(); + expect(await desktop.getByLabel("Schedule headers").isVisible()).toBe(false); + expect(await desktop.getByLabel("Schedule path").inputValue()).toBe("/tick"); + await screenshot(desktop, "gallery-desktop-schedule"); + await desktop.setViewportSize({ width: 390, height: 844 }); + await desktop.getByRole("status").filter({ hasText: /^Enabled$/ }).waitFor(); + await noHorizontalOverflow(desktop); + await screenshot(desktop, "gallery-mobile-schedule"); } finally { await browser.close(); server.stop(true); diff --git a/e2e/gallery-scripts.test.ts b/e2e/gallery-scripts.test.ts index 8a714a4..b27673e 100644 --- a/e2e/gallery-scripts.test.ts +++ b/e2e/gallery-scripts.test.ts @@ -41,17 +41,17 @@ test("gallery edits scripts without execution and runs requests only on demand", expect(await page.locator("iframe").count()).toBe(0); expect(calls).toEqual([]); await page.getByRole("textbox", { name: "script.ts", exact: true }).fill('export default { fetch() { return new Response("changed"); } };'); - const views = page.getByRole("group", { name: "Script view" }); + const views = page.getByRole("tablist", { name: "Script view" }); const sourceDimensions = await page.locator(".source-code-surface:visible").evaluate(el => ({ editor: el.getBoundingClientRect().height, pane: el.closest(".artifact-detail")!.getBoundingClientRect().height })); expect(sourceDimensions.editor).toBeGreaterThan(sourceDimensions.pane / 2); await screenshot("gallery-script-source"); - await views.getByRole("button", { name: "Requests", exact: true }).click(); - await views.getByRole("button", { name: "Source", exact: true }).click(); + await views.getByRole("tab", { name: "Requests", exact: true }).click(); + await views.getByRole("tab", { name: "Source", exact: true }).click(); expect(await page.getByRole("textbox", { name: "script.ts", exact: true }).innerText()).toContain('new Response("changed")'); await page.getByRole("button", { name: "Save and deploy", exact: true }).click(); await page.getByRole("status").filter({ hasText: "Script saved" }).waitFor(); expect(calls.map(call => call.name)).toEqual(["script_write"]); - await views.getByRole("button", { name: "Requests", exact: true }).click(); + await views.getByRole("tab", { name: "Requests", exact: true }).click(); await page.getByRole("combobox", { name: "Request method" }).selectOption("POST"); await page.getByRole("textbox", { name: "Request body" }).fill("hello 🌍"); await page.getByRole("textbox", { name: "Request headers" }).fill('{"content-type":"text/plain"}'); @@ -70,7 +70,8 @@ test("gallery edits scripts without execution and runs requests only on demand", await page.getByRole("button", { name: "new-handler Script", exact: true }).waitFor(); expect(await page.getByRole("button", { name: "new-handler Script", exact: true }).getAttribute("aria-current")).toBe("true"); expect(calls.filter(call => call.name === "script_run")).toHaveLength(1); - await page.getByRole("button", {name:"Remix",exact:true}).click(); + await page.getByRole("button", { name: "More actions", exact: true }).click(); + await page.getByRole("menuitem", { name: "Remix", exact: true }).click(); await page.getByRole("textbox", {name:"Remix name"}).fill("remixed-handler"); await page.getByRole("button", {name:"Create remix",exact:true}).click(); const remixedRow=page.getByRole("button",{name:"remixed-handler Script",exact:true}); @@ -79,6 +80,7 @@ test("gallery edits scripts without execution and runs requests only on demand", expect(calls.at(-1)).toEqual({name:"script_remix",arguments:{name:"new-handler",new_name:"remixed-handler"}}); hosted = false; await page.reload(); + await page.getByRole("tab", { name: "Preview", exact: true }).click(); await page.getByTitle("Preview of local").waitFor(); expect(await page.getByRole("button", { name: "New script" }).count()).toBe(0); expect(await page.getByRole("textbox", { name: "URL slug" }).count()).toBe(0); @@ -109,7 +111,7 @@ test("invalid saved drafts show live targets and clickable helper diagnostics", await page.getByRole("textbox", { name: "script.ts", exact: true }).fill("invalid source"); await page.getByRole("button", { name: "Save and deploy", exact: true }).click(); await page.getByRole("alert").filter({ hasText: "Invalid draft saved; live revision unchanged." }).waitFor(); - expect(await page.locator(".deployment-status").innerText()).toBe("Live revision 1 · Saved draft; live revision unchanged"); + expect(await page.locator(".detail-title p").count()).toBe(0); await page.getByRole("button", { name: "lib/value.ts:2:23", exact: false }).click(); const helper = page.getByRole("textbox", { name: "lib/value.ts", exact: true }); await helper.waitFor(); @@ -117,7 +119,7 @@ test("invalid saved drafts show live targets and clickable helper diagnostics", expect(await helper.evaluate(() => window.getSelection()?.anchorNode?.parentElement?.textContent)).toContain("export const broken"); await page.getByText("Raw details", { exact: true }).click(); expect(await page.locator(".save-feedback pre").innerText()).toContain('"revision_token": "invalid"'); - await page.getByRole("group", { name: "Script view" }).getByRole("button", { name: "Requests", exact: true }).click(); + await page.getByRole("tablist", { name: "Script view" }).getByRole("tab", { name: "Requests", exact: true }).click(); expect(await page.getByText("Run target:", { exact: false }).innerText()).toContain("Live revision 1"); } finally { await browser.close(); server.stop(true); } }, 30000); diff --git a/e2e/gallery-secrets.test.ts b/e2e/gallery-secrets.test.ts index 40ddade..ff16725 100644 --- a/e2e/gallery-secrets.test.ts +++ b/e2e/gallery-secrets.test.ts @@ -40,7 +40,8 @@ test("gallery exposes server secret names and writes for artifacts and scripts w await page.goto(server.url.href); for (const [name, label, kind] of [["app", "app", "artifact"], ["handler", "handler Script", "script"]] as const) { await page.getByRole("button", { name: label, exact: true }).click(); - await page.getByRole("group", { name: kind === "artifact" ? "Artifact view" : "Script view" }).getByRole("button", { name: "Secrets", exact: true }).click(); + await page.getByRole("button", { name: "More actions", exact: true }).click(); + await page.getByRole("menuitem", { name: "Settings", exact: true }).click(); await page.getByRole("button", { name: "Load secret names", exact: true }).click(); await page.getByLabel("Secret names").getByText("EXISTING", { exact: true }).waitFor(); expect(await page.locator("body").innerText()).not.toContain("hidden-fixture-value"); @@ -60,6 +61,6 @@ test("gallery exposes server secret names and writes for artifacts and scripts w hosted = false; await page.reload(); await page.getByRole("button", { name: "app", exact: true }).click(); - expect(await page.getByRole("group", { name: "Artifact view" }).getByRole("button", { name: "Secrets", exact: true }).count()).toBe(0); + expect(await page.getByRole("tablist", { name: "Artifact view" }).getByRole("tab", { name: "Settings", exact: true }).count()).toBe(0); } finally { await browser.close(); server.stop(true); } }, 30000); diff --git a/e2e/gallery-transfer.test.ts b/e2e/gallery-transfer.test.ts index 2dc6373..168b135 100644 --- a/e2e/gallery-transfer.test.ts +++ b/e2e/gallery-transfer.test.ts @@ -27,11 +27,14 @@ test("gallery imports a fresh complete project and exports its saved snapshot th expect(await page.getByRole("heading", { level: 1 }).innerText()).toBe("portable-import"); if (process.env.GALLERY_SCREENSHOT_DIR) { await mkdir(process.env.GALLERY_SCREENSHOT_DIR, { recursive: true }); await page.screenshot({ path: join(process.env.GALLERY_SCREENSHOT_DIR, "gallery-project-import.png"), fullPage: true }); } await page.getByRole("button", { name: "Close import", exact: true }).click(); - const [download] = await Promise.all([page.waitForEvent("download"), page.getByRole("link", { name: "Download", exact: true }).click()]); + await page.getByRole("button", { name: "More actions", exact: true }).click(); + const [download] = await Promise.all([page.waitForEvent("download"), page.getByRole("menuitem", { name: "Download", exact: true }).click()]); expect(download.suggestedFilename()).toBe("portable-import.artifact-project.json"); const path = join(directory, "export.json"); await download.saveAs(path); expect(await Bun.file(path).json()).toEqual({ ...archive, name: "portable-import" }); - expect(await page.getByRole("link", { name: "Download", exact: true }).getAttribute("href")).toContain("format=project"); + await page.getByRole("button", { name: "More actions", exact: true }).click(); + expect(await page.getByRole("menuitem", { name: "Download", exact: true }).getAttribute("href")).toContain("format=project"); + await page.getByRole("menu").press("Escape"); await page.getByRole("button", { name: "Share", exact: true }).click(); const shareUrl = await page.getByRole("textbox", { name: "Share link", exact: true }).inputValue(); expect(shareUrl).toBe(`${server.url}/a/portable-import`); diff --git a/package.json b/package.json index ab4d473..0dfbc88 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,7 @@ "build:executable": "bun run scripts/build-executable.ts" }, "dependencies": { + "@base-ui/react": "^1.8.0", "@better-auth/oauth-provider": "1.7.3", "@codemirror/commands": "^6.11.0", "@codemirror/lang-javascript": "^6.2.5", diff --git a/src/gallery/client.tsx b/src/gallery/client.tsx index 0a6d3d3..be6b7b7 100644 --- a/src/gallery/client.tsx +++ b/src/gallery/client.tsx @@ -1,3 +1,5 @@ +import { Tabs } from "@base-ui/react/tabs"; +import { Menu } from "@base-ui/react/menu"; import { ArtifactFolders } from "./folders"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; @@ -5,7 +7,7 @@ import { authClient, signInUrl } from "../auth/client-api"; import type { GalleryArtifact, GalleryData } from "./types"; import { ExecutionControls } from "./execution-controls"; import { SecretControls } from "./secrets"; -import { AgentOnboarding, ArtifactSourcePanel, LinkSettings, ScriptPanel, liveRevisionLabel, type ScriptView } from "./hosted"; +import { AgentOnboarding, ArtifactSourcePanel, LinkSettings, ScriptPanel, type ScriptView } from "./hosted"; import { artifactFileTransferUrl } from "../sdk/files"; import { RemixPanel } from "./remix"; import { Select } from "./select"; @@ -19,7 +21,7 @@ import { themeStyles } from "./theme"; import { subscribeGallery } from "./subscription"; type Scope = "current" | "all"; -type DetailTab = "preview" | "source" | "activity" | "requests" | "secrets"; +type DetailTab = "preview" | "source" | "schedule" | "requests" | "settings"; type KindFilter = "all" | "artifact" | "script"; type SourceState = | { status: "idle"; text: ""; error: "" } @@ -109,28 +111,23 @@ const styles = ` .library-footer summary { color: var(--muted); min-height: 24px; padding: 2px 0; } .library-footer p { margin: 6px 0; font-size: 12px; } .artifact-detail { display: flex; flex-direction: column; min-width: 0; min-height: 0; margin: 0 6px 6px 0; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; } - .detail-header { display: flex; gap: 16px; align-items: center; justify-content: space-between; padding: 10px 16px; flex-shrink: 0; flex-wrap: wrap; border-bottom: 1px solid var(--line); } + .detail-header { display: flex; gap: 16px; align-items: center; justify-content: space-between; padding: 10px 16px; flex-shrink: 0; flex-wrap: nowrap; border-bottom: 1px solid var(--line); } .detail-title { min-width: 0; flex: 1; } .detail-eyebrow { margin: 0 0 2px; color: var(--muted); font-size: 11px; } - .detail-title h1 { margin: 0; font-size: 15px; line-height: 1.3; font-weight: 600; letter-spacing: -.35px; overflow-wrap: anywhere; } + .detail-title h1 { margin: 0; font-size: 15px; line-height: 1.3; font-weight: 600; letter-spacing: -.35px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .detail-title h1:focus { outline: none; } - .detail-actions { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; justify-content: flex-end; } + .detail-actions { display: flex; align-items: center; gap: 4px; flex-wrap: nowrap; justify-content: flex-end; flex-shrink: 0; } .detail-actions > :is(button, a) { white-space: nowrap; } .download-link { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 32px; min-height: 32px; line-height: 20px; padding: 5px 10px; border: 1px solid transparent; border-radius: 8px; text-decoration: none; } - .detail-actions .open-link { margin-left: 6px; background: var(--primary); color: var(--primary-text); } - .detail-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; } - .view-control { display: flex; align-items: center; gap: 2px; } - .view-control button { padding: 5px 10px; color: var(--muted); } - .view-control button[aria-pressed="true"] { color: var(--text); background: var(--raised); } + .detail-actions .open-link { margin-left: 6px; border-color: var(--line); } + .view-control { display: flex; align-items: center; gap: 2px; overflow-x: auto; min-width: 0; } + .view-control button { padding: 5px 10px; color: var(--muted); flex-shrink: 0; border-radius: 0; border-bottom: 2px solid transparent; height: 40px; } + .view-control button[data-active] { color: var(--text); border-bottom-color: var(--text); background: transparent; } .revision-control { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); } .version-select { width: 140px; max-width: 140px; } .version-select select { border-color: transparent; background: transparent; color: var(--text); } .detail-actions button[aria-expanded="true"] { background: var(--raised); } - .back-library, .mobile-more { display: none; } - .mobile-more { position: relative; } - .mobile-more summary { padding: 5px 10px; list-style: none; } - .mobile-more summary::-webkit-details-marker { display: none; } - .mobile-more .download-link { position: absolute; top: 100%; right: 0; min-width: 160px; background: var(--panel); border: 1px solid var(--line); z-index: 2; } + .back-library { display: none; } .detail-disclosure { flex-shrink: 0; max-height: 42vh; overflow: auto; background: var(--panel); border-bottom: 1px solid var(--line); } .link-settings, .script-fields { display: flex; flex-wrap: wrap; align-items: end; gap: 8px; } .link-settings { padding: 12px 20px; } @@ -219,13 +216,13 @@ const styles = ` button:hover:not(:disabled), .download-link:hover { background: var(--raised); } .gallery-app button.primary-action:hover:not(:disabled) { background: var(--primary-hover); } .artifact-row[aria-current="true"]:hover { background: var(--selected); } - .detail-actions .open-link:hover, .gallery-app .new-script:hover { background: var(--primary-hover); } - .view-control button:hover:not([aria-pressed="true"]), .library-filters button:hover:not([aria-pressed="true"]) { color: var(--text); background: transparent; } + .gallery-app .new-script:hover { background: var(--primary-hover); } + .view-control button:hover:not([data-active]), .library-filters button:hover:not([aria-pressed="true"]) { color: var(--text); background: transparent; } } @media (max-width: 1100px) { .gallery-layout { grid-template-columns: 232px minmax(0, 1fr); } .wordmark { width: 200px; } - .detail-header { align-items: flex-start; gap: 10px; padding-inline: 16px; } + .detail-header { gap: 8px; padding-inline: 12px; } .detail-actions { max-width: 300px; } .revision-control > span:not(.select-control) { display: none; } .account-name { display: none; } @@ -246,18 +243,18 @@ const styles = ` .library-panel { border-right: 0; } .artifact-row { min-height: 44px; } .artifact-name { font-size: 14px; } - .back-library { display: inline-flex; align-self: flex-start; margin: 4px 4px 0; min-height: 36px; color: var(--muted); } - .detail-header { flex-direction: column; padding: 4px 12px 8px; } - .detail-actions { max-width: none; justify-content: flex-start; margin-left: -10px; } - .detail-title h1 { font-size: 19px; } - .detail-toolbar { gap: 4px; padding-inline: 4px; } + .back-library { display: inline-flex; padding: 0; width: 28px; flex-shrink: 0; color: var(--muted); } + .detail-header { padding: 4px 8px; gap: 4px; } + .detail-actions { max-width: none; } + .detail-title h1 { font-size: 13px; } + .detail-actions .open-link { display: none; } + .detail-header .view-control button { padding-inline: 6px; font-size: 12px; } + .detail-actions > button { padding-inline: 8px; } .version-select { width: 150px; max-width: 150px; } .version-select select { padding-left: 6px; } .view-control { gap: 0; } .view-control button { padding-inline: 8px; } .revision-control { min-width: 0; margin-left: auto; } - .desktop-download { display: none; } - .mobile-more { display: block; } .link-settings { padding: 12px; } .script-fields > :is(input, label, .select-control) { max-width: 100%; } .link-settings label { flex: 1; min-width: 0; } @@ -269,6 +266,22 @@ const styles = ` .source-code-status .editor-shortcuts { display: none; } .script-activity, .artifact-execution { padding: 12px; } } + .more-actions { width: 32px; padding: 0; font-size: 24px; line-height: 1; } + .mobile-open { display: none !important; } + @media (max-width: 760px) { .mobile-open { display: block !important; } } + .action-menu { padding: 4px; min-width: 170px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--text); box-shadow: 0 8px 28px #0002; } + .action-menu [role=menuitem] { display: block; padding: 8px 12px; border-radius: 6px; text-decoration: none; cursor: pointer; } + .action-menu [data-highlighted] { background: var(--raised); outline: none; } + .settings-panel { width: 100%; overflow: auto; } + .settings-title { margin: 16px 20px 0; font-size: 15px; } + .schedule-summary { padding-bottom: 12px; border-bottom: 1px solid var(--line); } + .schedule-summary h2, .run-history h2 { margin: 0 0 8px; font-size: 15px; } + .schedule-form { padding-block: 12px; } + .run-history { margin-top: 24px; overflow-x: auto; } + @media (max-width: 760px) { + .detail-actions { margin-left: 0; } + .revision-control { padding-bottom: 4px; } + } @media (pointer: coarse) { button, input, select, .download-link, summary { height: auto; min-height: 44px; } input, select, textarea, .script-panel textarea { font-size: 16px; } @@ -345,7 +358,7 @@ function App() { }, [theme]); const [remixing, setRemixing] = useState(false); const [creatingScript, setCreatingScript] = useState(false); - const [showNativeApps, setShowNativeApps] = useState(false); + const [showNativeApps, setShowNativeApps] = useState(() => new URLSearchParams(window.location.search).get("view") === "workers"); const scope: Scope = "all"; const [query, setQuery] = useState(""); const [kindFilter, setKindFilter] = useState("all"); @@ -362,7 +375,12 @@ function App() { const [gallery, setGallery] = useState(null); const [selectedKey, setSelectedKey] = useState(null); const [selectedVersion, setSelectedVersion] = useState(null); - const [tab, setTab] = useState("preview"); + const [tab, setTab] = useState(() => { + const value = new URLSearchParams(window.location.search).get("tab"); + if (value === "activity") return "schedule"; + if (value === "secrets") return "settings"; + return ["preview", "source", "schedule", "requests", "settings"].includes(value ?? "") ? value as DetailTab : "preview"; + }); const [loading, setLoading] = useState(true); const [galleryError, setGalleryError] = useState(""); const [source, setSource] = useState({ status: "idle", text: "", error: "" }); @@ -493,7 +511,7 @@ function App() { const activeTab: DetailTab = selectedArtifact?.kind === "script" ? tab === "preview" ? "source" : tab - : tab === "requests" || (tab === "secrets" && !gallery?.capabilities?.links) ? "preview" : tab; + : tab === "requests" || ((tab === "settings" || tab === "schedule") && !gallery?.capabilities?.links) ? "preview" : tab; const sortedVersions = useMemo( () => [...(selectedArtifact?.versions ?? [])].sort((left, right) => right.revision - left.revision), @@ -642,6 +660,27 @@ function App() { } }; + function workerDestination(open: boolean) { + if (open === showNativeApps) return; + const url = new URL(window.location.href); + if (open) url.searchParams.set("view", "workers"); + else { url.searchParams.delete("view"); url.searchParams.delete("app"); url.searchParams.delete("appTab"); } + window.history.pushState(null, "", url); + setShowNativeApps(open); + } + useEffect(() => { + // Worker apps can restore the URL when browser navigation would discard a draft. + const navigate = () => window.setTimeout(() => setShowNativeApps(new URLSearchParams(window.location.search).get("view") === "workers"), 0); + window.addEventListener("popstate", navigate); + return () => window.removeEventListener("popstate", navigate); + }, []); + useEffect(() => { + if (showNativeApps) return; + const url = new URL(window.location.href); + url.searchParams.set("tab", activeTab); + window.history.replaceState(null, "", url); + }, [activeTab, showNativeApps]); + const downloadSource = async (event: React.MouseEvent) => { if (!user) return; event.preventDefault(); @@ -670,27 +709,27 @@ function App() { {gallery?.workspace ? {gallery.workspace.split(/[\\/]/).filter(Boolean).at(-1) ?? gallery.workspace} : null}
- {gallery?.capabilities?.nativeApps ? : null} - + {gallery?.capabilities?.nativeApps ? : null} + {gallery?.libraryScope ? : null} - {gallery?.capabilities?.scripts ? : null} + {gallery?.capabilities?.scripts ? : null} {user ?
{user.name}
: null}
- {showNativeApps ? setShowNativeApps(false)} /> : null} + {showNativeApps && gallery ? workerDestination(false)} /> : null} {galleryError && gallery ?

Refresh failed: {galleryError}. Showing the last loaded files.

: null} {accountError ?

{accountError}

: null} -
+ + +
diff --git a/src/gallery/execution-controls.tsx b/src/gallery/execution-controls.tsx index df99444..416a0b5 100644 --- a/src/gallery/execution-controls.tsx +++ b/src/gallery/execution-controls.tsx @@ -5,12 +5,13 @@ import { nextOccurrence, normalizeTiming } from "../../cloudflare/schedule"; type Schedule = { interval_seconds?: number; cron?: string; timezone?: string; paused: boolean; next_run_at: number | null; request: { path: string; method: string; headers: [string, string][]; body?: string } }; type Run = { id: string; revision: string; trigger: string; started_at: string; duration_ms: number | null; status: string; http_status: number | null }; -const unpack = (value: unknown): { schedule: Schedule | null; runs: Run[] } => typeof value === "string" ? JSON.parse(value) : value as { schedule: Schedule | null; runs: Run[] }; +const unpack = (value: unknown): { schedule: Schedule | null; runs: Run[]; has_server?: boolean } => typeof value === "string" ? JSON.parse(value) : value as { schedule: Schedule | null; runs: Run[]; has_server?: boolean }; const units = { seconds: 1, minutes: 60, hours: 3600, days: 86400 }; type Unit = keyof typeof units; -export function ExecutionControls({ workspace, name, kind = "script" }: { workspace: string; name: string; kind?: "script" | "artifact" }) { - const [open, setOpen] = useState(false); +export function ExecutionControls({ workspace, name, kind = "script", active = true }: { workspace: string; name: string; kind?: "script" | "artifact"; active?: boolean }) { + const [hasServer, setHasServer] = useState(true); + const [runsLoaded, setRunsLoaded] = useState(false); const [schedule, setSchedule] = useState(null); const [loaded, setLoaded] = useState(false); const [mode, setMode] = useState("interval"); @@ -33,6 +34,7 @@ export function ExecutionControls({ workspace, name, kind = "script" }: { worksp if (request && !path.startsWith("/")) throw new Error("Path must start with /."); const result = unpack(await galleryTool(workspace, `${kind}_schedule`, { name, action, ...(action === "set" ? { ...timing(), request } : {}) })); setSchedule(result.schedule); setLoaded(true); + if (result.has_server !== undefined) setHasServer(result.has_server); if (action === "get") { const current = result.schedule; const seconds = current?.interval_seconds ?? 3600; @@ -45,36 +47,64 @@ export function ExecutionControls({ workspace, name, kind = "script" }: { worksp setBody(current?.request.body ? new TextDecoder().decode(Uint8Array.from(atob(current.request.body), character => character.charCodeAt(0))) : ""); } } - useEffect(() => { if (open && !loaded) void perform(() => control("get")); }, [open]); + async function loadRuns() { + setRuns(unpack(await galleryTool(workspace, `${kind}_runs`, { name })).runs ?? []); + setRunsLoaded(true); + } + useEffect(() => { + if (!active || loaded) return; + void perform(async () => { await control("get"); await loadRuns(); }); + }, [active, workspace, name]); + const displayTime = (value: number | string) => new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }).format(new Date(value)) + " (UTC)"; let preview = ""; - if (loaded) { try { preview = `Next run if saved now: ${new Date(nextOccurrence(timing())).toISOString()} (UTC)`; } catch (error) { preview = error instanceof Error ? error.message : String(error); } } + if (loaded) { try { preview = `Next run if saved now: ${displayTime(nextOccurrence(timing()))}`; } catch (error) { preview = error instanceof Error ? error.message : String(error); } } + let frequency = ""; + if (schedule?.interval_seconds) { + const displayUnit = (["days", "hours", "minutes", "seconds"] as Unit[]).find(value => schedule.interval_seconds! % units[value] === 0)!; + const amount = schedule.interval_seconds / units[displayUnit]; + frequency = amount === 1 ? `Every ${displayUnit.slice(0, -1)}` : `Every ${amount} ${displayUnit}`; + } const duplicates = originalHeaders && new Set(originalHeaders.map(([key]) => key.toLowerCase())).size < originalHeaders.length; return <> {error ?

{error}

: null} -
setOpen(event.currentTarget.open)}>Schedule +
+

Schedule

+ {!loaded ?

{busy ? "Loading schedule…" : "Load current settings before editing."}

: <> +

{schedule ? schedule.paused ? "Paused" : "Enabled" : "Not configured"}

+ {schedule ?

{schedule.cron ? `${schedule.cron} · ${schedule.timezone ?? "UTC"}` : frequency} · {schedule.next_run_at === null ? "No next run" : `Next run: ${displayTime(schedule.next_run_at)}`}

: null} + {runs[0] ?

Last result: {runs[0].status} · {displayTime(runs[0].started_at)}

: null} + } - {!loaded ?

{busy ? "Loading schedule…" : "Load current settings before editing."}

:

{schedule ? schedule.paused ? "Paused" : schedule.next_run_at === null ? "No next run" : `Next run: ${new Date(schedule.next_run_at).toISOString()} (UTC)` : "No schedule configured"}

} + {schedule ? <> : null} +
+ {loaded && !hasServer ?

This artifact has no validated server. Add and deploy a server handler in Source to schedule requests.

:
{schedule ? "Edit schedule" : "Create schedule"}
{ event.preventDefault(); if (loaded && !busy) void perform(() => control("set")); }}>
+
- {mode === "interval" ? <> : <>} + {mode === "interval" ? <> : <>} +
{loaded ?

{preview}

: null} +
+
+
Advanced request settings