| 1 | import assert from "node:assert/strict"; |
| 2 | import { managementDom } from "../test-support/managementDom"; |
| 3 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 4 | import type { SessionPreparationView } from "../generated/desktopContract.generated"; |
| 5 | |
| 6 | const dom = managementDom(); |
| 7 | const { default: React, act } = await import("react"); |
| 8 | const { createRoot } = await import("react-dom/client"); |
| 9 | const { LocaleProvider } = await import("../lib/i18n"); |
| 10 | const { HistoricalSessionBanners } = await import("../components/SessionTakeoverDialog"); |
| 11 | const { seedActiveTabMetaList } = await import("../lib/tabMetaRefresh"); |
| 12 | const { setHistoricalPreparation, historicalPreparationSnapshot, reconcileHistoricalPreparation } = await import("../app-runtime/desktopNavigationOwner"); |
| 13 | function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done; }); return { promise, resolve }; } |
| 14 | let cancellation: ReturnType<typeof deferred<SessionPreparationView>> | undefined; |
| 15 | let branch: ReturnType<typeof deferred<SessionPreparationView>> | undefined; |
| 16 | let branchError = false; |
| 17 | let polledPreparation: SessionPreparationView | undefined; |
| 18 | let navigationEpoch = 0; |
| 19 | let cancelled = 0; |
| 20 | const opened: string[] = []; |
| 21 | const source = { hostId: "local", sourceKey: "legacy", path: "/fixture/legacy.jsonl" }; |
| 22 | const host = installDesktopHostStub({ |
| 23 | CheckHistoricalSourceUpdate: async () => ({ sourceKey: "legacy", status: "available", version: "v2", source, retryable: false }), |
| 24 | PrepareHistoricalSourceVersion: async () => { |
| 25 | if (branchError) throw new Error("import request failed"); |
| 26 | return branch ? branch.promise : ({ operationId: "version-v2", sourceKey: "legacy", status: "ready", revision: 2, |
| 27 | target: { hostId: "local", sessionId: "branch-v2" }, retryable: false }); |
| 28 | }, |
| 29 | GetSessionPreparation: async () => { |
| 30 | if (polledPreparation) return polledPreparation; |
| 31 | throw new Error("terminal preparation must not poll"); |
| 32 | }, |
| 33 | CancelSessionPreparation: async (operationId: string) => { cancelled++; return cancellation ? cancellation.promise : { operationId, sourceKey: "legacy", status: "cancelled", revision: 3, retryable: true }; }, |
| 34 | }); |
| 35 | const root = createRoot(document.getElementById("root")!); |
| 36 | const baseProps = { |
| 37 | tab: { id: "base", scope: "global", workspaceRoot: "", workspaceName: "Global", topicId: "base", topicTitle: "Base", |
| 38 | label: "Base", ready: true, running: false, sessionId: "base" }, |
| 39 | navigate: async (intent: { kind: string; ref?: { sessionId: string } }) => { if (intent.ref) opened.push(intent.ref.sessionId); }, |
| 40 | captureNavigation: () => { const epoch = navigationEpoch; return () => epoch === navigationEpoch; }, |
| 41 | }; |
| 42 | setHistoricalPreparation({ |
| 43 | operationId: "prepare-legacy", status: "queued", retryable: false, |
| 44 | session: { scope: "global", title: "Legacy title", topicId: "legacy", source }, |
| 45 | }); |
| 46 | await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} /></LocaleProvider>)); |
| 47 | assert.ok(document.body.textContent?.includes("Importing: Legacy title")); |
| 48 | await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Cancel")!.click()); |
| 49 | assert.equal(cancelled, 1); |
| 50 | |
| 51 | await act(async () => setHistoricalPreparation(null)); |
| 52 | await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} /></LocaleProvider>)); |
| 53 | await act(async () => {}); |
| 54 | assert.ok(document.body.textContent?.includes("Historical source changed. Import as a separate branch.")); |
| 55 | assert.ok(!document.body.textContent?.includes("Not imported"), "an imported conversation must not be labelled unimported"); |
| 56 | await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Import and open · Branch")!.click()); |
| 57 | assert.deepEqual(opened, ["branch-v2"]); |
| 58 | |
| 59 | const importBranchButton = () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Import and open · Branch")!; |
| 60 | branch = deferred<SessionPreparationView>(); |
| 61 | await act(async () => importBranchButton().click()); |
| 62 | assert.equal(importBranchButton().disabled, true); |
| 63 | assert.ok(document.body.textContent?.includes("Importing")); |
| 64 | polledPreparation = { operationId: "version-v2", sourceKey: "legacy", status: "failed", revision: 4, errorCode: "import_failed", retryable: true }; |
| 65 | await act(async () => branch!.resolve({ ...polledPreparation!, status: "preparing", revision: 3 })); |
| 66 | await act(async () => { await new Promise(resolve => setTimeout(resolve, 350)); }); |
| 67 | assert.ok(document.querySelector('[role="alert"]')?.textContent?.includes("Import failed"), "polled failures must be visible"); |
| 68 | assert.equal(importBranchButton().disabled, false, "failed imports remain retryable"); |
| 69 | assert.deepEqual(opened, ["branch-v2"], "failed preparation must not navigate"); |
| 70 | |
| 71 | branch = deferred<SessionPreparationView>(); |
| 72 | await act(async () => importBranchButton().click()); |
| 73 | assert.equal(document.querySelector('[role="alert"]'), null, "retry clears the previous error"); |
| 74 | await act(async () => branch!.resolve({ ...polledPreparation!, status: "blocked", errorCode: "source_busy" })); |
| 75 | assert.ok(document.querySelector('[role="alert"]')?.textContent?.includes("In use by another instance")); |
| 76 | |
| 77 | branchError = true; |
| 78 | await act(async () => importBranchButton().click()); |
| 79 | assert.ok(document.querySelector('[role="alert"]')?.textContent?.includes("Import failed"), "RPC errors must not disappear"); |
| 80 | branchError = false; |
| 81 | branch = undefined; |
| 82 | await act(async () => importBranchButton().click()); |
| 83 | assert.equal(document.querySelector('[role="alert"]'), null); |
| 84 | assert.deepEqual(opened, ["branch-v2", "branch-v2"], "retry can open the imported branch"); |
| 85 | |
| 86 | const pendingA = { operationId: "prepare-a", status: "preparing", retryable: false, revision: 4, |
| 87 | session: { scope: "global", title: "A", source } }; |
| 88 | await act(async () => setHistoricalPreparation(pendingA)); |
| 89 | cancellation = deferred<SessionPreparationView>(); |
| 90 | await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Cancel")!.click()); |
| 91 | const pendingCancel = [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Cancel")!; |
| 92 | assert.equal(pendingCancel.disabled, true, "one operation accepts only one in-flight cancellation"); |
| 93 | await act(async () => pendingCancel.click()); |
| 94 | assert.equal(cancelled, 2, "a disabled cancellation control does not submit twice"); |
| 95 | await act(async () => setHistoricalPreparation({ ...pendingA, operationId: "prepare-b" })); |
| 96 | await act(async () => cancellation!.resolve({ operationId: "prepare-a", sourceKey: "legacy", status: "cancelled", revision: 5, retryable: true })); |
| 97 | assert.equal(historicalPreparationSnapshot()?.operationId, "prepare-b", "late cancellation cannot replace a newer selection"); |
| 98 | await act(async () => setHistoricalPreparation(pendingA)); |
| 99 | await act(async () => reconcileHistoricalPreparation(pendingA, { operationId: "prepare-a", sourceKey: "legacy", status: "cancelled", revision: 3, retryable: true })); |
| 100 | assert.equal(historicalPreparationSnapshot()?.status, "preparing", "older revisions cannot regress the task"); |
| 101 | await act(async () => reconcileHistoricalPreparation(pendingA, { operationId: "prepare-a", sourceKey: "legacy", status: "ready", revision: 6, retryable: false })); |
| 102 | assert.equal(historicalPreparationSnapshot()?.status, "preparing", "ready cancellation leaves activation to the navigation owner"); |
| 103 | await act(async () => setHistoricalPreparation(null)); |
| 104 | branch = deferred<SessionPreparationView>(); |
| 105 | await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Import and open · Branch")!.click()); |
| 106 | navigationEpoch++; |
| 107 | await act(async () => branch!.resolve({ operationId: "version-v2", sourceKey: "legacy", status: "ready", revision: 7, |
| 108 | target: { hostId: "local", sessionId: "stale-branch" }, retryable: false })); |
| 109 | assert.deepEqual(opened, ["branch-v2", "branch-v2"], "pending navigation invalidates a branch open even while the old tab is retained"); |
| 110 | branch = deferred<SessionPreparationView>(); |
| 111 | await act(async () => importBranchButton().click()); |
| 112 | navigationEpoch++; |
| 113 | await act(async () => branch!.resolve({ ...polledPreparation!, status: "failed" })); |
| 114 | assert.equal(document.querySelector('[role="alert"]'), null, "stale failures cannot replace the newer navigation state"); |
| 115 | const pendingIntents: unknown[] = []; |
| 116 | await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners |
| 117 | tab={{ ...baseProps.tab, sessionId: undefined, ready: false, historicalSource: source }} |
| 118 | navigate={async intent => { pendingIntents.push(intent); }} |
| 119 | /></LocaleProvider>)); |
| 120 | assert.deepEqual(pendingIntents, [], "restoring a legacy tab never starts preparation automatically"); |
| 121 | await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click()); |
| 122 | assert.equal(pendingIntents.length, 1); |
| 123 | assert.equal((pendingIntents[0] as { kind: string }).kind, "resume-session", "explicit preparation uses the shared navigation owner"); |
| 124 | const [canonicalTab] = seedActiveTabMetaList([{ ...baseProps.tab, historicalSource: source }], baseProps.tab); |
| 125 | assert.equal(canonicalTab.historicalSource, undefined, "canonical metadata omission clears the old preparation state"); |
| 126 | await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} tab={canonicalTab} /></LocaleProvider>)); |
| 127 | assert.equal(document.getElementById("reasonix-prepare-restored-session"), null, "successful activation removes the preparation action"); |
| 128 | await act(async () => root.unmount()); |
| 129 | host.uninstall(); |
| 130 | dom.window.close(); |
| 131 | console.log("PASS preparation banner, cancellation and source update branch import"); |
| 132 |