@wangyaoshen/remux 0.3.8-dev.29e114b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
  4. package/.github/dependabot.yml +33 -0
  5. package/.github/workflows/ci.yml +65 -0
  6. package/.github/workflows/deploy.yml +65 -0
  7. package/.github/workflows/publish.yml +312 -0
  8. package/.github/workflows/release-please.yml +21 -0
  9. package/.gitmodules +3 -0
  10. package/.nvmrc +1 -0
  11. package/.release-please-manifest.json +3 -0
  12. package/CLAUDE.md +104 -0
  13. package/Dockerfile +23 -0
  14. package/LICENSE +21 -0
  15. package/README.md +120 -0
  16. package/apps/ios/Config/signing.xcconfig +4 -0
  17. package/apps/ios/Package.swift +26 -0
  18. package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
  19. package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  20. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
  21. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
  22. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
  23. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
  24. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
  25. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
  26. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
  27. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
  28. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
  29. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
  30. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
  31. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
  32. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
  33. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
  34. package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
  35. package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
  36. package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
  37. package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
  38. package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
  39. package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
  40. package/apps/ios/Sources/Remux/RootView.swift +130 -0
  41. package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
  42. package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
  43. package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
  44. package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
  45. package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
  46. package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
  47. package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
  48. package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
  49. package/apps/macos/Package.swift +37 -0
  50. package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
  51. package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
  52. package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
  53. package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
  54. package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
  55. package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
  56. package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
  57. package/apps/macos/Resources/terminfo/67/ghostty +0 -0
  58. package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
  59. package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
  60. package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
  61. package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
  62. package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
  63. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
  64. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
  65. package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
  66. package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
  67. package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
  68. package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
  69. package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
  70. package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
  71. package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
  72. package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
  73. package/apps/macos/Sources/Remux/SocketController.swift +258 -0
  74. package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
  75. package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
  76. package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
  77. package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
  78. package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
  79. package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
  80. package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
  81. package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
  82. package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
  83. package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
  84. package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
  85. package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
  86. package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
  87. package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
  88. package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
  89. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
  90. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
  91. package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
  92. package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
  93. package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
  94. package/build.mjs +33 -0
  95. package/native/android/DecodeGoldenPayloads.kt +487 -0
  96. package/native/android/ProtocolModels.kt +188 -0
  97. package/native/ios/DecodeGoldenPayloads.swift +711 -0
  98. package/native/ios/ProtocolModels.swift +200 -0
  99. package/package.json +45 -0
  100. package/packages/RemuxKit/Package.swift +27 -0
  101. package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
  102. package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
  103. package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
  104. package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
  105. package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
  106. package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
  107. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
  108. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
  109. package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
  110. package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
  111. package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
  112. package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
  113. package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
  114. package/playwright.config.ts +17 -0
  115. package/pnpm-lock.yaml +1588 -0
  116. package/pty-daemon.js +303 -0
  117. package/release-please-config.json +14 -0
  118. package/scripts/auto-deploy.sh +46 -0
  119. package/scripts/build-dmg.sh +121 -0
  120. package/scripts/build-ghostty-kit.sh +43 -0
  121. package/scripts/check-active-terminology.mjs +132 -0
  122. package/scripts/setup-ci-secrets.sh +80 -0
  123. package/scripts/sync-ghostty-web.sh +28 -0
  124. package/scripts/upload-testflight.sh +100 -0
  125. package/server.js +7074 -0
  126. package/src/adapters/agent-events.ts +246 -0
  127. package/src/adapters/claude-code.ts +158 -0
  128. package/src/adapters/codex.ts +210 -0
  129. package/src/adapters/generic-shell.ts +58 -0
  130. package/src/adapters/index.ts +15 -0
  131. package/src/adapters/registry.ts +99 -0
  132. package/src/adapters/types.ts +41 -0
  133. package/src/auth.ts +174 -0
  134. package/src/e2ee.ts +236 -0
  135. package/src/git-service.ts +168 -0
  136. package/src/message-buffer.ts +137 -0
  137. package/src/pty-daemon.ts +357 -0
  138. package/src/push.ts +127 -0
  139. package/src/renderers.ts +455 -0
  140. package/src/server.ts +2407 -0
  141. package/src/service.ts +226 -0
  142. package/src/session.ts +978 -0
  143. package/src/store.ts +1422 -0
  144. package/src/team.ts +123 -0
  145. package/src/tunnel.ts +126 -0
  146. package/src/types.d.ts +50 -0
  147. package/src/vt-tracker.ts +188 -0
  148. package/src/workspace-head.ts +144 -0
  149. package/src/workspace.ts +153 -0
  150. package/src/ws-handler.ts +1526 -0
  151. package/start.ps1 +83 -0
  152. package/tests/adapters.test.js +171 -0
  153. package/tests/auth.test.js +243 -0
  154. package/tests/codex-adapter.test.js +535 -0
  155. package/tests/durable-stream.test.js +153 -0
  156. package/tests/e2e/app.spec.js +530 -0
  157. package/tests/e2ee.test.js +325 -0
  158. package/tests/message-buffer.test.js +245 -0
  159. package/tests/message-routing.test.js +305 -0
  160. package/tests/pty-daemon.test.js +346 -0
  161. package/tests/push.test.js +281 -0
  162. package/tests/renderers.test.js +391 -0
  163. package/tests/search-shell.test.js +499 -0
  164. package/tests/server.test.js +882 -0
  165. package/tests/service.test.js +267 -0
  166. package/tests/store.test.js +369 -0
  167. package/tests/tunnel.test.js +67 -0
  168. package/tests/workspace-head.test.js +116 -0
  169. package/tests/workspace.test.js +417 -0
  170. package/tsconfig.backend.json +11 -0
  171. package/tsconfig.json +15 -0
  172. package/tui/client/client_test.go +125 -0
  173. package/tui/client/connection.go +342 -0
  174. package/tui/client/host_manager.go +141 -0
  175. package/tui/config/cache.go +81 -0
  176. package/tui/config/config.go +53 -0
  177. package/tui/config/config_test.go +89 -0
  178. package/tui/go.mod +32 -0
  179. package/tui/go.sum +50 -0
  180. package/tui/main.go +261 -0
  181. package/tui/tests/integration_test.go +283 -0
  182. package/tui/ui/model.go +310 -0
  183. package/vitest.config.js +10 -0
