|
| 1 | +import { test, expect, type ElectronApplication, type Page } from "@playwright/test"; |
| 2 | +import fs from "node:fs"; |
| 3 | +import path from "node:path"; |
| 4 | +import { currentRow, items, launch, seedHome, tid } from "./support"; |
| 5 | + |
| 6 | +/** |
| 7 | + * What a turn's evidence shows (docs/conversation-evidence.md): the command that actually ran and how |
| 8 | + * it ended, output bounded with a visible note, a patch labelled as this turn's, a native memory |
| 9 | + * citation, and model-written links labelled as such. Items are sent to the window as main would. |
| 10 | + */ |
| 11 | +let app: ElectronApplication; |
| 12 | +let page: Page; |
| 13 | +let home: string; |
| 14 | +let repo: string; |
| 15 | +let threadId: string; |
| 16 | + |
| 17 | +const at = () => new Date().toISOString(); |
| 18 | +const origin = (item: string) => ({ thread: threadId, turn: "u-evidence", attempt: "E1", backend: "codex", native: { runtime: "R", thread: "nt", turn: "nu", item } }); |
| 19 | +async function send(events: Record<string, unknown>[]): Promise<void> { |
| 20 | + await app.evaluate(({ BrowserWindow }, list) => { |
| 21 | + for (const event of list) BrowserWindow.getAllWindows()[0]!.webContents.send("thread:event", event); |
| 22 | + }, events); |
| 23 | +} |
| 24 | +const item = (event: Record<string, unknown>) => ({ type: "item", threadId, item: event }); |
| 25 | + |
| 26 | +test.beforeAll(async () => { |
| 27 | + ({ home, repo } = seedHome()); |
| 28 | + ({ app, page } = await launch(home)); |
| 29 | + const script = path.join(home, "evidence-script.json"); |
| 30 | + fs.writeFileSync(script, JSON.stringify([{ content: "Ready." }])); |
| 31 | + await page.evaluate((mock_script) => window.modex!.invoke("settings:update", { mock_script }), script); |
| 32 | + await page.keyboard.press("Meta+n"); |
| 33 | + await expect(tid(page, "composer-input")).toBeFocused(); |
| 34 | + await tid(page, "composer-input").fill("start"); |
| 35 | + await page.keyboard.press("Meta+Enter"); |
| 36 | + await expect(tid(page, "thread-view")).toBeVisible({ timeout: 15_000 }); |
| 37 | + await expect(currentRow(page)).toHaveAttribute("data-status", "idle"); |
| 38 | + threadId = (await currentRow(page).getAttribute("data-thread-id"))!; |
| 39 | + const tool = (id: string, title: string, extra: Record<string, unknown>) => item({ id, kind: "tool", name: "shell", title, args: {}, status: "done", durationMs: 1200, origin: origin(id), at: at(), ...extra }); |
| 40 | + await send([ |
| 41 | + item({ id: "u-evidence", kind: "user", text: "evidence turn", at: at() }), |
| 42 | + tool("t-fail", "$ npm test", { exec: { command: "/bin/zsh -lc 'npm test -- --grep \"a b\"'", cwd: "/repo/pkg" }, exitCode: 2, outcome: "failed", ok: false, |
| 43 | + output: "…last lines\n1 failing\n", outputBytes: 3 * 1024 * 1024, outputTruncated: true }), |
| 44 | + tool("t-denied", "$ rm -rf build", { exec: { command: "/bin/zsh -lc 'rm -rf build'", cwd: "/repo" }, exitCode: null, outcome: "denied", ok: false }), |
| 45 | + tool("t-claude", "$ ls", { exec: { command: "ls" }, outcome: "succeeded", ok: true, output: "src\n" }), |
| 46 | + item({ id: "t-patch", kind: "tool", name: "apply_patch", title: "edit NOTE.md", args: { patch: "*** Begin Patch\n*** Add File: NOTE.md\n+hi\n*** End Patch" }, status: "done", outcome: "succeeded", ok: true, origin: origin("t-patch"), at: at() }), |
| 47 | + item({ id: "a-final", kind: "assistant", text: "Use the build flags from memory.\n\nSources:\n- [Node.js](https://nodejs.org/)\n- [npm docs](https://docs.npmjs.com/)", phase: "final", outcome: "succeeded", origin: origin("a-final"), at: at(), |
| 48 | + evidence: [{ kind: "memory", source: "codex", path: "notes/build.md", lineStart: 3, lineEnd: 5, note: "Build flags 🛠" }] }), |
| 49 | + { type: "status", threadId, status: "idle" }, |
| 50 | + ]); |
| 51 | + await tid(page, "turn-label").last().click(); |
| 52 | +}); |
| 53 | + |
| 54 | +test.afterAll(async () => { |
| 55 | + await app?.close(); |
| 56 | + fs.rmSync(home, { recursive: true, force: true }); |
| 57 | + fs.rmSync(repo, { recursive: true, force: true }); |
| 58 | +}); |
| 59 | + |
| 60 | +test("a command shows what actually ran, where, and how it ended, apart from its title", async () => { |
| 61 | + const failed = items(page, "tool").filter({ hasText: "$ npm test" }); |
| 62 | + await tid(failed, "item-toggle").click(); |
| 63 | + await expect(tid(failed, "tool-command")).toHaveText("/bin/zsh -lc 'npm test -- --grep \"a b\"'"); |
| 64 | + await expect(tid(failed, "tool-cwd")).toHaveText("in /repo/pkg"); |
| 65 | + await expect(tid(failed, "tool-exit")).toHaveText("Exit code 2"); |
| 66 | + const denied = items(page, "tool").filter({ hasText: "$ rm -rf build" }); |
| 67 | + await tid(denied, "item-toggle").click(); |
| 68 | + await expect(tid(denied, "tool-exit")).toHaveText("No exit code"); |
| 69 | + const claude = items(page, "tool").filter({ hasText: "$ ls" }); |
| 70 | + await tid(claude, "item-toggle").click(); |
| 71 | + await expect(tid(claude, "tool-exit")).toHaveText("Exit code not reported"); |
| 72 | + await expect(tid(claude, "tool-cwd")).toHaveCount(0); |
| 73 | +}); |
| 74 | + |
| 75 | +test("output past the preview says how much is shown", async () => { |
| 76 | + const failed = items(page, "tool").filter({ hasText: "$ npm test" }); |
| 77 | + await expect(tid(failed, "tool-truncated")).toHaveText("Showing the last 256 KB of 3.0 MB."); |
| 78 | + await expect(tid(failed, "tool-output")).toContainText("1 failing"); |
| 79 | +}); |
| 80 | + |
| 81 | +test("a patch is labelled as this turn's, apart from the working tree now", async () => { |
| 82 | + const patch = items(page, "tool").filter({ hasText: "edit NOTE.md" }); |
| 83 | + await tid(patch, "item-toggle").click(); |
| 84 | + await expect(tid(patch, "tool-patch-note")).toHaveText("The patch as this turn applied it. The working tree may have changed since; Changes shows it now."); |
| 85 | +}); |
| 86 | + |
| 87 | +test("a native memory citation is shown as one; links the model wrote are labelled as its own", async () => { |
| 88 | + const answer = items(page, "assistant").filter({ hasText: "Use the build flags" }); |
| 89 | + const memory = tid(answer, "evidence-memory"); |
| 90 | + await expect(memory).toContainText("Codex memory"); |
| 91 | + await expect(memory).toContainText("notes/build.md · lines 3–5"); |
| 92 | + await expect(memory).toContainText("Build flags 🛠"); |
| 93 | + const sources = tid(answer, "sources"); |
| 94 | + await expect(sources).toHaveAttribute("data-provenance", "reply-text"); |
| 95 | + await expect(sources).toContainText("Links in the reply · 2"); |
| 96 | + await expect(sources).toHaveAttribute("title", "Links the model wrote in its reply. Modex has not checked them."); |
| 97 | +}); |
0 commit comments