| 1 | import { JSDOM } from "jsdom"; |
| 2 | |
| 3 | const dom = new JSDOM("<!doctype html><html><body></body></html>"); |
| 4 | const previousWindow = globalThis.window; |
| 5 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 6 | |
| 7 | type Session = { |
| 8 | id: string; |
| 9 | title: string; |
| 10 | shell: string; |
| 11 | cwd: string; |
| 12 | createdAt: number; |
| 13 | running: boolean; |
| 14 | }; |
| 15 | type Workspace = { available: boolean; readOnly: boolean; sessions: Session[]; shells: { id: string; label: string }[] }; |
| 16 | const pending = new Map<string, (value: Workspace) => void>(); |
| 17 | const pendingReject = new Map<string, (error: Error) => void>(); |
| 18 | const createPending: Array<(value: Session) => void> = []; |
| 19 | const createReject: Array<(error: Error) => void> = []; |
| 20 | let calls = 0; |
| 21 | let closeError: Error | null = null; |
| 22 | let writeError: Error | null = null; |
| 23 | const fakeApp = { |
| 24 | TerminalWorkspaceForTab(tabId: string) { |
| 25 | calls += 1; |
| 26 | return new Promise<Workspace>((resolve, reject) => { |
| 27 | pending.set(tabId, resolve); |
| 28 | pendingReject.set(tabId, reject); |
| 29 | }); |
| 30 | }, |
| 31 | CreateTerminalForTab() { |
| 32 | return new Promise<Session>((resolve, reject) => { |
| 33 | createPending.push(resolve); |
| 34 | createReject.push(reject); |
| 35 | }); |
| 36 | }, |
| 37 | async CloseTerminalForTab() { |
| 38 | if (closeError) throw closeError; |
| 39 | }, |
| 40 | async WriteTerminalForTab() { |
| 41 | if (writeError) throw writeError; |
| 42 | }, |
| 43 | }; |
| 44 | const { installDesktopHostStub } = await import("./desktopHostStub"); |
| 45 | const hostStub = installDesktopHostStub(fakeApp); |
| 46 | |
| 47 | const { resetTerminalStoreForTests, useTerminalStore } = await import("../store/terminal"); |
| 48 | resetTerminalStoreForTests(); |
| 49 | |
| 50 | const oldRequest = useTerminalStore.getState().syncWorkspace("old-tab"); |
| 51 | const newRequest = useTerminalStore.getState().syncWorkspace("new-tab"); |
| 52 | pending.get("new-tab")?.({ available: true, readOnly: false, sessions: [], shells: [] }); |
| 53 | await newRequest; |
| 54 | pending.get("old-tab")?.({ available: true, readOnly: false, sessions: [], shells: [] }); |
| 55 | await oldRequest; |
| 56 | if (useTerminalStore.getState().tabId !== "new-tab") throw new Error("stale workspace response replaced the selected tab"); |
| 57 | if (calls !== 2) throw new Error(`expected two workspace calls, got ${calls}`); |
| 58 | |
| 59 | resetTerminalStoreForTests(); |
| 60 | calls = 0; |
| 61 | const shared = useTerminalStore.getState().ensureReady("same-tab"); |
| 62 | const sharedAgain = useTerminalStore.getState().ensureReady("same-tab"); |
| 63 | pending.get("same-tab")?.({ available: true, readOnly: false, sessions: [], shells: [] }); |
| 64 | await Promise.all([shared, sharedAgain]); |
| 65 | if (calls !== 1) throw new Error(`ensureReady duplicated the first request: ${calls}`); |
| 66 | process.stdout.write("PASS stale responses and first-ready deduplication\n"); |
| 67 | |
| 68 | resetTerminalStoreForTests(); |
| 69 | calls = 0; |
| 70 | createPending.length = 0; |
| 71 | const firstOpenCreate = useTerminalStore.getState().createSession("first-open"); |
| 72 | await Promise.resolve(); |
| 73 | const panelReady = useTerminalStore.getState().syncWorkspace("first-open"); |
| 74 | await Promise.resolve(); |
| 75 | if (calls !== 1) throw new Error(`panel readiness duplicated the first-open workspace request: ${calls}`); |
| 76 | pending.get("first-open")?.({ |
| 77 | available: true, |
| 78 | readOnly: false, |
| 79 | sessions: [], |
| 80 | shells: [{ id: "default", label: "Default shell" }], |
| 81 | }); |
| 82 | await panelReady; |
| 83 | await Promise.resolve(); |
| 84 | if (createPending.length !== 1) throw new Error(`first-open create did not reach the backend: ${createPending.length}`); |
| 85 | const firstOpenSession: Session = { |
| 86 | id: "first-open-session", |
| 87 | title: "default", |
| 88 | shell: "default", |
| 89 | cwd: ".", |
| 90 | createdAt: 1, |
| 91 | running: true, |
| 92 | }; |
| 93 | createPending[0]?.(firstOpenSession); |
| 94 | const createdOnFirstOpen = await firstOpenCreate; |
| 95 | if (createdOnFirstOpen?.id !== firstOpenSession.id) throw new Error("first-open create was discarded"); |
| 96 | process.stdout.write("PASS first-open panel readiness shares the pending workspace request\n"); |
| 97 | |
| 98 | resetTerminalStoreForTests(); |
| 99 | calls = 0; |
| 100 | const staleCapability = useTerminalStore.getState().syncWorkspace("capability-refresh"); |
| 101 | await Promise.resolve(); |
| 102 | const resolveStaleCapability = pending.get("capability-refresh"); |
| 103 | const latestCapability = useTerminalStore.getState().syncWorkspace("capability-refresh", true); |
| 104 | await Promise.resolve(); |
| 105 | if (calls !== 2) throw new Error(`forced capability refresh did not start a new request: ${calls}`); |
| 106 | pending.get("capability-refresh")?.({ |
| 107 | available: true, |
| 108 | readOnly: false, |
| 109 | sessions: [], |
| 110 | shells: [{ id: "default", label: "Default shell" }], |
| 111 | }); |
| 112 | await latestCapability; |
| 113 | resolveStaleCapability?.({ |
| 114 | available: true, |
| 115 | readOnly: true, |
| 116 | sessions: [], |
| 117 | shells: [{ id: "default", label: "Default shell" }], |
| 118 | }); |
| 119 | await staleCapability; |
| 120 | if (useTerminalStore.getState().workspace?.readOnly) { |
| 121 | throw new Error("stale read-only capability replaced the forced writable refresh"); |
| 122 | } |
| 123 | process.stdout.write("PASS forced capability refresh supersedes an in-flight same-tab request\n"); |
| 124 | |
| 125 | resetTerminalStoreForTests(); |
| 126 | calls = 0; |
| 127 | const paintedWorkspace: Workspace = { |
| 128 | available: true, |
| 129 | readOnly: false, |
| 130 | sessions: [{ |
| 131 | id: "painted", |
| 132 | title: "zsh", |
| 133 | shell: "default", |
| 134 | cwd: ".", |
| 135 | createdAt: 1, |
| 136 | running: true, |
| 137 | }], |
| 138 | shells: [{ id: "default", label: "Default shell" }], |
| 139 | }; |
| 140 | useTerminalStore.setState({ |
| 141 | tabId: "warm-tab", |
| 142 | workspace: paintedWorkspace, |
| 143 | loading: false, |
| 144 | activeSessionId: "painted", |
| 145 | }); |
| 146 | const warmRefresh = useTerminalStore.getState().syncWorkspace("warm-tab"); |
| 147 | await Promise.resolve(); |
| 148 | if (useTerminalStore.getState().workspace?.sessions[0]?.id !== "painted") { |
| 149 | throw new Error("same-tab refresh cleared the painted workspace before the response landed"); |
| 150 | } |
| 151 | if (useTerminalStore.getState().loading !== true) { |
| 152 | throw new Error("same-tab refresh should still mark loading while the request is in flight"); |
| 153 | } |
| 154 | pending.get("warm-tab")?.(paintedWorkspace); |
| 155 | await warmRefresh; |
| 156 | if (calls !== 1) throw new Error(`same-tab refresh expected one workspace call, got ${calls}`); |
| 157 | process.stdout.write("PASS same-tab refresh keeps the painted workspace warm\n"); |
| 158 | |
| 159 | resetTerminalStoreForTests(); |
| 160 | createPending.length = 0; |
| 161 | const readyWorkspace: Workspace = { |
| 162 | available: true, |
| 163 | readOnly: false, |
| 164 | sessions: [], |
| 165 | shells: [{ id: "default", label: "Default shell" }], |
| 166 | }; |
| 167 | useTerminalStore.setState({ |
| 168 | tabId: "create-tab", |
| 169 | workspace: readyWorkspace, |
| 170 | loading: false, |
| 171 | activeSessionId: null, |
| 172 | }); |
| 173 | const firstCreate = useTerminalStore.getState().createSession("create-tab"); |
| 174 | const secondCreate = useTerminalStore.getState().createSession("create-tab"); |
| 175 | await Promise.resolve(); |
| 176 | await Promise.resolve(); |
| 177 | if (createPending.length !== 2) throw new Error(`expected two create calls, got ${createPending.length}`); |
| 178 | const firstSession: Session = { |
| 179 | id: "first", |
| 180 | title: "first", |
| 181 | shell: "default", |
| 182 | cwd: ".", |
| 183 | createdAt: 1, |
| 184 | running: true, |
| 185 | }; |
| 186 | const secondSession: Session = { |
| 187 | id: "second", |
| 188 | title: "second", |
| 189 | shell: "default", |
| 190 | cwd: ".", |
| 191 | createdAt: 2, |
| 192 | running: true, |
| 193 | }; |
| 194 | createPending[1]?.(secondSession); |
| 195 | await secondCreate; |
| 196 | createPending[0]?.(firstSession); |
| 197 | await firstCreate; |
| 198 | const sessionIDs = useTerminalStore.getState().workspace?.sessions.map((session) => session.id).sort(); |
| 199 | if (JSON.stringify(sessionIDs) !== JSON.stringify(["first", "second"])) { |
| 200 | throw new Error(`concurrent creates lost a session: ${JSON.stringify(sessionIDs)}`); |
| 201 | } |
| 202 | process.stdout.write("PASS concurrent terminal creates merge into the latest workspace state\n"); |
| 203 | |
| 204 | resetTerminalStoreForTests(); |
| 205 | const failedSync = useTerminalStore.getState().syncWorkspace("sync-error"); |
| 206 | pendingReject.get("sync-error")?.(new Error("workspace unavailable")); |
| 207 | await failedSync.catch(() => {}); |
| 208 | if (useTerminalStore.getState().error !== "workspace unavailable") { |
| 209 | throw new Error(`workspace sync error was not exposed: ${useTerminalStore.getState().error}`); |
| 210 | } |
| 211 | if (useTerminalStore.getState().loading || useTerminalStore.getState().workspace !== null) { |
| 212 | throw new Error("failed workspace sync left a loading or partial workspace state"); |
| 213 | } |
| 214 | process.stdout.write("PASS workspace sync failures remain visible and retryable\n"); |
| 215 | |
| 216 | resetTerminalStoreForTests(); |
| 217 | createPending.length = 0; |
| 218 | createReject.length = 0; |
| 219 | useTerminalStore.setState({ |
| 220 | tabId: "create-error", |
| 221 | workspace: readyWorkspace, |
| 222 | loading: false, |
| 223 | activeSessionId: null, |
| 224 | }); |
| 225 | const failedCreate = useTerminalStore.getState().createSession("create-error"); |
| 226 | await Promise.resolve(); |
| 227 | createReject[0]?.(new Error("shell failed to start")); |
| 228 | await failedCreate.catch(() => {}); |
| 229 | if (useTerminalStore.getState().error !== "shell failed to start") { |
| 230 | throw new Error(`terminal create error was not exposed: ${useTerminalStore.getState().error}`); |
| 231 | } |
| 232 | if (useTerminalStore.getState().workspace?.sessions.length !== 0) { |
| 233 | throw new Error("failed terminal create inserted a phantom session"); |
| 234 | } |
| 235 | process.stdout.write("PASS terminal create failures do not insert phantom sessions\n"); |
| 236 | |
| 237 | resetTerminalStoreForTests(); |
| 238 | closeError = new Error("terminal did not close"); |
| 239 | useTerminalStore.setState({ |
| 240 | tabId: "close-error", |
| 241 | workspace: { ...readyWorkspace, sessions: [firstSession] }, |
| 242 | loading: false, |
| 243 | activeSessionId: firstSession.id, |
| 244 | }); |
| 245 | await useTerminalStore.getState().closeSession("close-error", firstSession.id).catch(() => {}); |
| 246 | if (useTerminalStore.getState().error !== "terminal did not close") { |
| 247 | throw new Error(`terminal close error was not exposed: ${useTerminalStore.getState().error}`); |
| 248 | } |
| 249 | if (useTerminalStore.getState().workspace?.sessions[0]?.id !== firstSession.id) { |
| 250 | throw new Error("failed terminal close removed the live session from the workspace"); |
| 251 | } |
| 252 | useTerminalStore.getState().clearError(); |
| 253 | if (useTerminalStore.getState().error !== null) throw new Error("terminal error dismissal did not clear the error"); |
| 254 | closeError = null; |
| 255 | process.stdout.write("PASS terminal close failures preserve the session and can be dismissed\n"); |
| 256 | |
| 257 | writeError = new Error("terminal input failed"); |
| 258 | await useTerminalStore.getState().write("close-error", firstSession.id, "echo test").catch(() => {}); |
| 259 | if (useTerminalStore.getState().error !== "terminal input failed") { |
| 260 | throw new Error(`terminal write error was not exposed: ${useTerminalStore.getState().error}`); |
| 261 | } |
| 262 | writeError = null; |
| 263 | process.stdout.write("PASS terminal write failures remain visible to the user\n"); |
| 264 | |
| 265 | const { startTerminalEventBridge } = await import("../lib/terminalEvents"); |
| 266 | const stopEventBridge = startTerminalEventBridge(); |
| 267 | useTerminalStore.getState().clearError(); |
| 268 | hostStub.emit("desktop:resync", { generation: "g1", reason: "gap" }); |
| 269 | if (!useTerminalStore.getState().error) throw new Error("an event gap left terminal output falsely complete"); |
| 270 | if (useTerminalStore.getState().workspace?.sessions[0]?.id !== firstSession.id) throw new Error("gap handling removed the existing terminal"); |
| 271 | stopEventBridge(); |
| 272 | process.stdout.write("PASS terminal event gaps preserve the view and expose incomplete output\n"); |
| 273 | |
| 274 | if (previousWindow) globalThis.window = previousWindow; |
| 275 | else delete (globalThis as { window?: unknown }).window; |
| 276 |