| 1 | // Run: tsx src/__tests__/remote-project-tree.test.tsx |
| 2 | // Source-contract test: the remote project group's tree behavior — session |
| 3 | // rows, the + action, the remote context menu, the connection badge, and |
| 4 | // the local-action guards — is wired exactly once and in the remote shape. |
| 5 | |
| 6 | import { readFileSync } from "node:fs"; |
| 7 | import { dirname, resolve } from "node:path"; |
| 8 | import { fileURLToPath } from "node:url"; |
| 9 | import { activeRemoteProjectAncestorKeys, mergeRemoteSessionsIntoTree, openRemoteSessionNode, remoteSessionActionIdentity } from "../components/ProjectTreeRemoteGroups"; |
| 10 | import type { RemoteTabOpenOptions } from "../lib/types"; |
| 11 | import type { ProjectNode } from "../lib/types"; |
| 12 | |
| 13 | let passed = 0; |
| 14 | let failed = 0; |
| 15 | function ok(value: boolean, label: string) { |
| 16 | if (value) { |
| 17 | process.stdout.write(` PASS ${label}\n`); |
| 18 | passed += 1; |
| 19 | } else { |
| 20 | process.stdout.write(` FAIL ${label}\n`); |
| 21 | failed += 1; |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | console.log("\nRemote project tree wiring"); |
| 26 | const here = dirname(fileURLToPath(import.meta.url)); |
| 27 | const source = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8"); |
| 28 | const remoteSource = readFileSync(resolve(here, "../components/ProjectTreeRemoteGroups.tsx"), "utf8"); |
| 29 | const compositionSource = readFileSync(resolve(here, "../app-runtime/useAppSessionComposition.ts"), "utf8"); |
| 30 | const todoSource = readFileSync(resolve(here, "../app-runtime/useTodoPanelCommands.ts"), "utf8"); |
| 31 | const paletteSource = readFileSync(resolve(here, "../app-runtime/usePaletteCommands.tsx"), "utf8"); |
| 32 | const exportSource = readFileSync(resolve(here, "../app-runtime/useSessionExportCommands.ts"), "utf8"); |
| 33 | const exportOperationSource = readFileSync(resolve(here, "../lib/sessionExportOperation.ts"), "utf8"); |
| 34 | const bridgeSource = readFileSync(resolve(here, "../lib/remoteProjectBridge.ts"), "utf8"); |
| 35 | const remoteOpenSource = readFileSync(resolve(here, "../../../remote_projects.go"), "utf8"); |
| 36 | const remotePendingSelectionSource = readFileSync(resolve(here, "../../../remote_tab_pending_selection.go"), "utf8"); |
| 37 | const explicitEnsureSource = remoteSource.match( |
| 38 | /const ensureRemoteGroupSessions[\s\S]*?\n const openRemoteWindow/, |
| 39 | )?.[0] ?? ""; |
| 40 | |
| 41 | const sameWorkspaceRemoteTree: ProjectNode[] = [ |
| 42 | { key: "remote-host-a", kind: "project", label: "A", remote: { hostId: "host-a", workspace: "/repo" }, children: [] }, |
| 43 | { key: "remote-host-b", kind: "project", label: "B", remote: { hostId: "host-b", workspace: "/repo" }, children: [] }, |
| 44 | ]; |
| 45 | ok( |
| 46 | JSON.stringify(activeRemoteProjectAncestorKeys(sameWorkspaceRemoteTree, { hostId: "host-b", workspace: "/repo" }, (node) => node.key ?? "")) === JSON.stringify(["remote-host-b"]), |
| 47 | "active remote group expansion uses host-qualified identity instead of local workspace fallback", |
| 48 | ); |
| 49 | |
| 50 | ok( |
| 51 | /remoteSession: \{ hostId: node\.remote!\.hostId, workspace: node\.remote!\.workspace, name: row\.name, path: row\.path, sessionId: row\.sessionId, title: row\.title, current: row\.current \}/.test(remoteSource) && |
| 52 | /openRemoteSessionNode\(remote, openRemoteProject\)/.test(source) && |
| 53 | /sessionPath: remote\.path, sessionId: remote\.sessionId, sessionTitle: remote\.title/.test(remoteSource) && |
| 54 | /remoteSessionIdentity\(row\)/.test(remoteSource), |
| 55 | "session rows open the matching in-app remote session", |
| 56 | ); |
| 57 | // The synthetic "current" row for a canonical session carries only a |
| 58 | // sessionId: no legacy path, no basename. Opening it must hand that id to |
| 59 | // OpenRemoteProjectTab as `sessionId`, never re-encoded as a path route. |
| 60 | { |
| 61 | const opened: Array<{ hostId: string; workspace: string; opts?: RemoteTabOpenOptions }> = []; |
| 62 | const open = async (ref: { hostId: string; workspace: string }, opts?: RemoteTabOpenOptions) => { opened.push({ ...ref, opts }); }; |
| 63 | const merged = mergeRemoteSessionsIntoTree( |
| 64 | [{ key: "remote-host-a", kind: "project", label: "A", remote: { hostId: "host-a", workspace: "/repo" }, children: [] }], |
| 65 | { "host-a\u0000/repo": [{ name: "", sessionId: "sess-canonical", title: "", turns: 0, current: true }] }, |
| 66 | ((key: string) => key) as never, |
| 67 | ); |
| 68 | const row = merged[0]?.children?.[0]; |
| 69 | ok(row?.remoteSession?.sessionId === "sess-canonical" && row.remoteSession.current === true && row.sessionPath === undefined, |
| 70 | "a canonical current row keeps its sessionId and never synthesizes a path"); |
| 71 | ok(openRemoteSessionNode(row?.remoteSession, open) && opened.length === 1 |
| 72 | && opened[0].opts?.sessionId === "sess-canonical" && !opened[0].opts?.sessionPath && !opened[0].opts?.sessionName, |
| 73 | "opening a sessionId-only remote row forwards sessionId to OpenRemoteProjectTab without a path route"); |
| 74 | ok(remoteSessionActionIdentity({ sessionId: "sess-canonical", name: "" }) === "sess-canonical" |
| 75 | && remoteSessionActionIdentity({ name: "", path: "session-id:legacy-encoded" }) === "legacy-encoded", |
| 76 | "mutations address a canonical row by sessionId and decode the legacy session-id path only as a fallback"); |
| 77 | } |
| 78 | ok( |
| 79 | /rows\.map\(\(row\): ProjectNode =>/.test(remoteSource) && /useRemoteRuntimeTree\(tree, remoteSessions, t\)/.test(source) && |
| 80 | /root: node\.remote!\.workspace/.test(remoteSource) && /sessionPath: row\.path/.test(remoteSource), |
| 81 | "remote group children render with the active workspace and session identity", |
| 82 | ); |
| 83 | ok( |
| 84 | /app\.RemoteProjectSessions\(hostId, workspace\)/.test(remoteSource), |
| 85 | "sessions are fetched through the bridge", |
| 86 | ); |
| 87 | ok( |
| 88 | /state !== "connected" && state !== "degraded"/.test(remoteSource), |
| 89 | "session fetch waits for a connected host", |
| 90 | ); |
| 91 | ok( |
| 92 | /key: "remote-new-session"[\s\S]*?key: "remote-open-window"[\s\S]*?key: "remote-stop-server"[\s\S]*?key: "remote-unpin"/.test(remoteSource), |
| 93 | "the remote menu exposes new-session, browser, stop, and unpin actions", |
| 94 | ); |
| 95 | ok( |
| 96 | /items=\{node\.remote \? remoteProjectMenuItems :/.test(source), |
| 97 | "remote groups swap out the local project menu", |
| 98 | ); |
| 99 | ok( |
| 100 | /app\.ConnectRemoteHost\(ref\.hostId\)[\s\S]*?waitForRemoteConnection\(ref\.hostId\)[\s\S]*?publishNavigationIntent\("remote-workspace"\)[\s\S]*?app\.OpenRemoteWorkspace\(ref\.hostId, ref\.workspace\)/.test(remoteSource), |
| 101 | "separate remote window registers its intent before switching the external surface", |
| 102 | ); |
| 103 | ok( |
| 104 | /app\.RemoveRemoteProject\(ref\.hostId, ref\.workspace\)/.test(remoteSource) && /void refresh\(\);/.test(remoteSource), |
| 105 | "unpin removes the registration and refreshes the tree", |
| 106 | ); |
| 107 | ok( |
| 108 | /remoteServeBadgeState\(remoteServers\[node\.remote\.hostId\]\?\.\[node\.remote\.workspace\], remoteGroupBusy/.test(source), |
| 109 | "the group row badge reflects the workspace-specific serve state", |
| 110 | ); |
| 111 | ok( |
| 112 | /sessionLoads\.current\.has\(key\)/.test(remoteSource) && |
| 113 | /sessionLoadGenerations\.current\.set\(key, load\)/.test(remoteSource) && |
| 114 | /eligibleSessionKeys\.current\.has\(key\)/.test(remoteSource) && |
| 115 | /filter\(\(\[key\]\) => retained\.has\(key\)\)/.test(remoteSource) && |
| 116 | /acceptRemoteSessionRows\(key, rows\)/.test(remoteSource) && |
| 117 | /catch\(\(error\) => \{[\s\S]*?sessionLoadGenerations\.current\.get\(key\) === load && eligibleSessionKeys\.current\.has\(key\)[\s\S]*?recordRemoteSessionLoadError\(key, error\)/.test(remoteSource) && |
| 118 | /recordRemoteSessionLoadError[\s\S]*?setGroupError/.test(remoteSource) && |
| 119 | /setGroupError\(\(current\) => current\[key\] \? \{ \.\.\.current, \[key\]: "" \} : current\)/.test(remoteSource), |
| 120 | "session fetches fence stale results, retain rows on passive failures, and clear recovered group errors", |
| 121 | ); |
| 122 | ok( |
| 123 | /catch \(error\) \{[\s\S]*?recordRemoteSessionLoadError\(key, error\)/.test(explicitEnsureSource) && |
| 124 | !/removeRemoteSessionCache\(key\)/.test(explicitEnsureSource) && |
| 125 | !/setSessions\(/.test(explicitEnsureSource), |
| 126 | "an explicit session refresh preserves the last successful rows and cache when Serve fails", |
| 127 | ); |
| 128 | ok( |
| 129 | /item\.id !== "cmd-terminal" && item\.id !== "cmd-reload-runtime"/.test(paletteSource), |
| 130 | "remote command palettes hide local-only terminal and runtime reload actions", |
| 131 | ); |
| 132 | ok( |
| 133 | /const remote = index\.get\(topicId\);[\s\S]*?if \(!remote\) return false;[\s\S]*?await action\(remote\);/.test(remoteSource) && |
| 134 | /remote\.name \|\| remote\.sessionId/.test(remoteSource) && |
| 135 | !/if \(remote\.name\) await action\(remote\)/.test(remoteSource), |
| 136 | "synthetic blank remote sessions still invoke rename, pin, and delete mutations", |
| 137 | ); |
| 138 | ok( |
| 139 | /onRemoteTabUpdated\(\(meta\)/.test(remoteSource) && |
| 140 | /RemoteProjectSessions\(meta\.remote\.hostId, meta\.remote\.workspace\)/.test(remoteSource), |
| 141 | "remote tab metadata updates refresh the affected session group", |
| 142 | ); |
| 143 | ok( |
| 144 | /selector: activeTab\?\.session\?\.sessionId \? \{ ref: activeTab\.session \}/.test(compositionSource) && |
| 145 | /runSessionExport\(\{[\s\S]*?selector: input\.selector \?\? \{\}[\s\S]*?tabId: tabId \?\? ""[\s\S]*?remote/.test(exportSource) && |
| 146 | /app\.BeginSessionExportForTarget\(input\.selector, input\.tabId, input\.format, input\.title, observation\)/.test(exportOperationSource) && |
| 147 | !/sessionItemsToMarkdown\(/.test(exportSource), |
| 148 | "remote exports bind the explicit remote session snapshot instead of resident transcript items", |
| 149 | ); |
| 150 | ok( |
| 151 | /items: visibleRuntimeState\.items/.test(compositionSource) && |
| 152 | /const metaTodos = input\.meta\?\.canonicalTodos/.test(todoSource) && |
| 153 | /resolveTodoPanelTodos\(metaTodos\)/.test(todoSource) && |
| 154 | /setDismissedTodoKeys/.test(todoSource), |
| 155 | "remote todo shelf consumes the shared runtime projection with mount-local dismissal", |
| 156 | ); |
| 157 | ok( |
| 158 | /EnsureRemoteProjectSessions\(hostId: string, workspace: string\): Promise<RemoteSessionView\[\]>;/.test(bridgeSource), |
| 159 | "the bridge exposes the explicit-intent ensure listing", |
| 160 | ); |
| 161 | ok( |
| 162 | /app\.EnsureRemoteProjectSessions\(hostId, workspace\)/.test(remoteSource) && |
| 163 | /if \(groupBusyRef\.current\.has\(key\)\) return;/.test(remoteSource) && |
| 164 | /sessionLoadGenerations\.current\.set\(key, load\);[\s\S]*?EnsureRemoteProjectSessions\(hostId, workspace\)[\s\S]*?sessionLoadGenerations\.current\.get\(key\) !== load/.test(remoteSource), |
| 165 | "a group click cold-starts the serve through the deduped, generation-fenced ensure listing", |
| 166 | ); |
| 167 | ok( |
| 168 | /if \(node\.remote && willExpand\) \{\s*void ensureRemoteGroupSessions\(node\.remote\.hostId, node\.remote\.workspace\);/.test(source), |
| 169 | "an explicit expand refreshes remote rows even when an optimistic cache exists", |
| 170 | ); |
| 171 | ok( |
| 172 | /activeRemoteProjectAncestorKeys\(treeWithRemoteSessions, activeRemote, projectNodeKey\)/.test(source) && |
| 173 | /defaultExpandedProjectTreeKeys\(treeWithRemoteSessions, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath\)/.test(source), |
| 174 | "active remote ancestors come from the merged tree and host-qualified group identity", |
| 175 | ); |
| 176 | ok( |
| 177 | /markActive\(treeWithRemoteSessions\)/.test(source) && |
| 178 | /markNodeRead, treeWithRemoteSessions\]\);/.test(source) && |
| 179 | !/markActive\(tree\);/.test(source), |
| 180 | "active synthesized remote rows are marked read when the merged session tree arrives", |
| 181 | ); |
| 182 | ok( |
| 183 | /project-tree__remote-status/.test(remoteSource) && /projectTree\.remoteConnectFailed/.test(remoteSource), |
| 184 | "remote groups render retryable connect/error rows instead of going silent", |
| 185 | ); |
| 186 | ok( |
| 187 | /existing\.selectionRevision\+\+/.test(remoteOpenSource) && |
| 188 | /a\.goRemoteTabSafe\("remoteTabResume"[\s\S]*?resumeRemoteTabSessionPathForOpenSelection\(tabID, name, sessionPath, sessionTitle, revision, selection\)/.test(remotePendingSelectionSource), |
| 189 | "session switches resume in the background behind a generation guard", |
| 190 | ); |
| 191 | ok( |
| 192 | /if \(\(project\.kind !== "project" && project\.kind !== "global_folder"\) \|\| project\.remote\) return;/.test(source), |
| 193 | "remote groups never reach ListProjectTopics with their virtual root", |
| 194 | ); |
| 195 | |
| 196 | process.stdout.write(`\n${passed} passed, ${failed} failed\n`); |
| 197 | if (failed > 0) process.exit(1); |
| 198 |