From aa4217b8b1921fc62193e6879f230c85370cdd5f Mon Sep 17 00:00:00 2001 From: Randalix Date: Fri, 9 Oct 2026 16:28:26 +0200 Subject: [PATCH] test: build every in-process WebServer on port 0 (#440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The files on the guard's legacy list build new WebServer(0, …) and read server.boundPort after start(), as sse-tile-grid-filter does. Files with several servers read each server's own port; quick-start's local helper lost its port parameter so the literal 0 sits where the server is built. Header comments that named a port say "ephemeral". No assertion changed except where it embedded the port. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/admin-routes.test.ts | 7 +++--- test/base-path-server.test.ts | 8 +++---- test/capture-geometry-retry.browser.test.ts | 10 ++++---- test/capture-load-window.browser.test.ts | 10 ++++---- test/case-custom-path.browser.test.ts | 6 ++--- test/doctor-settings.browser.test.ts | 6 ++--- test/edge-cases.test.ts | 13 +++++----- test/file-link-click.test.ts | 8 +++---- test/git-status.browser.test.ts | 5 ++-- test/hooks-config.test.ts | 12 ++++------ test/http-contract.test.ts | 7 +++--- test/inline-rename.test.ts | 24 ++++++++----------- test/integration-flows.test.ts | 9 ++++--- test/key-tester.browser.test.ts | 6 ++--- test/opencode-resize.test.ts | 10 ++++---- test/operation-lightspeed.test.ts | 5 ++-- test/ownership-scoping.test.ts | 7 +++--- test/pane-exit-sweep.test.ts | 9 ++++--- test/paste-image-dir-shared.test.ts | 9 ++++--- test/perf-browser.test.ts | 16 ++++++------- test/quick-start.test.ts | 21 ++++++++-------- test/ralph-integration.test.ts | 7 +++--- test/scheduled-runs.test.ts | 10 ++++---- test/security-regression.test.ts | 14 +++++------ test/session-cleanup.test.ts | 9 ++++--- test/session-pane-exit.test.ts | 6 ++--- test/session.test.ts | 6 ++--- test/shift-enter-keypress.browser.test.ts | 10 ++++---- test/split-pane-auto-collapse.browser.test.ts | 8 +++---- test/split-pane-orchestration.browser.test.ts | 8 +++---- test/split-pane-terminal.browser.test.ts | 8 +++---- test/sse-cors-headers.test.ts | 5 ++-- test/sse-events.test.ts | 10 ++++---- test/sse-routing-remote.test.ts | 2 +- test/sse-subscription-filter.test.ts | 6 ++--- test/static-cache-headers.test.ts | 6 ++--- test/terminal-copy-shortcut.test.ts | 12 +++++----- ...rminal-keycode229-recovery.browser.test.ts | 10 ++++---- test/webgl-fallback.test.ts | 10 ++++---- test/webhook-settings.browser.test.ts | 5 ++-- test/webview-lost-root-frame.test.ts | 5 ++-- test/webview-sse.test.ts | 2 +- 42 files changed, 163 insertions(+), 204 deletions(-) diff --git a/test/admin-routes.test.ts b/test/admin-routes.test.ts index 6dcb89be..e2ea176a 100644 --- a/test/admin-routes.test.ts +++ b/test/admin-routes.test.ts @@ -1,5 +1,5 @@ /** - * @fileoverview Phase 5 admin API tests (live server, port 3173). + * @fileoverview Phase 5 admin API tests (live server, ephemeral port). * * Covers the admin user-management endpoints: multi-user gate, requireAdmin, * create (one-time password), patch + last-admin invariant, reset-password, @@ -16,9 +16,8 @@ import { createUser, invalidateUsersCache } from '../src/user-store.js'; vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true); -const PORT = 3173; const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64'); -const url = (p: string) => `http://localhost:${PORT}${p}`; +const url = (p: string) => `http://localhost:${server.boundPort}${p}`; const admin = { Authorization: basic('root', 'rootpass123'), 'Content-Type': 'application/json' }; const adminNoBody = { Authorization: basic('root', 'rootpass123') }; const regular = { Authorization: basic('joe', 'joepass1234'), 'Content-Type': 'application/json' }; @@ -48,7 +47,7 @@ beforeAll(async () => { invalidateUsersCache(); await createUser({ username: 'root', role: 'admin', password: 'rootpass123' }); await createUser({ username: 'joe', role: 'user', password: 'joepass1234' }); - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); }); diff --git a/test/base-path-server.test.ts b/test/base-path-server.test.ts index 38231d98..01d87eba 100644 --- a/test/base-path-server.test.ts +++ b/test/base-path-server.test.ts @@ -8,14 +8,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { WebServer } from '../src/web/server.js'; -const PORT = 3197; - describe('reverse-proxy base path: server wiring', () => { let server: WebServer; // eslint-disable-next-line @typescript-eslint/no-explicit-any let app: any; beforeAll(async () => { - server = new WebServer(PORT, false, true, '127.0.0.1', undefined, false, '/codeman'); + server = new WebServer(0, false, true, '127.0.0.1', undefined, false, '/codeman'); await server.start(); // eslint-disable-next-line @typescript-eslint/no-explicit-any app = (server as any).app; @@ -66,7 +64,9 @@ describe('reverse-proxy base path: server wiring', () => { const { WebSocket } = await import('ws'); const close = (path: string) => new Promise<{ code: number; reason: string }>((resolve) => { - const ws = new WebSocket(`ws://127.0.0.1:${PORT}${path}`, { headers: { origin: `http://127.0.0.1:${PORT}` } }); + const ws = new WebSocket(`ws://127.0.0.1:${server.boundPort}${path}`, { + headers: { origin: `http://127.0.0.1:${server.boundPort}` }, + }); ws.on('close', (code, reason) => resolve({ code, reason: reason.toString() })); ws.on('error', (e) => resolve({ code: -1, reason: String(e) })); }); diff --git a/test/capture-geometry-retry.browser.test.ts b/test/capture-geometry-retry.browser.test.ts index 2a127e78..f40e0bfe 100644 --- a/test/capture-geometry-retry.browser.test.ts +++ b/test/capture-geometry-retry.browser.test.ts @@ -20,7 +20,7 @@ * because the mismatch itself needs two viewports to stage against live tmux. * Without the fix the first assertion below sees one fetch instead of two. * - * Port: 3252 (capture geometry retry) + * Port: ephemeral * * Run: npx vitest run --config config/vitest.browser.config.ts test/capture-geometry-retry.browser.test.ts */ @@ -29,15 +29,15 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; -const PORT = 3252; -const BASE_URL = `http://localhost:${PORT}`; +let baseUrl: string; let server: WebServer; let browser: Browser; beforeAll(async () => { - server = new WebServer(PORT, false, true); // testMode + server = new WebServer(0, false, true); // testMode await server.start(); + baseUrl = `http://localhost:${server.boundPort}`; browser = await chromium.launch({ headless: true }); }, 60_000); @@ -176,7 +176,7 @@ async function stubTerminalAtRequestedSize(page: Page, counter: { n: number; url const WIDER_THAN_ANY_TERMINAL_COLS = 500; async function openSession(page: Page): Promise { - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 }); // xterm is loaded from /vendor, so the terminal appears a beat after the app. // Without it `app.terminal.rows` reads 0 and every height comparison below diff --git a/test/capture-load-window.browser.test.ts b/test/capture-load-window.browser.test.ts index 8cea77ef..701b4ab9 100644 --- a/test/capture-load-window.browser.test.ts +++ b/test/capture-load-window.browser.test.ts @@ -14,7 +14,7 @@ * own `json()` call, which is the one place guaranteed to land after the * headers and before the chunked write. * - * Port: 3256 (capture load window) + * Port: ephemeral * * Run: npx vitest run --config config/vitest.browser.config.ts test/capture-load-window.browser.test.ts */ @@ -23,16 +23,16 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; -const PORT = 3256; -const BASE_URL = `http://localhost:${PORT}`; +let baseUrl: string; const MARKER = 'ARRIVED-AFTER-THE-CAPTURE'; let server: WebServer; let browser: Browser; beforeAll(async () => { - server = new WebServer(PORT, false, true); // testMode + server = new WebServer(0, false, true); // testMode await server.start(); + baseUrl = `http://localhost:${server.boundPort}`; browser = await chromium.launch({ headless: true }); }, 60_000); @@ -115,7 +115,7 @@ async function runLoad(page: Page, sessionId: string, source: string): Promise { - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 }); // xterm loads from /vendor, so the terminal appears a beat after the app. // Without it every buffer assertion below would throw rather than compare. diff --git a/test/case-custom-path.browser.test.ts b/test/case-custom-path.browser.test.ts index dcaf0a3d..8cd89ca4 100644 --- a/test/case-custom-path.browser.test.ts +++ b/test/case-custom-path.browser.test.ts @@ -8,8 +8,6 @@ import { WebServer } from '../src/web/server.js'; declare const PathPicker: any; // evaluated inside the page, where it is a global -const PORT = 3193; - describe('Create a case in a custom folder', () => { let server: WebServer; let browser: Browser; @@ -18,11 +16,11 @@ describe('Create a case in a custom folder', () => { beforeAll(async () => { parent = mkdtempSync(join(homedir(), 'custom-case-')); - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); browser = await chromium.launch({ headless: true }); page = await browser.newPage(); - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); }, 90000); diff --git a/test/doctor-settings.browser.test.ts b/test/doctor-settings.browser.test.ts index d33ff17c..c576f06a 100644 --- a/test/doctor-settings.browser.test.ts +++ b/test/doctor-settings.browser.test.ts @@ -3,8 +3,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { chromium, type Browser, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; -const PORT = 3196; - const REPORT = { platform: { environment: 'linux' }, summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 }, @@ -46,13 +44,13 @@ describe('Diagnostics panel in a real browser', () => { let page: Page; beforeAll(async () => { - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); browser = await chromium.launch({ headless: true }); // A controlling service worker can swallow requests before page.route() sees them, letting the // real /api/doctor (a forked Node process) answer instead; block it so the stub is reliable. page = await (await browser.newContext({ serviceWorkers: 'block' })).newPage(); - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); await page.evaluate(() => (window as any).app.openAppSettings()); }, 90000); diff --git a/test/edge-cases.test.ts b/test/edge-cases.test.ts index 576b442a..a3724705 100644 --- a/test/edge-cases.test.ts +++ b/test/edge-cases.test.ts @@ -4,7 +4,6 @@ import { join } from 'node:path'; import { homedir } from 'node:os'; import { safeRmHomeTree } from './mocks/index.js'; -const TEST_PORT = 3110; const CASES_DIR = join(homedir(), 'codeman-cases'); describe('Edge Cases and Error Handling', () => { @@ -13,9 +12,9 @@ describe('Edge Cases and Error Handling', () => { const createdCases: string[] = []; beforeAll(async () => { - server = new WebServer(TEST_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterEach(() => { @@ -263,9 +262,9 @@ describe('Concurrent Session Handling', () => { let baseUrl: string; beforeAll(async () => { - server = new WebServer(TEST_PORT + 1, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT + 1}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { @@ -360,9 +359,9 @@ describe('API Request Validation', () => { let baseUrl: string; beforeAll(async () => { - server = new WebServer(TEST_PORT + 2, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT + 2}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { diff --git a/test/file-link-click.test.ts b/test/file-link-click.test.ts index 0cdd745a..17577e18 100644 --- a/test/file-link-click.test.ts +++ b/test/file-link-click.test.ts @@ -4,7 +4,7 @@ * Tests that file paths displayed in terminal output are clickable * and open the log viewer window correctly. * - * Port allocation: 3154 (see CLAUDE.md test port table) + * Port allocation: ephemeral port */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -14,8 +14,7 @@ import { writeFileSync, mkdirSync, rmSync, appendFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -const TEST_PORT = 3154; -const baseUrl = `http://localhost:${TEST_PORT}`; +let baseUrl: string; const BROWSER_TIMEOUT = 30000; // Helper to run agent-browser commands @@ -97,8 +96,9 @@ describe('File Link Click Tests', () => { testLogFile = join(testDir, 'test.log'); writeFileSync(testLogFile, '=== Test Log Started ===\n'); - server = new WebServer(TEST_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); + baseUrl = `http://localhost:${server.boundPort}`; await new Promise((r) => setTimeout(r, 1000)); // Test if browser is available diff --git a/test/git-status.browser.test.ts b/test/git-status.browser.test.ts index e86b5953..e8c06e9b 100644 --- a/test/git-status.browser.test.ts +++ b/test/git-status.browser.test.ts @@ -7,7 +7,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { chromium, type Browser, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; -const PORT = 3192; const ENV = { ...process.env, GIT_AUTHOR_NAME: 'T', @@ -85,7 +84,7 @@ describe('Git status indicator in a real browser', () => { write('a.txt', '2\n'); write('new file.txt', 'n\n'); - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); browser = await chromium.launch({ headless: true }); page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); @@ -96,7 +95,7 @@ describe('Git status indicator in a real browser', () => { page.on('response', (r) => { if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status()); }); - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); repoSession = await createSession(repo); plainSession = await createSession(plain); diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts index 502a21b2..9077fc5d 100644 --- a/test/hooks-config.test.ts +++ b/test/hooks-config.test.ts @@ -782,21 +782,19 @@ describe('subagent stop guard helper', () => { }); // ========== Hook Event API Integration Tests ========== -// Port 3130 reserved for hooks integration tests +// Hooks integration tests use an ephemeral port import { WebServer } from '../src/web/server.js'; -const TEST_PORT = 3130; - describe('Hook Event API', () => { let server: WebServer; let baseUrl: string; let testSessionId: string; beforeAll(async () => { - server = new WebServer(TEST_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; // Create a test session const createRes = await fetch(`${baseUrl}/api/sessions`, { @@ -976,9 +974,9 @@ describe('Hook Data Sanitization', () => { let testSessionId: string; beforeAll(async () => { - server = new WebServer(TEST_PORT + 1, false, true); // Port 3131 + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT + 1}`; + baseUrl = `http://localhost:${server.boundPort}`; // Create a test session const createRes = await fetch(`${baseUrl}/api/sessions`, { diff --git a/test/http-contract.test.ts b/test/http-contract.test.ts index 11dcdd60..55e68594 100644 --- a/test/http-contract.test.ts +++ b/test/http-contract.test.ts @@ -11,15 +11,14 @@ import { flattenOwnerSessionOrder, type TabLayout } from '../src/tab-layout.js'; import { WebServer } from '../src/web/server.js'; import { SseEvent } from '../src/web/sse-events.js'; -const PORT = 3168; - describe('Stable HTTP contract (live server)', () => { let server: WebServer; - const base = `http://localhost:${PORT}`; + let base: string; beforeAll(async () => { - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); + base = `http://localhost:${server.boundPort}`; }); afterAll(async () => { diff --git a/test/inline-rename.test.ts b/test/inline-rename.test.ts index aa8c7c17..aeecb6f5 100644 --- a/test/inline-rename.test.ts +++ b/test/inline-rename.test.ts @@ -13,18 +13,14 @@ * Strategy: stub a synthetic .tab-name node and a fake session entry, then * drive the rename function directly via page.evaluate(). No real PTY/tmux. * - * Ports: 3164, plus 3165 and 3192 for the two server-backed describes below - * (per MEMORY.md, ports 3150+ for tests) + * Ports: ephemeral, for this and the two server-backed describes below */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { chromium, type Browser, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; -const PORT = 3164; -const ORDERING_PORT = 3165; -const LONG_PREFIX_PORT = 3192; -const BASE_URL = `http://localhost:${PORT}`; +let baseUrl: string; describe('Inline rename input', () => { let server: WebServer; @@ -32,11 +28,12 @@ describe('Inline rename input', () => { let page: Page; beforeAll(async () => { - server = new WebServer(PORT, false, true); // testMode = true + server = new WebServer(0, false, true); // testMode = true await server.start(); + baseUrl = `http://localhost:${server.boundPort}`; browser = await chromium.launch({ headless: true }); page = await browser.newPage(); - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); // Wait for app.js to expose window.app and finish constructor init. await page.waitForFunction( () => @@ -667,11 +664,11 @@ describe('Inline rename write ordering', () => { type Pending = { body: string; resolve: (response: Response) => void }; beforeAll(async () => { - server = new WebServer(ORDERING_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); browser = await chromium.launch({ headless: true }); page = await browser.newPage(); - await page.goto(`http://localhost:${ORDERING_PORT}`, { waitUntil: 'domcontentloaded' }); + await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); await page.waitForFunction( () => typeof (window as { app?: unknown }).app !== 'undefined' && @@ -1010,14 +1007,13 @@ describe('Inline rename write ordering', () => { describe('Vertical rail rename editor with a long prefix', () => { let server: WebServer; let browser: Browser; - const port = LONG_PREFIX_PORT; const NAME = 'w3-this_is_a_very_long_valid_prefix: charlie'; let sessionId = ''; beforeAll(async () => { - server = new WebServer(port, false, true); + server = new WebServer(0, false, true); await server.start(); - const res = await fetch(`http://localhost:${port}/api/sessions`, { + const res = await fetch(`http://localhost:${server.boundPort}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: NAME, mode: 'shell' }), @@ -1048,7 +1044,7 @@ describe('Vertical rail rename editor with a long prefix', () => { settings ); const page = await context.newPage(); - await page.goto(`http://localhost:${port}`, { waitUntil: 'domcontentloaded' }); + await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); // One #sessionTabs list, moved into the rail or the sidebar by the layout. const row = page.locator(`#sessionTabs .session-tab[data-id="${sessionId}"]`); await row.waitFor({ state: 'visible', timeout: 15000 }); diff --git a/test/integration-flows.test.ts b/test/integration-flows.test.ts index 2d38aa89..9b78c015 100644 --- a/test/integration-flows.test.ts +++ b/test/integration-flows.test.ts @@ -4,7 +4,6 @@ import { join } from 'node:path'; import { homedir } from 'node:os'; import { safeRmHomeTree } from './mocks/index.js'; -const TEST_PORT = 3115; const CASES_DIR = join(homedir(), 'codeman-cases'); /** @@ -18,9 +17,9 @@ describe('Integration Flows', () => { const createdSessions: string[] = []; beforeAll(async () => { - server = new WebServer(TEST_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterEach(() => { @@ -282,9 +281,9 @@ describe('SSE Event Flow', () => { const createdSessions: string[] = []; beforeAll(async () => { - server = new WebServer(TEST_PORT + 1, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT + 1}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { diff --git a/test/key-tester.browser.test.ts b/test/key-tester.browser.test.ts index edc6eb5e..d6824c87 100644 --- a/test/key-tester.browser.test.ts +++ b/test/key-tester.browser.test.ts @@ -3,19 +3,17 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { chromium, type Browser, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; -const PORT = 3194; - describe('Key tester in a real browser', () => { let server: WebServer; let browser: Browser; let page: Page; beforeAll(async () => { - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); browser = await chromium.launch({ headless: true }); page = await browser.newPage(); - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); await page.evaluate(() => (window as any).app.openAppSettings()); await page.focus('#keyTesterInput'); diff --git a/test/opencode-resize.test.ts b/test/opencode-resize.test.ts index 5a2cb696..6dac0007 100644 --- a/test/opencode-resize.test.ts +++ b/test/opencode-resize.test.ts @@ -6,7 +6,7 @@ * - Close modal shows "Kill Tmux & OpenCode" (not "Claude Code") * - needsRefresh handler sends resize * - * Port: 3211 (opencode UI tests) + * Port: ephemeral * * Run: npx vitest run test/opencode-resize.test.ts */ @@ -16,8 +16,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; -const PORT = 3211; -const BASE_URL = `http://localhost:${PORT}`; +let baseUrl: string; const HAS_OPENCODE = (() => { try { @@ -40,15 +39,16 @@ async function freshPage(): Promise<{ context: BrowserContext; page: Page }> { } async function navigateAndWait(page: Page): Promise { - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 5000, }); } beforeAll(async () => { - server = new WebServer(PORT, false, true); // testMode + server = new WebServer(0, false, true); // testMode await server.start(); + baseUrl = `http://localhost:${server.boundPort}`; browser = await chromium.launch({ headless: true }); }, 30_000); diff --git a/test/operation-lightspeed.test.ts b/test/operation-lightspeed.test.ts index 2f59cdb1..0e93da5c 100644 --- a/test/operation-lightspeed.test.ts +++ b/test/operation-lightspeed.test.ts @@ -15,7 +15,6 @@ import { WebServer } from '../src/web/server.js'; import { safeRmHomeTree } from './mocks/index.js'; import { homedir } from 'node:os'; import { join } from 'node:path'; -const TEST_PORT = 3215; // Helper to parse SSE events from raw text function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> { @@ -106,9 +105,9 @@ describe('Operation Lightspeed', () => { let baseUrl: string; beforeAll(async () => { - server = new WebServer(TEST_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { diff --git a/test/ownership-scoping.test.ts b/test/ownership-scoping.test.ts index aa6ad7df..986e9500 100644 --- a/test/ownership-scoping.test.ts +++ b/test/ownership-scoping.test.ts @@ -1,5 +1,5 @@ /** - * @fileoverview Phase 3 ownership-scoping tests (live server, port 3172). + * @fileoverview Phase 3 ownership-scoping tests (live server, ephemeral port). * * Verifies multi-user isolation at the API level: case lists are disjoint per user, * a non-admin cannot read/kill another user's session, workingDir confinement + @@ -17,14 +17,13 @@ import { canAccessOwned, findSessionOrFail, sessionCapacityState } from '../src/ vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true); -const PORT = 3172; const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64'); let server: WebServer; let dataDir: string; let spacesDir: string; const saved: Record = {}; -const url = (p: string) => `http://localhost:${PORT}${p}`; +const url = (p: string) => `http://localhost:${server.boundPort}${p}`; // Route returns are wrapped in the {success,data} envelope; unwrap to the payload. async function getJson(p: string, headers: Record): Promise { @@ -58,7 +57,7 @@ beforeAll(async () => { await createUser({ username: 'alice', role: 'user', password: 'alicepass1' }); await createUser({ username: 'bob', role: 'user', password: 'bobpass1234' }); - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); }); diff --git a/test/pane-exit-sweep.test.ts b/test/pane-exit-sweep.test.ts index 6839e846..d9aed019 100644 --- a/test/pane-exit-sweep.test.ts +++ b/test/pane-exit-sweep.test.ts @@ -18,7 +18,7 @@ * 4. **Only local mux-backed sessions.** A remote session's local pane is its * ssh client, whose death may be a transport drop. * - * Port: 3189 + * Port: ephemeral */ import { afterEach, describe, expect, it, vi } from 'vitest'; import { Session } from '../src/session.js'; @@ -33,7 +33,6 @@ import { shouldCloseCleanlyExitedSession, } from '../src/pane-exit-sweep.js'; -const PORT = 3189; const AT = 1_700_000_000_000; describe('isCleanPaneExit', () => { @@ -144,7 +143,7 @@ describe('the sweep on a real server', () => { /** A server whose mux reports `exit` for every pane, seen by `reads` reads. */ const build = (exit: PaneExit | undefined, reads: number) => { - const web = new WebServer(PORT, false, true); + const web = new WebServer(0, false, true); server = web; const mux = (web as unknown as { mux: Record }).mux; const state = { exit, reads }; @@ -245,7 +244,7 @@ describe('the sweep on a real server', () => { it('removes the session for real through cleanupSession', async () => { // No spy on cleanupSession here: the close runs the same path as the X // button, and the session leaves the server's map. - const web = new WebServer(PORT, false, true); + const web = new WebServer(0, false, true); server = web; const mux = (web as unknown as { mux: Record }).mux; mux.getPaneExit = () => ({ status: 0, at: AT }); @@ -276,7 +275,7 @@ describe('a session the server is closing', () => { }); it('is cleared again when the server gives the close up', async () => { - const web = new WebServer(PORT, false, true); + const web = new WebServer(0, false, true); try { const s = session(); const sessions = (web as unknown as { sessions: Map }).sessions; diff --git a/test/paste-image-dir-shared.test.ts b/test/paste-image-dir-shared.test.ts index 1adc5a53..3ac66e93 100644 --- a/test/paste-image-dir-shared.test.ts +++ b/test/paste-image-dir-shared.test.ts @@ -9,7 +9,7 @@ * closes sessions unattended, which turns that from an occasional loss into a * routine one. * - * Port: 3188 + * Port: ephemeral */ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -18,8 +18,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { WebServer } from '../src/web/server.js'; import { pasteImageDirInUseByOtherSession } from '../src/web/paste-image-gc.js'; -const PORT = 3188; - describe('pasteImageDirInUseByOtherSession', () => { const none = new Set(); const check = ( @@ -108,12 +106,13 @@ describe('pasteImageDirInUseByOtherSession', () => { describe('deleting a session that shares its working directory', () => { let server: WebServer; let workingDir: string; - const base = `http://localhost:${PORT}`; + let base: string; beforeAll(async () => { workingDir = mkdtempSync(join(tmpdir(), 'codeman-paste-shared-')); - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); + base = `http://localhost:${server.boundPort}`; }); afterAll(async () => { diff --git a/test/perf-browser.test.ts b/test/perf-browser.test.ts index 0c5a2c92..9a2a4ed0 100644 --- a/test/perf-browser.test.ts +++ b/test/perf-browser.test.ts @@ -5,7 +5,7 @@ * terminal input latency, subagent window management, settings modal, * SSE connection speed, and rendering under load. * - * Port: 3210 (perf-browser tests) + * Port: ephemeral * * Run: npx vitest run test/perf-browser.test.ts */ @@ -14,8 +14,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; -const PORT = 3210; -const BASE_URL = `http://localhost:${PORT}`; +let baseUrl: string; // Thresholds (ms) const THRESHOLDS = { @@ -53,7 +52,7 @@ async function freshPage(): Promise<{ context: BrowserContext; page: Page }> { } async function navigateAndWait(page: Page): Promise { - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); // Wait for app.js to initialize (app-loaded class added to body) await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 5000, @@ -120,8 +119,9 @@ async function getHeapMB(page: Page): Promise { // ─── Setup / Teardown ───────────────────────────────────── beforeAll(async () => { - server = new WebServer(PORT, false, true); // testMode + server = new WebServer(0, false, true); // testMode await server.start(); + baseUrl = `http://localhost:${server.boundPort}`; browser = await chromium.launch({ headless: true }); }, 30_000); @@ -144,7 +144,7 @@ describe('Page load performance', () => { ({ context, page } = await freshPage()); const wallTiming = await measure(async () => { - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); }); const navigationTiming = await getBrowserNavigationTiming(page); @@ -158,7 +158,7 @@ describe('Page load performance', () => { ({ context, page } = await freshPage()); const timing = await measure(async () => { - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 5000, }); @@ -190,7 +190,7 @@ describe('Page load performance', () => { it('loading skeleton disappears after init', async () => { ({ context, page } = await freshPage()); - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); // Skeleton should be visible initially const skeletonVisible = await page.locator('.loading-skeleton').isVisible(); diff --git a/test/quick-start.test.ts b/test/quick-start.test.ts index 3ff346f5..8daeff46 100644 --- a/test/quick-start.test.ts +++ b/test/quick-start.test.ts @@ -4,7 +4,6 @@ import { existsSync, rmSync, mkdirSync, mkdtempSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -const TEST_PORT = 3299; const ORIGINAL_HOME = process.env.HOME; const TEST_HOME = mkdtempSync(join(tmpdir(), 'codeman-quick-start-')); const CASES_DIR = join(TEST_HOME, 'codeman-cases'); @@ -12,10 +11,10 @@ let webServerModule: Promise | undefined; process.env.HOME = TEST_HOME; -async function createTestServer(port: number): Promise { +async function createTestServer(): Promise { webServerModule ??= import('../src/web/server.js'); const { WebServer: TestWebServer } = await webServerModule; - return new TestWebServer(port, false, true); + return new TestWebServer(0, false, true); } afterAll(() => { @@ -30,9 +29,9 @@ describe('Quick Start API', () => { const createdCases: string[] = []; beforeAll(async () => { - server = await createTestServer(TEST_PORT); + server = await createTestServer(); await server.start(); - baseUrl = `http://localhost:${TEST_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }, 30000); afterEach(() => { @@ -164,9 +163,9 @@ describe('Session Management', () => { let baseUrl: string; beforeAll(async () => { - server = await createTestServer(TEST_PORT + 1); + server = await createTestServer(); await server.start(); - baseUrl = `http://localhost:${TEST_PORT + 1}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { @@ -223,9 +222,9 @@ describe('Case Management', () => { const createdCases: string[] = []; beforeAll(async () => { - server = await createTestServer(TEST_PORT + 2); + server = await createTestServer(); await server.start(); - baseUrl = `http://localhost:${TEST_PORT + 2}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { @@ -339,9 +338,9 @@ describe('Agent skill injection (agentSkillEnabled)', () => { const createdCases: string[] = []; beforeAll(async () => { - server = await createTestServer(TEST_PORT + 4); // 3103 + server = await createTestServer(); await server.start(); - baseUrl = `http://localhost:${TEST_PORT + 4}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { diff --git a/test/ralph-integration.test.ts b/test/ralph-integration.test.ts index 597c03e0..09c4eff9 100644 --- a/test/ralph-integration.test.ts +++ b/test/ralph-integration.test.ts @@ -8,7 +8,7 @@ * - Todo item detection and management * - Ralph configuration via API * - * Test port: 3125 + * Test port: ephemeral */ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; @@ -17,7 +17,6 @@ import { join } from 'node:path'; import { homedir } from 'node:os'; import { safeRmHomeTree } from './mocks/index.js'; -const TEST_PORT = 3125; const CASES_DIR = join(homedir(), 'codeman-cases'); describe('Ralph Integration Tests', () => { @@ -27,9 +26,9 @@ describe('Ralph Integration Tests', () => { const createdSessions: string[] = []; beforeAll(async () => { - server = new WebServer(TEST_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterEach(() => { diff --git a/test/scheduled-runs.test.ts b/test/scheduled-runs.test.ts index 4e1ee55d..f136e8c1 100644 --- a/test/scheduled-runs.test.ts +++ b/test/scheduled-runs.test.ts @@ -1,17 +1,15 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { WebServer } from '../src/web/server.js'; -const TEST_PORT = 3105; - describe('Scheduled Runs API', () => { let server: WebServer; let baseUrl: string; const createdRuns: string[] = []; beforeAll(async () => { - server = new WebServer(TEST_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { @@ -181,9 +179,9 @@ describe('Quick Run API', () => { let baseUrl: string; beforeAll(async () => { - server = new WebServer(TEST_PORT + 1, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT + 1}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { diff --git a/test/security-regression.test.ts b/test/security-regression.test.ts index 099d4648..60e07e87 100644 --- a/test/security-regression.test.ts +++ b/test/security-regression.test.ts @@ -11,7 +11,7 @@ * - text/plain bodies kept RAW (the closed "simple request" CSRF vector) * - WebSocket anti-CSWSH (Origin/Host validated on upgrade → close 4003) * - * Port: 3167 + * Port: ephemeral */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import http from 'node:http'; @@ -19,8 +19,6 @@ import WebSocket from 'ws'; import { WebServer } from '../src/web/server.js'; import { TmuxManager } from '../src/tmux-manager.js'; -const PORT = 3167; - vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true); interface RawResponse { @@ -32,7 +30,7 @@ interface RawResponse { /** Raw HTTP request with full control over Host/Origin headers (fetch/undici rewrites Host). */ function raw(method: string, path: string, headers: Record = {}, body?: string): Promise { return new Promise((resolve, reject) => { - const req = http.request({ host: '127.0.0.1', port: PORT, path, method, headers }, (res) => { + const req = http.request({ host: '127.0.0.1', port: server.boundPort, path, method, headers }, (res) => { let data = ''; res.on('data', (c) => (data += c)); res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: data })); @@ -46,7 +44,7 @@ function raw(method: string, path: string, headers: Record = {}, /** Open a WS to `path` with optional Origin and resolve with the close code the server sends. */ function wsCloseCode(path: string, origin?: string): Promise { return new Promise((resolve, reject) => { - const ws = new WebSocket(`ws://127.0.0.1:${PORT}${path}`, { + const ws = new WebSocket(`ws://127.0.0.1:${server.boundPort}${path}`, { headers: origin ? { origin } : {}, }); const timer = setTimeout(() => { @@ -71,7 +69,7 @@ let server: WebServer; beforeAll(async () => { delete process.env.CODEMAN_PASSWORD; // guard must work even on the no-auth default - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); }); @@ -87,7 +85,7 @@ describe('Host-header allowlist (anti DNS-rebinding), wired', () => { }); it('allows a loopback Host', async () => { - const res = await raw('GET', '/api/status', { Host: `localhost:${PORT}` }); + const res = await raw('GET', '/api/status', { Host: `localhost:${server.boundPort}` }); expect(res.status).toBe(200); }); @@ -114,7 +112,7 @@ describe('cross-site Origin / CSRF guard, wired', () => { }); it('allows a state-changing request from a same-site Origin', async () => { - const res = await raw('POST', PROBE, { Origin: `http://localhost:${PORT}` }); + const res = await raw('POST', PROBE, { Origin: `http://localhost:${server.boundPort}` }); expect(res.status).not.toBe(403); }); diff --git a/test/session-cleanup.test.ts b/test/session-cleanup.test.ts index e7676729..8161bd49 100644 --- a/test/session-cleanup.test.ts +++ b/test/session-cleanup.test.ts @@ -5,7 +5,6 @@ import { join } from 'node:path'; import { homedir, tmpdir } from 'node:os'; import { safeRmHomeTree } from './mocks/index.js'; -const TEST_PORT = 3120; const CASES_DIR = join(homedir(), 'codeman-cases'); /** @@ -22,9 +21,9 @@ describe('Session Cleanup', () => { beforeAll(async () => { testWorkingDir = mkdtempSync(join(tmpdir(), 'codeman-cleanup-test-')); - server = new WebServer(TEST_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterEach(() => { @@ -218,9 +217,9 @@ describe('Resource Management', () => { const createdCases: string[] = []; beforeAll(async () => { - server = new WebServer(TEST_PORT + 1, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT + 1}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { diff --git a/test/session-pane-exit.test.ts b/test/session-pane-exit.test.ts index afa4f623..c8167836 100644 --- a/test/session-pane-exit.test.ts +++ b/test/session-pane-exit.test.ts @@ -24,7 +24,7 @@ * server and the persisted record is the only thing left that can say the * agent was already gone. * - * Port: 3187 + * Port: ephemeral */ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -37,8 +37,6 @@ import type { PaneExit, SessionRemote, SessionDocker, SessionState } from '../sr import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js'; import { hasObservablePaneSession } from '../src/tmux-manager.js'; -const PORT = 3187; - const EXIT: PaneExit = { status: 0, at: 1_700_000_000_000 }; /** The two mux members `Session` reads when it decides whether the field applies. */ @@ -294,7 +292,7 @@ describe('a pane read reaching the session record', () => { }); const build = () => { - const web = new WebServer(PORT, false, true); + const web = new WebServer(0, false, true); const mux = (web as unknown as { mux: Record }).mux; const session = localMuxSession(); (web as unknown as { sessions: Map }).sessions.set(session.id, session); diff --git a/test/session.test.ts b/test/session.test.ts index 6d8fe084..1eeaf591 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -2,8 +2,6 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { mkdirSync } from 'node:fs'; import { WebServer } from '../src/web/server.js'; -const TEST_PORT = 3102; - describe('Interactive Session Lifecycle', () => { let server: WebServer; let baseUrl: string; @@ -12,9 +10,9 @@ describe('Interactive Session Lifecycle', () => { // The 'custom working directory' test creates a session in /tmp/test; workingDir // validation requires the dir to exist, so ensure it does (idempotent, CI-safe). mkdirSync('/tmp/test', { recursive: true }); - server = new WebServer(TEST_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { diff --git a/test/shift-enter-keypress.browser.test.ts b/test/shift-enter-keypress.browser.test.ts index 2a22b2a4..bf87a603 100644 --- a/test/shift-enter-keypress.browser.test.ts +++ b/test/shift-enter-keypress.browser.test.ts @@ -18,15 +18,14 @@ * The CI gate's guard on the same handlers is the source check in * test/shift-enter-keypress-swallowed.test.ts. * - * Port: 3267 (per CLAUDE.md, ports 3150+ for tests) + * Port: ephemeral */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { chromium, type Browser, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; -const PORT = 3267; -const BASE_URL = `http://localhost:${PORT}`; +let baseUrl: string; const MAIN_ID = 'shift-enter-probe-main'; const PANE_B_ID = 'shift-enter-probe-pane-b'; @@ -45,11 +44,12 @@ describe('Shift/Ctrl+Enter through the shipped key handlers (keypress must be sw let page: Page; beforeAll(async () => { - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); + baseUrl = `http://localhost:${server.boundPort}`; browser = await chromium.launch({ headless: true }); page = await browser.newPage(); - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); await page.evaluate( ({ paneBId }) => { diff --git a/test/split-pane-auto-collapse.browser.test.ts b/test/split-pane-auto-collapse.browser.test.ts index c321df56..5215c7bb 100644 --- a/test/split-pane-auto-collapse.browser.test.ts +++ b/test/split-pane-auto-collapse.browser.test.ts @@ -4,8 +4,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { chromium, type Browser, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; -const PORT = 3177; -const BASE_URL = `http://localhost:${PORT}`; +let baseUrl: string; describe('split-pane auto-collapse in a real browser', () => { let server: WebServer; @@ -13,11 +12,12 @@ describe('split-pane auto-collapse in a real browser', () => { let page: Page; beforeAll(async () => { - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); + baseUrl = `http://localhost:${server.boundPort}`; browser = await chromium.launch({ headless: true }); page = await browser.newPage(); - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); }, 90000); diff --git a/test/split-pane-orchestration.browser.test.ts b/test/split-pane-orchestration.browser.test.ts index a8c3190d..aa084f1d 100644 --- a/test/split-pane-orchestration.browser.test.ts +++ b/test/split-pane-orchestration.browser.test.ts @@ -3,8 +3,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { chromium, type Browser, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; -const PORT = 3176; -const BASE_URL = `http://localhost:${PORT}`; +let baseUrl: string; describe('split-pane orchestration in a real browser', () => { let server: WebServer; @@ -12,11 +11,12 @@ describe('split-pane orchestration in a real browser', () => { let page: Page; beforeAll(async () => { - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); + baseUrl = `http://localhost:${server.boundPort}`; browser = await chromium.launch({ headless: true }); page = await browser.newPage(); - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); }, 90000); diff --git a/test/split-pane-terminal.browser.test.ts b/test/split-pane-terminal.browser.test.ts index 5463cef4..c62b14fd 100644 --- a/test/split-pane-terminal.browser.test.ts +++ b/test/split-pane-terminal.browser.test.ts @@ -3,8 +3,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { chromium, type Browser, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; -const PORT = 3175; -const BASE_URL = `http://localhost:${PORT}`; +let baseUrl: string; describe('TerminalTile in a real browser', () => { let server: WebServer; @@ -12,11 +11,12 @@ describe('TerminalTile in a real browser', () => { let page: Page; beforeAll(async () => { - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); + baseUrl = `http://localhost:${server.boundPort}`; browser = await chromium.launch({ headless: true }); page = await browser.newPage(); - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); }, 90000); diff --git a/test/sse-cors-headers.test.ts b/test/sse-cors-headers.test.ts index f0edc206..3a2bf759 100644 --- a/test/sse-cors-headers.test.ts +++ b/test/sse-cors-headers.test.ts @@ -17,7 +17,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { WebServer } from '../src/web/server.js'; -const TEST_PORT = 3119; const LOCAL_ORIGIN = 'http://localhost:5173'; /** Open /api/events, read the response headers, then abort — it never ends on its own. */ @@ -42,9 +41,9 @@ describe('GET /api/events header inheritance', () => { let baseUrl: string; beforeAll(async () => { - server = new WebServer(TEST_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { diff --git a/test/sse-events.test.ts b/test/sse-events.test.ts index d602880e..71fecf37 100644 --- a/test/sse-events.test.ts +++ b/test/sse-events.test.ts @@ -5,8 +5,6 @@ import { safeRmHomeTree } from './mocks/index.js'; import { homedir } from 'node:os'; import { join } from 'node:path'; -const TEST_PORT = 3107; - // Helper to parse SSE events function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> { const events: Array<{ event: string; data: unknown }> = []; @@ -40,9 +38,9 @@ describe('SSE Events', () => { let baseUrl: string; beforeAll(async () => { - server = new WebServer(TEST_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { @@ -186,9 +184,9 @@ describe('SSE Event Types', () => { let baseUrl: string; beforeAll(async () => { - server = new WebServer(TEST_PORT + 1, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT + 1}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { diff --git a/test/sse-routing-remote.test.ts b/test/sse-routing-remote.test.ts index ff0a3e09..e62f12da 100644 --- a/test/sse-routing-remote.test.ts +++ b/test/sse-routing-remote.test.ts @@ -13,7 +13,7 @@ import { WebServer } from '../src/web/server.js'; type Hint = { owner?: string; username?: string; adminOnly?: boolean; sessionScoped?: boolean } | undefined; function hintFor(event: string, payload: Record, owners: Record = {}): Hint { - const server = new WebServer(3999, false, true) as unknown as { + const server = new WebServer(0, false, true) as unknown as { sessions: Map; deriveSseHint(event: string, data: unknown): Hint; }; diff --git a/test/sse-subscription-filter.test.ts b/test/sse-subscription-filter.test.ts index 54514785..e809c91b 100644 --- a/test/sse-subscription-filter.test.ts +++ b/test/sse-subscription-filter.test.ts @@ -4,8 +4,6 @@ import { safeRmHomeTree } from './mocks/index.js'; import { homedir } from 'node:os'; import { join } from 'node:path'; -const TEST_PORT = 3212; - // Helper to parse SSE events from raw text function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> { const events: Array<{ event: string; data: unknown }> = []; @@ -76,9 +74,9 @@ describe('SSE Subscription Filtering', () => { let baseUrl: string; beforeAll(async () => { - server = new WebServer(TEST_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { diff --git a/test/static-cache-headers.test.ts b/test/static-cache-headers.test.ts index 99ca6f6d..eec9a2fc 100644 --- a/test/static-cache-headers.test.ts +++ b/test/static-cache-headers.test.ts @@ -22,8 +22,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { WebServer } from '../src/web/server.js'; -const TEST_PORT = 3183; - /** * Fetch and fully drain the body. Both halves matter for teardown: an unconsumed * body leaves undici holding the socket, and a pooled keep-alive socket makes @@ -40,9 +38,9 @@ describe('static asset Cache-Control headers', () => { let baseUrl: string; beforeAll(async () => { - server = new WebServer(TEST_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TEST_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { diff --git a/test/terminal-copy-shortcut.test.ts b/test/terminal-copy-shortcut.test.ts index f719ed3c..a83692d5 100644 --- a/test/terminal-copy-shortcut.test.ts +++ b/test/terminal-copy-shortcut.test.ts @@ -14,15 +14,14 @@ * Browser-driven, so it is excluded from `npm run test:ci` like the other * Playwright suites. Run locally: npm run test:browser -- test/terminal-copy-shortcut.test.ts * - * Port: 3174 (per MEMORY.md, ports 3150+ for tests) + * Port: ephemeral */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { chromium, type Browser, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; -const PORT = 3174; -const BASE_URL = `http://localhost:${PORT}`; +let baseUrl: string; const IME_PUNCTUATION = ',。!?;:“”、《》、()'; describe('terminal Ctrl+C smart copy', () => { @@ -31,12 +30,13 @@ describe('terminal Ctrl+C smart copy', () => { let page: Page; beforeAll(async () => { - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); + baseUrl = `http://localhost:${server.boundPort}`; browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] }); page = await context.newPage(); - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); // The first write after load can be dropped while the app finishes wiring // its render pipeline, so poll until one really lands in the buffer. @@ -226,7 +226,7 @@ describe('terminal Ctrl+C smart copy', () => { const touchContext = await browser.newContext({ hasTouch: true }); try { const touchPage = await touchContext.newPage(); - await touchPage.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await touchPage.goto(baseUrl, { waitUntil: 'domcontentloaded' }); const touchChunks = await captureImeInput(touchPage); expect(touchChunks.join('')).toBe('中文' + IME_PUNCTUATION); } finally { diff --git a/test/terminal-keycode229-recovery.browser.test.ts b/test/terminal-keycode229-recovery.browser.test.ts index c8e78dbe..c17186b1 100644 --- a/test/terminal-keycode229-recovery.browser.test.ts +++ b/test/terminal-keycode229-recovery.browser.test.ts @@ -20,15 +20,14 @@ * Playwright suites. Run locally: * npm run test:browser -- test/terminal-keycode229-recovery.browser.test.ts * - * Port: 3186 (per CLAUDE.md, ports 3150+ for tests) + * Port: ephemeral */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { chromium, type Browser, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; -const PORT = 3186; -const BASE_URL = `http://localhost:${PORT}`; +let baseUrl: string; describe('orphaned terminal input recovery wiring', () => { let server: WebServer; @@ -36,11 +35,12 @@ describe('orphaned terminal input recovery wiring', () => { let page: Page; beforeAll(async () => { - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); + baseUrl = `http://localhost:${server.boundPort}`; browser = await chromium.launch({ headless: true }); page = await browser.newPage(); - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); await page.waitForFunction(() => (window as any).app?._keyCode229Recovery, null, { timeout: 30000 }); }, 90000); diff --git a/test/webgl-fallback.test.ts b/test/webgl-fallback.test.ts index 1a8b6514..ac999d9c 100644 --- a/test/webgl-fallback.test.ts +++ b/test/webgl-fallback.test.ts @@ -18,15 +18,14 @@ * the trip math is pure and the dispose path is a couple of property * mutations, both of which run on any page where app.js loaded. * - * Port: 3166 (per MEMORY.md, ports 3150+ for tests) + * Port: ephemeral */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { chromium, type Browser, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; -const PORT = 3166; -const BASE_URL = `http://localhost:${PORT}`; +let baseUrl: string; describe('WebGL longtask auto-fallback', () => { let server: WebServer; @@ -34,11 +33,12 @@ describe('WebGL longtask auto-fallback', () => { let page: Page; beforeAll(async () => { - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); + baseUrl = `http://localhost:${server.boundPort}`; browser = await chromium.launch({ headless: true }); page = await browser.newPage(); - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); // Wait for constants.js + app.js to have loaded — both expose globals. await page.waitForFunction( () => diff --git a/test/webhook-settings.browser.test.ts b/test/webhook-settings.browser.test.ts index 7f1ddd98..484920ea 100644 --- a/test/webhook-settings.browser.test.ts +++ b/test/webhook-settings.browser.test.ts @@ -5,7 +5,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { chromium, type Browser, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; -const PORT = 3195; const SECRET = 'SUPERSECRET-topic-123'; describe('Webhook settings in a real browser', () => { @@ -30,11 +29,11 @@ describe('Webhook settings in a real browser', () => { await new Promise((r) => receiver.listen(0, '127.0.0.1', r)); receiverPort = (receiver.address() as AddressInfo).port; - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); browser = await chromium.launch({ headless: true }); page = await browser.newPage(); - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); await page.evaluate(() => (window as any).app.openAppSettings()); await page.waitForSelector('#webhookGroup', { state: 'attached' }); diff --git a/test/webview-lost-root-frame.test.ts b/test/webview-lost-root-frame.test.ts index a96425ca..e3fb90ca 100644 --- a/test/webview-lost-root-frame.test.ts +++ b/test/webview-lost-root-frame.test.ts @@ -12,12 +12,11 @@ * real WebServer in test mode for the passwordless one, where the index route * itself has to answer. * - * Port: 3198 + * Port: ephemeral */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { WebServer } from '../src/web/server.js'; -const PORT = 3198; const lostFrame = { 'sec-fetch-dest': 'iframe', 'sec-fetch-mode': 'navigate', accept: 'text/html,*/*;q=0.8' }; describe('landing-page reload of a proxied dashboard, passwordless install', () => { @@ -25,7 +24,7 @@ describe('landing-page reload of a proxied dashboard, passwordless install', () // eslint-disable-next-line @typescript-eslint/no-explicit-any let app: any; beforeAll(async () => { - server = new WebServer(PORT); + server = new WebServer(0); await server.start(); // eslint-disable-next-line @typescript-eslint/no-explicit-any app = (server as any).app; diff --git a/test/webview-sse.test.ts b/test/webview-sse.test.ts index bcc47cf9..4f7bbb8c 100644 --- a/test/webview-sse.test.ts +++ b/test/webview-sse.test.ts @@ -34,7 +34,7 @@ function client() { /** The server's real event → routing-hint derivation, without starting the server. */ function serverHint(event: string, data: unknown): SseRoutingHint | undefined { - const server = new WebServer(3999, false, true) as unknown as { + const server = new WebServer(0, false, true) as unknown as { deriveSseHint(event: string, data: unknown): SseRoutingHint | undefined; }; return server.deriveSseHint(event, data);