| 1 | import { registerHooks } from "node:module"; |
| 2 | registerHooks({ resolve(specifier, context, nextResolve) { return specifier.endsWith(".svg") ? nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url }) : nextResolve(specifier, context); } }); |
| 3 | import assert from "node:assert/strict"; |
| 4 | import { managementDom } from "../test-support/managementDom"; |
| 5 | import type { SessionMeta } from "../lib/types"; |
| 6 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 7 | const dom = managementDom(); |
| 8 | const { default: React, act } = await import("react"); |
| 9 | const { createRoot } = await import("react-dom/client"); |
| 10 | const { LocaleProvider } = await import("../lib/i18n"); |
| 11 | const { TrashPage } = await import("../components/TrashPage"); |
| 12 | type Row = { id: string; ref: { hostId: string; sessionId: string }; title: string; workspaceId: string; workspaceTitle: string; archivedAt: number; health: string; canPreview: boolean; canRestore: boolean; canPurge: boolean }; |
| 13 | const row = (id: string): Row => ({ id, ref: { hostId: "local", sessionId: id }, title: id, workspaceId: "global", workspaceTitle: "Global", archivedAt: 0, health: "ready", canPreview: true, canRestore: true, canPurge: true }); |
| 14 | let rows = [row("a"), row("b"), row("c")]; |
| 15 | let latePreview!: () => void; |
| 16 | let listFails = false; |
| 17 | let throwConflict = false; |
| 18 | const calls: string[] = [], requests: string[] = []; |
| 19 | const fullRequests: string[] = []; |
| 20 | const terminal = new Set<string>(); |
| 21 | installDesktopHostStub({ |
| 22 | ListTrashEntries: async () => { if (listFails) throw new Error("read failure"); return { items: rows, generation: 7 }; }, |
| 23 | ListRecoveryEntries: async () => ({ items: [{ id: "protected", title: "Protected history", canRestore: true, canPreview: true }], generation: 7 }), |
| 24 | ReadSessionHistory: (ref: {sessionId:string}) => ref.sessionId === "a" ? new Promise(resolve => { latePreview = () => resolve({ messages: [{ messageId: "old", role: "user", content: "stale preview" }] }); }) : Promise.resolve({ messages: [{ messageId: "b", role: "user", content: "B history" }] }), |
| 25 | ApplySessionLifecycle: async (request: { operationId:string; action:string; targets:{ref:{sessionId:string}}[] }) => { |
| 26 | requests.push(request.operationId); |
| 27 | fullRequests.push(JSON.stringify(request)); |
| 28 | if (throwConflict) { |
| 29 | throwConflict = false; |
| 30 | throw new Error("workspace mutation conflicts with persisted state"); |
| 31 | } |
| 32 | const items = request.targets.map(target => { |
| 33 | const id = target.ref.sessionId; |
| 34 | // Completed children are not executed again when the whole command retries. |
| 35 | if (!rows.some(row => row.id === id)) return { target, ref: target.ref, committed: true, workspaceId: "global" }; |
| 36 | if (terminal.has(id)) return { target, ref: target.ref, committed: false, retryable: false, errorCode: "state_conflict", workspaceId: "global" }; |
| 37 | calls.push(`${request.action}:${id}`); |
| 38 | const conflict = request.action === "purge" && id === "c"; |
| 39 | const committed = request.action === "restore" || (id !== "b" && !conflict); |
| 40 | if (committed) rows = rows.filter(row => row.id !== id); |
| 41 | if (conflict) terminal.add(id); |
| 42 | if (request.action === "restore") listFails = true; |
| 43 | return { target, ref: target.ref, committed, retryable: !committed && !conflict, |
| 44 | errorCode: committed ? "" : conflict ? "state_conflict" : "operation_failed", workspaceId: "global" }; |
| 45 | }); |
| 46 | return { operationId: request.operationId, generation: 8, committed: items.every(item => item.committed), items }; |
| 47 | }, |
| 48 | }); |
| 49 | const root = createRoot(document.getElementById("root")!); |
| 50 | const render = (active = true) => <LocaleProvider><TrashPage active={active} onBack={() => {}} onOpenSession={async () => {}} list={async ():Promise<SessionMeta[]> => { throw new Error("legacy list must not be queried"); }} purge={async () => { throw new Error("legacy purge must not be called"); }} restore={async () => {}} /></LocaleProvider>; |
| 51 | const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(node => node.textContent?.trim() === text)!; |
| 52 | await act(async () => root.render(render())); |
| 53 | assert.equal(document.querySelector('[aria-pressed="true"]'), null, "no archived/deleted sub-tabs"); |
| 54 | assert.equal(document.querySelectorAll('.archived-sessions__row').length, 3); |
| 55 | await act(async () => (document.querySelectorAll('.archived-sessions__open')[0] as HTMLButtonElement).click()); |
| 56 | await act(async () => (document.querySelectorAll('.archived-sessions__open')[1] as HTMLButtonElement).click()); |
| 57 | await act(async () => latePreview()); |
| 58 | assert.ok(document.querySelector('.archived-sessions__preview')?.textContent?.includes("B history")); |
| 59 | assert.ok(!document.body.textContent?.includes("stale preview")); |
| 60 | await act(async () => (document.querySelector('.history-clear') as HTMLButtonElement).click()); |
| 61 | assert.ok(document.querySelector('[role="dialog"]')?.textContent?.includes("these 3 archived conversations")); |
| 62 | assert.equal(document.activeElement?.textContent, "Cancel"); |
| 63 | await act(async () => button("Permanently delete").click()); |
| 64 | assert.deepEqual(calls, ["purge:a", "purge:b", "purge:c"]); |
| 65 | assert.ok(document.body.textContent?.includes("changed state. Refresh and try again")); |
| 66 | assert.equal(document.querySelectorAll('.archived-sessions__row').length, 2); |
| 67 | await act(async () => button("Retry failed items").click()); |
| 68 | assert.deepEqual(calls, ["purge:a", "purge:b", "purge:c", "purge:b"]); |
| 69 | assert.equal(requests[0], requests[1], "retry keeps the durable operation ID"); |
| 70 | assert.equal(fullRequests[0], fullRequests[1], "mixed retry keeps every original target and the original version"); |
| 71 | await act(async () => button("Restore").click()); |
| 72 | assert.ok(document.body.textContent?.includes("Operation completed. Refresh failed")); |
| 73 | assert.equal(document.querySelectorAll('.archived-sessions__row').length, 1); |
| 74 | listFails = false; |
| 75 | throwConflict = true; |
| 76 | await act(async () => (document.querySelector('.archived-sessions__delete') as HTMLButtonElement).click()); |
| 77 | await act(async () => button("Permanently delete").click()); |
| 78 | assert.ok(document.body.textContent?.includes("changed state. Refresh and try again"), "top-level conflict uses the refresh guidance"); |
| 79 | assert.equal(button("Retry").textContent?.trim(), "Retry", "terminal conflict is not eligible for request retry"); |
| 80 | await act(async () => root.render(render(false))); |
| 81 | assert.equal(document.querySelector('[role="dialog"]'), null); |
| 82 | await act(async () => root.unmount()); |
| 83 | dom.window.close(); |
| 84 | console.log("PASS unified trash, stale preview, confirmation, conflict refresh, failed-only retry and committed refresh failure"); |
| 85 |