@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.
- package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
- package/.github/dependabot.yml +33 -0
- package/.github/workflows/ci.yml +65 -0
- package/.github/workflows/deploy.yml +65 -0
- package/.github/workflows/publish.yml +312 -0
- package/.github/workflows/release-please.yml +21 -0
- package/.gitmodules +3 -0
- package/.nvmrc +1 -0
- package/.release-please-manifest.json +3 -0
- package/CLAUDE.md +104 -0
- package/Dockerfile +23 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/apps/ios/Config/signing.xcconfig +4 -0
- package/apps/ios/Package.swift +26 -0
- package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
- package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
- package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
- package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
- package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
- package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
- package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
- package/apps/ios/Sources/Remux/RootView.swift +130 -0
- package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
- package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
- package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
- package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
- package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
- package/apps/macos/Package.swift +37 -0
- package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
- package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
- package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
- package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
- package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
- package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
- package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
- package/apps/macos/Resources/terminfo/67/ghostty +0 -0
- package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
- package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
- package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
- package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
- package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
- package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
- package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
- package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
- package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
- package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
- package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
- package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
- package/apps/macos/Sources/Remux/SocketController.swift +258 -0
- package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
- package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
- package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
- package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
- package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
- package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
- package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
- package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
- package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
- package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
- package/build.mjs +33 -0
- package/native/android/DecodeGoldenPayloads.kt +487 -0
- package/native/android/ProtocolModels.kt +188 -0
- package/native/ios/DecodeGoldenPayloads.swift +711 -0
- package/native/ios/ProtocolModels.swift +200 -0
- package/package.json +45 -0
- package/packages/RemuxKit/Package.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
- package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
- package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
- package/playwright.config.ts +17 -0
- package/pnpm-lock.yaml +1588 -0
- package/pty-daemon.js +303 -0
- package/release-please-config.json +14 -0
- package/scripts/auto-deploy.sh +46 -0
- package/scripts/build-dmg.sh +121 -0
- package/scripts/build-ghostty-kit.sh +43 -0
- package/scripts/check-active-terminology.mjs +132 -0
- package/scripts/setup-ci-secrets.sh +80 -0
- package/scripts/sync-ghostty-web.sh +28 -0
- package/scripts/upload-testflight.sh +100 -0
- package/server.js +7074 -0
- package/src/adapters/agent-events.ts +246 -0
- package/src/adapters/claude-code.ts +158 -0
- package/src/adapters/codex.ts +210 -0
- package/src/adapters/generic-shell.ts +58 -0
- package/src/adapters/index.ts +15 -0
- package/src/adapters/registry.ts +99 -0
- package/src/adapters/types.ts +41 -0
- package/src/auth.ts +174 -0
- package/src/e2ee.ts +236 -0
- package/src/git-service.ts +168 -0
- package/src/message-buffer.ts +137 -0
- package/src/pty-daemon.ts +357 -0
- package/src/push.ts +127 -0
- package/src/renderers.ts +455 -0
- package/src/server.ts +2407 -0
- package/src/service.ts +226 -0
- package/src/session.ts +978 -0
- package/src/store.ts +1422 -0
- package/src/team.ts +123 -0
- package/src/tunnel.ts +126 -0
- package/src/types.d.ts +50 -0
- package/src/vt-tracker.ts +188 -0
- package/src/workspace-head.ts +144 -0
- package/src/workspace.ts +153 -0
- package/src/ws-handler.ts +1526 -0
- package/start.ps1 +83 -0
- package/tests/adapters.test.js +171 -0
- package/tests/auth.test.js +243 -0
- package/tests/codex-adapter.test.js +535 -0
- package/tests/durable-stream.test.js +153 -0
- package/tests/e2e/app.spec.js +530 -0
- package/tests/e2ee.test.js +325 -0
- package/tests/message-buffer.test.js +245 -0
- package/tests/message-routing.test.js +305 -0
- package/tests/pty-daemon.test.js +346 -0
- package/tests/push.test.js +281 -0
- package/tests/renderers.test.js +391 -0
- package/tests/search-shell.test.js +499 -0
- package/tests/server.test.js +882 -0
- package/tests/service.test.js +267 -0
- package/tests/store.test.js +369 -0
- package/tests/tunnel.test.js +67 -0
- package/tests/workspace-head.test.js +116 -0
- package/tests/workspace.test.js +417 -0
- package/tsconfig.backend.json +11 -0
- package/tsconfig.json +15 -0
- package/tui/client/client_test.go +125 -0
- package/tui/client/connection.go +342 -0
- package/tui/client/host_manager.go +141 -0
- package/tui/config/cache.go +81 -0
- package/tui/config/config.go +53 -0
- package/tui/config/config_test.go +89 -0
- package/tui/go.mod +32 -0
- package/tui/go.sum +50 -0
- package/tui/main.go +261 -0
- package/tui/tests/integration_test.go +283 -0
- package/tui/ui/model.go +310 -0
- 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
|
+
});
|