| 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 | (globalThis.window as unknown as { go: unknown }).go = { main: { App: fakeApp } }; |
| 45 | |
| 46 | const { resetTerminalStoreForTests, useTerminalStore } = await import("../store/terminal"); |
| 47 | resetTerminalStoreForTests(); |
| 48 | |
| 49 | const oldRequest = useTerminalStore.getState().syncWorkspace("old-tab"); |
| 50 | const newRequest = useTerminalStore.getState().syncWorkspace("new-tab"); |
| 51 | pending.get("new-tab")?.({ available: true, readOnly: false, sessions: [], shells: [] }); |
| 52 | await newRequest; |
| 53 | pending.get("old-tab")?.({ available: true, readOnly: false, sessions: [], shells: [] }); |
| 54 | await oldRequest; |
| 55 | if (useTerminalStore.getState().tabId !== "new-tab") throw new Error("stale workspace response replaced the selected tab"); |
| 56 | if (calls !== 2) throw new Error(`expected two workspace calls, got ${calls}`); |
| 57 | |
| 58 | resetTerminalStoreForTests(); |
| 59 | calls = 0; |
| 60 | const shared = useTerminalStore.getState().ensureReady("same-tab"); |
| 61 | const sharedAgain = useTerminalStore.getState().ensureReady("same-tab"); |
| 62 | pending.get("same-tab")?.({ available: true, readOnly: false, sessions: [], shells: [] }); |
| 63 | await Promise.all([shared, sharedAgain]); |
| 64 | if (calls !== 1) throw new Error(`ensureReady duplicated the first request: ${calls}`); |
| 65 | process.stdout.write("PASS stale responses and first-ready deduplication\n"); |
| 66 | |
| 67 | resetTerminalStoreForTests(); |
| 68 | calls = 0; |
| 69 | createPending.length = 0; |
| 70 | const firstOpenCreate = useTerminalStore.getState().createSession("first-open"); |
| 71 | await Promise.resolve(); |
| 72 | const panelReady = useTerminalStore.getState().syncWorkspace("first-open"); |
| 73 | await Promise.resolve(); |
| 74 | if (calls !== 1) throw new Error(`panel readiness duplicated the first-open workspace request: ${calls}`); |
| 75 | pending.get("first-open")?.({ |
| 76 | available: true, |
| 77 | readOnly: false, |
| 78 | sessions: [], |
| 79 | shells: [{ id: "default", label: "Default shell" }], |
| 80 | }); |
| 81 | await panelReady; |
| 82 | await Promise.resolve(); |
| 83 | if (createPending.length !== 1) throw new Error(`first-open create did not reach the backend: ${createPending.length}`); |
| 84 | const firstOpenSession: Session = { |
| 85 | id: "first-open-session", |
| 86 | title: "default", |
| 87 | shell: "default", |
| 88 | cwd: ".", |
| 89 | createdAt: 1, |
| 90 | running: true, |
| 91 | }; |
| 92 | createPending[0]?.(firstOpenSession); |
| 93 | const createdOnFirstOpen = await firstOpenCreate; |
| 94 | if (createdOnFirstOpen?.id !== firstOpenSession.id) throw new Error("first-open create was discarded"); |
| 95 | process.stdout.write("PASS first-open panel readiness shares the pending workspace request\n"); |
| 96 | |
| 97 | resetTerminalStoreForTests(); |
| 98 | calls = 0; |
| 99 | const staleCapability = useTerminalStore.getState().syncWorkspace("capability-refresh"); |
| 100 | await Promise.resolve(); |
| 101 | const resolveStaleCapability = pending.get("capability-refresh"); |
| 102 | const latestCapability = useTerminalStore.getState().syncWorkspace("capability-refresh", true); |
| 103 | await Promise.resolve(); |
| 104 | if (calls !== 2) throw new Error(`forced capability refresh did not start a new request: ${calls}`); |
| 105 | pending.get("capability-refresh")?.({ |
| 106 | available: true, |
| 107 | readOnly: false, |
| 108 | sessions: [], |
| 109 | shells: [{ id: "default", label: "Default shell" }], |
| 110 | }); |
| 111 | await latestCapability; |
| 112 | resolveStaleCapability?.({ |
| 113 | available: true, |
| 114 | readOnly: true, |
| 115 | sessions: [], |
| 116 | shells: [{ id: "default", label: "Default shell" }], |
| 117 | }); |
| 118 | await staleCapability; |
| 119 | if (useTerminalStore.getState().workspace?.readOnly) { |
| 120 | throw new Error("stale read-only capability replaced the forced writable refresh"); |
| 121 | } |
| 122 | process.stdout.write("PASS forced capability refresh supersedes an in-flight same-tab request\n"); |
| 123 | |
| 124 | resetTerminalStoreForTests(); |
| 125 | createPending.length = 0; |
| 126 | const readyWorkspace: Workspace = { |
| 127 | available: true, |
| 128 | readOnly: false, |
| 129 | sessions: [], |
| 130 | shells: [{ id: "default", label: "Default shell" }], |
| 131 | }; |
| 132 | useTerminalStore.setState({ |
| 133 | tabId: "create-tab", |
| 134 | workspace: readyWorkspace, |
| 135 | loading: false, |
| 136 | activeSessionId: null, |
| 137 | }); |
| 138 | const firstCreate = useTerminalStore.getState().createSession("create-tab"); |
| 139 | const secondCreate = useTerminalStore.getState().createSession("create-tab"); |
| 140 | await Promise.resolve(); |
| 141 | await Promise.resolve(); |
| 142 | if (createPending.length !== 2) throw new Error(`expected two create calls, got ${createPending.length}`); |
| 143 | const firstSession: Session = { |
| 144 | id: "first", |
| 145 | title: "first", |
| 146 | shell: "default", |
| 147 | cwd: ".", |
| 148 | createdAt: 1, |
| 149 | running: true, |
| 150 | }; |
| 151 | const secondSession: Session = { |
| 152 | id: "second", |
| 153 | title: "second", |
| 154 | shell: "default", |
| 155 | cwd: ".", |
| 156 | createdAt: 2, |
| 157 | running: true, |
| 158 | }; |
| 159 | createPending[1]?.(secondSession); |
| 160 | await secondCreate; |
| 161 | createPending[0]?.(firstSession); |
| 162 | await firstCreate; |
| 163 | const sessionIDs = useTerminalStore.getState().workspace?.sessions.map((session) => session.id).sort(); |
| 164 | if (JSON.stringify(sessionIDs) !== JSON.stringify(["first", "second"])) { |
| 165 | throw new Error(`concurrent creates lost a session: ${JSON.stringify(sessionIDs)}`); |
| 166 | } |
| 167 | process.stdout.write("PASS concurrent terminal creates merge into the latest workspace state\n"); |
| 168 | |
| 169 | resetTerminalStoreForTests(); |
| 170 | const failedSync = useTerminalStore.getState().syncWorkspace("sync-error"); |
| 171 | pendingReject.get("sync-error")?.(new Error("workspace unavailable")); |
| 172 | await failedSync.catch(() => {}); |
| 173 | if (useTerminalStore.getState().error !== "workspace unavailable") { |
| 174 | throw new Error(`workspace sync error was not exposed: ${useTerminalStore.getState().error}`); |
| 175 | } |
| 176 | if (useTerminalStore.getState().loading || useTerminalStore.getState().workspace !== null) { |
| 177 | throw new Error("failed workspace sync left a loading or partial workspace state"); |
| 178 | } |
| 179 | process.stdout.write("PASS workspace sync failures remain visible and retryable\n"); |
| 180 | |
| 181 | resetTerminalStoreForTests(); |
| 182 | createPending.length = 0; |
| 183 | createReject.length = 0; |
| 184 | useTerminalStore.setState({ |
| 185 | tabId: "create-error", |
| 186 | workspace: readyWorkspace, |
| 187 | loading: false, |
| 188 | activeSessionId: null, |
| 189 | }); |
| 190 | const failedCreate = useTerminalStore.getState().createSession("create-error"); |
| 191 | await Promise.resolve(); |
| 192 | createReject[0]?.(new Error("shell failed to start")); |
| 193 | await failedCreate.catch(() => {}); |
| 194 | if (useTerminalStore.getState().error !== "shell failed to start") { |
| 195 | throw new Error(`terminal create error was not exposed: ${useTerminalStore.getState().error}`); |
| 196 | } |
| 197 | if (useTerminalStore.getState().workspace?.sessions.length !== 0) { |
| 198 | throw new Error("failed terminal create inserted a phantom session"); |
| 199 | } |
| 200 | process.stdout.write("PASS terminal create failures do not insert phantom sessions\n"); |
| 201 | |
| 202 | resetTerminalStoreForTests(); |
| 203 | closeError = new Error("terminal did not close"); |
| 204 | useTerminalStore.setState({ |
| 205 | tabId: "close-error", |
| 206 | workspace: { ...readyWorkspace, sessions: [firstSession] }, |
| 207 | loading: false, |
| 208 | activeSessionId: firstSession.id, |
| 209 | }); |
| 210 | await useTerminalStore.getState().closeSession("close-error", firstSession.id).catch(() => {}); |
| 211 | if (useTerminalStore.getState().error !== "terminal did not close") { |
| 212 | throw new Error(`terminal close error was not exposed: ${useTerminalStore.getState().error}`); |
| 213 | } |
| 214 | if (useTerminalStore.getState().workspace?.sessions[0]?.id !== firstSession.id) { |
| 215 | throw new Error("failed terminal close removed the live session from the workspace"); |
| 216 | } |
| 217 | useTerminalStore.getState().clearError(); |
| 218 | if (useTerminalStore.getState().error !== null) throw new Error("terminal error dismissal did not clear the error"); |
| 219 | closeError = null; |
| 220 | process.stdout.write("PASS terminal close failures preserve the session and can be dismissed\n"); |
| 221 | |
| 222 | writeError = new Error("terminal input failed"); |
| 223 | await useTerminalStore.getState().write("close-error", firstSession.id, "echo test").catch(() => {}); |
| 224 | if (useTerminalStore.getState().error !== "terminal input failed") { |
| 225 | throw new Error(`terminal write error was not exposed: ${useTerminalStore.getState().error}`); |
| 226 | } |
| 227 | writeError = null; |
| 228 | process.stdout.write("PASS terminal write failures remain visible to the user\n"); |
| 229 | |
| 230 | if (previousWindow) globalThis.window = previousWindow; |
| 231 | else delete (globalThis as { window?: unknown }).window; |
| 232 |