| 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 preparedSelectors: unknown[] = []; |
| 22 | let preparedSource: SessionPreparationView | undefined; |
| 23 | const source = { hostId: "local", sourceKey: "legacy", path: "/fixture/legacy.jsonl" }; |
| 24 | const host = installDesktopHostStub({ |
| 25 | CheckHistoricalSourceUpdate: async () => ({ sourceKey: "legacy", status: "available", version: "v2", source, retryable: false }), |
| 26 | PrepareHistoricalSourceVersion: async () => { |
| 27 | if (branchError) throw new Error("import request failed"); |
| 28 | return branch ? branch.promise : ({ operationId: "version-v2", sourceKey: "legacy", status: "ready", revision: 2, |
| 29 | target: { hostId: "local", sessionId: "branch-v2" }, retryable: false }); |
| 30 | }, |
| 31 | GetSessionPreparation: async () => { |
| 32 | if (polledPreparation) return polledPreparation; |
| 33 | throw new Error("terminal preparation must not poll"); |
| 34 | }, |
| 35 | PrepareSession: async (selector: unknown) => { |
| 36 | preparedSelectors.push(selector); |
| 37 | if (preparedSource) return preparedSource; |
| 38 | return { operationId: "prepare-source", sourceKey: "legacy", status: "ready", revision: 8, |
| 39 | target: { hostId: "local", sessionId: "imported-source" }, retryable: false }; |
| 40 | }, |
| 41 | CancelSessionPreparation: async (operationId: string) => { cancelled++; return cancellation ? cancellation.promise : { operationId, sourceKey: "legacy", status: "cancelled", revision: 3, retryable: true }; }, |
| 42 | }); |
| 43 | const root = createRoot(document.getElementById("root")!); |
| 44 | const baseProps = { |
| 45 | tab: { id: "base", scope: "global", workspaceRoot: "", workspaceName: "Global", topicId: "base", topicTitle: "Base", |
| 46 | label: "Base", ready: true, running: false, sessionId: "base" }, |
| 47 | navigate: async (intent: { kind: string; ref?: { sessionId: string } }) => { if (intent.ref) opened.push(intent.ref.sessionId); }, |
| 48 | captureNavigation: () => { const epoch = navigationEpoch; return () => epoch === navigationEpoch; }, |
| 49 | }; |
| 50 | setHistoricalPreparation({ |
| 51 | operationId: "prepare-legacy", status: "queued", retryable: false, |
| 52 | session: { scope: "global", title: "Legacy title", topicId: "legacy", source }, |
| 53 | }); |
| 54 | await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} /></LocaleProvider>)); |
| 55 | assert.ok(document.body.textContent?.includes("Importing: Legacy title")); |
| 56 | await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Cancel")!.click()); |
| 57 | assert.equal(cancelled, 1); |
| 58 | |
| 59 | await act(async () => setHistoricalPreparation(null)); |
| 60 | await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} /></LocaleProvider>)); |
| 61 | await act(async () => {}); |
| 62 | assert.ok(document.body.textContent?.includes("Historical source changed. Import as a separate branch.")); |
| 63 | assert.ok(!document.body.textContent?.includes("Not imported"), "an imported conversation must not be labelled unimported"); |
| 64 | await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Import and open · Branch")!.click()); |
| 65 | assert.deepEqual(opened, ["branch-v2"]); |
| 66 | |
| 67 | const importBranchButton = () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Import and open · Branch")!; |
| 68 | branch = deferred<SessionPreparationView>(); |
| 69 | await act(async () => importBranchButton().click()); |
| 70 | assert.equal(importBranchButton().disabled, true); |
| 71 | assert.ok(document.body.textContent?.includes("Importing")); |
| 72 | polledPreparation = { operationId: "version-v2", sourceKey: "legacy", status: "failed", revision: 4, errorCode: "import_failed", retryable: true }; |
| 73 | await act(async () => branch!.resolve({ ...polledPreparation!, status: "preparing", revision: 3 })); |
| 74 | await act(async () => { await new Promise(resolve => setTimeout(resolve, 350)); }); |
| 75 | assert.ok(document.querySelector('[role="alert"]')?.textContent?.includes("Import failed"), "polled failures must be visible"); |
| 76 | assert.equal(importBranchButton().disabled, false, "failed imports remain retryable"); |
| 77 | assert.deepEqual(opened, ["branch-v2"], "failed preparation must not navigate"); |
| 78 | |
| 79 | branch = deferred<SessionPreparationView>(); |
| 80 | await act(async () => importBranchButton().click()); |
| 81 | assert.equal(document.querySelector('[role="alert"]'), null, "retry clears the previous error"); |
| 82 | await act(async () => branch!.resolve({ ...polledPreparation!, status: "blocked", errorCode: "source_busy" })); |
| 83 | assert.ok(document.querySelector('[role="alert"]')?.textContent?.includes("In use by another instance")); |
| 84 | |
| 85 | branchError = true; |
| 86 | await act(async () => importBranchButton().click()); |
| 87 | assert.ok(document.querySelector('[role="alert"]')?.textContent?.includes("Import failed"), "RPC errors must not disappear"); |
| 88 | branchError = false; |
| 89 | branch = undefined; |
| 90 | await act(async () => importBranchButton().click()); |
| 91 | assert.equal(document.querySelector('[role="alert"]'), null); |
| 92 | assert.deepEqual(opened, ["branch-v2", "branch-v2"], "retry can open the imported branch"); |
| 93 | |
| 94 | const pendingA = { operationId: "prepare-a", status: "preparing", retryable: false, revision: 4, |
| 95 | session: { scope: "global", title: "A", source } }; |
| 96 | await act(async () => setHistoricalPreparation(pendingA)); |
| 97 | cancellation = deferred<SessionPreparationView>(); |
| 98 | await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Cancel")!.click()); |
| 99 | const pendingCancel = [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Cancel")!; |
| 100 | assert.equal(pendingCancel.disabled, true, "one operation accepts only one in-flight cancellation"); |
| 101 | await act(async () => pendingCancel.click()); |
| 102 | assert.equal(cancelled, 2, "a disabled cancellation control does not submit twice"); |
| 103 | await act(async () => setHistoricalPreparation({ ...pendingA, operationId: "prepare-b" })); |
| 104 | await act(async () => cancellation!.resolve({ operationId: "prepare-a", sourceKey: "legacy", status: "cancelled", revision: 5, retryable: true })); |
| 105 | assert.equal(historicalPreparationSnapshot()?.operationId, "prepare-b", "late cancellation cannot replace a newer selection"); |
| 106 | await act(async () => setHistoricalPreparation(pendingA)); |
| 107 | await act(async () => reconcileHistoricalPreparation(pendingA, { operationId: "prepare-a", sourceKey: "legacy", status: "cancelled", revision: 3, retryable: true })); |
| 108 | assert.equal(historicalPreparationSnapshot()?.status, "preparing", "older revisions cannot regress the task"); |
| 109 | await act(async () => reconcileHistoricalPreparation(pendingA, { operationId: "prepare-a", sourceKey: "legacy", status: "ready", revision: 6, retryable: false })); |
| 110 | assert.equal(historicalPreparationSnapshot()?.status, "preparing", "ready cancellation leaves activation to the navigation owner"); |
| 111 | await act(async () => setHistoricalPreparation(null)); |
| 112 | branch = deferred<SessionPreparationView>(); |
| 113 | await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Import and open · Branch")!.click()); |
| 114 | navigationEpoch++; |
| 115 | await act(async () => branch!.resolve({ operationId: "version-v2", sourceKey: "legacy", status: "ready", revision: 7, |
| 116 | target: { hostId: "local", sessionId: "stale-branch" }, retryable: false })); |
| 117 | assert.deepEqual(opened, ["branch-v2", "branch-v2"], "pending navigation invalidates a branch open even while the old tab is retained"); |
| 118 | branch = deferred<SessionPreparationView>(); |
| 119 | await act(async () => importBranchButton().click()); |
| 120 | navigationEpoch++; |
| 121 | await act(async () => branch!.resolve({ ...polledPreparation!, status: "failed" })); |
| 122 | assert.equal(document.querySelector('[role="alert"]'), null, "stale failures cannot replace the newer navigation state"); |
| 123 | const pendingIntents: unknown[] = []; |
| 124 | await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners |
| 125 | tab={{ ...baseProps.tab, sessionId: undefined, ready: false, historicalSource: source }} |
| 126 | navigate={async intent => { pendingIntents.push(intent); }} |
| 127 | /></LocaleProvider>)); |
| 128 | assert.deepEqual(preparedSelectors, [], "restoring a legacy tab never starts preparation automatically"); |
| 129 | assert.ok(document.body.textContent?.includes("Import this conversation to keep sending messages"), "the banner states why sending waits"); |
| 130 | await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click()); |
| 131 | assert.deepEqual(preparedSelectors, [{ source }], "the action prepares the source instead of reopening it in place"); |
| 132 | assert.deepEqual(pendingIntents, [{ kind: "canonical-session", ref: { hostId: "local", sessionId: "imported-source" } }], |
| 133 | "a prepared source opens as its canonical session"); |
| 134 | const [canonicalTab] = seedActiveTabMetaList([{ ...baseProps.tab, historicalSource: source }], baseProps.tab); |
| 135 | assert.equal(canonicalTab.historicalSource, undefined, "canonical metadata omission clears the old preparation state"); |
| 136 | await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} tab={canonicalTab} /></LocaleProvider>)); |
| 137 | assert.equal(document.getElementById("reasonix-prepare-restored-session"), null, "successful activation removes the preparation action"); |
| 138 | const failingSourceTab = { ...baseProps.tab, sessionId: undefined, ready: false, historicalSource: source }; |
| 139 | let recoveryOpened = 0; |
| 140 | const openFailures: unknown[] = []; |
| 141 | preparedSource = { operationId: "prepare-conflict", sourceKey: "legacy", status: "failed", revision: 9, errorCode: "workspace_conflict", |
| 142 | errorDetail: "session workspace identity is inconsistent; the session files were left unchanged", retryable: true }; |
| 143 | await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners tab={failingSourceTab} |
| 144 | navigate={async intent => { openFailures.push(intent); throw new Error("open refused: workspace registry busy"); }} |
| 145 | openRecoveryDetails={() => { recoveryOpened++; }} /></LocaleProvider>)); |
| 146 | assert.equal(document.body.textContent?.includes("Review recovery details"), false, "recovery details appear only with a failure"); |
| 147 | await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click()); |
| 148 | const failureAlert = () => document.querySelector('[role="alert"]'); |
| 149 | assert.ok(failureAlert()?.textContent?.includes("records a different project folder"), "the banner names the failure class the host reported"); |
| 150 | await act(async () => failureAlert()!.querySelector<HTMLButtonElement>(".user-error__toggle")!.click()); |
| 151 | assert.ok(failureAlert()?.textContent?.includes("session workspace identity is inconsistent"), "the host's failure detail is reachable from the banner"); |
| 152 | await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Review recovery details")!.click()); |
| 153 | assert.equal(recoveryOpened, 1, "the promised recovery details open"); |
| 154 | assert.deepEqual(openFailures, [], "a failed preparation never navigates"); |
| 155 | preparedSource = { operationId: "prepare-ready", sourceKey: "legacy", status: "ready", revision: 10, |
| 156 | target: { hostId: "local", sessionId: "imported-source" }, retryable: false }; |
| 157 | await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click()); |
| 158 | assert.ok(failureAlert()?.textContent?.includes("Imported, but the conversation could not be opened"), "an open failure is not reported as an import failure"); |
| 159 | await act(async () => failureAlert()!.querySelector<HTMLButtonElement>(".user-error__toggle")!.click()); |
| 160 | assert.ok(failureAlert()?.textContent?.includes("open refused: workspace registry busy"), "the open failure keeps its cause"); |
| 161 | await act(async () => root.unmount()); |
| 162 | host.uninstall(); |
| 163 | dom.window.close(); |
| 164 | console.log("PASS preparation banner, cancellation and source update branch import"); |
| 165 |