This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Works in Chrome

Nathan Herald (Mar 13, 2026, 10:27 AM +0100) 10bf74f3 8810a158

+1273 -14
+21
DEVELOPMENT.md
··· 34 34 npx tsx src/cli.ts list 35 35 npx tsx src/cli.ts restart <name> 36 36 npx tsx src/cli.ts kill <name> 37 + npx tsx src/cli.ts web 38 + npx tsx src/cli.ts web --port 8080 39 + npx tsx src/cli.ts web --code SECRET 37 40 ``` 38 41 39 42 Detach from any attached/following session with **Ctrl+\\**. Press Ctrl+\\ twice quickly to send it to the process. ··· 67 70 (attach) (attach) (read-only) 68 71 ``` 69 72 73 + ### Web UI 74 + 75 + ``` 76 + Browser (xterm.js) ←WebSocket→ Web Server (127.0.0.1) ←Unix Socket→ PtyServer daemons 77 + ←HTTP→ pty web process 78 + ``` 79 + 80 + The `pty web` command starts a separate web server process. It does not modify PtyServer — it connects to Unix sockets as a client on behalf of browsers. Each WebSocket maps 1:1 to a Unix socket connection. The server re-frames PtyServer packets (using `PacketReader`) so each WebSocket message contains exactly one complete protocol packet. The browser uses `DataView` on `ArrayBuffer` to encode/decode the same binary protocol. 81 + 82 + Auth is optional (`--code` flag). When set, a connect code must be entered before accessing sessions. The code is validated via `POST /auth`, which sets an HttpOnly cookie. All subsequent requests and WebSocket upgrades check this cookie. When the code is all digits, the login form uses `inputmode="numeric"` for a numeric keyboard on mobile. 83 + 70 84 Each session is a detached Node.js process running `src/server.ts` via tsx. It spawns the command in a PTY, feeds all output through xterm-headless to maintain a screen buffer, and listens on a Unix socket for client connections. 71 85 72 86 ## Protocol ··· 151 165 152 166 - `protocol.test.ts` — Unit tests for packet encoding, decoding, and streaming reassembly (partial reads, split packets, large payloads) 153 167 - `integration.test.ts` — Full integration tests that spawn real PTY sessions, connect clients via sockets, and verify behavior 168 + - `web.test.ts` — Playwright browser tests for the web UI (run separately via `npm run test:web`) 154 169 - `screenshot.test.ts` — Screenshot-based tests that capture terminal state (ANSI and plain text) and assert on visual output from real programs (vim, nano, ls, bash). Covers control characters, terminal resize, alternate screen buffer, multiple clients, unicode, and daemon spawning. 155 170 156 171 All tests run in `/tmp/` directories to avoid polluting the project folder (e.g., vim swap files). Integration and screenshot tests use real processes and real Unix sockets. Each test creates a uniquely-named session and cleans up afterward. There is a `200ms` delay in some tests to allow xterm-headless to process async writes before checking screen state — this is a known characteristic of xterm's write pipeline, not a flaky test. ··· 161 176 npm test # run once 162 177 npm run test:watch # watch mode 163 178 npx vitest run -t "peek" # run tests matching "peek" 179 + npm run test:web # run web UI tests (Playwright) 164 180 ``` 165 181 166 182 ### node-pty on macOS ··· 180 196 client.ts attach() and peek() functions 181 197 protocol.ts Packet types, encoding, decoding, PacketReader 182 198 sessions.ts Session discovery, socket/PID file management 199 + web/ 200 + server.ts WebServer class (HTTP + WebSocket bridge) 201 + static/ 202 + index.html Single-page browser app (xterm.js) 183 203 tests/ 184 204 protocol.test.ts 185 205 integration.test.ts 186 206 screenshot.test.ts 207 + web.test.ts 187 208 completions/ 188 209 pty.bash Bash tab completion 189 210 pty.zsh Zsh tab completion
+115 -1
package-lock.json
··· 9 9 "version": "0.1.0", 10 10 "hasInstallScript": true, 11 11 "dependencies": { 12 + "@xterm/addon-fit": "^0.11.0", 12 13 "@xterm/addon-serialize": "^0.14.0", 13 14 "@xterm/headless": "^6.0.0", 15 + "@xterm/xterm": "^6.0.0", 14 16 "node-pty": "^1.0.0", 15 - "tsx": "^4.19.0" 17 + "tsx": "^4.19.0", 18 + "ws": "^8.19.0" 16 19 }, 17 20 "bin": { 18 21 "pty": "bin/pty" 19 22 }, 20 23 "devDependencies": { 24 + "@playwright/test": "^1.58.2", 21 25 "@types/node": "^25.3.5", 26 + "@types/ws": "^8.18.1", 22 27 "typescript": "^5.7.0", 23 28 "vitest": "^4.0.18" 24 29 } ··· 446 451 "dev": true, 447 452 "license": "MIT" 448 453 }, 454 + "node_modules/@playwright/test": { 455 + "version": "1.58.2", 456 + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", 457 + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", 458 + "dev": true, 459 + "license": "Apache-2.0", 460 + "dependencies": { 461 + "playwright": "1.58.2" 462 + }, 463 + "bin": { 464 + "playwright": "cli.js" 465 + }, 466 + "engines": { 467 + "node": ">=18" 468 + } 469 + }, 449 470 "node_modules/@rollup/rollup-android-arm-eabi": { 450 471 "version": "4.59.0", 451 472 "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", ··· 838 859 "undici-types": "~7.18.0" 839 860 } 840 861 }, 862 + "node_modules/@types/ws": { 863 + "version": "8.18.1", 864 + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", 865 + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", 866 + "dev": true, 867 + "license": "MIT", 868 + "dependencies": { 869 + "@types/node": "*" 870 + } 871 + }, 841 872 "node_modules/@vitest/expect": { 842 873 "version": "4.0.18", 843 874 "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", ··· 949 980 "url": "https://opencollective.com/vitest" 950 981 } 951 982 }, 983 + "node_modules/@xterm/addon-fit": { 984 + "version": "0.11.0", 985 + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", 986 + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", 987 + "license": "MIT" 988 + }, 952 989 "node_modules/@xterm/addon-serialize": { 953 990 "version": "0.14.0", 954 991 "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz", ··· 959 996 "version": "6.0.0", 960 997 "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", 961 998 "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", 999 + "license": "MIT", 1000 + "workspaces": [ 1001 + "addons/*" 1002 + ] 1003 + }, 1004 + "node_modules/@xterm/xterm": { 1005 + "version": "6.0.0", 1006 + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", 1007 + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", 962 1008 "license": "MIT", 963 1009 "workspaces": [ 964 1010 "addons/*" ··· 1179 1225 "url": "https://github.com/sponsors/jonschlinkert" 1180 1226 } 1181 1227 }, 1228 + "node_modules/playwright": { 1229 + "version": "1.58.2", 1230 + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", 1231 + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", 1232 + "dev": true, 1233 + "license": "Apache-2.0", 1234 + "dependencies": { 1235 + "playwright-core": "1.58.2" 1236 + }, 1237 + "bin": { 1238 + "playwright": "cli.js" 1239 + }, 1240 + "engines": { 1241 + "node": ">=18" 1242 + }, 1243 + "optionalDependencies": { 1244 + "fsevents": "2.3.2" 1245 + } 1246 + }, 1247 + "node_modules/playwright-core": { 1248 + "version": "1.58.2", 1249 + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", 1250 + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", 1251 + "dev": true, 1252 + "license": "Apache-2.0", 1253 + "bin": { 1254 + "playwright-core": "cli.js" 1255 + }, 1256 + "engines": { 1257 + "node": ">=18" 1258 + } 1259 + }, 1260 + "node_modules/playwright/node_modules/fsevents": { 1261 + "version": "2.3.2", 1262 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 1263 + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 1264 + "dev": true, 1265 + "hasInstallScript": true, 1266 + "license": "MIT", 1267 + "optional": true, 1268 + "os": [ 1269 + "darwin" 1270 + ], 1271 + "engines": { 1272 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 1273 + } 1274 + }, 1182 1275 "node_modules/postcss": { 1183 1276 "version": "8.5.8", 1184 1277 "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", ··· 1545 1638 }, 1546 1639 "engines": { 1547 1640 "node": ">=8" 1641 + } 1642 + }, 1643 + "node_modules/ws": { 1644 + "version": "8.19.0", 1645 + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", 1646 + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", 1647 + "license": "MIT", 1648 + "engines": { 1649 + "node": ">=10.0.0" 1650 + }, 1651 + "peerDependencies": { 1652 + "bufferutil": "^4.0.1", 1653 + "utf-8-validate": ">=5.0.2" 1654 + }, 1655 + "peerDependenciesMeta": { 1656 + "bufferutil": { 1657 + "optional": true 1658 + }, 1659 + "utf-8-validate": { 1660 + "optional": true 1661 + } 1548 1662 } 1549 1663 } 1550 1664 }
+9 -3
package.json
··· 10 10 "postinstall": "chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper 2>/dev/null || true", 11 11 "install-completions": "sh scripts/install-completions.sh", 12 12 "typecheck": "tsc", 13 - "test": "vitest run", 14 - "test:watch": "vitest" 13 + "test": "vitest run --exclude tests/web.test.ts", 14 + "test:watch": "vitest", 15 + "test:web": "npx playwright test" 15 16 }, 16 17 "dependencies": { 18 + "@xterm/addon-fit": "^0.11.0", 17 19 "@xterm/addon-serialize": "^0.14.0", 18 20 "@xterm/headless": "^6.0.0", 21 + "@xterm/xterm": "^6.0.0", 19 22 "node-pty": "^1.0.0", 20 - "tsx": "^4.19.0" 23 + "tsx": "^4.19.0", 24 + "ws": "^8.19.0" 21 25 }, 22 26 "devDependencies": { 27 + "@playwright/test": "^1.58.2", 23 28 "@types/node": "^25.3.5", 29 + "@types/ws": "^8.18.1", 24 30 "typescript": "^5.7.0", 25 31 "vitest": "^4.0.18" 26 32 }
+9
playwright.config.ts
··· 1 + import { defineConfig } from "@playwright/test"; 2 + 3 + export default defineConfig({ 4 + testDir: "./tests", 5 + testMatch: "web.test.ts", 6 + timeout: 30000, 7 + use: { headless: true }, 8 + projects: [{ name: "chromium", use: { browserName: "chromium" } }], 9 + });
+52
src/cli.ts
··· 39 39 pty list List active sessions 40 40 pty list --json List sessions as JSON 41 41 pty kill <name> Kill or remove a session 42 + pty web Start web UI 43 + pty web --port 8080 Start on a different port 44 + pty web --code SECRET Require a connect code 42 45 43 46 Detach from a session with Ctrl+\\ (press twice to send Ctrl+\\ to the process)`); 44 47 } ··· 237 240 process.exit(1); 238 241 } 239 242 await cmdKill(args[1]); 243 + break; 244 + } 245 + 246 + case "web": { 247 + let port = 7681; 248 + let connectCode: string | undefined; 249 + let wi = 1; 250 + while (wi < args.length && args[wi].startsWith("-")) { 251 + if (args[wi] === "--port" || args[wi] === "-p") { 252 + wi++; 253 + port = parseInt(args[wi], 10); 254 + if (isNaN(port)) { 255 + console.error("--port requires a number."); 256 + process.exit(1); 257 + } 258 + } else if (args[wi] === "--code" || args[wi] === "-c") { 259 + wi++; 260 + connectCode = args[wi]; 261 + if (!connectCode) { 262 + console.error("--code requires a value."); 263 + process.exit(1); 264 + } 265 + } else { 266 + break; 267 + } 268 + wi++; 269 + } 270 + await cmdWeb(port, connectCode); 240 271 break; 241 272 } 242 273 ··· 654 685 if (hours < 24) return `${hours}h ago`; 655 686 const days = Math.floor(hours / 24); 656 687 return `${days}d ago`; 688 + } 689 + 690 + async function cmdWeb(port: number, connectCode?: string): Promise<void> { 691 + const { WebServer } = await import("./web/server.ts"); 692 + const server = new WebServer({ port, connectCode }); 693 + const addr = await server.ready; 694 + 695 + console.log(`Web UI: http://${addr.host}:${addr.port}`); 696 + if (connectCode) { 697 + console.log(`Connect code: ${connectCode}`); 698 + } 699 + console.log("Press Ctrl+C to stop."); 700 + 701 + await new Promise<void>((resolve) => { 702 + process.on("SIGINT", () => { 703 + server.close().then(resolve); 704 + }); 705 + process.on("SIGTERM", () => { 706 + server.close().then(resolve); 707 + }); 708 + }); 657 709 } 658 710 659 711 main().catch((err) => {
+13 -10
src/sessions.ts
··· 3 3 import * as os from "node:os"; 4 4 import * as net from "node:net"; 5 5 6 - const SESSION_DIR = 7 - process.env.PTY_SESSION_DIR ?? 8 - path.join(os.homedir(), ".local", "state", "pty"); 6 + function sessionDir(): string { 7 + return ( 8 + process.env.PTY_SESSION_DIR ?? 9 + path.join(os.homedir(), ".local", "state", "pty") 10 + ); 11 + } 9 12 10 13 const DEAD_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours 11 14 ··· 26 29 } 27 30 28 31 export function getSessionDir(): string { 29 - return SESSION_DIR; 32 + return sessionDir(); 30 33 } 31 34 32 35 export function ensureSessionDir(): void { 33 - fs.mkdirSync(SESSION_DIR, { recursive: true }); 36 + fs.mkdirSync(sessionDir(), { recursive: true }); 34 37 } 35 38 36 39 export function getSocketPath(name: string): string { 37 - return path.join(SESSION_DIR, `${name}.sock`); 40 + return path.join(sessionDir(), `${name}.sock`); 38 41 } 39 42 40 43 export function getPidPath(name: string): string { 41 - return path.join(SESSION_DIR, `${name}.pid`); 44 + return path.join(sessionDir(), `${name}.pid`); 42 45 } 43 46 44 47 export function getMetadataPath(name: string): string { 45 - return path.join(SESSION_DIR, `${name}.json`); 48 + return path.join(sessionDir(), `${name}.json`); 46 49 } 47 50 48 51 export interface SessionMetadata { ··· 83 86 84 87 let entries: string[]; 85 88 try { 86 - entries = fs.readdirSync(SESSION_DIR); 89 + entries = fs.readdirSync(sessionDir()); 87 90 } catch { 88 91 return []; 89 92 } ··· 213 216 } 214 217 215 218 function getLockPath(name: string): string { 216 - return path.join(SESSION_DIR, `${name}.lock`); 219 + return path.join(sessionDir(), `${name}.lock`); 217 220 } 218 221 219 222 /**
+308
src/web/server.ts
··· 1 + import * as http from "node:http"; 2 + import * as net from "node:net"; 3 + import * as fs from "node:fs"; 4 + import * as path from "node:path"; 5 + import * as crypto from "node:crypto"; 6 + import { fileURLToPath } from "node:url"; 7 + import { createRequire } from "node:module"; 8 + import type { 9 + WebSocket as WSType, 10 + WebSocketServer as WSSType, 11 + } from "ws"; 12 + import { PacketReader, encodePacket } from "../protocol.ts"; 13 + import { listSessions, getSocketPath } from "../sessions.ts"; 14 + 15 + // ws is CJS-only. Different ESM loaders (tsx, Playwright, Node native) 16 + // handle CJS interop inconsistently. createRequire always works. 17 + const require = createRequire(import.meta.url); 18 + const { WebSocketServer } = require("ws") as { WebSocketServer: typeof WSSType }; 19 + 20 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 21 + 22 + export interface WebServerOptions { 23 + host?: string; 24 + port?: number; 25 + connectCode?: string; 26 + } 27 + 28 + export class WebServer { 29 + readonly ready: Promise<{ port: number; host: string }>; 30 + 31 + private httpServer: http.Server; 32 + private wss: WSSType; 33 + private authToken: string | undefined; 34 + private connectCode: string | undefined; 35 + private connections = new Set<{ ws: WSType; unix: net.Socket }>(); 36 + 37 + constructor(options: WebServerOptions = {}) { 38 + const host = options.host ?? "127.0.0.1"; 39 + const port = options.port ?? 7681; 40 + this.connectCode = options.connectCode; 41 + 42 + if (this.connectCode) { 43 + this.authToken = crypto.randomUUID(); 44 + } 45 + 46 + this.httpServer = http.createServer((req, res) => this.handleHTTP(req, res)); 47 + this.wss = new WebSocketServer({ noServer: true }); 48 + 49 + this.httpServer.on("upgrade", (req, socket, head) => { 50 + if (this.connectCode && !this.checkAuth(req)) { 51 + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); 52 + socket.destroy(); 53 + return; 54 + } 55 + 56 + const url = new URL(req.url ?? "/", `http://${req.headers.host}`); 57 + const match = url.pathname.match(/^\/ws\/(.+)$/); 58 + if (!match) { 59 + socket.write("HTTP/1.1 404 Not Found\r\n\r\n"); 60 + socket.destroy(); 61 + return; 62 + } 63 + 64 + const name = decodeURIComponent(match[1]); 65 + 66 + this.wss.handleUpgrade(req, socket as net.Socket, head, (wsClient: WSType) => { 67 + this.handleWS(wsClient, name); 68 + }); 69 + }); 70 + 71 + this.ready = new Promise((resolve) => { 72 + this.httpServer.listen(port, host, () => { 73 + const addr = this.httpServer.address() as net.AddressInfo; 74 + resolve({ port: addr.port, host: addr.address }); 75 + }); 76 + }); 77 + } 78 + 79 + close(): Promise<void> { 80 + return new Promise((resolve) => { 81 + for (const conn of this.connections) { 82 + conn.ws.close(); 83 + conn.unix.destroy(); 84 + } 85 + this.connections.clear(); 86 + 87 + this.wss.close(() => { 88 + this.httpServer.close(() => resolve()); 89 + }); 90 + }); 91 + } 92 + 93 + private checkAuth(req: http.IncomingMessage): boolean { 94 + if (!this.connectCode || !this.authToken) return true; 95 + const cookies = req.headers.cookie ?? ""; 96 + const match = cookies.match(/(?:^|;\s*)pty_token=([^\s;]+)/); 97 + return match?.[1] === this.authToken; 98 + } 99 + 100 + private isNumericCode(): boolean { 101 + return this.connectCode != null && /^\d+$/.test(this.connectCode); 102 + } 103 + 104 + private async handleHTTP( 105 + req: http.IncomingMessage, 106 + res: http.ServerResponse 107 + ): Promise<void> { 108 + const url = new URL(req.url ?? "/", `http://${req.headers.host}`); 109 + 110 + if (req.method === "POST" && url.pathname === "/auth") { 111 + await this.handleAuth(req, res); 112 + return; 113 + } 114 + 115 + // Auth check for API routes 116 + if (this.connectCode && !this.checkAuth(req)) { 117 + if (url.pathname === "/api/sessions") { 118 + res.writeHead(401, { "Content-Type": "application/json" }); 119 + res.end( 120 + JSON.stringify({ error: "unauthorized", numeric: this.isNumericCode() }) 121 + ); 122 + return; 123 + } 124 + // Serve index.html and vendor files even when unauthed — 125 + // the JS handles showing the login form. 126 + if (url.pathname === "/") { 127 + this.serveIndex(res); 128 + return; 129 + } 130 + if (url.pathname.startsWith("/vendor/")) { 131 + this.serveVendor(url.pathname, res); 132 + return; 133 + } 134 + res.writeHead(401, { "Content-Type": "application/json" }); 135 + res.end( 136 + JSON.stringify({ error: "unauthorized", numeric: this.isNumericCode() }) 137 + ); 138 + return; 139 + } 140 + 141 + if (url.pathname === "/") { 142 + this.serveIndex(res); 143 + return; 144 + } 145 + 146 + if (url.pathname === "/api/sessions") { 147 + const sessions = await listSessions(); 148 + const output = sessions.map((s) => ({ 149 + name: s.name, 150 + status: s.status, 151 + pid: s.pid, 152 + command: s.metadata 153 + ? [s.metadata.displayCommand, ...s.metadata.args].join(" ") 154 + : null, 155 + cwd: s.metadata?.cwd ?? null, 156 + createdAt: s.metadata?.createdAt ?? null, 157 + exitCode: s.metadata?.exitCode ?? null, 158 + exitedAt: s.metadata?.exitedAt ?? null, 159 + })); 160 + res.writeHead(200, { "Content-Type": "application/json" }); 161 + res.end(JSON.stringify(output)); 162 + return; 163 + } 164 + 165 + if (url.pathname.startsWith("/vendor/")) { 166 + this.serveVendor(url.pathname, res); 167 + return; 168 + } 169 + 170 + res.writeHead(404); 171 + res.end("Not Found"); 172 + } 173 + 174 + private async handleAuth( 175 + req: http.IncomingMessage, 176 + res: http.ServerResponse 177 + ): Promise<void> { 178 + const body = await readBody(req); 179 + const params = new URLSearchParams(body); 180 + const code = params.get("code"); 181 + 182 + if (code === this.connectCode) { 183 + res.writeHead(200, { 184 + "Content-Type": "application/json", 185 + "Set-Cookie": `pty_token=${this.authToken}; Path=/; HttpOnly; SameSite=Strict`, 186 + }); 187 + res.end(JSON.stringify({ ok: true })); 188 + } else { 189 + res.writeHead(403, { "Content-Type": "application/json" }); 190 + res.end(JSON.stringify({ ok: false, numeric: this.isNumericCode() })); 191 + } 192 + } 193 + 194 + private serveIndex(res: http.ServerResponse): void { 195 + const indexPath = path.join(__dirname, "static", "index.html"); 196 + try { 197 + const content = fs.readFileSync(indexPath, "utf-8"); 198 + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); 199 + res.end(content); 200 + } catch { 201 + res.writeHead(500); 202 + res.end("Internal Server Error"); 203 + } 204 + } 205 + 206 + private serveVendor(pathname: string, res: http.ServerResponse): void { 207 + const nodeModules = path.join(__dirname, "..", "..", "node_modules"); 208 + 209 + const vendorMap: Record<string, { file: string; contentType: string }> = { 210 + "/vendor/xterm.mjs": { 211 + file: path.join(nodeModules, "@xterm", "xterm", "lib", "xterm.mjs"), 212 + contentType: "application/javascript", 213 + }, 214 + "/vendor/xterm.css": { 215 + file: path.join(nodeModules, "@xterm", "xterm", "css", "xterm.css"), 216 + contentType: "text/css", 217 + }, 218 + "/vendor/addon-fit.mjs": { 219 + file: path.join( 220 + nodeModules, 221 + "@xterm", 222 + "addon-fit", 223 + "lib", 224 + "addon-fit.mjs" 225 + ), 226 + contentType: "application/javascript", 227 + }, 228 + }; 229 + 230 + const vendor = vendorMap[pathname]; 231 + if (!vendor) { 232 + res.writeHead(404); 233 + res.end("Not Found"); 234 + return; 235 + } 236 + 237 + try { 238 + const content = fs.readFileSync(vendor.file); 239 + res.writeHead(200, { "Content-Type": vendor.contentType }); 240 + res.end(content); 241 + } catch { 242 + res.writeHead(500); 243 + res.end("Vendor file not found"); 244 + } 245 + } 246 + 247 + private handleWS(wsClient: WSType, name: string): void { 248 + const socketPath = getSocketPath(name); 249 + const unixSocket = net.createConnection(socketPath); 250 + const packetReader = new PacketReader(); 251 + 252 + const conn = { ws: wsClient, unix: unixSocket }; 253 + this.connections.add(conn); 254 + 255 + unixSocket.on("error", () => { 256 + wsClient.close(); 257 + this.connections.delete(conn); 258 + }); 259 + 260 + unixSocket.on("connect", () => { 261 + // Browser -> PtyServer: forward raw bytes 262 + wsClient.on("message", (data: Buffer | ArrayBuffer | Buffer[]) => { 263 + if (Buffer.isBuffer(data)) { 264 + unixSocket.write(data); 265 + } else if (data instanceof ArrayBuffer) { 266 + unixSocket.write(Buffer.from(data)); 267 + } else { 268 + unixSocket.write(Buffer.concat(data)); 269 + } 270 + }); 271 + 272 + // PtyServer -> Browser: re-frame into complete packets 273 + unixSocket.on("data", (data: Buffer) => { 274 + for (const packet of packetReader.feed(data)) { 275 + if (wsClient.readyState === 1) { 276 + // OPEN 277 + wsClient.send(encodePacket(packet.type, packet.payload)); 278 + } 279 + } 280 + }); 281 + }); 282 + 283 + unixSocket.on("close", () => { 284 + wsClient.close(); 285 + this.connections.delete(conn); 286 + }); 287 + 288 + wsClient.on("close", () => { 289 + unixSocket.destroy(); 290 + this.connections.delete(conn); 291 + }); 292 + 293 + wsClient.on("error", () => { 294 + unixSocket.destroy(); 295 + this.connections.delete(conn); 296 + }); 297 + } 298 + } 299 + 300 + function readBody(req: http.IncomingMessage): Promise<string> { 301 + return new Promise((resolve) => { 302 + let body = ""; 303 + req.on("data", (chunk: Buffer) => { 304 + body += chunk.toString(); 305 + }); 306 + req.on("end", () => resolve(body)); 307 + }); 308 + }
+521
src/web/static/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <title>pty</title> 7 + <link rel="stylesheet" href="/vendor/xterm.css" /> 8 + <script type="importmap"> 9 + { "imports": { 10 + "@xterm/xterm": "/vendor/xterm.mjs", 11 + "@xterm/addon-fit": "/vendor/addon-fit.mjs" 12 + } } 13 + </script> 14 + <style> 15 + * { margin: 0; padding: 0; box-sizing: border-box; } 16 + body { 17 + background: #1e1e1e; 18 + color: #ccc; 19 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 20 + height: 100vh; 21 + overflow: hidden; 22 + } 23 + 24 + /* Login view */ 25 + #login-view { 26 + display: none; 27 + justify-content: center; 28 + align-items: center; 29 + height: 100vh; 30 + } 31 + #login-view form { 32 + background: #2a2a2a; 33 + padding: 2rem; 34 + border-radius: 8px; 35 + min-width: 300px; 36 + text-align: center; 37 + } 38 + #login-view label { 39 + display: block; 40 + margin-bottom: 0.5rem; 41 + font-size: 1.1rem; 42 + } 43 + #login-view input { 44 + width: 100%; 45 + padding: 0.75rem; 46 + font-size: 1.2rem; 47 + background: #1e1e1e; 48 + color: #fff; 49 + border: 1px solid #555; 50 + border-radius: 4px; 51 + margin-bottom: 1rem; 52 + text-align: center; 53 + letter-spacing: 0.2em; 54 + } 55 + #login-view input:focus { 56 + outline: none; 57 + border-color: #888; 58 + } 59 + #login-view button { 60 + width: 100%; 61 + padding: 0.75rem; 62 + font-size: 1rem; 63 + background: #444; 64 + color: #fff; 65 + border: none; 66 + border-radius: 4px; 67 + cursor: pointer; 68 + } 69 + #login-view button:hover { background: #555; } 70 + #login-error { 71 + display: none; 72 + color: #f66; 73 + margin-top: 0.5rem; 74 + font-size: 0.9rem; 75 + } 76 + 77 + /* Session list view */ 78 + #list-view { 79 + display: none; 80 + flex-direction: column; 81 + height: 100vh; 82 + padding: 1.5rem; 83 + } 84 + #list-view h1 { 85 + font-size: 1.4rem; 86 + font-weight: 500; 87 + margin-bottom: 1rem; 88 + color: #eee; 89 + } 90 + .session-item { 91 + background: #2a2a2a; 92 + padding: 1rem 1.25rem; 93 + border-radius: 6px; 94 + margin-bottom: 0.5rem; 95 + cursor: pointer; 96 + display: flex; 97 + justify-content: space-between; 98 + align-items: center; 99 + } 100 + .session-item:hover { background: #333; } 101 + .session-name { 102 + font-weight: 600; 103 + color: #fff; 104 + font-size: 1.05rem; 105 + } 106 + .session-command { 107 + color: #999; 108 + font-size: 0.9rem; 109 + margin-top: 0.2rem; 110 + } 111 + .session-status { 112 + font-size: 0.85rem; 113 + padding: 0.2rem 0.6rem; 114 + border-radius: 3px; 115 + } 116 + .status-running { 117 + background: #1a3a1a; 118 + color: #6f6; 119 + } 120 + .status-exited { 121 + background: #3a1a1a; 122 + color: #f66; 123 + } 124 + #no-sessions { 125 + color: #666; 126 + font-style: italic; 127 + margin-top: 1rem; 128 + } 129 + 130 + /* Terminal view */ 131 + #terminal-view { 132 + display: none; 133 + flex-direction: column; 134 + height: 100vh; 135 + } 136 + #toolbar { 137 + display: flex; 138 + justify-content: space-between; 139 + align-items: center; 140 + padding: 0.4rem 0.75rem; 141 + background: #2a2a2a; 142 + border-bottom: 1px solid #444; 143 + flex-shrink: 0; 144 + } 145 + #session-label { 146 + font-weight: 600; 147 + color: #eee; 148 + } 149 + #detach-btn { 150 + padding: 0.3rem 0.8rem; 151 + font-size: 0.85rem; 152 + background: #444; 153 + color: #ccc; 154 + border: none; 155 + border-radius: 3px; 156 + cursor: pointer; 157 + } 158 + #detach-btn:hover { background: #555; } 159 + #terminal-container { 160 + flex: 1; 161 + overflow: hidden; 162 + } 163 + #exit-overlay { 164 + display: none; 165 + position: absolute; 166 + top: 50%; 167 + left: 50%; 168 + transform: translate(-50%, -50%); 169 + background: rgba(0,0,0,0.85); 170 + padding: 1.5rem 2.5rem; 171 + border-radius: 8px; 172 + color: #f66; 173 + font-size: 1.1rem; 174 + text-align: center; 175 + z-index: 10; 176 + } 177 + </style> 178 + </head> 179 + <body> 180 + 181 + <div id="login-view"> 182 + <div id="login-form"> 183 + <label for="code-input">Connect Code</label> 184 + <input id="code-input" type="text" autocomplete="off" /> 185 + <button id="login-btn">Connect</button> 186 + <p id="login-error"></p> 187 + </div> 188 + </div> 189 + 190 + <div id="list-view"> 191 + <h1>pty sessions</h1> 192 + <div id="session-list"></div> 193 + <p id="no-sessions">No sessions</p> 194 + </div> 195 + 196 + <div id="terminal-view"> 197 + <div id="toolbar"> 198 + <span id="session-label"></span> 199 + <button id="detach-btn">Detach</button> 200 + </div> 201 + <div id="terminal-container"></div> 202 + <div id="exit-overlay"></div> 203 + </div> 204 + 205 + <script type="module"> 206 + import { Terminal } from "@xterm/xterm"; 207 + import { FitAddon } from "@xterm/addon-fit"; 208 + 209 + // ── Protocol ── 210 + 211 + const MSG = { DATA: 0, ATTACH: 1, DETACH: 2, RESIZE: 3, EXIT: 4, SCREEN: 5, PEEK: 6 }; 212 + const encoder = new TextEncoder(); 213 + const decoder = new TextDecoder(); 214 + 215 + function makePacket(type, payload) { 216 + const buf = new ArrayBuffer(5 + payload.byteLength); 217 + const view = new DataView(buf); 218 + view.setUint8(0, type); 219 + view.setUint32(1, payload.byteLength, false); 220 + new Uint8Array(buf, 5).set(new Uint8Array(payload)); 221 + return buf; 222 + } 223 + 224 + function attachPacket(rows, cols) { 225 + const p = new ArrayBuffer(4); 226 + const v = new DataView(p); 227 + v.setUint16(0, rows, false); 228 + v.setUint16(2, cols, false); 229 + return makePacket(MSG.ATTACH, p); 230 + } 231 + 232 + function dataPacket(text) { 233 + const p = encoder.encode(text); 234 + return makePacket(MSG.DATA, p.buffer); 235 + } 236 + 237 + function resizePacket(rows, cols) { 238 + const p = new ArrayBuffer(4); 239 + const v = new DataView(p); 240 + v.setUint16(0, rows, false); 241 + v.setUint16(2, cols, false); 242 + return makePacket(MSG.RESIZE, p); 243 + } 244 + 245 + function detachPacket() { 246 + return makePacket(MSG.DETACH, new ArrayBuffer(0)); 247 + } 248 + 249 + function parsePacket(buf) { 250 + const view = new DataView(buf); 251 + const type = view.getUint8(0); 252 + const length = view.getUint32(1, false); 253 + const payload = buf.slice(5, 5 + length); 254 + return { type, payload }; 255 + } 256 + 257 + // ── DOM refs ── 258 + 259 + const loginView = document.getElementById("login-view"); 260 + const loginBtn = document.getElementById("login-btn"); 261 + const codeInput = document.getElementById("code-input"); 262 + const loginError = document.getElementById("login-error"); 263 + 264 + const listView = document.getElementById("list-view"); 265 + const sessionList = document.getElementById("session-list"); 266 + const noSessions = document.getElementById("no-sessions"); 267 + 268 + const terminalView = document.getElementById("terminal-view"); 269 + const sessionLabel = document.getElementById("session-label"); 270 + const detachBtn = document.getElementById("detach-btn"); 271 + const terminalContainer = document.getElementById("terminal-container"); 272 + const exitOverlay = document.getElementById("exit-overlay"); 273 + 274 + let refreshTimer = null; 275 + let currentWs = null; 276 + let currentTerm = null; 277 + let currentFit = null; 278 + let resizeObserver = null; 279 + 280 + // ── Views ── 281 + 282 + function showView(id) { 283 + loginView.style.display = id === "login" ? "flex" : "none"; 284 + listView.style.display = id === "list" ? "flex" : "none"; 285 + terminalView.style.display = id === "terminal" ? "flex" : "none"; 286 + exitOverlay.style.display = "none"; 287 + } 288 + 289 + // ── Auth ── 290 + 291 + function configureCodeInput(numeric) { 292 + if (numeric) { 293 + codeInput.inputMode = "numeric"; 294 + codeInput.pattern = "[0-9]*"; 295 + } else { 296 + codeInput.inputMode = "text"; 297 + codeInput.removeAttribute("pattern"); 298 + } 299 + } 300 + 301 + async function submitCode() { 302 + loginError.style.display = "none"; 303 + const code = codeInput.value; 304 + try { 305 + const res = await fetch("/auth", { 306 + method: "POST", 307 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 308 + body: "code=" + encodeURIComponent(code), 309 + }); 310 + const json = await res.json(); 311 + if (json.ok) { 312 + codeInput.value = ""; 313 + loadSessions(); 314 + } else { 315 + loginError.textContent = "Invalid code"; 316 + loginError.style.display = "block"; 317 + if (json.numeric != null) configureCodeInput(json.numeric); 318 + } 319 + } catch { 320 + loginError.textContent = "Connection error"; 321 + loginError.style.display = "block"; 322 + } 323 + } 324 + 325 + loginBtn.addEventListener("click", submitCode); 326 + codeInput.addEventListener("keydown", (e) => { 327 + if (e.key === "Enter") submitCode(); 328 + }); 329 + 330 + // ── Session List ── 331 + 332 + async function loadSessions() { 333 + let res; 334 + try { 335 + res = await fetch("/api/sessions"); 336 + } catch { 337 + return; 338 + } 339 + 340 + if (res.status === 401) { 341 + const json = await res.json().catch(() => ({})); 342 + configureCodeInput(!!json.numeric); 343 + showView("login"); 344 + codeInput.focus(); 345 + stopRefresh(); 346 + return; 347 + } 348 + 349 + const sessions = await res.json(); 350 + showView("list"); 351 + renderSessions(sessions); 352 + startRefresh(); 353 + } 354 + 355 + function renderSessions(sessions) { 356 + sessionList.innerHTML = ""; 357 + if (sessions.length === 0) { 358 + noSessions.style.display = "block"; 359 + return; 360 + } 361 + noSessions.style.display = "none"; 362 + 363 + for (const s of sessions) { 364 + const el = document.createElement("div"); 365 + el.className = "session-item"; 366 + el.innerHTML = ` 367 + <div> 368 + <div class="session-name">${esc(s.name)}</div> 369 + <div class="session-command">${esc(s.command || "")}</div> 370 + </div> 371 + <span class="session-status ${s.status === "running" ? "status-running" : "status-exited"}">${esc(s.status)}</span> 372 + `; 373 + el.addEventListener("click", () => connectSession(s.name)); 374 + sessionList.appendChild(el); 375 + } 376 + } 377 + 378 + function esc(str) { 379 + const d = document.createElement("div"); 380 + d.textContent = str; 381 + return d.innerHTML; 382 + } 383 + 384 + function startRefresh() { 385 + stopRefresh(); 386 + refreshTimer = setInterval(loadSessions, 3000); 387 + } 388 + 389 + function stopRefresh() { 390 + if (refreshTimer) { 391 + clearInterval(refreshTimer); 392 + refreshTimer = null; 393 + } 394 + } 395 + 396 + // ── Terminal ── 397 + 398 + function connectSession(name) { 399 + stopRefresh(); 400 + showView("terminal"); 401 + sessionLabel.textContent = name; 402 + 403 + const term = new Terminal({ 404 + cursorBlink: true, 405 + fontSize: 14, 406 + theme: { background: "#1e1e1e" }, 407 + }); 408 + const fitAddon = new FitAddon(); 409 + term.loadAddon(fitAddon); 410 + term.open(terminalContainer); 411 + fitAddon.fit(); 412 + 413 + currentTerm = term; 414 + currentFit = fitAddon; 415 + 416 + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; 417 + const wsUrl = `${protocol}//${location.host}/ws/${encodeURIComponent(name)}`; 418 + const socket = new WebSocket(wsUrl); 419 + socket.binaryType = "arraybuffer"; 420 + currentWs = socket; 421 + 422 + socket.addEventListener("open", () => { 423 + socket.send(attachPacket(term.rows, term.cols)); 424 + }); 425 + 426 + socket.addEventListener("message", (event) => { 427 + const pkt = parsePacket(event.data); 428 + switch (pkt.type) { 429 + case MSG.SCREEN: 430 + term.reset(); 431 + term.write(decoder.decode(pkt.payload)); 432 + break; 433 + case MSG.DATA: 434 + term.write(new Uint8Array(pkt.payload)); 435 + break; 436 + case MSG.EXIT: { 437 + const view = new DataView(pkt.payload); 438 + const code = pkt.payload.byteLength >= 4 ? view.getInt32(0, false) : -1; 439 + exitOverlay.textContent = `Process exited (code ${code})`; 440 + exitOverlay.style.display = "block"; 441 + setTimeout(() => disconnect(), 2000); 442 + break; 443 + } 444 + } 445 + }); 446 + 447 + socket.addEventListener("close", () => { 448 + // If we didn't trigger the close, go back to list 449 + if (currentWs === socket) disconnect(); 450 + }); 451 + 452 + socket.addEventListener("error", () => { 453 + if (currentWs === socket) disconnect(); 454 + }); 455 + 456 + // Input 457 + term.onData((data) => { 458 + if (socket.readyState === WebSocket.OPEN) { 459 + socket.send(dataPacket(data)); 460 + } 461 + }); 462 + 463 + // Resize 464 + resizeObserver = new ResizeObserver(() => { 465 + fitAddon.fit(); 466 + }); 467 + resizeObserver.observe(terminalContainer); 468 + 469 + term.onResize(({ rows, cols }) => { 470 + if (socket.readyState === WebSocket.OPEN) { 471 + socket.send(resizePacket(rows, cols)); 472 + } 473 + }); 474 + 475 + // Detach via button 476 + detachBtn.addEventListener("click", doDetach); 477 + 478 + // Detach via Ctrl+backslash 479 + term.attachCustomKeyEventHandler((event) => { 480 + if (event.type === "keydown" && event.ctrlKey && event.key === "\\") { 481 + doDetach(); 482 + return false; 483 + } 484 + return true; 485 + }); 486 + 487 + term.focus(); 488 + } 489 + 490 + function doDetach() { 491 + if (currentWs && currentWs.readyState === WebSocket.OPEN) { 492 + currentWs.send(detachPacket()); 493 + } 494 + disconnect(); 495 + } 496 + 497 + function disconnect() { 498 + if (resizeObserver) { 499 + resizeObserver.disconnect(); 500 + resizeObserver = null; 501 + } 502 + if (currentWs) { 503 + const ws = currentWs; 504 + currentWs = null; 505 + try { ws.close(); } catch {} 506 + } 507 + if (currentTerm) { 508 + currentTerm.dispose(); 509 + currentTerm = null; 510 + currentFit = null; 511 + } 512 + detachBtn.removeEventListener("click", doDetach); 513 + loadSessions(); 514 + } 515 + 516 + // ── Init ── 517 + 518 + loadSessions(); 519 + </script> 520 + </body> 521 + </html>
+4
test-results/.last-run.json
··· 1 + { 2 + "status": "passed", 3 + "failedTests": [] 4 + }
+3
tests/integration.test.ts
··· 28 28 29 29 // All tests run in a tmp directory to avoid polluting the project 30 30 const testCwd = fs.mkdtempSync(path.join(os.tmpdir(), "pty-int-")); 31 + const testSessionDir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-int-state-")); 32 + process.env.PTY_SESSION_DIR = testSessionDir; 31 33 afterAll(() => { 32 34 fs.rmSync(testCwd, { recursive: true, force: true }); 35 + fs.rmSync(testSessionDir, { recursive: true, force: true }); 33 36 }); 34 37 35 38 let servers: PtyServer[] = [];
+3
tests/screenshot.test.ts
··· 19 19 20 20 // All tests run in a tmp directory to avoid polluting the project 21 21 const testCwd = fs.mkdtempSync(path.join(os.tmpdir(), "pty-ss-")); 22 + const testSessionDir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-ss-state-")); 23 + process.env.PTY_SESSION_DIR = testSessionDir; 22 24 afterAll(() => { 23 25 fs.rmSync(testCwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 26 + fs.rmSync(testSessionDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 24 27 }); 25 28 26 29 // ─── Types ───
+215
tests/web.test.ts
··· 1 + import { test, expect } from "@playwright/test"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { PtyServer } from "../src/server.ts"; 6 + import { WebServer } from "../src/web/server.ts"; 7 + import { cleanupAll } from "../src/sessions.ts"; 8 + 9 + // Each test gets isolated temp directories 10 + let testCwd: string; 11 + let testSessionDir: string; 12 + let servers: PtyServer[] = []; 13 + let webServers: WebServer[] = []; 14 + let sessionNames: string[] = []; 15 + 16 + function uniqueName(): string { 17 + const name = `web-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; 18 + sessionNames.push(name); 19 + return name; 20 + } 21 + 22 + async function startPty( 23 + name: string, 24 + command: string, 25 + args: string[] = [] 26 + ): Promise<PtyServer> { 27 + const server = new PtyServer({ 28 + name, 29 + command, 30 + args, 31 + displayCommand: command, 32 + cwd: testCwd, 33 + rows: 24, 34 + cols: 80, 35 + }); 36 + servers.push(server); 37 + await server.ready; 38 + return server; 39 + } 40 + 41 + async function startWeb( 42 + opts: { connectCode?: string } = {} 43 + ): Promise<{ server: WebServer; url: string }> { 44 + const server = new WebServer({ port: 0, ...opts }); 45 + webServers.push(server); 46 + const addr = await server.ready; 47 + return { server, url: `http://${addr.host}:${addr.port}` }; 48 + } 49 + 50 + test.beforeEach(() => { 51 + testCwd = fs.mkdtempSync(path.join(os.tmpdir(), "pty-web-")); 52 + testSessionDir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-web-state-")); 53 + process.env.PTY_SESSION_DIR = testSessionDir; 54 + }); 55 + 56 + test.afterEach(async () => { 57 + for (const s of webServers) await s.close(); 58 + webServers = []; 59 + for (const s of servers) await s.close(); 60 + servers = []; 61 + for (const name of sessionNames) cleanupAll(name); 62 + sessionNames = []; 63 + fs.rmSync(testCwd, { recursive: true, force: true }); 64 + fs.rmSync(testSessionDir, { recursive: true, force: true }); 65 + }); 66 + 67 + test("session list renders", async ({ page }) => { 68 + const name = uniqueName(); 69 + await startPty(name, "cat"); 70 + const { url } = await startWeb(); 71 + 72 + await page.goto(url); 73 + await expect(page.locator(".session-item")).toBeVisible({ timeout: 5000 }); 74 + await expect(page.locator(".session-name")).toHaveText(name); 75 + }); 76 + 77 + test("connect and see output", async ({ page }) => { 78 + const name = uniqueName(); 79 + await startPty(name, "sh", ["-c", "echo hello; sleep 30"]); 80 + // Wait for xterm-headless to process 81 + await new Promise((r) => setTimeout(r, 200)); 82 + const { url } = await startWeb(); 83 + 84 + await page.goto(url); 85 + await page.locator(".session-item").click(); 86 + 87 + // Wait for terminal to contain the output 88 + await expect(page.locator(".xterm-rows")).toContainText("hello", { 89 + timeout: 5000, 90 + }); 91 + }); 92 + 93 + test("send input", async ({ page }) => { 94 + const name = uniqueName(); 95 + await startPty(name, "cat"); 96 + const { url } = await startWeb(); 97 + 98 + await page.goto(url); 99 + await page.locator(".session-item").click(); 100 + 101 + // Wait for terminal to be ready 102 + await expect(page.locator(".xterm-rows")).toBeVisible({ timeout: 5000 }); 103 + await new Promise((r) => setTimeout(r, 300)); 104 + 105 + // Click the terminal to focus, then type via keyboard 106 + await page.locator("#terminal-container").click(); 107 + await page.keyboard.type("test123"); 108 + 109 + // Should see echoed text (cat echoes input) 110 + await expect(page.locator(".xterm-rows")).toContainText("test123", { 111 + timeout: 5000, 112 + }); 113 + }); 114 + 115 + test("detach button returns to list", async ({ page }) => { 116 + const name = uniqueName(); 117 + await startPty(name, "cat"); 118 + const { url } = await startWeb(); 119 + 120 + await page.goto(url); 121 + await page.locator(".session-item").click(); 122 + await expect(page.locator("#terminal-view")).toBeVisible({ timeout: 5000 }); 123 + 124 + await page.locator("#detach-btn").click(); 125 + await expect(page.locator("#list-view")).toBeVisible({ timeout: 5000 }); 126 + }); 127 + 128 + test("auth required with connect code", async ({ page }) => { 129 + const name = uniqueName(); 130 + await startPty(name, "cat"); 131 + const { url } = await startWeb({ connectCode: "123456" }); 132 + 133 + await page.goto(url); 134 + await expect(page.locator("#login-view")).toBeVisible({ timeout: 5000 }); 135 + 136 + // Wrong code 137 + await page.locator("#code-input").fill("wrong"); 138 + await page.locator("#login-btn").click(); 139 + await expect(page.locator("#login-error")).toBeVisible({ timeout: 5000 }); 140 + 141 + // Correct code 142 + await page.locator("#code-input").fill("123456"); 143 + await page.locator("#login-btn").click(); 144 + await expect(page.locator("#list-view")).toBeVisible({ timeout: 5000 }); 145 + }); 146 + 147 + test("numeric code shows numeric keyboard", async ({ page }) => { 148 + const name = uniqueName(); 149 + await startPty(name, "cat"); 150 + const { url } = await startWeb({ connectCode: "9999" }); 151 + 152 + await page.goto(url); 153 + await expect(page.locator("#login-view")).toBeVisible({ timeout: 5000 }); 154 + // After the first session fetch returns 401 with numeric=true, 155 + // the input should have inputmode="numeric" 156 + await expect(page.locator("#code-input")).toHaveAttribute( 157 + "inputmode", 158 + "numeric", 159 + { timeout: 3000 } 160 + ); 161 + }); 162 + 163 + test("no auth when no code", async ({ page }) => { 164 + const name = uniqueName(); 165 + await startPty(name, "cat"); 166 + const { url } = await startWeb(); 167 + 168 + await page.goto(url); 169 + // Should show list directly (no login form) 170 + await expect(page.locator("#list-view")).toBeVisible({ timeout: 5000 }); 171 + }); 172 + 173 + test("session exit displayed", async ({ page }) => { 174 + const name = uniqueName(); 175 + // Process that lives long enough for the browser to attach, then exits 176 + await startPty(name, "sh", ["-c", "echo goodbye; sleep 0.5; exit 42"]); 177 + const { url } = await startWeb(); 178 + 179 + await page.goto(url); 180 + await page.locator(".session-item").click(); 181 + 182 + // Should see exit overlay after the process exits 183 + await expect(page.locator("#exit-overlay")).toContainText("exited", { 184 + timeout: 10000, 185 + }); 186 + }); 187 + 188 + test("auto-refresh shows new session", async ({ page }) => { 189 + const { url } = await startWeb(); 190 + await page.goto(url); 191 + await expect(page.locator("#list-view")).toBeVisible({ timeout: 5000 }); 192 + 193 + // No sessions yet 194 + await expect(page.locator("#no-sessions")).toBeVisible(); 195 + 196 + // Start a session after the page loaded 197 + const name = uniqueName(); 198 + await startPty(name, "cat"); 199 + 200 + // Should appear within the refresh interval (3s + margin) 201 + await expect(page.locator(".session-name")).toHaveText(name, { 202 + timeout: 6000, 203 + }); 204 + }); 205 + 206 + test("multiple sessions listed", async ({ page }) => { 207 + const name1 = uniqueName(); 208 + const name2 = uniqueName(); 209 + await startPty(name1, "cat"); 210 + await startPty(name2, "cat"); 211 + const { url } = await startWeb(); 212 + 213 + await page.goto(url); 214 + await expect(page.locator(".session-item")).toHaveCount(2, { timeout: 5000 }); 215 + });