@@ -0,0 +1,882 @@
1
+ /**
2
+ * Integration tests for Remux server.
3
+ * Tests: startup, HTTP auth, WebSocket session/tab management, VT snapshot,
4
+ * persistence, protocol envelope, client connection state (active/observer).
5
+ */
6
+
7
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
8
+ import { spawn } from "child_process";
9
+ import http from "http";
10
+ import WebSocket from "ws";
11
+ import fs from "fs";
12
+ import path from "path";
13
+ import { homedir } from "os";
14
+
15
+ const PORT = 19876 + Math.floor(Math.random() * 1000); // randomized test port
16
+ const TOKEN = "test-token-" + Date.now();
17
+ const INSTANCE_ID = "test-" + Date.now();
18
+ const PERSIST_DIR = path.join(homedir(), ".remux");
19
+ const PERSIST_FILE = path.join(PERSIST_DIR, `sessions-${INSTANCE_ID}.json`);
20
+ const DB_FILE = path.join(PERSIST_DIR, `remux-${INSTANCE_ID}.db`);
21
+ let serverProc;
22
+
23
+ function httpGet(urlPath) {
24
+ return new Promise((resolve, reject) => {
25
+ http.get(`http://localhost:${PORT}${urlPath}`, (res) => {
26
+ let body = "";
27
+ res.on("data", (d) => (body += d));
28
+ res.on("end", () => resolve({ status: res.statusCode, body }));
29
+ }).on("error", reject);
30
+ });
31
+ }
32
+
33
+ function connectWs() {
34
+ return new Promise((resolve, reject) => {
35
+ const ws = new WebSocket(`ws://localhost:${PORT}/ws`);
36
+ ws.on("open", () => resolve(ws));
37
+ ws.on("error", reject);
38
+ });
39
+ }
40
+
41
+ /**
42
+ * Unwrap envelope: if message has v:1, flatten type + payload.
43
+ * This allows tests to work with both enveloped and legacy messages.
44
+ */
45
+ function unwrap(parsed) {
46
+ if (parsed && parsed.v === 1 && typeof parsed.type === "string") {
47
+ return { type: parsed.type, ...(parsed.payload || {}) };
48
+ }
49
+ return parsed;
50
+ }
51
+
52
+ /** Connect, authenticate, and consume the initial state broadcast. */
53
+ async function connectAuthed() {
54
+ const ws = await connectWs();
55
+ const msgs = await sendAndCollect(
56
+ ws,
57
+ { type: "auth", token: TOKEN },
58
+ { timeout: 3000 },
59
+ );
60
+ const authOk = msgs.find((m) => m.type === "auth_ok");
61
+ if (!authOk) throw new Error("auth failed");
62
+ return ws;
63
+ }
64
+
65
+ function sendAndCollect(ws, msg, { timeout = 3000, filter } = {}) {
66
+ return new Promise((resolve) => {
67
+ const messages = [];
68
+ const handler = (raw) => {
69
+ const s = raw.toString();
70
+ try {
71
+ const parsed = unwrap(JSON.parse(s));
72
+ if (!filter || filter(parsed)) messages.push(parsed);
73
+ } catch {
74
+ messages.push({ _raw: s });
75
+ }
76
+ };
77
+ ws.on("message", handler);
78
+ if (msg) ws.send(JSON.stringify(msg));
79
+ setTimeout(() => {
80
+ ws.removeListener("message", handler);
81
+ resolve(messages);
82
+ }, timeout);
83
+ });
84
+ }
85
+
86
+ function waitForMsg(ws, type, timeout = 3000) {
87
+ return new Promise((resolve, reject) => {
88
+ const timer = setTimeout(() => {
89
+ ws.removeListener("message", handler);
90
+ reject(new Error(`timeout waiting for ${type}`));
91
+ }, timeout);
92
+ const handler = (raw) => {
93
+ try {
94
+ const msg = unwrap(JSON.parse(raw.toString()));
95
+ if (msg.type === type) {
96
+ clearTimeout(timer);
97
+ ws.removeListener("message", handler);
98
+ resolve(msg);
99
+ }
100
+ } catch {}
101
+ };
102
+ ws.on("message", handler);
103
+ });
104
+ }
105
+
106
+ beforeAll(async () => {
107
+ // Clean persistence files to ensure clean state
108
+ try { fs.unlinkSync(PERSIST_FILE); } catch {}
109
+ try { fs.unlinkSync(DB_FILE); } catch {}
110
+ try { fs.unlinkSync(DB_FILE + "-wal"); } catch {}
111
+ try { fs.unlinkSync(DB_FILE + "-shm"); } catch {}
112
+
113
+ // Explicitly remove REMUX_PASSWORD to avoid env leaking from parent
114
+ const cleanEnv = { ...process.env };
115
+ delete cleanEnv.REMUX_PASSWORD;
116
+ serverProc = spawn("node", ["server.js"], {
117
+ env: {
118
+ ...cleanEnv,
119
+ PORT: String(PORT),
120
+ REMUX_TOKEN: TOKEN,
121
+ REMUX_INSTANCE_ID: INSTANCE_ID,
122
+ },
123
+ stdio: "pipe",
124
+ });
125
+
126
+ // Collect stderr for diagnostics on failure
127
+ let stderrBuf = "";
128
+ serverProc.stderr.on("data", (d) => { stderrBuf += d.toString(); });
129
+
130
+ // Wait for server: stdout "Remux running" then HTTP probe via http.get
131
+ let serverExited = false;
132
+ serverProc.on("exit", (code) => { serverExited = true; stderrBuf += `\n[process exited ${code}]`; });
133
+
134
+ // Phase 1: wait for stdout signal
135
+ await new Promise((resolve, reject) => {
136
+ const timeout = setTimeout(() => {
137
+ if (serverExited) reject(new Error(`Server exited early: ${stderrBuf}`));
138
+ else reject(new Error(`Server stdout timeout after 20s. stderr: ${stderrBuf}`));
139
+ }, 20000);
140
+ serverProc.stdout.on("data", (d) => {
141
+ if (d.toString().includes("Remux running")) {
142
+ clearTimeout(timeout);
143
+ resolve();
144
+ }
145
+ });
146
+ serverProc.on("error", (e) => { clearTimeout(timeout); reject(e); });
147
+ });
148
+
149
+ // Phase 2: poll HTTP to confirm full readiness (WASM, DB, etc.)
150
+ const httpProbe = () => new Promise((resolve) => {
151
+ const req = http.get(`http://127.0.0.1:${PORT}/`, (res) => {
152
+ res.resume();
153
+ resolve(res.statusCode < 500);
154
+ });
155
+ req.on("error", () => resolve(false));
156
+ req.setTimeout(2000, () => { req.destroy(); resolve(false); });
157
+ });
158
+ for (let i = 0; i < 20; i++) {
159
+ if (await httpProbe()) return;
160
+ await new Promise(r => setTimeout(r, 500));
161
+ }
162
+ throw new Error(`Server HTTP not ready after polling. stderr: ${stderrBuf}`);
163
+ }, 35000);
164
+
165
+ afterAll(() => {
166
+ if (serverProc) serverProc.kill("SIGTERM");
167
+ try { fs.unlinkSync(PERSIST_FILE); } catch {}
168
+ try { fs.unlinkSync(DB_FILE); } catch {}
169
+ try { fs.unlinkSync(DB_FILE + "-wal"); } catch {}
170
+ try { fs.unlinkSync(DB_FILE + "-shm"); } catch {}
171
+ });
172
+
173
+ // ── HTTP ──────────────────────────────────────────────────────────
174
+
175
+ describe("HTTP", () => {
176
+ it("rejects requests without token", async () => {
177
+ const res = await httpGet("/");
178
+ expect(res.status).toBe(403);
179
+ });
180
+
181
+ it("serves page with correct token", async () => {
182
+ const res = await httpGet(`/?token=${TOKEN}`);
183
+ expect(res.status).toBe(200);
184
+ expect(res.body).toContain("ghostty-web");
185
+ expect(res.body).toContain("<title>Remux</title>");
186
+ });
187
+
188
+ it("serves ghostty-web JS", async () => {
189
+ const res = await httpGet("/dist/ghostty-web.js");
190
+ expect(res.status).toBe(200);
191
+ });
192
+
193
+ it("serves WASM file", async () => {
194
+ const res = await httpGet("/ghostty-vt.wasm");
195
+ expect(res.status).toBe(200);
196
+ });
197
+ });
198
+
199
+ // ── Protocol envelope ────────────────────────────────────────────
200
+
201
+ describe("protocol envelope", () => {
202
+ it("server sends messages in envelope format (v:1)", async () => {
203
+ const ws = await connectWs();
204
+ const rawMsgs = [];
205
+ ws.on("message", (raw) => rawMsgs.push(raw.toString()));
206
+ ws.send(JSON.stringify({ type: "auth", token: TOKEN }));
207
+ await new Promise((r) => setTimeout(r, 3000));
208
+
209
+ // All JSON messages should have envelope format
210
+ const jsonMsgs = rawMsgs
211
+ .filter((s) => s.startsWith("{"))
212
+ .map((s) => JSON.parse(s));
213
+ expect(jsonMsgs.length).toBeGreaterThan(0);
214
+ for (const m of jsonMsgs) {
215
+ expect(m.v).toBe(1);
216
+ expect(typeof m.type).toBe("string");
217
+ expect(m).toHaveProperty("payload");
218
+ }
219
+ ws.close();
220
+ });
221
+
222
+ it("server accepts legacy bare messages (backward compat)", async () => {
223
+ const ws = await connectAuthed();
224
+ // Send a legacy message without envelope wrapper
225
+ const msgs = await sendAndCollect(
226
+ ws,
227
+ { type: "attach_first", session: "main", cols: 80, rows: 24 },
228
+ { timeout: 3000 },
229
+ );
230
+ const attached = msgs.find((m) => m.type === "attached");
231
+ expect(attached).toBeDefined();
232
+ expect(attached.session).toBe("main");
233
+ ws.close();
234
+ });
235
+
236
+ it("server accepts enveloped messages", async () => {
237
+ const ws = await connectAuthed();
238
+ // Send an enveloped message
239
+ ws.send(JSON.stringify({
240
+ v: 1,
241
+ type: "attach_first",
242
+ payload: { session: "main", cols: 80, rows: 24 },
243
+ }));
244
+ const msg = await waitForMsg(ws, "attached");
245
+ expect(msg.session).toBe("main");
246
+ ws.close();
247
+ });
248
+
249
+ it("envelope payload contains correct data", async () => {
250
+ const ws = await connectWs();
251
+ const rawMsgs = [];
252
+ ws.on("message", (raw) => rawMsgs.push(raw.toString()));
253
+ ws.send(JSON.stringify({ type: "auth", token: TOKEN }));
254
+ await new Promise((r) => setTimeout(r, 3000));
255
+
256
+ const authOk = rawMsgs
257
+ .filter((s) => s.startsWith("{"))
258
+ .map((s) => JSON.parse(s))
259
+ .find((m) => m.type === "auth_ok");
260
+ expect(authOk).toBeDefined();
261
+ expect(authOk.v).toBe(1);
262
+ expect(authOk.type).toBe("auth_ok");
263
+
264
+ const stateMsg = rawMsgs
265
+ .filter((s) => s.startsWith("{"))
266
+ .map((s) => JSON.parse(s))
267
+ .find((m) => m.type === "state");
268
+ expect(stateMsg).toBeDefined();
269
+ expect(stateMsg.payload.sessions).toBeDefined();
270
+ expect(stateMsg.payload.clients).toBeDefined();
271
+ ws.close();
272
+ });
273
+ });
274
+
275
+ // ── WebSocket auth ────────────────────────────────────────────────
276
+
277
+ describe("WebSocket auth", () => {
278
+ it("rejects connection without auth", async () => {
279
+ const ws = await connectWs();
280
+ ws.send(JSON.stringify({ type: "attach_first", session: "main" }));
281
+ const msg = await waitForMsg(ws, "auth_error");
282
+ expect(msg.reason).toBe("invalid token");
283
+ ws.close();
284
+ });
285
+
286
+ it("accepts connection with valid token and sends initial state", async () => {
287
+ const ws = await connectWs();
288
+ const msgs = await sendAndCollect(
289
+ ws,
290
+ { type: "auth", token: TOKEN },
291
+ { timeout: 3000 },
292
+ );
293
+ expect(msgs.some((m) => m.type === "auth_ok")).toBe(true);
294
+ expect(msgs.some((m) => m.type === "state")).toBe(true);
295
+ ws.close();
296
+ });
297
+ });
298
+
299
+ // ── Session and tab management ────────────────────────────────────
300
+
301
+ describe("session and tab management", () => {
302
+ let ws;
303
+
304
+ beforeAll(async () => {
305
+ ws = await connectAuthed();
306
+ });
307
+
308
+ afterAll(() => ws?.close());
309
+
310
+ it("default state has main session with tabs", async () => {
311
+ // Request state by attaching (triggers broadcastState)
312
+ const msgs = await sendAndCollect(
313
+ ws,
314
+ { type: "attach_first", session: "main", cols: 80, rows: 24 },
315
+ { timeout: 3000 },
316
+ );
317
+ const state = msgs.filter((m) => m.type === "state").pop();
318
+ expect(state).toBeDefined();
319
+ const main = state.sessions.find((s) => s.name === "main");
320
+ expect(main).toBeDefined();
321
+ expect(main.tabs.length).toBeGreaterThanOrEqual(1);
322
+
323
+ const attached = msgs.find((m) => m.type === "attached");
324
+ expect(attached).toBeDefined();
325
+ expect(attached.session).toBe("main");
326
+ expect(typeof attached.tabId).toBe("number");
327
+ });
328
+
329
+ it("receives terminal data after attach", async () => {
330
+ // Wait a bit for shell to produce output
331
+ await new Promise((r) => setTimeout(r, 1000));
332
+
333
+ const msgs = await sendAndCollect(
334
+ ws,
335
+ { type: "attach_first", session: "main", cols: 80, rows: 24 },
336
+ { timeout: 3000 },
337
+ );
338
+ // Should receive some terminal data (VT snapshot or shell output)
339
+ const hasTermData = msgs.some((m) => m._raw);
340
+ expect(hasTermData).toBe(true);
341
+ });
342
+
343
+ it("creates new tab in current session", async () => {
344
+ const msgs = await sendAndCollect(
345
+ ws,
346
+ { type: "new_tab", session: "main", cols: 80, rows: 24 },
347
+ { timeout: 3000 },
348
+ );
349
+ const attached = msgs.find((m) => m.type === "attached");
350
+ expect(attached).toBeDefined();
351
+ expect(attached.session).toBe("main");
352
+
353
+ const state = msgs.filter((m) => m.type === "state").pop();
354
+ if (state) {
355
+ const main = state.sessions.find((s) => s.name === "main");
356
+ expect(main.tabs.length).toBeGreaterThanOrEqual(2);
357
+ }
358
+ });
359
+
360
+ it("creates new session with tab", async () => {
361
+ const msgs = await sendAndCollect(
362
+ ws,
363
+ { type: "new_session", name: "test-session", cols: 80, rows: 24 },
364
+ { timeout: 3000 },
365
+ );
366
+ const attached = msgs.find((m) => m.type === "attached");
367
+ expect(attached).toBeDefined();
368
+ expect(attached.session).toBe("test-session");
369
+
370
+ const state = msgs.filter((m) => m.type === "state").pop();
371
+ if (state) {
372
+ expect(state.sessions.some((s) => s.name === "test-session")).toBe(true);
373
+ }
374
+ });
375
+
376
+ it("does not create duplicate session with same name", async () => {
377
+ // Create "dup-test" session
378
+ await sendAndCollect(
379
+ ws,
380
+ { type: "new_session", name: "dup-test", cols: 80, rows: 24 },
381
+ { timeout: 2000 },
382
+ );
383
+
384
+ // Try to create again with same name — should attach, not duplicate
385
+ const msgs = await sendAndCollect(
386
+ ws,
387
+ { type: "new_session", name: "dup-test", cols: 80, rows: 24 },
388
+ { timeout: 2000 },
389
+ );
390
+ const state = msgs.filter((m) => m.type === "state").pop();
391
+ if (state) {
392
+ const matches = state.sessions.filter((s) => s.name === "dup-test");
393
+ expect(matches.length).toBe(1);
394
+ }
395
+
396
+ // Cleanup
397
+ await sendAndCollect(ws, { type: "attach_first", session: "main", cols: 80, rows: 24 }, { timeout: 1000 });
398
+ ws.send(JSON.stringify({ type: "delete_session", name: "dup-test" }));
399
+ await new Promise((r) => setTimeout(r, 500));
400
+ });
401
+
402
+ it("deletes session", async () => {
403
+ // Switch back to main first
404
+ await sendAndCollect(ws, { type: "attach_first", session: "main", cols: 80, rows: 24 }, { timeout: 1000 });
405
+
406
+ const msgs = await sendAndCollect(
407
+ ws,
408
+ { type: "delete_session", name: "test-session" },
409
+ { timeout: 2000 },
410
+ );
411
+ const state = msgs.filter((m) => m.type === "state").pop();
412
+ if (state) {
413
+ expect(state.sessions.some((s) => s.name === "test-session")).toBe(false);
414
+ }
415
+ });
416
+
417
+ it("handles resize", async () => {
418
+ ws.send(JSON.stringify({ type: "resize", cols: 120, rows: 40 }));
419
+ await new Promise((r) => setTimeout(r, 200));
420
+ // No error = pass
421
+ });
422
+
423
+ it("close_tab removes tab from session", async () => {
424
+ // Create a new tab
425
+ const newTabMsgs = await sendAndCollect(
426
+ ws,
427
+ { type: "new_tab", session: "main", cols: 80, rows: 24 },
428
+ { timeout: 3000 },
429
+ );
430
+ const attached = newTabMsgs.find((m) => m.type === "attached");
431
+ expect(attached).toBeDefined();
432
+ const newTabId = attached.tabId;
433
+
434
+ // Close it
435
+ const closeMsgs = await sendAndCollect(
436
+ ws,
437
+ { type: "close_tab", tabId: newTabId },
438
+ { timeout: 2000 },
439
+ );
440
+ const state = closeMsgs.filter((m) => m.type === "state").pop();
441
+ if (state) {
442
+ const main = state.sessions.find((s) => s.name === "main");
443
+ expect(main.tabs.some((t) => t.id === newTabId)).toBe(false);
444
+ }
445
+ });
446
+ });
447
+
448
+ // ── Client connection state ──────────────────────────────────────
449
+
450
+ describe("client connection state", () => {
451
+ it("attached message includes clientId and role", async () => {
452
+ const ws = await connectAuthed();
453
+ const msgs = await sendAndCollect(
454
+ ws,
455
+ { type: "attach_first", session: "main", cols: 80, rows: 24 },
456
+ { timeout: 3000 },
457
+ );
458
+ const attached = msgs.find((m) => m.type === "attached");
459
+ expect(attached).toBeDefined();
460
+ expect(typeof attached.clientId).toBe("string");
461
+ expect(attached.clientId.length).toBe(8); // 4 bytes hex = 8 chars
462
+ expect(["active", "observer"]).toContain(attached.role);
463
+ ws.close();
464
+ });
465
+
466
+ it("state broadcasts include clients list", async () => {
467
+ const ws = await connectAuthed();
468
+ const msgs = await sendAndCollect(
469
+ ws,
470
+ { type: "attach_first", session: "main", cols: 80, rows: 24 },
471
+ { timeout: 3000 },
472
+ );
473
+ const state = msgs.filter((m) => m.type === "state").pop();
474
+ expect(state).toBeDefined();
475
+ expect(Array.isArray(state.clients)).toBe(true);
476
+ expect(state.clients.length).toBeGreaterThanOrEqual(1);
477
+ const client = state.clients[0];
478
+ expect(typeof client.clientId).toBe("string");
479
+ expect(["active", "observer"]).toContain(client.role);
480
+ ws.close();
481
+ });
482
+
483
+ it("first client on tab becomes active, second becomes observer", async () => {
484
+ const ws1 = await connectAuthed();
485
+ const ws2 = await connectAuthed();
486
+
487
+ const msgs1 = await sendAndCollect(
488
+ ws1,
489
+ { type: "attach_first", session: "main", cols: 100, rows: 30 },
490
+ { timeout: 3000 },
491
+ );
492
+ const att1 = msgs1.find((m) => m.type === "attached");
493
+ expect(att1).toBeDefined();
494
+ expect(att1.role).toBe("active");
495
+ const tabId = att1.tabId;
496
+
497
+ const msgs2 = await sendAndCollect(
498
+ ws2,
499
+ { type: "attach_tab", tabId, cols: 80, rows: 24 },
500
+ { timeout: 3000 },
501
+ );
502
+ const att2 = msgs2.find((m) => m.type === "attached");
503
+ expect(att2).toBeDefined();
504
+ expect(att2.role).toBe("observer");
505
+ expect(att2.tabId).toBe(tabId);
506
+
507
+ ws1.close();
508
+ ws2.close();
509
+ });
510
+
511
+ it("observer terminal input is silently dropped", async () => {
512
+ const ws1 = await connectAuthed();
513
+ const ws2 = await connectAuthed();
514
+
515
+ // ws1 attaches first (active)
516
+ await sendAndCollect(
517
+ ws1,
518
+ { type: "attach_first", session: "main", cols: 80, rows: 24 },
519
+ { timeout: 3000 },
520
+ );
521
+
522
+ // ws2 attaches to same tab (observer)
523
+ const msgs2 = await sendAndCollect(
524
+ ws2,
525
+ { type: "attach_first", session: "main", cols: 80, rows: 24 },
526
+ { timeout: 3000 },
527
+ );
528
+ const att2 = msgs2.find((m) => m.type === "attached");
529
+ expect(att2.role).toBe("observer");
530
+
531
+ // Observer sends terminal input -- should be silently dropped (no error)
532
+ ws2.send("echo observer-test-should-not-run\n");
533
+ await new Promise((r) => setTimeout(r, 1000));
534
+
535
+ // ws2 is still connected (no error, no disconnect)
536
+ expect(ws2.readyState).toBe(WebSocket.OPEN);
537
+
538
+ ws1.close();
539
+ ws2.close();
540
+ });
541
+
542
+ it("request_control: observer takes control from active", async () => {
543
+ const ws1 = await connectAuthed();
544
+ const ws2 = await connectAuthed();
545
+
546
+ // ws1 attaches first (active)
547
+ const msgs1 = await sendAndCollect(
548
+ ws1,
549
+ { type: "attach_first", session: "main", cols: 80, rows: 24 },
550
+ { timeout: 3000 },
551
+ );
552
+ const att1 = msgs1.find((m) => m.type === "attached");
553
+ expect(att1.role).toBe("active");
554
+ const clientId1 = att1.clientId;
555
+
556
+ // ws2 attaches (observer)
557
+ const msgs2 = await sendAndCollect(
558
+ ws2,
559
+ { type: "attach_first", session: "main", cols: 80, rows: 24 },
560
+ { timeout: 3000 },
561
+ );
562
+ const att2 = msgs2.find((m) => m.type === "attached");
563
+ expect(att2.role).toBe("observer");
564
+ const clientId2 = att2.clientId;
565
+
566
+ // Collect messages on both sockets
567
+ const ws1RoleChanges = [];
568
+ const ws2RoleChanges = [];
569
+ const handler1 = (raw) => {
570
+ try {
571
+ const msg = unwrap(JSON.parse(raw.toString()));
572
+ if (msg.type === "role_changed") ws1RoleChanges.push(msg);
573
+ } catch {}
574
+ };
575
+ const handler2 = (raw) => {
576
+ try {
577
+ const msg = unwrap(JSON.parse(raw.toString()));
578
+ if (msg.type === "role_changed") ws2RoleChanges.push(msg);
579
+ } catch {}
580
+ };
581
+ ws1.on("message", handler1);
582
+ ws2.on("message", handler2);
583
+
584
+ // ws2 requests control
585
+ ws2.send(JSON.stringify({ type: "request_control" }));
586
+ await new Promise((r) => setTimeout(r, 1500));
587
+
588
+ ws1.removeListener("message", handler1);
589
+ ws2.removeListener("message", handler2);
590
+
591
+ // ws1 should get demoted
592
+ const ws1Demoted = ws1RoleChanges.find(
593
+ (m) => m.clientId === clientId1 && m.role === "observer",
594
+ );
595
+ expect(ws1Demoted).toBeDefined();
596
+
597
+ // ws2 should become active
598
+ const ws2Promoted = ws2RoleChanges.find(
599
+ (m) => m.clientId === clientId2 && m.role === "active",
600
+ );
601
+ expect(ws2Promoted).toBeDefined();
602
+
603
+ ws1.close();
604
+ ws2.close();
605
+ });
606
+
607
+ it("release_control: active releases, first observer promoted", async () => {
608
+ const ws1 = await connectAuthed();
609
+ const ws2 = await connectAuthed();
610
+
611
+ // ws1 attaches first (active)
612
+ const msgs1 = await sendAndCollect(
613
+ ws1,
614
+ { type: "attach_first", session: "main", cols: 80, rows: 24 },
615
+ { timeout: 3000 },
616
+ );
617
+ const att1 = msgs1.find((m) => m.type === "attached");
618
+ expect(att1.role).toBe("active");
619
+ const clientId1 = att1.clientId;
620
+
621
+ // ws2 attaches (observer)
622
+ const msgs2 = await sendAndCollect(
623
+ ws2,
624
+ { type: "attach_first", session: "main", cols: 80, rows: 24 },
625
+ { timeout: 3000 },
626
+ );
627
+ const att2 = msgs2.find((m) => m.type === "attached");
628
+ expect(att2.role).toBe("observer");
629
+ const clientId2 = att2.clientId;
630
+
631
+ // Listen for role changes
632
+ const ws1RoleChanges = [];
633
+ const ws2RoleChanges = [];
634
+ ws1.on("message", (raw) => {
635
+ try {
636
+ const msg = unwrap(JSON.parse(raw.toString()));
637
+ if (msg.type === "role_changed") ws1RoleChanges.push(msg);
638
+ } catch {}
639
+ });
640
+ ws2.on("message", (raw) => {
641
+ try {
642
+ const msg = unwrap(JSON.parse(raw.toString()));
643
+ if (msg.type === "role_changed") ws2RoleChanges.push(msg);
644
+ } catch {}
645
+ });
646
+
647
+ // ws1 releases control
648
+ ws1.send(JSON.stringify({ type: "release_control" }));
649
+ await new Promise((r) => setTimeout(r, 1500));
650
+
651
+ // ws1 should become observer
652
+ const ws1Released = ws1RoleChanges.find(
653
+ (m) => m.clientId === clientId1 && m.role === "observer",
654
+ );
655
+ expect(ws1Released).toBeDefined();
656
+
657
+ // ws2 should be promoted to active
658
+ const ws2Promoted = ws2RoleChanges.find(
659
+ (m) => m.clientId === clientId2 && m.role === "active",
660
+ );
661
+ expect(ws2Promoted).toBeDefined();
662
+
663
+ ws1.close();
664
+ ws2.close();
665
+ });
666
+
667
+ it("active disconnect promotes observer", async () => {
668
+ const ws1 = await connectAuthed();
669
+ const ws2 = await connectAuthed();
670
+
671
+ // ws1 attaches (active)
672
+ await sendAndCollect(
673
+ ws1,
674
+ { type: "attach_first", session: "main", cols: 80, rows: 24 },
675
+ { timeout: 3000 },
676
+ );
677
+
678
+ // ws2 attaches (observer)
679
+ const msgs2 = await sendAndCollect(
680
+ ws2,
681
+ { type: "attach_first", session: "main", cols: 80, rows: 24 },
682
+ { timeout: 3000 },
683
+ );
684
+ const att2 = msgs2.find((m) => m.type === "attached");
685
+ expect(att2.role).toBe("observer");
686
+ const clientId2 = att2.clientId;
687
+
688
+ // Listen for role changes on ws2
689
+ const ws2RoleChanges = [];
690
+ ws2.on("message", (raw) => {
691
+ try {
692
+ const msg = unwrap(JSON.parse(raw.toString()));
693
+ if (msg.type === "role_changed") ws2RoleChanges.push(msg);
694
+ } catch {}
695
+ });
696
+
697
+ // Disconnect ws1 (the active client)
698
+ ws1.close();
699
+ await new Promise((r) => setTimeout(r, 1500));
700
+
701
+ // ws2 should be promoted to active
702
+ const promoted = ws2RoleChanges.find(
703
+ (m) => m.clientId === clientId2 && m.role === "active",
704
+ );
705
+ expect(promoted).toBeDefined();
706
+
707
+ ws2.close();
708
+ });
709
+ });
710
+
711
+ // ── Multi-client ──────────────────────────────────────────────────
712
+
713
+ describe("multi-client", () => {
714
+ it("two clients can attach to same tab", async () => {
715
+ const ws1 = await connectAuthed();
716
+ const ws2 = await connectAuthed();
717
+
718
+ const msgs1 = await sendAndCollect(
719
+ ws1,
720
+ { type: "attach_first", session: "main", cols: 100, rows: 30 },
721
+ { timeout: 3000 },
722
+ );
723
+ const msgs2 = await sendAndCollect(
724
+ ws2,
725
+ { type: "attach_first", session: "main", cols: 80, rows: 24 },
726
+ { timeout: 3000 },
727
+ );
728
+
729
+ const att1 = msgs1.find((m) => m.type === "attached");
730
+ const att2 = msgs2.find((m) => m.type === "attached");
731
+ expect(att1).toBeDefined();
732
+ expect(att2).toBeDefined();
733
+ expect(att1.tabId).toBe(att2.tabId);
734
+
735
+ // State should show 2+ clients on the tab
736
+ const state = msgs2.filter((m) => m.type === "state").pop();
737
+ if (state) {
738
+ const main = state.sessions.find((s) => s.name === "main");
739
+ const tab = main.tabs.find((t) => t.id === att1.tabId);
740
+ expect(tab.clients).toBeGreaterThanOrEqual(2);
741
+ }
742
+
743
+ ws1.close();
744
+ ws2.close();
745
+ });
746
+
747
+ it("one client disconnect does not affect other", async () => {
748
+ const ws1 = await connectAuthed();
749
+ const ws2 = await connectAuthed();
750
+
751
+ await sendAndCollect(ws1, { type: "attach_first", session: "main", cols: 80, rows: 24 }, { timeout: 3000 });
752
+ await sendAndCollect(ws2, { type: "attach_first", session: "main", cols: 80, rows: 24 }, { timeout: 3000 });
753
+
754
+ // Disconnect ws1
755
+ ws1.close();
756
+ await new Promise((r) => setTimeout(r, 500));
757
+
758
+ // ws2 should still be able to send and receive terminal data
759
+ const msgs = await sendAndCollect(ws2, null, { timeout: 2000 });
760
+ ws2.send("echo alive\n");
761
+ const afterSend = await sendAndCollect(ws2, null, { timeout: 2000 });
762
+ // The key assertion: ws2 is still open and functional
763
+ expect(ws2.readyState).toBe(WebSocket.OPEN);
764
+
765
+ ws2.close();
766
+ });
767
+ });
768
+
769
+ // ── VT snapshot ───────────────────────────────────────────────────
770
+
771
+ describe("VT snapshot", () => {
772
+ it("sends VT snapshot on attach", async () => {
773
+ const ws = await connectAuthed();
774
+
775
+ // First attach to generate some terminal activity
776
+ await sendAndCollect(ws, { type: "attach_first", session: "main", cols: 80, rows: 24 }, { timeout: 2000 });
777
+ ws.send("echo snapshot-test\n");
778
+ await new Promise((r) => setTimeout(r, 1000));
779
+
780
+ // Re-attach to trigger snapshot
781
+ let termData = "";
782
+ const collector = (raw) => {
783
+ const s = raw.toString();
784
+ if (!s.startsWith("{")) termData += s;
785
+ };
786
+ ws.on("message", collector);
787
+ ws.send(JSON.stringify({ type: "attach_first", session: "main", cols: 80, rows: 24 }));
788
+ await new Promise((r) => setTimeout(r, 3000));
789
+ ws.removeListener("message", collector);
790
+
791
+ // Snapshot should contain VT escape sequences
792
+ expect(termData.length).toBeGreaterThan(0);
793
+ expect(termData).toContain("\x1b[");
794
+
795
+ ws.close();
796
+ }, 10000);
797
+
798
+ it("snapshot includes cursor positioning", async () => {
799
+ const ws = await connectAuthed();
800
+
801
+ await sendAndCollect(ws, { type: "attach_first", session: "main", cols: 80, rows: 24 }, { timeout: 2000 });
802
+ ws.send("echo cursor-test\n");
803
+ await new Promise((r) => setTimeout(r, 1000));
804
+
805
+ // Re-attach to get snapshot
806
+ let termData = "";
807
+ const collector = (raw) => {
808
+ const s = raw.toString();
809
+ if (!s.startsWith("{")) termData += s;
810
+ };
811
+ ws.on("message", collector);
812
+ ws.send(JSON.stringify({ type: "attach_first", session: "main", cols: 80, rows: 24 }));
813
+ await new Promise((r) => setTimeout(r, 3000));
814
+ ws.removeListener("message", collector);
815
+
816
+ // Snapshot should contain cursor positioning: ESC[<row>;<col>H
817
+ expect(termData).toMatch(/\x1b\[\d+;\d+H/);
818
+
819
+ ws.close();
820
+ }, 10000);
821
+ });
822
+
823
+ // ── Inspect ───────────────────────────────────────────────────────
824
+
825
+ describe("inspect", () => {
826
+ it("returns text snapshot of current tab", async () => {
827
+ const ws = await connectAuthed();
828
+
829
+ // Attach and run a command
830
+ await sendAndCollect(ws, { type: "attach_first", session: "main", cols: 80, rows: 24 }, { timeout: 2000 });
831
+ ws.send("echo inspect-test-output\n");
832
+ await new Promise((r) => setTimeout(r, 1000));
833
+
834
+ // Request inspect
835
+ const msgs = await sendAndCollect(
836
+ ws,
837
+ { type: "inspect" },
838
+ { timeout: 3000 },
839
+ );
840
+ const result = msgs.find((m) => m.type === "inspect_result");
841
+ expect(result).toBeDefined();
842
+ expect(typeof result.text).toBe("string");
843
+ expect(result.text).toContain("inspect-test-output");
844
+ expect(result.meta).toBeDefined();
845
+ expect(result.meta.session).toBe("main");
846
+ expect(typeof result.meta.cols).toBe("number");
847
+ expect(typeof result.meta.timestamp).toBe("number");
848
+
849
+ ws.close();
850
+ });
851
+
852
+ it("inspect text has no ANSI escape sequences", async () => {
853
+ const ws = await connectAuthed();
854
+ await sendAndCollect(ws, { type: "attach_first", session: "main", cols: 80, rows: 24 }, { timeout: 2000 });
855
+
856
+ const msgs = await sendAndCollect(ws, { type: "inspect" }, { timeout: 3000 });
857
+ const result = msgs.find((m) => m.type === "inspect_result");
858
+ expect(result).toBeDefined();
859
+ // No ESC sequences in the text
860
+ expect(result.text).not.toMatch(/\x1b\[/);
861
+
862
+ ws.close();
863
+ });
864
+ });
865
+
866
+ // ── Persistence ───────────────────────────────────────────────────
867
+
868
+ describe("persistence", () => {
869
+ it("saves sessions to SQLite within 10 seconds", async () => {
870
+ // Wait for persistence timer (8s interval + margin)
871
+ await new Promise((r) => setTimeout(r, 10000));
872
+
873
+ // Check that the SQLite DB file was created
874
+ expect(fs.existsSync(DB_FILE)).toBe(true);
875
+ // Verify it's a valid SQLite file (magic bytes)
876
+ const header = Buffer.alloc(16);
877
+ const fd = fs.openSync(DB_FILE, "r");
878
+ fs.readSync(fd, header, 0, 16, 0);
879
+ fs.closeSync(fd);
880
+ expect(header.toString("utf8", 0, 15)).toBe("SQLite format 3");
881
+ }, 15000);
882
+ });