| 1 | import assert from "node:assert/strict"; |
| 2 | import React, { act } from "react"; |
| 3 | import { createRoot } from "react-dom/client"; |
| 4 | import { JSDOM } from "jsdom"; |
| 5 | import { useDesktopNavigation } from "../app-runtime/useDesktopNavigation"; |
| 6 | import type { DesktopNavigationPorts } from "../app-runtime/desktopNavigationOwner"; |
| 7 | import type { SessionMeta, TabMeta } from "../lib/types"; |
| 8 | import type { SidebarImConnection } from "../app-runtime/sidebarImProjection"; |
| 9 | import type { Translator } from "../lib/i18n"; |
| 10 | import { __emitMockRemoteTabOpened } from "../lib/remoteTabEvents"; |
| 11 | import { useRemoteTabOpened } from "../lib/useRemoteTabOpened"; |
| 12 | import { projectTreeTopicOpenRequest } from "../lib/projectTreeTopic"; |
| 13 | |
| 14 | function deferred<T>() { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; |
| 15 | const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; } |
| 16 | const dom = new JSDOM("<div id='root'></div>"); |
| 17 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); |
| 18 | const root = createRoot(document.getElementById("root")!); |
| 19 | const tab = (id: string) => ({ id, label: id } as TabMeta); |
| 20 | const pending = new Map<string, ReturnType<typeof deferred<TabMeta>>>(); |
| 21 | const calls: string[] = []; |
| 22 | const acceptedTopics: number[] = []; |
| 23 | let intent = 0; |
| 24 | let registration: ReturnType<typeof deferred<string>> | undefined; |
| 25 | let preparationReads = 0; |
| 26 | let api!: ReturnType<typeof useDesktopNavigation>; |
| 27 | const activate = (id: string) => { calls.push(`open:${id}`); const request = deferred<TabMeta>(); pending.set(id, request); return request.promise; }; |
| 28 | const ports: Parameters<typeof useDesktopNavigation>[0]["ports"] = { |
| 29 | isNavigationIntentCurrent: seq => seq === intent, |
| 30 | registeredNavigationIntent: async seq => registration ? registration.promise : String(seq), |
| 31 | openRemoteProject: async (_host, workspace) => activate(`remote:${workspace}`), |
| 32 | switchRemoteTab: async (meta, seq) => { calls.push(`remote-switch:${meta.id}:${seq}`); }, |
| 33 | activateTopic: async (_scope, _workspace, id) => activate(id), |
| 34 | openCanonicalSession: async (ref) => { await activate(`canonical:${ref.sessionId}`); }, |
| 35 | openTopicSession: async (_scope, _workspace, id) => { calls.push("tab-session"); return activate(id); }, |
| 36 | openGlobalTab: async id => { calls.push("tab-global"); return activate(id); }, |
| 37 | openProjectTab: async (_workspace, id) => { calls.push("tab-project"); return activate(id); }, |
| 38 | ensureBlankSurface: async (_scope, workspace) => activate(`blank:${workspace}`), |
| 39 | ensureBlankTab: async (_scope, workspace) => { calls.push("tab-blank"); return activate(`blank:${workspace}`); }, |
| 40 | createIsolatedWorktree: async workspace => ({ tab: await activate(`worktree:${workspace}`), branch: "fixture", sourceDirty: true }) as Awaited<ReturnType<DesktopNavigationPorts["createIsolatedWorktree"]>>, |
| 41 | openChannelSession: async (path, id) => { calls.push(`channel:${id}:${path}`); }, |
| 42 | resumeSession: async (path, id) => { calls.push(`resume:${id}:${path}`); }, |
| 43 | listTabs: async () => [], applyTabs: () => { calls.push("tabs"); }, seedTab: value => { calls.push(`seed:${value.id}`); }, |
| 44 | listSessions: async () => { calls.push("history-refresh"); return []; }, |
| 45 | prepareSession: async selector => { calls.push(`prepare:${selector.source?.sourceKey}`); return { operationId: "prepare-legacy", sourceKey: selector.source?.sourceKey || "", status: "queued", revision: 1, retryable: false }; }, |
| 46 | getSessionPreparation: async () => { |
| 47 | preparationReads++; |
| 48 | return preparationReads < 2 |
| 49 | ? { operationId: "prepare-legacy", sourceKey: "legacy", status: "preparing", revision: 2, retryable: false } |
| 50 | : { operationId: "prepare-legacy", sourceKey: "legacy", status: "ready", revision: 3, target: { hostId: "local", sessionId: "prepared-target" }, retryable: false }; |
| 51 | }, |
| 52 | topicAccepted: seq => { acceptedTopics.push(seq); }, |
| 53 | }; |
| 54 | function Probe({ visible = "A" }: { visible?: string }) { |
| 55 | useRemoteTabOpened(meta => { calls.push(`resource:${meta.id}`); }, () => {}); |
| 56 | api = useDesktopNavigation({ visible: { tabId: visible, sessionKey: visible }, ports, |
| 57 | setTabRevealSignal: () => { calls.push("reveal-tab"); }, |
| 58 | setProjectRevision: () => { calls.push("project"); }, setHistory: () => { calls.push("history-close"); }, |
| 59 | t: ((key: string) => key) as Translator, showToast: message => { calls.push(`notice:${message}`); }, |
| 60 | noteIntent: () => ++intent, beginSurface: seq => { calls.push(`begin:${seq}`); }, |
| 61 | settleSurface: seq => { if (seq === intent) calls.push(`settle:${seq}`); }, showChat: () => {}, |
| 62 | }); |
| 63 | return null; |
| 64 | } |
| 65 | const paint = (visible = "A") => act(async () => root.render(<Probe visible={visible} />)); |
| 66 | const topic = (id: string) => api.enqueueNavigation({ kind: "topic", scope: "project", workspaceRoot: "fixture", topicId: id }); |
| 67 | async function finish(id: string, task: Promise<void>) { |
| 68 | const deadline = Date.now() + 5000; |
| 69 | while (!pending.has(id) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 1)); |
| 70 | assert.ok(pending.has(id), `navigation reached ${id}`); |
| 71 | pending.get(id)!.resolve(tab(id)); await task; |
| 72 | } |
| 73 | try { |
| 74 | await paint(); |
| 75 | const canonical = api.enqueueNavigation({ kind: "canonical-session", ref: { hostId: "local", sessionId: "canonical-target" } }); |
| 76 | await finish("canonical:canonical-target", canonical); |
| 77 | assert.ok(calls.includes("history-close") && calls.includes("tabs") && calls.includes("reveal-tab")); |
| 78 | calls.length = 0; intent = 0; |
| 79 | const entry = api.enqueueNavigation; |
| 80 | const a = topic("A"), b = topic("B"), c = topic("C"); |
| 81 | await b; |
| 82 | assert.deepEqual(calls.filter(value => value.startsWith("open:")), ["open:A"]); |
| 83 | await finish("A", a); |
| 84 | assert.deepEqual(calls.filter(value => value.startsWith("open:")), ["open:A", "open:C"]); |
| 85 | await finish("C", c); |
| 86 | assert.deepEqual(calls.filter(value => value.startsWith("seed:")), ["seed:C"]); |
| 87 | assert.deepEqual(acceptedTopics, [3], "only the accepted queue target can release an automation link"); |
| 88 | assert.deepEqual(calls.filter(value => value.startsWith("settle:")), ["settle:3"], "old finally cannot settle the current surface"); |
| 89 | calls.length = 0; |
| 90 | const stale = topic("stale"); intent++; |
| 91 | await paint("B"); await paint("A"); |
| 92 | assert.equal(api.enqueueNavigation, entry); |
| 93 | await finish("stale", stale); |
| 94 | assert.deepEqual(calls.filter(value => /^(seed|tabs|notice|reveal|settle)/.test(value)), [], "ABA never restores old UI rights"); |
| 95 | |
| 96 | calls.length = 0; |
| 97 | const connection = { sessionId: "path:channel.jsonl", sessionSource: "auto", scope: "project", workspaceRoot: "im", title: "fixture" } as SidebarImConnection; |
| 98 | const im = api.enqueueNavigation({ kind: "sidebar-im", connection }); |
| 99 | intent++; |
| 100 | await finish("blank:im", im); |
| 101 | assert.ok(!calls.some(value => value.startsWith("channel:")), "cancellation between blank activation and hydrate prevents a second mutation"); |
| 102 | calls.length = 0; |
| 103 | const validIM = api.enqueueNavigation({ kind: "sidebar-im", connection }); |
| 104 | await finish("blank:im", validIM); |
| 105 | assert.ok(calls.includes("channel:blank:im:channel.jsonl")); |
| 106 | |
| 107 | calls.length = 0; |
| 108 | const isolated = api.enqueueNavigation({ kind: "isolated-worktree", workspaceRoot: "dirty" }); |
| 109 | await paint(); // Normal commits do not change the request epoch. |
| 110 | await finish("worktree:dirty", isolated); |
| 111 | assert.ok(calls.includes("notice:projectTree.worktreeCreatedDirty")); |
| 112 | assert.ok(calls.includes("project")); |
| 113 | |
| 114 | calls.length = 0; |
| 115 | const history = api.enqueueNavigation({ kind: "resume-session", session: { scope: "global", topicId: "history", path: "history.jsonl" } as SessionMeta }); |
| 116 | await finish("history", history); |
| 117 | assert.ok(calls.includes("open:history"), "resuming a session activates its topic surface"); |
| 118 | assert.ok(!calls.includes("tab-session"), "every layout style takes the surface path, never a legacy tab"); |
| 119 | assert.ok(calls.includes("history-close")); |
| 120 | |
| 121 | calls.length = 0; preparationReads = 0; |
| 122 | const legacy = api.enqueueNavigation({ kind: "resume-session", session: { scope: "global", topicId: "legacy-topic", title: "Legacy", path: "legacy.jsonl", |
| 123 | source: { hostId: "local", sourceKey: "legacy", path: "legacy.jsonl" } } as SessionMeta }); |
| 124 | await finish("legacy-topic", legacy); |
| 125 | assert.ok(calls.includes("prepare:legacy"), "legacy navigation prepares through the shared coordinator"); |
| 126 | assert.equal(preparationReads, 2, "navigation polls revisioned preparation until ready"); |
| 127 | |
| 128 | calls.length = 0; preparationReads = 0; |
| 129 | const sidebarRequest = projectTreeTopicOpenRequest({ kind: "global_topic", key: "cold-v4", label: "Cold v4", |
| 130 | topicId: "cold-topic", source: { hostId: "local", sourceKey: "cold-v4", path: "cold-store" } }); |
| 131 | assert.ok(sidebarRequest?.sessionPath?.startsWith("session-source:"), "headless canonical sources keep their explicit identity"); |
| 132 | const sidebar = api.enqueueNavigation({ kind: "topic", ...sidebarRequest! }); |
| 133 | const sidebarIntent = intent; |
| 134 | await finish("cold-topic", sidebar); |
| 135 | assert.ok(calls.includes("prepare:cold-v4"), "sidebar uses the same preparation owner as history"); |
| 136 | assert.equal(preparationReads, 2); |
| 137 | assert.equal(acceptedTopics.at(-1), sidebarIntent, "prepared sidebar navigation retains topic acceptance"); |
| 138 | |
| 139 | calls.length = 0; |
| 140 | const failed = topic("failed"); |
| 141 | pending.get("failed")!.reject(new Error("fixture failure")); await failed; |
| 142 | assert.deepEqual(calls.filter(value => value.startsWith("notice:")), ["notice:history.failedOpenSession"]); |
| 143 | |
| 144 | calls.length = 0; |
| 145 | registration = deferred<string>(); |
| 146 | const waitingRemote = api.openRemoteProject({ hostId: "fixture", workspace: "waiting" }, { newSession: true }); |
| 147 | const winsRegistration = topic("wins-registration"); |
| 148 | registration.resolve("registered"); |
| 149 | assert.equal((await waitingRemote).status, "cancelled"); |
| 150 | assert.ok(!pending.has("remote:waiting"), "superseded registration cannot issue an OpenRemoteProjectTab request"); |
| 151 | await finish("wins-registration", winsRegistration); |
| 152 | registration = undefined; |
| 153 | |
| 154 | calls.length = 0; |
| 155 | const remote = api.openRemoteProject({ hostId: "fixture", workspace: "remote" }, { sessionName: "selected" }); |
| 156 | await act(async () => {}); |
| 157 | const remoteMeta = { ...tab("remote:remote"), remote: { hostId: "fixture", workspace: "remote" } }; |
| 158 | await act(async () => __emitMockRemoteTabOpened(remoteMeta)); |
| 159 | assert.deepEqual(calls.filter(value => /^(seed|remote-switch)/.test(value)), [], "opened event before the response cannot independently navigate"); |
| 160 | const localWins = topic("local-wins"); |
| 161 | pending.get("remote:remote")!.resolve(remoteMeta); |
| 162 | assert.equal((await remote).status, "cancelled"); |
| 163 | await finish("local-wins", localWins); |
| 164 | assert.ok(!calls.some(value => value.startsWith("remote-switch:"))); |
| 165 | calls.length = 0; |
| 166 | const successfulRemote = api.openRemoteProject({ hostId: "fixture", workspace: "success" }, {}); |
| 167 | await act(async () => {}); |
| 168 | pending.get("remote:success")!.resolve({ ...remoteMeta, id: "remote:success" }); |
| 169 | const outcome = await successfulRemote; |
| 170 | assert.equal(outcome.status, "completed"); |
| 171 | assert.ok(calls.includes(`remote-switch:remote:success:${intent}`), "the request's exact intent reaches dedicated remote activation"); |
| 172 | assert.ok(!calls.includes("tab-session")); |
| 173 | |
| 174 | calls.length = 0; |
| 175 | const retainedRemote = api.openRemoteProject; |
| 176 | const disposed = topic("disposed"), queued = topic("never"); |
| 177 | await act(async () => root.unmount()); |
| 178 | await finish("disposed", disposed); await queued; |
| 179 | entry({ kind: "blank", scope: "global", workspaceRoot: "" }); |
| 180 | assert.deepEqual(calls.filter(value => /^(open|seed|tabs|notice|reveal|settle)/.test(value)), ["open:disposed"], "unmount releases pending input and fences queued and running continuations"); |
| 181 | assert.deepEqual(await retainedRemote({ hostId: "fixture", workspace: "disposed" }, {}), { status: "cancelled", reason: "disposed" }); |
| 182 | console.log("desktop navigation: queue ownership, ABA, IM hydrate, dirty-worktree warning, tab resume, failure and disposal passed"); |
| 183 | } finally { dom.window.close(); } |
| 184 |