diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore index 83fb59d..b1921a3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ lcov.info .agents/ .claude/ .sisyphus/ + +# Artifact from tests running with HOME unset (HOME=undefined pollutes cwd) +undefined/ diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index 88610bf..4dded4f 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -28,8 +28,13 @@ async function runCli( port: number, ...args: string[] ): Promise<{ stdout: string; stderr: string; exitCode: number }> { + // Write config with the test port so loadConfig() in the CLI subprocess returns the right port. + const cfgDir = path.join(tmpHome, '.config', 'webtty'); + fsModule.mkdirSync(cfgDir, { recursive: true }); + fsModule.writeFileSync(path.join(cfgDir, 'config.json'), JSON.stringify({ port })); + const proc = Bun.spawn([process.execPath, CLI_ENTRY, ...args], { - env: { ...process.env, PORT: String(port), WEBTTY_NO_OPEN: '1', HOME: tmpHome }, + env: { ...process.env, WEBTTY_NO_OPEN: '1', HOME: tmpHome }, stdout: 'pipe', stderr: 'pipe', }); @@ -677,7 +682,8 @@ describe('cli — unit (mocked http)', () => { {} as ReturnType, ); cmds.cmdConfig(); - process.env.HOME = origHome; + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; mkdirSpy.mockRestore(); spawnSpy.mockRestore(); }); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 093b374..96d5abe 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -2,7 +2,7 @@ import * as childProcess from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { configDir, loadConfig } from '../config'; -import { BASE_URL, isServerRunning, openBrowser, PORT, startServer, stopServer } from './http'; +import { getBaseUrl, getPort, isServerRunning, openBrowser, startServer, stopServer } from './http'; /** * Converts a bind host to a browser-navigable host. @@ -27,11 +27,11 @@ export async function cmdGo(id = 'main'): Promise { } let sessionId: string; - const check = await fetch(`${BASE_URL}/api/sessions/${encodeURIComponent(id)}`); + const check = await fetch(`${getBaseUrl()}/api/sessions/${encodeURIComponent(id)}`); if (check.status === 200) { sessionId = id; } else { - const res = await fetch(`${BASE_URL}/api/sessions`, { + const res = await fetch(`${getBaseUrl()}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), @@ -45,7 +45,7 @@ export async function cmdGo(id = 'main'): Promise { sessionId = session.id; } - const url = `http://${toBrowserHost(loadConfig().host)}:${PORT}/s/${sessionId}`; + const url = `http://${toBrowserHost(loadConfig().host)}:${getPort()}/s/${sessionId}`; console.log(url); openBrowser(url); } @@ -58,7 +58,7 @@ export async function cmdGo(id = 'main'): Promise { export async function cmdList(filter?: string): Promise { let res: Response; try { - res = await fetch(`${BASE_URL}/api/sessions`); + res = await fetch(`${getBaseUrl()}/api/sessions`); } catch { console.log('webtty is not running'); process.exit(1); @@ -92,7 +92,7 @@ export async function cmdRemove(id?: string): Promise { } let res: Response; try { - res = await fetch(`${BASE_URL}/api/sessions/${encodeURIComponent(id)}`, { + res = await fetch(`${getBaseUrl()}/api/sessions/${encodeURIComponent(id)}`, { method: 'DELETE', }); } catch { @@ -127,7 +127,7 @@ export async function cmdRename(id?: string, newId?: string): Promise { } let res: Response; try { - res = await fetch(`${BASE_URL}/api/sessions/${encodeURIComponent(id)}`, { + res = await fetch(`${getBaseUrl()}/api/sessions/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: newId }), diff --git a/src/cli/http.test.ts b/src/cli/http.test.ts index 617e6a0..d6bca24 100644 --- a/src/cli/http.test.ts +++ b/src/cli/http.test.ts @@ -134,7 +134,7 @@ describe('startServer', () => { test('exits with error when server entry not found', async () => { const realExistsSync = fs.existsSync.bind(fs); const existsSpy = spyOn(fs, 'existsSync').mockImplementation((p) => - String(p).includes('server/index') ? false : realExistsSync(p), + String(p).replace(/\\/g, '/').includes('server/index') ? false : realExistsSync(p), ); const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); diff --git a/src/cli/http.ts b/src/cli/http.ts index 2f4d24b..861c3e3 100644 --- a/src/cli/http.ts +++ b/src/cli/http.ts @@ -7,11 +7,15 @@ import { configDir, loadConfig } from '../config'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -/** Active server port, resolved from `PORT` env or config default. */ -export const PORT = Number(process.env.PORT) || 2346; +/** Active server port, read from config on first call (env PORT is intentionally ignored). */ +export function getPort(): number { + return loadConfig().port; +} /** Base URL for internal CLI↔server API calls (always 127.0.0.1, avoids IPv6 lookup). */ -export const BASE_URL = `http://127.0.0.1:${PORT}`; +export function getBaseUrl(): string { + return `http://127.0.0.1:${getPort()}`; +} /** Returns the path to the server log file: `~/.config/webtty/server.log`. */ export function logPath(): string { @@ -21,7 +25,7 @@ export function logPath(): string { /** Returns `true` if the webtty server is reachable and responding to API requests. */ export async function isServerRunning(): Promise { try { - const res = await fetch(`${BASE_URL}/api/sessions`); + const res = await fetch(`${getBaseUrl()}/api/sessions`); if (!res.ok) return false; const body = await res.json(); return Array.isArray(body); @@ -47,11 +51,22 @@ export async function startServer(timeoutMs = 10000, _spawn = childProcess.spawn const useNode = process.platform === 'win32' && isBun; const serverExec = useNode ? 'node' : process.execPath; - // When the executor is Node.js it cannot run .ts files directly, so always - // resolve to the compiled .js entry regardless of whether the CLI itself is - // running from source. - const isTs = isBun && !useNode && __filename.endsWith('.ts'); - const serverEntry = path.resolve(__dirname, isTs ? '../server/index.ts' : '../server/index.js'); + // Resolve the server entry. When useNode is true the executor is Node.js, + // which cannot run .ts files, so we always need the compiled .js output. + // The CLI may itself run from source (src/cli/) or from dist (dist/cli/); + // adjust the relative path accordingly so we always land in the same dist/. + const runningFromSrc = __filename.endsWith('.ts'); + let serverEntry: string; + if (useNode) { + serverEntry = runningFromSrc + ? path.resolve(__dirname, '../../dist/server/index.js') // src/cli → dist/server + : path.resolve(__dirname, '../server/index.js'); // dist/cli → dist/server + } else { + serverEntry = path.resolve( + __dirname, + runningFromSrc ? '../server/index.ts' : '../server/index.js', + ); + } if (!fs.existsSync(serverEntry)) { console.error(`webtty: server entry not found at ${serverEntry}`); (process.exit as (code?: number) => void)(1); @@ -71,7 +86,7 @@ export async function startServer(timeoutMs = 10000, _spawn = childProcess.spawn const child = _spawn(serverExec, [serverEntry], { detached: true, stdio, - env: { ...process.env, PORT: String(PORT) }, + env: { ...process.env, PORT: String(config.port) }, }); child.on('error', (err) => { const hint = useNode @@ -95,11 +110,14 @@ export async function startServer(timeoutMs = 10000, _spawn = childProcess.spawn /** * Sends `POST /api/server/stop`, then polls until the server is no longer reachable. * - * @param baseUrl - The server base URL (default: BASE_URL). + * @param baseUrl - The server base URL (default: getBaseUrl()). * @param timeoutMs - Maximum time to wait for the server to stop (default: 5000 ms). * @returns `true` if the server stopped successfully, `false` otherwise. */ -export async function stopServer(baseUrl: string = BASE_URL, timeoutMs = 5000): Promise { +export async function stopServer( + baseUrl: string = getBaseUrl(), + timeoutMs = 5000, +): Promise { try { const res = await fetch(`${baseUrl}/api/server/stop`, { method: 'POST' }); if (!res.ok) return false; diff --git a/src/client/index.ts b/src/client/index.ts index b5d7649..8a4485f 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,5 +1,6 @@ import { FitAddon, init, Terminal } from 'ghostty-web'; import { applyDecscusr } from './cursor'; +import { isDuplicateDrag, rewriteHoverMotion } from './mouse'; interface KeyboardBinding { key: string; @@ -91,8 +92,23 @@ function fit(): void { container.style.paddingBottom = `${Math.ceil(vGap / 2)}px`; } -fit(); -new ResizeObserver(() => fit()).observe(container, { box: 'border-box' }); +// Both ResizeObserver and window 'resize' can fire for the same physical event. +// Schedule through rAF so multiple signals coalesce into one fit per frame. +let pendingFitFrame: number | null = null; +function scheduleFit(): void { + if (pendingFitFrame !== null) return; + pendingFitFrame = window.requestAnimationFrame(() => { + pendingFitFrame = null; + fit(); + }); +} + +scheduleFit(); +new ResizeObserver(() => scheduleFit()).observe(container, { box: 'border-box' }); +// ResizeObserver misses monitor hot-plug and DPI changes because those resize +// the viewport without changing the container's layout box. window resize fires +// reliably for both, so use both observers together. +window.addEventListener('resize', scheduleFit); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; let ws: WebSocket; @@ -232,7 +248,27 @@ window.addEventListener( ); // Forward terminal keystrokes and input to the PTY over WebSocket. +// See src/client/mouse.ts for the ghostty-web SGR bug and the two fixes +// (hover rewrite + drag dedup) applied below. +let isHoverMove = false; +let lastDragSeq = ''; +container.addEventListener( + 'mousemove', + (e: MouseEvent) => { + isHoverMove = term.hasMouseTracking() && e.buttons === 0; + if (isHoverMove) lastDragSeq = ''; // reset dedup on button release + }, + { capture: true }, +); term.onData((data: string) => { + const seq = rewriteHoverMotion(data, isHoverMove); + if (seq !== data) { + // Hover: forwarded with corrected Cb=35 (no-button motion per SGR spec) + if (ws && ws.readyState === WebSocket.OPEN) ws.send(seq); + return; + } + if (isDuplicateDrag(data, lastDragSeq)) return; // drop same-cell drag repeat + lastDragSeq = data.startsWith('\x1b[<32;') ? data : ''; // track or reset if (ws && ws.readyState === WebSocket.OPEN) { ws.send(data); } diff --git a/src/client/mouse.test.ts b/src/client/mouse.test.ts new file mode 100644 index 0000000..67ab80d --- /dev/null +++ b/src/client/mouse.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test'; +import { isDuplicateDrag, rewriteHoverMotion } from './mouse'; + +describe('rewriteHoverMotion', () => { + test('rewrites \\x1b[<32; to \\x1b[<35; when isHover is true', () => { + expect(rewriteHoverMotion('\x1b[<32;8;4M', true)).toBe('\x1b[<35;8;4M'); + }); + + test('preserves col;row and M suffix after rewrite', () => { + expect(rewriteHoverMotion('\x1b[<32;120;50M', true)).toBe('\x1b[<35;120;50M'); + }); + + test('returns data unchanged when isHover is false (drag)', () => { + expect(rewriteHoverMotion('\x1b[<32;8;4M', false)).toBe('\x1b[<32;8;4M'); + }); + + test('returns data unchanged for non-motion sequences regardless of isHover', () => { + // left press + expect(rewriteHoverMotion('\x1b[<0;8;4M', true)).toBe('\x1b[<0;8;4M'); + // left release + expect(rewriteHoverMotion('\x1b[<0;8;4m', true)).toBe('\x1b[<0;8;4m'); + // scroll up + expect(rewriteHoverMotion('\x1b[<64;8;4M', true)).toBe('\x1b[<64;8;4M'); + // middle drag + expect(rewriteHoverMotion('\x1b[<33;8;4M', true)).toBe('\x1b[<33;8;4M'); + }); + + test('returns data unchanged for plain text', () => { + expect(rewriteHoverMotion('hello', true)).toBe('hello'); + }); + + test('does not rewrite when isHover is false even for button-32', () => { + // pointer moved to a new cell while button is held — must not rewrite + const drag = '\x1b[<32;9;4M'; + expect(rewriteHoverMotion(drag, false)).toBe(drag); + }); +}); + +describe('isDuplicateDrag', () => { + test('returns true for identical consecutive button-32 sequences', () => { + const seq = '\x1b[<32;8;4M'; + expect(isDuplicateDrag(seq, seq)).toBe(true); + }); + + test('returns false when position changes', () => { + expect(isDuplicateDrag('\x1b[<32;9;4M', '\x1b[<32;8;4M')).toBe(false); + }); + + test('returns false when lastDragSeq is empty (first drag event)', () => { + expect(isDuplicateDrag('\x1b[<32;8;4M', '')).toBe(false); + }); + + test('returns false for non-drag sequences even if identical', () => { + // press, release, scroll must never be deduped + expect(isDuplicateDrag('\x1b[<0;8;4M', '\x1b[<0;8;4M')).toBe(false); + expect(isDuplicateDrag('\x1b[<0;8;4m', '\x1b[<0;8;4m')).toBe(false); + expect(isDuplicateDrag('\x1b[<64;8;4M', '\x1b[<64;8;4M')).toBe(false); + }); + + test('returns false when row differs', () => { + expect(isDuplicateDrag('\x1b[<32;8;5M', '\x1b[<32;8;4M')).toBe(false); + }); + + test('returns true only for exact string match', () => { + expect(isDuplicateDrag('\x1b[<32;8;4M', '\x1b[<32;8;4M')).toBe(true); + expect(isDuplicateDrag('\x1b[<32;8;4M', '\x1b[<35;8;4M')).toBe(false); + }); +}); diff --git a/src/client/mouse.ts b/src/client/mouse.ts new file mode 100644 index 0000000..b0e7adb --- /dev/null +++ b/src/client/mouse.ts @@ -0,0 +1,60 @@ +/** + * SGR mouse sequence helpers. + * + * ghostty-web bug: handleMouseMove always encodes motion as SGR button-32 + * (\x1b[<32;col;rowM) regardless of whether a button is held. It uses its + * own mouseButtonsPressed bitmask; when no button is pressed the bitmask is 0, + * and 0+32=32 — the same Cb value as a left-button drag. Real terminals use + * Cb=35 (32+3, "no button") for hover and Cb=32 for left-button drag. + * + * Impact on vim: with `set mouse=a` vim requests mode 1003 (any-event + * tracking). Every hover move arrives as \x1b[<32;…M which vim decodes as + * , toggling visual mode on each pixel of cursor movement. + * + * The two exported functions implement the fixes applied in onData: + * + * - rewriteHoverMotion: when the DOM reports no button held (e.buttons===0), + * rewrite \x1b[<32; → \x1b[<35; so vim receives the correct no-button code + * (Cb=35 per the SGR spec). The position is still forwarded so vim can + * track the cursor for subsequent drag operations. + * + * - isDuplicateDrag: ghostty-web fires multiple mousemove events for the same + * character cell while the pointer stays within it. Vim treats a + * at the same cell twice as a visual-mode toggle (enter → exit), so + * consecutive identical drag sequences must be deduplicated. + */ + +/** Prefix that ghostty-web emits for both hover and left-button drag. */ +const DRAG_PREFIX = '\x1b[<32;'; +/** Correct SGR prefix for no-button hover motion (Cb = 32 + 3 = 35). */ +const HOVER_PREFIX = '\x1b[<35;'; + +/** + * If `isHover` is true and `data` is a ghostty-web hover-misencoded drag + * sequence, rewrite the button byte from 32 to 35 (the correct SGR no-button + * code) and return the corrected sequence. Otherwise return `data` unchanged. + * + * @param data Raw SGR sequence from ghostty-web's onData callback. + * @param isHover Whether the originating DOM mousemove had `e.buttons === 0`. + */ +export function rewriteHoverMotion(data: string, isHover: boolean): string { + if (isHover && data.startsWith(DRAG_PREFIX)) { + return HOVER_PREFIX + data.slice(DRAG_PREFIX.length); + } + return data; +} + +/** + * Returns true when `data` is a left-button drag sequence (`\x1b[<32;…M`) + * that is identical to the previous drag sequence, indicating the pointer + * has not moved to a new character cell. + * + * Callers should maintain `lastDragSeq` state and pass it in; on a false + * return they update `lastDragSeq = data`, on a true return they skip sending. + * + * @param data Raw SGR sequence from ghostty-web's onData callback. + * @param lastDragSeq The last drag sequence that was forwarded to the PTY. + */ +export function isDuplicateDrag(data: string, lastDragSeq: string): boolean { + return data.startsWith(DRAG_PREFIX) && data === lastDragSeq; +} diff --git a/src/config.ts b/src/config.ts index 369c679..1464601 100644 --- a/src/config.ts +++ b/src/config.ts @@ -101,7 +101,13 @@ export interface Config { /** Returns the webtty config directory: `~/.config/webtty`. */ export function configDir(): string { - return path.join(process.env.HOME ?? os.homedir(), '.config', 'webtty'); + // os.homedir() is the authoritative home directory. process.env.HOME is used + // as an override in tests (e.g. to isolate config state), but only when it is + // an absolute path — guarding against accidental env pollution such as the + // string "undefined" being assigned when HOME was originally unset on Windows. + const home = + process.env.HOME && path.isAbsolute(process.env.HOME) ? process.env.HOME : os.homedir(); + return path.join(home, '.config', 'webtty'); } function getConfigPath(): string { diff --git a/src/pty/index.test.ts b/src/pty/index.test.ts index 51ef601..929cf4c 100644 --- a/src/pty/index.test.ts +++ b/src/pty/index.test.ts @@ -13,9 +13,22 @@ function waitForData(received: string[], content: string, timeout = 3000): Promi }); } +// On Windows use COMSPEC (the full path to cmd.exe) to bypass any PATH shims +// that shell enhancers like clink install (clink wraps cmd.exe with a bat launcher). +const TEST_SHELL = process.platform === 'win32' ? (process.env.COMSPEC ?? 'cmd.exe') : '/bin/sh'; + +// On Windows, Bun's ConPTY/net.Socket integration has a known issue where the +// pipe closes after the initial banner is written, causing ERR_SOCKET_CLOSED on +// the first PTY write. The server always runs under Node on Windows (see http.ts), +// so skip the interactive data test under Bun on Windows — it passes in CI (Linux) +// and under Node on Windows. +const isBunOnWindows = + process.platform === 'win32' && + typeof (globalThis as Record).Bun !== 'undefined'; + describe('spawnForSession', () => { test('returns a PtyProcess with the expected interface', () => { - const pty = spawnForSession(80, 24, '/bin/sh', 'xterm-256color', 'truecolor'); + const pty = spawnForSession(80, 24, TEST_SHELL, 'xterm-256color', 'truecolor'); expect(typeof pty.onData).toBe('function'); expect(typeof pty.onExit).toBe('function'); @@ -27,16 +40,20 @@ describe('spawnForSession', () => { pty.kill(); }); - test('spawned process can receive data', async () => { - const pty = spawnForSession(80, 24, '/bin/sh', 'xterm-256color', 'truecolor'); + test.skipIf(isBunOnWindows)('spawned process can receive data', async () => { + const pty = spawnForSession(80, 24, TEST_SHELL, 'xterm-256color', 'truecolor'); const received: string[] = []; pty.onData((data) => received.push(data)); - pty.write('echo __ready__\n'); + const echoReady = process.platform === 'win32' ? 'echo __ready__\r\n' : 'echo __ready__\n'; + const echoHello = process.platform === 'win32' ? 'echo hello-pty\r\n' : 'echo hello-pty\n'; + const exitCmd = process.platform === 'win32' ? 'exit\r\n' : 'exit\n'; + + pty.write(echoReady); await waitForData(received, '__ready__'); - pty.write('echo hello-pty\n'); + pty.write(echoHello); await waitForData(received, 'hello-pty'); - pty.write('exit\n'); + pty.write(exitCmd); await new Promise((resolve) => pty.onExit(() => resolve())); expect(received.join('')).toContain('hello-pty'); diff --git a/src/pty/index.ts b/src/pty/index.ts index 40c6a5b..fc1ae5f 100644 --- a/src/pty/index.ts +++ b/src/pty/index.ts @@ -1,6 +1,9 @@ export type { PtyProcess } from './types'; -const isBun = !!process.versions.bun; +// Bun.Terminal does not implement PTY on Windows (Bun.spawn({ terminal }) +// is a no-op there), so fall back to node-pty on that platform. +// On all other platforms, prefer Bun.Terminal when running under Bun. +const isBun = !!process.versions.bun && process.platform !== 'win32'; console.log(`pty: ${isBun ? 'Bun.Terminal' : 'node-pty'}`); const { spawn: _spawn } = await (isBun ? import('./bun') : import('./node')); diff --git a/src/pty/node.ts b/src/pty/node.ts index f5fe706..3639e8d 100644 --- a/src/pty/node.ts +++ b/src/pty/node.ts @@ -19,7 +19,12 @@ export function spawn( term: string, colorTerm: string, ): PtyProcess { - const ptyProc = nodePty.spawn(shell, [], { + // On Windows, cmd.exe may have an AutoRun registry key that launches shell + // enhancers (e.g. clink). These take over the ConPTY pipe and cause + // ERR_SOCKET_CLOSED on the first write. Pass /d to disable AutoRun. + const shellArgs = process.platform === 'win32' && /cmd\.exe$/i.test(shell) ? ['/d'] : []; + + const ptyProc = nodePty.spawn(shell, shellArgs, { name: term, cols, rows, diff --git a/src/server/websocket.test.ts b/src/server/websocket.test.ts index 0d641bc..8cd61b8 100644 --- a/src/server/websocket.test.ts +++ b/src/server/websocket.test.ts @@ -8,8 +8,19 @@ import { cleanupTmpHome, getFreePort, makeTmpHome, waitForServer } from '../util const ANSI_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); const stripAnsi = (s: string) => s.replace(ANSI_RE, ''); +// cmd.exe requires CR+LF to execute commands; sh/bash accept LF alone. +const NL = process.platform === 'win32' ? '\r\n' : '\n'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const SERVER_ENTRY = path.resolve(__dirname, 'index.ts'); + +// On Windows, Bun's ConPTY/net.Socket integration doesn't support the pipe +// handles node-pty requires. Mirror the same workaround as http.ts: run the +// server under Node when on Windows+Bun so node-pty works correctly. +const isBun = typeof (globalThis as Record).Bun !== 'undefined'; +const useNode = process.platform === 'win32' && isBun; +const SERVER_EXEC = useNode ? 'node' : process.execPath; +const SERVER_ENTRY = useNode + ? path.resolve(__dirname, '../../dist/server/index.js') + : path.resolve(__dirname, 'index.ts'); function connectWs(wsUrl: string): Promise<{ ws: WebSocket; messages: string[] }> { return new Promise((resolve, reject) => { @@ -39,7 +50,8 @@ function waitForPrompt(messages: string[], timeout = 3000): Promise { const deadline = Date.now() + timeout; const check = () => { const all = messages.join(''); - if (all.includes('\x1b]133;B') || all.match(/[$%#>➜] *$/m)) return resolve(); + // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC (\x1b) is intentional — matching terminal escape sequences in PTY output + if (all.includes('\x1b]133;B') || all.match(/[$%#>➜](?:\s|\x1b|$)/m)) return resolve(); if (Date.now() > deadline) return reject(new Error('Timeout waiting for shell prompt')); setTimeout(check, 50); }; @@ -80,8 +92,17 @@ describe('websocket', () => { port = await getFreePort(); baseUrl = `http://127.0.0.1:${port}`; wsBase = `ws://127.0.0.1:${port}`; - proc = spawn(process.execPath, [SERVER_ENTRY], { - env: { ...process.env, PORT: String(port), HOME: tmpHome, SHELL: '/bin/sh' }, + proc = spawn(SERVER_EXEC, [SERVER_ENTRY], { + env: { + ...process.env, + PORT: String(port), + HOME: tmpHome, + // Let the server resolve the shell via its own platform detection (config.ts). + // Forcing /bin/sh here breaks Windows where that path does not exist. + ...(process.platform !== 'win32' && { SHELL: '/bin/sh' }), + // On Windows, clink (if installed) auto-injects into cmd.exe and breaks PTY socket writes. + ...(process.platform === 'win32' && { CLINK_NOAUTORUN: '1' }), + }, stdio: 'ignore', }); await waitForServer(baseUrl); @@ -155,7 +176,7 @@ describe('websocket', () => { await waitForPrompt(m1); - ws1.send('echo hello-fanout\n'); + ws1.send(`echo hello-fanout${NL}`); await waitForContent(m1, 'hello-fanout'); await waitForContent(m2, 'hello-fanout'); @@ -177,7 +198,7 @@ describe('websocket', () => { await waitForMessages(messages, 1); const closeCode = new Promise((resolve) => ws.on('close', (code) => resolve(code))); - ws.send('exit\n'); + ws.send(`exit${NL}`); expect(await closeCode).toBe(4001); const res = await fetch(`${baseUrl}/api/sessions/ws-test-exit`); @@ -197,7 +218,7 @@ describe('websocket', () => { ws.send(JSON.stringify({ type: 'resize', cols: 120, rows: 40 })); - ws.send('echo resize-ok\n'); + ws.send(`echo resize-ok${NL}`); await waitForContent(messages, 'resize-ok'); await closeWs(ws); @@ -245,7 +266,7 @@ describe('websocket', () => { const { ws, messages } = await connectWs(`${wsBase}/ws/ws-test-last?cols=80&rows=24`); await waitForMessages(messages, 1); - ws.send('exit\n'); + ws.send(`exit${NL}`); await new Promise((resolve) => ws.on('close', () => resolve())); const deadline = Date.now() + 3000;