diff --git a/docs/agent-control.md b/docs/agent-control.md index 59af4fd70..0720fc633 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -8,7 +8,10 @@ agents. The only product entry point is ordinary desktop startup. ## Normal desktop workflow Run from the feature worktree with `bin/just desktop`, not a management-only -launcher. This opens **Buzz Foundation** using the ordinary live-development +launcher. The command prepares the pinned agent runtime before starting Tauri; +the first build may take several minutes. Later launches verify and reuse matching +resources, rebuilding missing, stale or corrupt ones. Preparation failure stops +launch rather than opening a desktop that cannot run agents. This opens **Buzz Foundation** using the ordinary live-development configuration and persistent native settings. Coordinate the native rebuild/relaunch; quit other Foundation copies first. Saved enabled agents can restore on startup. Keep imported agents disabled and old Buzz running until an attended handover. @@ -27,6 +30,8 @@ During a Create/profile wait, **Close** leaves the native operation running and exposes the existing cards' recovery Stop. Closing before creation returns skips automatic profile publication; refresh status and retry on the saved card. Late completion never closes a subsequently opened dialog. +Create is blocked with an explanation if this app’s runtime is unavailable; +existing agents and profile retry remain intact. The dev broker and native host must both support this flow. Packaged human signing remains unavailable. @@ -118,9 +123,11 @@ resources. Production has no disposable storage override or preview launch mode. unavailable capability; no fetch fallback, local storage, signing or runner. - `bundled/agents/AgentControlPanel.tsx`: compose with `{ control }` independently of selected community or relay connectivity. It owns only observation and UI - drafts. Its five-second refresh runs while visible/ready; reads coalesce. On - errors it stops polling and exposes explicit Retry. Unmount clears the timer, - not enabled intent or processes. + drafts. Its five-second refresh runs while visible/ready; reads coalesce. A read + rejected specifically because native startup is initializing or its lock is busy + stays pending for at most twenty 250ms waits. Genuine errors or exhausted retries + stop polling and expose explicit Retry; writes are never automatically retried. + Unmount clears the timer, not enabled intent or processes. - Native host owns persistent state, credential custody, process groups, lock and duplicate ownership checks, source import validation and sanitized diagnostics. It must bound IPC operations and reject with deliberately user-facing strings; @@ -224,7 +231,9 @@ resources. Production has no disposable storage override or preview launch mode. ## Runtime resources -Build without launching any app or accessing old credentials: +Ordinary `bin/just desktop` and `bin/pnpm tauri build` prepare these resources +automatically. Direct Cargo builds do not run that JavaScript preparation step. +To prepare/build without launching any app or accessing old credentials: ```sh bin/pnpm install --frozen-lockfile diff --git a/docs/contributing.md b/docs/contributing.md index 88fcaafba..c9abf4df2 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -48,7 +48,10 @@ need their own validation. e.g. `just web --port 1431 --host 127.0.0.1`. Vite uses the requested port (default: 1430) or the next available port, allowing parallel browser development. - `just desktop [args...]`: install locked dependencies and forward arguments to - Tauri, e.g. `just desktop --port 1431 --no-watch`. The desktop adapter consumes + Tauri, e.g. `just desktop --port 1431 --no-watch`. Before launching, the adapter + builds the pinned agent runtime when missing/outdated, or verifies and reuses it. + A preparation failure stops launch; help does not prepare resources. + The desktop adapter consumes `--port N` or `--port=N` to set both Vite's port and Tauri's development URL; Tauri's own `--port` is for its static-file server, not Vite. Without this flag, the existing Tauri configuration is unchanged (port 1430). Desktop requires the diff --git a/scripts/build-agent-runtime.mjs b/scripts/build-agent-runtime.mjs index 4e4d1b1f8..eee93e6cd 100644 --- a/scripts/build-agent-runtime.mjs +++ b/scripts/build-agent-runtime.mjs @@ -3,6 +3,9 @@ import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { readFile, + lstat, + access, + constants, mkdir, mkdtemp, copyFile, @@ -48,6 +51,46 @@ const target = (await run(join(root, "bin/rustc"), ["-vV"], true)).match( /^host: (.+)$/m, )?.[1]; if (!target) throw new Error("Could not resolve pinned Rust target"); +const destination = join(root, "src-tauri/resources/agent-runtime"); +const filenames = spec.tools.map((name) => + process.platform === "win32" ? `${name}.exe` : name, +); +async function currentBundle() { + try { + if (!(await lstat(destination)).isDirectory()) return false; + const manifestPath = join(destination, "manifest.json"); + const meta = await lstat(manifestPath); + if (!meta.isFile() || meta.size > 16384) return false; + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + if ( + Object.keys(manifest).length !== 4 || + manifest.version !== 1 || + manifest.revision !== spec.revision || + manifest.target !== target || + Object.keys(manifest.files).length !== filenames.length + ) + return false; + for (const filename of filenames) { + const path = join(destination, filename); + if (!(await lstat(path)).isFile()) return false; + await access(path, constants.X_OK); + const hash = createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + if (manifest.files[filename] !== hash) return false; + } + return true; + } catch { + return false; + } +} +if (await currentBundle()) { + console.log(`Agent runtime ready (${spec.revision}, ${target})`); + process.exit(0); +} +console.log( + "Preparing the agent runtime; the first build can take several minutes.", +); await mkdir(join(root, "target"), { recursive: true }); const stage = await mkdtemp(join(root, "target/agent-runtime-stage-")); try { @@ -67,7 +110,6 @@ try { "buzz-cli", "git-credential-nostr", ]); - const destination = join(root, "src-tauri/resources/agent-runtime"); await mkdir(destination, { recursive: true }); const files = {}; for (const name of spec.tools) { diff --git a/scripts/desktop-dev.mjs b/scripts/desktop-dev.mjs index e541ff66e..f405e8793 100644 --- a/scripts/desktop-dev.mjs +++ b/scripts/desktop-dev.mjs @@ -25,6 +25,18 @@ for (; index < args.length && args[index] !== "--"; index++) { } } +// Prepare resources before Tauri can compile or observe an already-running Vite. +// Its dev-server readiness timeout must not include a cold runtime build. +if (!forwarded.some((arg) => arg === "--help" || arg === "-h")) { + const prepared = spawnSync( + process.execPath, + [fileURLToPath(new URL("./build-agent-runtime.mjs", import.meta.url))], + { stdio: "inherit" }, + ); + if (prepared.error) console.error(prepared.error.message); + if (prepared.signal) process.kill(process.pid, prepared.signal); + if (prepared.status !== 0) process.exit(prepared.status ?? 1); +} const config = {}; const icon = worktreeIcon(fileURLToPath(new URL("../", import.meta.url))); if (icon) config.bundle = { icon: [icon] }; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 510871775..f1ce27a10 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -6,7 +6,7 @@ "build": { "beforeDevCommand": "pnpm dev:desktop", "devUrl": "http://localhost:1430", - "beforeBuildCommand": "pnpm build", + "beforeBuildCommand": "node scripts/build-agent-runtime.mjs && pnpm build", "frontendDist": "../dist" }, "app": { diff --git a/src/bundled/agents/AgentCreateDialog.tsx b/src/bundled/agents/AgentCreateDialog.tsx index c1db3a046..a2bf7da7b 100644 --- a/src/bundled/agents/AgentCreateDialog.tsx +++ b/src/bundled/agents/AgentCreateDialog.tsx @@ -54,9 +54,10 @@ export function AgentCreateDialog({ state.data?.createAvailable && control.create ); + const runtimeBlocked = !state.data?.runtimeAvailable && !saved; const blocked = busy || state.busy || state.status !== "ready"; const create = async () => { - if (blocked || !available || !control.create) return; + if (blocked || runtimeBlocked || !available || !control.create) return; setError(undefined); setBusy(true); try { @@ -129,6 +130,13 @@ export function AgentCreateDialog({ an agent.

)} + {runtimeBlocked && ( +

+ This app’s agent runtime is unavailable. Repair or rebuild the + desktop app before creating an agent. + {state.data?.runtimeMessage && ` ${state.data.runtimeMessage}`} +

+ )} {busy && (

You can close this dialog to stop another agent. Saving @@ -158,7 +166,7 @@ export function AgentCreateDialog({ diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index 3c930f677..fd58c2ad8 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -22,6 +22,7 @@ const disposals: (() => void)[] = []; afterEach(() => { cleanup(); vi.restoreAllMocks(); + vi.useRealTimers(); for (const dispose of disposals.splice(0)) dispose(); }); function setup( @@ -591,3 +592,147 @@ for (const stage of ["create", "profile"] as const) { }); } } + +it("blocks creation before native writes when the runtime is missing and preserves the draft for recovery", async () => { + const prepare = vi.fn(async () => ({ + id: "created", + pubkey: "cd".repeat(32), + })); + const commit = vi.fn(); + const { f, control } = setup("connected", (fixture) => { + fixture.data.createAvailable = true; + fixture.data.runtimeAvailable = false; + fixture.data.runtimeMessage = + "Agent runtime is not packaged; build its resources first"; + fixture.host.prepareCreate = prepare; + fixture.host.commitCreate = commit; + }); + fireEvent.click(await screen.findByRole("button", { name: "Add agent" })); + const dialog = screen.getByRole("dialog", { name: "Create agent" }); + fireEvent.change(within(dialog).getByLabelText("Name"), { + target: { value: "Calvin" }, + }); + const create = within(dialog).getByRole("button", { name: "Create agent" }); + expect(create).toBeDisabled(); + expect(within(dialog).getByRole("alert")).toHaveTextContent( + "agent runtime is unavailable", + ); + const form = create.closest("form"); + if (!form) throw Error("Missing create form"); + await act(async () => fireEvent.submit(form)); + expect(prepare).not.toHaveBeenCalled(); + expect(commit).not.toHaveBeenCalled(); + f.data.runtimeAvailable = true; + await act(async () => control.refresh()); + expect(within(dialog).getByLabelText("Name")).toHaveValue("Calvin"); + expect(create).toBeEnabled(); + expect(within(dialog).queryByRole("alert")).toBeNull(); +}); + +it("retries the same saved profile even if the runtime becomes unavailable", async () => { + const prepare = vi.fn(async () => ({ + id: "created", + pubkey: "cd".repeat(32), + })); + const commit = vi.fn(); + const profile = vi.fn(); + vi.spyOn(communityApi, "communityRequest").mockResolvedValue({ auth: [] }); + const { f, control } = setup("connected", (fixture) => { + fixture.data.createAvailable = true; + fixture.data.defaultWorkspace = "/fixture/workspace"; + fixture.host.prepareCreate = prepare; + fixture.host.commitCreate = commit.mockImplementation( + async (_request, edit) => { + fixture.data.agents.push({ + ...structuredClone(fixture.agent), + id: "created", + name: edit.name, + enabled: false, + status: "stopped", + profilePending: true, + }); + return structuredClone(fixture.data); + }, + ); + fixture.host.publishProfile = profile + .mockRejectedValueOnce("Synthetic profile failure") + .mockImplementation(async () => { + const created = fixture.data.agents.find( + (agent) => agent.id === "created", + ); + if (!created) throw Error("Missing created fixture"); + created.profilePending = false; + return structuredClone(fixture.data); + }); + }); + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "Add agent" })); + const dialog = screen.getByRole("dialog", { name: "Create agent" }); + await user.type(within(dialog).getByLabelText("Name"), "Calvin"); + await user.click( + within(dialog).getByRole("button", { name: "Create agent" }), + ); + await within(dialog).findByText(/Calvin is saved and stopped/); + expect(profile).toHaveBeenCalledExactlyOnceWith("created"); + f.data.runtimeAvailable = false; + await act(async () => control.refresh()); + const retry = within(dialog).getByRole("button", { name: "Retry profile" }); + expect(retry).toBeEnabled(); + await user.click(retry); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(prepare).toHaveBeenCalledOnce(); + expect(commit).toHaveBeenCalledOnce(); + expect(profile.mock.calls).toEqual([["created"], ["created"]]); +}); + +for (const error of [ + "Agent runtime is initializing; retry shortly", + "Another native agent operation is in progress", +]) { + it(`shows agents without manual Retry after a transient native read: ${error}`, async () => { + vi.useFakeTimers(); + let snapshot!: ReturnType; + setup("ready", (f) => { + snapshot = vi.spyOn(f.host, "snapshot").mockRejectedValueOnce(error); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByText("Reading local agent status…")).toBeVisible(); + expect(screen.queryByRole("button", { name: "Retry status" })).toBeNull(); + expect(snapshot).toHaveBeenCalledTimes(1); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + expect( + screen.getAllByRole("article", { name: "Agent Fixture agent" }), + ).toHaveLength(2); + expect(screen.queryByRole("button", { name: "Retry status" })).toBeNull(); + expect(snapshot).toHaveBeenCalledTimes(2); + }); +} +it("shows Retry after persistent or genuine read failure without hiding the error", async () => { + vi.useFakeTimers(); + const { f } = setup("ready", (f) => { + vi.spyOn(f.host, "snapshot").mockRejectedValue( + "Agent runtime is initializing; retry shortly", + ); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(screen.getByRole("button", { name: "Retry status" })).toBeVisible(); + expect(screen.getByText(/Could not refresh local agents/)).toBeVisible(); + expect(f.host.snapshot).toHaveBeenCalledTimes(21); + await act(async () => { + await vi.advanceTimersByTimeAsync(10000); + }); + expect(f.host.snapshot).toHaveBeenCalledTimes(21); + vi.mocked(f.host.snapshot).mockRejectedValue("Store is unreadable"); + fireEvent.click(screen.getByRole("button", { name: "Retry status" })); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(f.host.snapshot).toHaveBeenCalledTimes(22); + expect(screen.getByRole("button", { name: "Retry status" })).toBeVisible(); +}); diff --git a/src/features/agents/control.test.ts b/src/features/agents/control.test.ts index 0f0a71269..46e62da0d 100644 --- a/src/features/agents/control.test.ts +++ b/src/features/agents/control.test.ts @@ -4,7 +4,10 @@ import { controlFixture } from "./control-testing"; import * as communityApi from "../communities/api"; import { agentDraft, agentEdit } from "../../bundled/agents/agent-edit"; -afterEach(() => vi.restoreAllMocks()); +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { @@ -742,3 +745,119 @@ it("a no-match mention is a native no-op and cannot erase an existing wake failu expect(control.snapshot().mentionError).toBe(error); control.dispose(); }); + +const transientReads = [ + "Agent runtime is initializing; retry shortly", + "Another native agent operation is in progress", +]; +for (const error of transientReads) { + it(`keeps a coalesced read loading until native recovers from ${error}`, async () => { + vi.useFakeTimers(); + const fixture = controlFixture(); + const snapshot = vi + .spyOn(fixture.host, "snapshot") + .mockRejectedValueOnce(error) + .mockRejectedValueOnce(error); + const control = createAgentControl(fixture.host); + const pending = control.refresh(); + await vi.advanceTimersByTimeAsync(0); + expect(control.refresh()).toBe(pending); + expect(snapshot).toHaveBeenCalledTimes(1); + expect(control.snapshot()).toMatchObject({ + status: "loading", + error: null, + }); + await vi.advanceTimersByTimeAsync(250); + expect(snapshot).toHaveBeenCalledTimes(2); + expect(control.snapshot().status).toBe("loading"); + await vi.advanceTimersByTimeAsync(250); + await pending; + expect(snapshot).toHaveBeenCalledTimes(3); + expect(control.snapshot().status).toBe("ready"); + expect(control.snapshot().data).toEqual(fixture.data); + expect(fixture.calls).toEqual([{ action: "snapshot" }]); + control.dispose(); + }); +} +for (const previousSnapshot of [false, true]) { + it(`bounds transient reads and retains previous evidence: ${previousSnapshot}`, async () => { + vi.useFakeTimers(); + const fixture = controlFixture(); + const control = createAgentControl(fixture.host); + if (previousSnapshot) await control.refresh(); + const snapshot = vi + .spyOn(fixture.host, "snapshot") + .mockRejectedValue(transientReads[0]); + const pending = control.refresh(); + await vi.advanceTimersByTimeAsync(4999); + expect(snapshot).toHaveBeenCalledTimes(20); + expect(control.snapshot().status).toBe( + previousSnapshot ? "ready" : "loading", + ); + await vi.advanceTimersByTimeAsync(1); + await pending; + expect(snapshot).toHaveBeenCalledTimes(21); + expect(control.snapshot().status).toBe("error"); + expect(control.snapshot().data).toEqual( + previousSnapshot ? fixture.data : null, + ); + await vi.advanceTimersByTimeAsync(10000); + expect(snapshot).toHaveBeenCalledTimes(21); + snapshot.mockResolvedValue(fixture.data); + await control.refresh(); + expect(control.snapshot().status).toBe("ready"); + control.dispose(); + }); +} +it("does not retry a genuine read failure or replay a write with a transient-looking error", async () => { + vi.useFakeTimers(); + const fixture = controlFixture(); + const control = createAgentControl(fixture.host); + const snapshot = vi + .spyOn(fixture.host, "snapshot") + .mockRejectedValueOnce("Unreadable store"); + await control.refresh(); + expect(control.snapshot().status).toBe("error"); + await vi.advanceTimersByTimeAsync(5000); + expect(snapshot).toHaveBeenCalledTimes(1); + await control.refresh(); + const action = vi + .spyOn(fixture.host, "action") + .mockRejectedValue(transientReads[1]); + await expect(control.action(fixture.agent.id, "start")).rejects.toThrow( + "Could not confirm", + ); + await vi.advanceTimersByTimeAsync(5000); + expect(action).toHaveBeenCalledTimes(1); + expect(snapshot).toHaveBeenCalledTimes(2); + expect(control.snapshot().status).toBe("error"); + control.dispose(); +}); +for (const boundary of ["dispose", "newer write"] as const) { + it(`retires a waiting status read on ${boundary}`, async () => { + vi.useFakeTimers(); + const fixture = controlFixture(); + const control = createAgentControl(fixture.host); + await control.refresh(); + const snapshot = vi + .spyOn(fixture.host, "snapshot") + .mockRejectedValue(transientReads[1]); + const pending = control.refresh(); + await vi.advanceTimersByTimeAsync(0); + expect(snapshot).toHaveBeenCalledTimes(1); + if (boundary === "dispose") control.dispose(); + else await control.action(fixture.agent.id, "stop"); + const before = control.snapshot(); + await vi.advanceTimersByTimeAsync(5000); + await pending; + expect(snapshot).toHaveBeenCalledTimes(1); + expect(control.snapshot()).toBe(before); + if (boundary === "newer write") { + expect(control.snapshot().data?.agents[0]?.status).toBe("stopped"); + expect( + fixture.calls.filter((call) => call.action === "stop"), + ).toHaveLength(1); + } + control.dispose(); + }); +} diff --git a/src/features/agents/control.ts b/src/features/agents/control.ts index 6ec0a6c16..e2c71cdf5 100644 --- a/src/features/agents/control.ts +++ b/src/features/agents/control.ts @@ -182,10 +182,27 @@ export function createAgentControl( const current = generation; if (!state.data) update({ status: "loading", error: null }); const pending = Promise.resolve() - .then(() => host.snapshot()) + .then(async () => { + // Only read-only native startup/contention failures are transient. Keep + // the coalesced read loading for up to twenty 250ms waits, not a UI error. + for (let attempt = 0; !disposed && current === generation; attempt++) { + try { + return await host.snapshot(); + } catch (error) { + if (disposed || current !== generation) return; + if ( + attempt === 20 || + (error !== "Agent runtime is initializing; retry shortly" && + error !== "Another native agent operation is in progress") + ) + throw error; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + }) .then( (data) => { - if (current === generation) ready(data); + if (data && current === generation) ready(data); }, () => { if (current === generation) diff --git a/tests/browser/channel-opening.spec.mjs b/tests/browser/channel-opening.spec.mjs index b700ef66b..de634cec8 100644 --- a/tests/browser/channel-opening.spec.mjs +++ b/tests/browser/channel-opening.spec.mjs @@ -107,18 +107,26 @@ test("cold opening bypasses held DM labels; warm switching paints within 100ms w const before = submittedHeads.length; // Browser-clock click → first visible row → paint, excluding Playwright IPC. for (const name of ["Alpha", "Beta", "Alpha", "Beta"]) { - const visibleMs = await page + const timing = await page .getByRole("button", { name, exact: true }) .evaluate( async (button, { name, ids }) => { const start = performance.now(); button.click(); + const clickDispatchMs = performance.now() - start; + const frames = []; + let firstVisibleMs; + let paintOpportunity; await new Promise((resolve, reject) => { const deadline = setTimeout( () => reject(new Error("warm switch did not paint")), 1000, ); - const check = () => { + const check = (timestamp) => { + frames.push({ + frameMs: timestamp - start, + callbackMs: performance.now() - start, + }); const composer = document.querySelector( `[role="textbox"][aria-label="Message #${name}"]`, ); @@ -140,14 +148,27 @@ test("cold opening bypasses held DM labels; warm switching paints within 100ms w }, ); if (!visible || !composer) return requestAnimationFrame(check); - requestAnimationFrame(() => { + firstVisibleMs = performance.now() - start; + requestAnimationFrame((timestamp) => { + paintOpportunity = { + frameMs: timestamp - start, + callbackMs: performance.now() - start, + }; clearTimeout(deadline); resolve(); }); }; requestAnimationFrame(check); }); - return performance.now() - start; + const warmVisibleMs = performance.now() - start; + return { + warmVisibleMs, + // Synchronous button.click() only, not all React/render work. + clickDispatchMs, + frames, + firstVisibleMs, + paintOpportunity, + }; }, { name, @@ -156,8 +177,8 @@ test("cold opening bypasses held DM labels; warm switching paints within 100ms w .map((event) => event.id), }, ); - app.report.measurements.push({ name, warmVisibleMs: visibleMs }); - expect(visibleMs).toBeLessThan(100); + app.report.measurements.push({ name, ...timing }); + expect(timing.warmVisibleMs).toBeLessThan(100); } expect(submittedHeads).toHaveLength(before); const diagnostics = page diff --git a/tests/browser/gifs.spec.mjs b/tests/browser/gifs.spec.mjs index cc0f28780..c29d3c6a4 100644 --- a/tests/browser/gifs.spec.mjs +++ b/tests/browser/gifs.spec.mjs @@ -52,12 +52,16 @@ test("relay-backed GIF tab searches KLIPY and inserts URL-only media", async ({ }); }); await page.route("**/api/relay/*/gifs", async (route) => { - requests.push(route.request().postDataJSON()); + const request = route.request().postDataJSON(); + requests.push(request); await route.fulfill({ json: { result: true, data: { - data: [result(1, "Hello", 240), result(2, "Celebrate", 320)], + data: [ + result(1, request.query ? "Hello" : "Trending Hello", 240), + result(2, "Celebrate", 320), + ], }, }, }); @@ -252,15 +256,22 @@ test("relay-backed GIF tab searches KLIPY and inserts URL-only media", async ({ await clear.click(); await expect(search).toHaveValue(""); await expect(search).toBeFocused(); + // Clearing starts a debounced request. Old results have the same count, so + // wait for the cleared query's rendered result before clicking its replacement. + const trending = page.getByRole("button", { + name: "Choose Trending Hello", + exact: true, + }); + await expect(trending).toBeVisible(); await expect( page.getByTestId("klipy-gif-grid").getByRole("button"), ).toHaveCount(2); await page.screenshot({ path: testInfo.outputPath("gif-picker.png") }); - await page.getByRole("button", { name: "Choose Hello", exact: true }).click(); + await trending.click(); await expect(draft).toHaveJSProperty( "value", - "![Hello](https://gif.fixture.invalid/1.gif)", + "![Trending Hello](https://gif.fixture.invalid/1.gif)", ); await expect( page.getByRole("searchbox", { name: "Search GIFs" }), diff --git a/tests/integration/agent-runtime-fixture.mjs b/tests/integration/agent-runtime-fixture.mjs new file mode 100644 index 000000000..b16beb093 --- /dev/null +++ b/tests/integration/agent-runtime-fixture.mjs @@ -0,0 +1,37 @@ +import { copyFileSync, mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +// Real preparation script and manifest; only the compiler toolchain is synthetic. +export function runtimeFixture(directory) { + for (const name of ["scripts", "runtime", "bin"]) + mkdirSync(path.join(directory, name), { recursive: true }); + for (const name of [ + "scripts/build-agent-runtime.mjs", + "runtime/agent-runtime.json", + ]) + copyFileSync( + new URL(`../../${name}`, import.meta.url), + path.join(directory, name), + ); + const tool = (name, body) => + writeFileSync( + path.join(directory, "bin", name), + `#!${process.execPath}\n${body}\n`, + { mode: 0o755 }, + ); + tool("rustc", 'console.log("host: fixture-target");'); + tool( + "cargo", + ` +const fs = require("node:fs"); +const path = require("node:path"); +fs.appendFileSync("build-calls.jsonl", JSON.stringify(process.argv.slice(2)) + "\\n"); +if (fs.existsSync("fail-build")) process.exit(17); +const stage = process.argv[process.argv.indexOf("--root") + 1]; +fs.mkdirSync(path.join(stage, "bin"), { recursive: true }); +const spec = JSON.parse(fs.readFileSync("runtime/agent-runtime.json", "utf8")); +for (const name of spec.tools) fs.writeFileSync(path.join(stage, "bin", + process.platform === "win32" ? name + ".exe" : name), "fixture " + name, { mode: 0o755 }); +`, + ); +} diff --git a/tests/integration/agent-runtime.test.mjs b/tests/integration/agent-runtime.test.mjs new file mode 100644 index 000000000..8b6e861d5 --- /dev/null +++ b/tests/integration/agent-runtime.test.mjs @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { runtimeFixture } from "./agent-runtime-fixture.mjs"; + +test("runtime preparation builds missing resources, reuses verified files, and repairs stale or corrupt bundles", (t) => { + const directory = mkdtempSync(path.join(tmpdir(), "buzz-agent-runtime-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + runtimeFixture(directory); + const run = () => { + const result = spawnSync( + process.execPath, + ["scripts/build-agent-runtime.mjs"], + { + cwd: directory, + encoding: "utf8", + timeout: 10_000, + }, + ); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + return result.stdout; + }; + const bundle = path.join(directory, "src-tauri/resources/agent-runtime"); + const manifestPath = path.join(bundle, "manifest.json"); + const count = () => + readFileSync(path.join(directory, "build-calls.jsonl"), "utf8") + .trim() + .split("\n").length; + assert.match(run(), /Verified inputs staged/); + assert.equal(count(), 1); + assert.match(run(), /Agent runtime ready/); + assert.equal(count(), 1, "warm preparation must not invoke Cargo"); + const filename = + process.platform === "win32" ? "buzz-agent.exe" : "buzz-agent"; + const binary = path.join(bundle, filename); + for (const mutation of [ + () => writeFileSync(binary, "corrupt"), + () => rmSync(binary), + () => writeFileSync(manifestPath, "not json"), + () => + writeFileSync( + manifestPath, + " ".repeat(16385) + readFileSync(manifestPath, "utf8"), + ), + () => { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + manifest.unexpected = true; + writeFileSync(manifestPath, JSON.stringify(manifest)); + }, + ...["revision", "target", "version"].map((key) => () => { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + manifest[key] = "outdated"; + writeFileSync(manifestPath, JSON.stringify(manifest)); + }), + ...(process.platform === "win32" ? [] : [() => chmodSync(binary, 0o644)]), + ]) { + const before = count(); + mutation(); + assert.match(run(), /Verified inputs staged/); + assert.equal(count(), before + 1); + assert.ok(existsSync(binary)); + assert.match(run(), /Agent runtime ready/); + assert.equal(count(), before + 1); + } +}); + +test("packaged desktop build prepares runtime before frontend compilation", () => { + const config = JSON.parse( + readFileSync( + new URL("../../src-tauri/tauri.conf.json", import.meta.url), + "utf8", + ), + ); + assert.equal( + config.build.beforeBuildCommand, + "node scripts/build-agent-runtime.mjs && pnpm build", + ); +}); diff --git a/tests/integration/dev-commands.test.mjs b/tests/integration/dev-commands.test.mjs index 0eeb746fa..887da5a4a 100644 --- a/tests/integration/dev-commands.test.mjs +++ b/tests/integration/dev-commands.test.mjs @@ -13,8 +13,9 @@ import { import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; +import { runtimeFixture } from "./agent-runtime-fixture.mjs"; -function recipe(name, ...args) { +function recipeWithRuntime(failRuntime, name, ...args) { const directory = mkdtempSync(path.join(tmpdir(), "buzz-dev-command-")); const callsFile = path.join(directory, "calls.jsonl"); try { @@ -31,11 +32,16 @@ function recipe(name, ...args) { new URL("../../scripts/worktree-icon.mjs", import.meta.url), path.join(directory, "scripts/worktree-icon.mjs"), ); - // Run the real recipes and adapter, recording only the package-manager boundary. + runtimeFixture(directory); + if (failRuntime) writeFileSync(path.join(directory, "fail-build"), ""); + // Run the real recipes, adapter and preparation; never open a native app. symlinkSync(process.execPath, path.join(directory, "node")); writeFileSync( path.join(directory, "pnpm"), - `#!${process.execPath}\nrequire("node:fs").appendFileSync(process.env.BUZZ_TEST_CALLS, JSON.stringify(process.argv.slice(2)) + "\\n");\n`, + `#!${process.execPath}\nrequire("node:fs").appendFileSync(process.env.BUZZ_TEST_CALLS, JSON.stringify(process.argv.slice(2)) + "\\n"); +if (process.argv[2] === "tauri" && !process.argv.includes("--help") && !process.argv.includes("-h")) { + if (!require("node:fs").existsSync("src-tauri/resources/agent-runtime/manifest.json")) process.exit(19); +}\n`, { mode: 0o755 }, ); // Set PATH inside the recipe shell: Hermit proxies restore their own PATH. @@ -69,12 +75,27 @@ function recipe(name, ...args) { const calls = existsSync(callsFile) ? readFileSync(callsFile, "utf8").trim().split("\n").map(JSON.parse) : []; - return { ...result, calls }; + const built = existsSync(path.join(directory, "build-calls.jsonl")); + return { ...result, calls, built }; } finally { rmSync(directory, { recursive: true, force: true }); } } +function recipe(name, ...args) { + return recipeWithRuntime(false, name, ...args); +} + +test("runtime preparation failure prevents desktop launch, while help does not build", () => { + const result = recipeWithRuntime(true, "desktop"); + assert.notEqual(result.status, 0); + assert.equal(result.built, true); + assert.deepEqual(result.calls, [["install", "--frozen-lockfile"]]); + const help = recipeWithRuntime(true, "desktop", "--help"); + assert.equal(help.status, 0, help.stderr); + assert.equal(help.built, false); +}); + function launched(target, ...args) { const result = recipe(target, ...args); assert.equal(result.status, 0, result.stderr); diff --git a/tests/integration/worktree-icon.test.mjs b/tests/integration/worktree-icon.test.mjs index 5c090957e..b1aa728dc 100644 --- a/tests/integration/worktree-icon.test.mjs +++ b/tests/integration/worktree-icon.test.mjs @@ -13,6 +13,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { runtimeFixture } from "./agent-runtime-fixture.mjs"; // Real Git worktrees and subprocesses; only Swift rendering and pnpm are stubs. // The macOS-only launcher wiring also runs on a Mac, without opening an app. @@ -71,7 +72,7 @@ function fixture(t) { ); env.PATH = `${tools}${path.delimiter}${env.PATH}`; for (const cwd of [main, linked]) { - mkdirSync(path.join(cwd, "scripts")); + runtimeFixture(cwd); for (const file of ["desktop-dev.mjs", "worktree-icon.mjs"]) { copyFileSync( new URL(`../../scripts/${file}`, import.meta.url), @@ -168,7 +169,9 @@ test("macOS launcher combines icon and port before explicit config and runner ar const launch = () => JSON.parse( run(linked, ["scripts/desktop-dev.mjs", "--port=1431", ...forwarded]) - .stdout, + .stdout.trim() + .split("\n") + .at(-1), ); const call = launch(); assert.deepEqual(call.slice(0, 3), ["tauri", "dev", "--config"]);