Merge pull request #570: test: bind every test server to an ephemeral port (#440, 2/2)

This commit is contained in:
Codeman maintainer
2026-10-10 02:53:27 +02:00
65 changed files with 465 additions and 508 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot
The browser suite also runs nightly (and on demand) in `.github/workflows/browser-suite.yml`; it is informational, not a gate.
If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port. Mobile tests (`test/mobile/**`, via `createTestServer(PORT)`) keep the fixed-port convention in `test/mobile/README.md` for now, because that helper caches servers by port. Never 3000.
If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; mobile tests call `createTestServer()` and read `server.boundPort`. `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port and a raw `listen` on a fixed one. Never 3000.
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
+1 -1
View File
@@ -12,6 +12,6 @@ Quick pointers:
- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check`
- Tests: `npm test` (the CI gate, safe to run bare) or `npm test -- test/<file>.test.ts` for one file
- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`), except mobile tests, which keep `createTestServer(PORT)` for now
- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`); mobile tests use `createTestServer()` and read `server.boundPort`
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
- Never commit secrets or local state from `~/.codeman/`
+2 -2
View File
@@ -436,7 +436,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`.
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`. New header buttons must stay off phones (`test/mobile-header-buttons-policy.test.ts`).
- **New test**: a test that constructs `WebServer` itself binds port 0 (`new WebServer(0, …)`, then `server.boundPort`; enforced by `test/test-ports-guard.test.ts`), and a raw Fastify/`ws` server listens on `port: 0` and reads `address().port`. Mobile tests (`test/mobile/**`, via `createTestServer(PORT)`) keep the fixed-port convention in `test/mobile/README.md` until the follow-up sweep, because that helper caches servers by port. Route tests use `app.inject()` (no port needed); see `test/routes/_route-test-utils.ts`.
- **New test**: a test that constructs `WebServer` itself binds port 0 (`new WebServer(0, …)`, then `server.boundPort`), and a raw `http`/`net`/Fastify/`ws` server listens on `0` and reads `address().port`; mobile tests get theirs from `createTestServer()` and read `boundPort` too. Both enforced by `test/test-ports-guard.test.ts`. Route tests use `app.inject()` (no port needed); see `test/routes/_route-test-utils.ts`.
**Validation**: Zod v4 (different API from v3). Define schemas in `schemas.ts`, use `.parse()`/`.safeParse()`.
@@ -475,7 +475,7 @@ Raw `npx vitest` skips the config (and with it `setup.ts`); always use `npm test
**Tmux safety**: under vitest (`VITEST`), `TmuxManager` no-ops ALL shell commands (`IS_TEST_MODE` in `src/tmux-manager.ts`), docker IO is no-op'd likewise, and `Session` spawns an echo PTY (`TEST_PTY_SCRIPT`) instead of attaching tmux. `test/setup.ts` gives each file a temp `HOME`/`USERPROFILE` and strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME`, `CODEMAN_GESTURE` and `CODEMAN_INSTANCE`/`CODEMAN_DATA_DIR`/`CODEMAN_TMUX_SOCKET` (pinned by `test/test-env-isolation.test.ts`). ⚠️ `CODEMAN_DATA_DIR` overrides the temp HOME, so never drop its strip; strip `CODEMAN_INSTANCE` in the setup file, never in a hook (captured at first import). ⚠️ Delete case trees only via `safeRmHomeTree()`. ⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and its isolation. → [architecture-invariants#test-isolation-tmux-docker-and-home](docs/architecture-invariants.md#test-isolation-tmux-docker-and-home)
**Ports**: a test that constructs `WebServer` binds port 0 and reads `server.boundPort`; `test/test-ports-guard.test.ts` fails any other port outside its shrink-only legacy list. A raw Fastify/`ws` server listens on `port: 0` and reads `address().port`. The mobile suite keeps fixed ports (3150+, per `test/mobile/README.md`) until the follow-up sweep, since `createTestServer()` caches servers by port. Never 3000 (the live instance).
**Ports**: every test binds port 0. A test that constructs `WebServer` reads `server.boundPort` (mobile tests: `createTestServer()`, then `server.boundPort`); a raw `http`/`net`/Fastify/`ws` server listens on `0` and reads `address().port`. `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port and a raw `listen` on a number or `…PORT` constant. A test that needs a port nothing listens on binds 0, reads it and closes (`closedPort()` in `test/daemon-control.test.ts`), never "the server's port + 1".
⚠️ **Browser tests can pass vacuously on mobile input paths.** Two traps, both hit on 2026-07-27 while fixing the phone Enter button: **(1)** driving input with `app.sendInput('…')` writes PAST the `LocalEchoOverlay`, so `pendingText` stays empty and any overlay bug is invisible — type with `page.keyboard.type()` instead; **(2)** headless Chromium reports `MobileDetection.isTouchDevice()` **false even with `hasTouch: true`**, so `_localEchoEnabled` is off and the local-echo branch never executes. Force it (`app._localEchoEnabled = true`) or the test proves nothing. Assert on real state (`app._localEchoOverlay.pendingText`, plus `tmux -L codeman capture-pane -p -t <pane>` for what actually reached the PTY), not on HTTP 200.
+2 -3
View File
@@ -63,9 +63,8 @@ provide what they need; that means "not runnable here", not a regression.
Tests are tmux-safe by design: under vitest the tmux layer becomes an in-memory mock, so
tests cannot touch real sessions. If you add a test that binds a port, bind port 0
(`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`),
or use `app.inject()` when no socket is needed. Never 3000. Mobile tests (`test/mobile/**`,
via `createTestServer(PORT)`) keep the fixed ports in `test/mobile/README.md` for now,
because that helper caches servers by port.
or use `app.inject()` when no socket is needed. Mobile tests call `createTestServer()` and
read `server.boundPort`. Never 3000.
## Finding your way around
+3 -4
View File
@@ -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();
});
+4 -4
View File
@@ -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) }));
});
+5 -5
View File
@@ -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<string> {
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
+5 -5
View File
@@ -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<n
}
async function openSession(page: Page): Promise<string> {
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.
+2 -4
View File
@@ -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);
+19 -6
View File
@@ -6,6 +6,7 @@
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import {
buildBaseUrl,
buildStatusUrl,
@@ -16,7 +17,17 @@ import {
probeServer,
} from '../src/daemon-control.js';
const PORT = 3216;
/**
* A port nothing listens on: bind 0, read what the OS handed out, close. Free at the
* moment of use, unlike "the server's port + 1", which anything may hold.
*/
async function closedPort(): Promise<number> {
const probe = http.createServer();
await new Promise<void>((resolve) => probe.listen(0, '127.0.0.1', resolve));
const { port: free } = probe.address() as AddressInfo;
await new Promise<void>((resolve) => probe.close(() => resolve()));
return free;
}
describe('buildWebArgs', () => {
it('always passes host and port through explicitly', () => {
@@ -143,6 +154,7 @@ describe('isProcessAlive', () => {
describe('probeServer', () => {
let server: http.Server;
let port: number;
beforeAll(async () => {
server = http.createServer((req, res) => {
@@ -157,7 +169,8 @@ describe('probeServer', () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, data: { version: '9.9.9' } }));
});
await new Promise<void>((resolve) => server.listen(PORT, '127.0.0.1', resolve));
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
port = (server.address() as AddressInfo).port;
});
afterAll(async () => {
@@ -165,23 +178,23 @@ describe('probeServer', () => {
});
it('reports up and reads the version back', async () => {
const result = await probeServer(`http://127.0.0.1:${PORT}/api/status`);
const result = await probeServer(`http://127.0.0.1:${port}/api/status`);
expect(result.up).toBe(true);
expect(result.version).toBe('9.9.9');
});
it('counts a 401 as up, because auth being active proves a server is there', async () => {
const result = await probeServer(`http://127.0.0.1:${PORT}/unauthorized`);
const result = await probeServer(`http://127.0.0.1:${port}/unauthorized`);
expect(result.up).toBe(true);
});
it('does not mistake an unrelated service squatting on the port for Codeman', async () => {
const result = await probeServer(`http://127.0.0.1:${PORT}/foreign`);
const result = await probeServer(`http://127.0.0.1:${port}/foreign`);
expect(result.up).toBe(false);
});
it('reports down when nothing is listening', async () => {
const result = await probeServer(`http://127.0.0.1:${PORT + 1}/api/status`, 1000);
const result = await probeServer(`http://127.0.0.1:${await closedPort()}/api/status`, 1000);
expect(result.up).toBe(false);
});
+7 -4
View File
@@ -16,6 +16,7 @@
import { describe, expect, it, beforeEach, beforeAll, afterAll } from 'vitest';
import { execFileSync, spawn } from 'node:child_process';
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, chmodSync } from 'node:fs';
import { dirname } from 'node:path';
import {
@@ -25,8 +26,6 @@ import {
DEEPSEEK_STATE_TO_HOOK_EVENT,
} from '../src/deepseek-status-shim.js';
const PORT = 3251;
describe('DeepSeek status shim: provisioning', () => {
beforeEach(() => {
resetDeepSeekStatusShimForTest();
@@ -73,6 +72,7 @@ describe('DeepSeek status shim: provisioning', () => {
describe('DeepSeek status shim: the supervisor contract', () => {
let server: Server | undefined;
let port: number;
const received: Array<{ body: unknown; secret: string | undefined }> = [];
let status = 200;
@@ -96,7 +96,10 @@ describe('DeepSeek status shim: the supervisor contract', () => {
res.end('{}');
});
});
server.listen(PORT, '127.0.0.1', resolve);
server.listen(0, '127.0.0.1', () => {
port = (server!.address() as AddressInfo).port;
resolve();
});
});
beforeAll(() => listen());
@@ -120,7 +123,7 @@ describe('DeepSeek status shim: the supervisor contract', () => {
const child = spawn(process.execPath, [path, ...args], {
env: {
...process.env,
CODEMAN_API_URL: `http://127.0.0.1:${PORT}`,
CODEMAN_API_URL: `http://127.0.0.1:${port}`,
CODEMAN_SESSION_ID: 'sess-from-env',
...env,
},
+2 -4
View File
@@ -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);
+6 -7
View File
@@ -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 () => {
+4 -4
View File
@@ -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
+2 -3
View File
@@ -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);
+5 -7
View File
@@ -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`, {
+3 -4
View File
@@ -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 () => {
+10 -14
View File
@@ -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 });
+4 -5
View File
@@ -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 () => {
+2 -4
View File
@@ -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');
+23 -19
View File
@@ -44,16 +44,18 @@ npm run test:mobile -- test/mobile/visual-regression.test.ts
## Test Files
| File | Port | Description |
|------|------|-------------|
| `keyboard.test.ts` | 3200 | Virtual keyboard simulation (3-layer: CDP, mock, DOM) |
| `tabs.test.ts` | 3201 | Tab switching, swipe navigation, keyboard nav |
| `subagent-windows.test.ts` | 3202 | Mobile subagent card dimensions, stacking, interactions |
| `settings.test.ts` | 3203 | Settings modal, mobile defaults, persistence |
| `layout.test.ts` | 3204 | General mobile layout, fixed elements, device classes |
| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (138 devices) |
| `visual-regression.test.ts` | 3206 | Screenshot comparison at key breakpoints |
| `accessibility.test.ts` | 3207 | WCAG touch targets, zoom, focus, ARIA |
Each file starts its own server on an ephemeral port (`createTestServer()`, then `server.boundPort`), so files never compete for a port.
| File | Description |
|------|-------------|
| `keyboard.test.ts` | Virtual keyboard simulation (3-layer: CDP, mock, DOM) |
| `tabs.test.ts` | Tab switching, swipe navigation, keyboard nav |
| `subagent-windows.test.ts` | Mobile subagent card dimensions, stacking, interactions |
| `settings.test.ts` | Settings modal, mobile defaults, persistence |
| `layout.test.ts` | General mobile layout, fixed elements, device classes |
| `device-matrix.test.ts` | Cross-device parametric tests (138 devices) |
| `visual-regression.test.ts` | Screenshot comparison at key breakpoints |
| `accessibility.test.ts` | WCAG touch targets, zoom, focus, ARIA |
## Device Matrix
@@ -99,7 +101,7 @@ The phone block is `max-width: 599px` and the tablet block starts at `min-width:
```
Test File
├─ helpers/server.ts → WebServer(port, false, testMode=true)
├─ helpers/server.ts → WebServer(0, false, testMode=true), ephemeral port
├─ helpers/browser.ts → Playwright Chromium / WebKit
├─ helpers/cdp.ts → Chrome DevTools Protocol (Chromium only)
├─ helpers/keyboard-sim.ts → 3-layer keyboard simulation
@@ -144,10 +146,10 @@ Snapshots at 10 key breakpoints: 320, 375, 390, 393, 430, 440, 600, 768, 834, 10
## Helpers Reference
### `helpers/constants.ts`
All magic numbers centralized: ports, CSS selectors, breakpoint thresholds, keyboard constants, swipe parameters, touch target minimums, body CSS classes, localStorage keys.
All magic numbers centralized: CSS selectors, breakpoint thresholds, keyboard constants, swipe parameters, touch target minimums, body CSS classes, localStorage keys.
### `helpers/server.ts`
`createTestServer(port)` / `stopTestServer(server)` — wraps `WebServer` with `testMode=true`.
`createTestServer()` / `stopTestServer(server)` — wraps `WebServer` with `testMode=true` on an ephemeral port; read it from `server.boundPort`.
### `helpers/browser.ts`
`createDevicePage(device, url, engine?)` — creates a Playwright browser context with the device's viewport, DPR, UA, touch support, navigates to the URL.
@@ -196,9 +198,7 @@ Unified `swipe()` / `tap()` with auto CDP/synthetic selection. Also exports per-
## Adding New Tests
1. Pick a unique port (next: 3208+) — search `const PORT =` across test files
2. Add the port to `helpers/constants.ts` PORTS object
3. Use the standard test pattern:
1. Use the standard test pattern (no port to pick: the server binds port 0):
```typescript
import { createTestServer, stopTestServer } from './helpers/server.js';
@@ -207,12 +207,16 @@ import { REPRESENTATIVE_DEVICES } from './devices.js';
describe('My Test', () => {
let server;
beforeAll(async () => { server = await createTestServer(MY_PORT); });
let baseUrl: string;
beforeAll(async () => {
server = await createTestServer();
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => { await stopTestServer(server); await closeAllBrowsers(); });
it('works on phone', async () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
const { page, context } = await createDevicePage(device, `http://localhost:${MY_PORT}`);
const { page, context } = await createDevicePage(device, baseUrl);
try {
// ... assertions ...
} finally {
@@ -222,7 +226,7 @@ describe('My Test', () => {
});
```
4. For new devices: add to `devices.ts` with correct category, viewport, UA, DPR
2. For new devices: add to `devices.ts` with correct category, viewport, UA, DPR
## Findings Log
+13 -13
View File
@@ -1,7 +1,7 @@
// Port 3207 - Mobile accessibility tests
// Ephemeral port - Mobile accessibility tests
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import type { Page, BrowserContext } from 'playwright';
import { PORTS, SELECTORS, KEYBOARD, MIN_TOUCH_TARGET, WAIT } from './helpers/constants.js';
import { SELECTORS, KEYBOARD, MIN_TOUCH_TARGET, WAIT } from './helpers/constants.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js';
@@ -12,14 +12,14 @@ import {
import { REPRESENTATIVE_DEVICES } from './devices.js';
import type { WebServer } from '../src/web/server.js';
const PORT = PORTS.ACCESSIBILITY;
const BASE_URL = `http://localhost:${PORT}`;
let baseUrl: string;
describe('Mobile Accessibility', () => {
let server: WebServer;
beforeAll(async () => {
server = await createTestServer(PORT);
server = await createTestServer();
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -39,7 +39,7 @@ describe('Mobile Accessibility', () => {
it('all visible interactive elements >= 44x44px on phones', async () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
const result = await createDevicePage(device, BASE_URL, 'chromium');
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
@@ -64,7 +64,7 @@ describe('Mobile Accessibility', () => {
it('all visible interactive elements >= 44x44px on tablets', async () => {
const device = REPRESENTATIVE_DEVICES['standard-tablet'];
const result = await createDevicePage(device, BASE_URL, 'chromium');
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
@@ -83,7 +83,7 @@ describe('Mobile Accessibility', () => {
it('keyboard accessory bar buttons meet size requirement', async () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
const result = await createDevicePage(device, BASE_URL, 'chromium');
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
@@ -118,7 +118,7 @@ describe('Mobile Accessibility', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
const result = await createDevicePage(device, BASE_URL, 'chromium');
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
@@ -159,7 +159,7 @@ describe('Mobile Accessibility', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
const result = await createDevicePage(device, BASE_URL, 'chromium');
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
@@ -229,7 +229,7 @@ describe('Mobile Accessibility', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
const result = await createDevicePage(device, BASE_URL, 'chromium');
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
@@ -294,7 +294,7 @@ describe('Mobile Accessibility', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
const result = await createDevicePage(device, BASE_URL, 'chromium');
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
@@ -357,7 +357,7 @@ describe('Mobile Accessibility', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
const result = await createDevicePage(device, BASE_URL, 'chromium');
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
+8 -8
View File
@@ -1,10 +1,10 @@
// Port 3205 - Cross-device parametric tests
// Ephemeral port - Cross-device parametric tests
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { BrowserContext, Page } from 'playwright';
import type { WebServer } from '../src/web/server.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { PORTS, SELECTORS, BREAKPOINTS, BODY_CLASSES } from './helpers/constants.js';
import { SELECTORS, BREAKPOINTS, BODY_CLASSES } from './helpers/constants.js';
import {
assertFixedPosition,
assertHidden,
@@ -17,8 +17,7 @@ import {
} from './helpers/assertions.js';
import { REPRESENTATIVE_DEVICES, DEVICE_REGISTRY, type DeviceEntry, type DeviceCategory } from './devices.js';
const PORT = PORTS.DEVICE_MATRIX;
const BASE_URL = `http://localhost:${PORT}`;
let baseUrl: string;
let server: WebServer;
@@ -36,7 +35,8 @@ const PHONE_ONLY_SELECTORS = [SELECTORS.SETTINGS_MOBILE, SELECTORS.CASE_MOBILE];
describe('Device Matrix', () => {
beforeAll(async () => {
server = await createTestServer(PORT);
server = await createTestServer();
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -53,7 +53,7 @@ describe('Device Matrix', () => {
let page: Page;
beforeAll(async () => {
({ context, page } = await createDevicePage(device, BASE_URL));
({ context, page } = await createDevicePage(device, baseUrl));
});
afterAll(async () => {
@@ -143,7 +143,7 @@ describe('Device Matrix', () => {
describe.skipIf(process.env.CI_QUICK === '1')('Full Device Matrix', () => {
for (const device of DEVICE_REGISTRY) {
it(`${device.name} (${device.viewport.width}x${device.viewport.height}): correct layout`, async () => {
const { context, page } = await createDevicePage(device, BASE_URL);
const { context, page } = await createDevicePage(device, baseUrl);
try {
// Verify viewport was applied
const viewportSize = page.viewportSize();
@@ -178,7 +178,7 @@ describe('Device Matrix', () => {
const results: Record<string, { position: string; top: string; zIndex: number }> = {};
for (const engine of engines) {
const { context, page } = await createDevicePage(device, BASE_URL, engine);
const { context, page } = await createDevicePage(device, baseUrl, engine);
try {
const position = await getCSSProperty(page, SELECTORS.HEADER, 'position');
const top = await getCSSProperty(page, SELECTORS.HEADER, 'top');
+8 -8
View File
@@ -1,4 +1,4 @@
// Port 3208 - Header button visibility (real-browser E2E).
// Ephemeral port - Header button visibility (real-browser E2E).
//
// Companion to the CI static guard (test/mobile-header-buttons-policy.test.ts).
// Renders the real app and asserts ACTUAL computed visibility, catching CSS/layout
@@ -13,16 +13,16 @@ import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { assertHidden, assertVisible } from './helpers/assertions.js';
import { REPRESENTATIVE_DEVICES } from './devices.js';
import { PORTS, WAIT } from './helpers/constants.js';
import { WAIT } from './helpers/constants.js';
const PORT = PORTS.HEADER_BUTTONS;
const BASE_URL = `http://localhost:${PORT}`;
let baseUrl: string;
describe('Header button visibility (E2E)', () => {
let server: WebServer;
beforeAll(async () => {
server = await createTestServer(PORT);
server = await createTestServer();
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -31,20 +31,20 @@ describe('Header button visibility (E2E)', () => {
});
it('hides the settings gear + lifecycle log on a standard phone (iPhone 14 Pro)', async () => {
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], BASE_URL);
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], baseUrl);
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await assertHidden(page, '.btn-icon-header.btn-settings');
await assertHidden(page, '.btn-icon-header.btn-lifecycle-log');
});
it('keeps the opt-in attachments button HIDDEN by default on a desktop-class viewport', async () => {
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], BASE_URL);
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], baseUrl);
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await assertHidden(page, '#attachmentsHistoryBtn');
});
it('shows the attachments button once the setting is enabled', async () => {
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], BASE_URL);
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], baseUrl);
// Desktop-class devices use the non-mobile settings blob.
await page.evaluate(() => {
const cur = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
-13
View File
@@ -1,16 +1,3 @@
// Port assignments
export const PORTS = {
KEYBOARD: 3200,
TABS: 3201,
SUBAGENT_WINDOWS: 3202,
SETTINGS: 3203,
LAYOUT: 3204,
DEVICE_MATRIX: 3205,
VISUAL_REGRESSION: 3206,
ACCESSIBILITY: 3207,
HEADER_BUTTONS: 3208,
} as const;
// CSS Selectors
export const SELECTORS = {
// Header
+7 -14
View File
@@ -1,29 +1,22 @@
import { WebServer } from '../../../src/web/server.js';
let servers: Map<number, WebServer> = new Map();
const servers = new Set<WebServer>();
export async function createTestServer(port: number): Promise<WebServer> {
if (servers.has(port)) {
return servers.get(port)!;
}
const server = new WebServer(port, false, true); // testMode = true
/** A test server on an ephemeral port; read the port from `server.boundPort` once this resolves. */
export async function createTestServer(): Promise<WebServer> {
const server = new WebServer(0, false, true); // testMode = true
await server.start();
servers.set(port, server);
servers.add(server);
return server;
}
export async function stopTestServer(server: WebServer): Promise<void> {
await server.stop();
for (const [port, s] of servers) {
if (s === server) {
servers.delete(port);
break;
}
}
servers.delete(server);
}
export async function stopAllTestServers(): Promise<void> {
for (const server of servers.values()) {
for (const server of servers) {
await server.stop();
}
servers.clear();
+14 -14
View File
@@ -1,7 +1,7 @@
// Port 3200 - Virtual keyboard simulation tests
// Ephemeral port - Virtual keyboard simulation tests
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import type { Page, BrowserContext } from 'playwright';
import { PORTS, KEYBOARD, SELECTORS, BODY_CLASSES, WAIT } from './helpers/constants.js';
import { KEYBOARD, SELECTORS, BODY_CLASSES, WAIT } from './helpers/constants.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, getBrowser, closeAllBrowsers } from './helpers/browser.js';
import {
@@ -26,8 +26,7 @@ import {
import { REPRESENTATIVE_DEVICES } from './devices.js';
import type { WebServer } from '../src/web/server.js';
const PORT = PORTS.KEYBOARD;
const BASE_URL = `http://localhost:${PORT}`;
let baseUrl: string;
// ─── Page-global access helpers ───
// KeyboardHandler is a `const` in app.js — NOT on `window`.
@@ -59,7 +58,8 @@ describe('Virtual Keyboard', () => {
let server: WebServer;
beforeAll(async () => {
server = await createTestServer(PORT);
server = await createTestServer();
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -75,7 +75,7 @@ describe('Virtual Keyboard', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone']; // iPhone 14 Pro
beforeEach(async () => {
const result = await createDevicePage(device, BASE_URL, 'chromium');
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
@@ -153,7 +153,7 @@ describe('Virtual Keyboard', () => {
const { context, page } = await createDevicePage(device, 'about:blank', 'chromium');
try {
await setupViewportMock(page);
await page.goto(BASE_URL, { waitUntil: WAIT.DOM_CONTENT_LOADED });
await page.goto(baseUrl, { waitUntil: WAIT.DOM_CONTENT_LOADED });
await page.waitForTimeout(2000);
const success = await showKeyboardViaMock(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
@@ -167,7 +167,7 @@ describe('Virtual Keyboard', () => {
const { context, page } = await createDevicePage(device, 'about:blank', 'chromium');
try {
await setupViewportMock(page);
await page.goto(BASE_URL, { waitUntil: WAIT.DOM_CONTENT_LOADED });
await page.goto(baseUrl, { waitUntil: WAIT.DOM_CONTENT_LOADED });
await page.waitForTimeout(2000);
const success = await showKeyboardViaMock(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
@@ -186,7 +186,7 @@ describe('Virtual Keyboard', () => {
const result = await createDevicePage(device, 'about:blank', 'webkit');
context = result.context;
await setupViewportMock(result.page);
await result.page.goto(BASE_URL, { waitUntil: WAIT.DOM_CONTENT_LOADED });
await result.page.goto(baseUrl, { waitUntil: WAIT.DOM_CONTENT_LOADED });
await result.page.waitForTimeout(2000);
const success = await showKeyboardViaMock(result.page, KEYBOARD.TYPICAL_IOS_HEIGHT);
@@ -213,7 +213,7 @@ describe('Virtual Keyboard', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
const result = await createDevicePage(device, BASE_URL, 'chromium');
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
@@ -1469,7 +1469,7 @@ describe('Virtual Keyboard', () => {
let page: Page;
beforeAll(async () => {
({ context, page } = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], BASE_URL, 'chromium'));
({ context, page } = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], baseUrl, 'chromium'));
await page.waitForTimeout(WAIT.PAGE_SETTLE);
});
@@ -1638,7 +1638,7 @@ describe('Virtual Keyboard', () => {
describe('Cross-device keyboard behavior', () => {
it('phone: full keyboard handling active', async () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
try {
const state = await getKeyboardState(page);
expect(state.exists).toBe(true);
@@ -1654,7 +1654,7 @@ describe('Virtual Keyboard', () => {
it('tablet: keyboard handling active', async () => {
const device = REPRESENTATIVE_DEVICES['standard-tablet']; // iPad Mini
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
try {
const state = await getKeyboardState(page);
expect(state.exists).toBe(true);
@@ -1674,7 +1674,7 @@ describe('Virtual Keyboard', () => {
});
const page = await context.newPage();
try {
await page.goto(BASE_URL, { waitUntil: WAIT.DOM_CONTENT_LOADED });
await page.goto(baseUrl, { waitUntil: WAIT.DOM_CONTENT_LOADED });
await page.waitForTimeout(1000);
const state = await getKeyboardState(page);
+30 -30
View File
@@ -1,10 +1,10 @@
// Port 3204 - General mobile layout tests
// Ephemeral port - General mobile layout tests
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { BrowserContext, Page } from 'playwright';
import type { WebServer } from '../src/web/server.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { PORTS, SELECTORS, BREAKPOINTS, BODY_CLASSES, WAIT, MIN_TOUCH_TARGET } from './helpers/constants.js';
import { SELECTORS, BREAKPOINTS, BODY_CLASSES, WAIT, MIN_TOUCH_TARGET } from './helpers/constants.js';
import {
assertFixedPosition,
assertHidden,
@@ -21,8 +21,7 @@ import {
} from './helpers/assertions.js';
import { REPRESENTATIVE_DEVICES, DEVICE_REGISTRY, type DeviceEntry } from './devices.js';
const PORT = PORTS.LAYOUT;
const BASE_URL = `http://localhost:${PORT}`;
let baseUrl: string;
let server: WebServer;
@@ -33,7 +32,8 @@ const iPadPro = REPRESENTATIVE_DEVICES['large-tablet'];
describe('Mobile Layout', () => {
beforeAll(async () => {
server = await createTestServer(PORT);
server = await createTestServer();
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -48,7 +48,7 @@ describe('Mobile Layout', () => {
let page: Page;
beforeAll(async () => {
({ context, page } = await createDevicePage(iPhone14Pro, BASE_URL));
({ context, page } = await createDevicePage(iPhone14Pro, baseUrl));
});
afterAll(async () => {
@@ -98,7 +98,7 @@ describe('Mobile Layout', () => {
let page: Page;
beforeAll(async () => {
({ context, page } = await createDevicePage(iPhone14Pro, BASE_URL));
({ context, page } = await createDevicePage(iPhone14Pro, baseUrl));
});
afterAll(async () => {
@@ -124,7 +124,7 @@ describe('Mobile Layout', () => {
describe('Mobile-Only Visible Elements', () => {
it('.btn-settings-mobile visible on phones', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
await assertVisible(page, SELECTORS.SETTINGS_MOBILE);
} finally {
@@ -133,7 +133,7 @@ describe('Mobile Layout', () => {
});
it('.btn-case-mobile visible on phones', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
await assertVisible(page, SELECTORS.CASE_MOBILE);
} finally {
@@ -142,7 +142,7 @@ describe('Mobile Layout', () => {
});
it('.btn-settings-mobile hidden on desktop', async () => {
const { context, page } = await createDevicePage(iPadPro, BASE_URL);
const { context, page } = await createDevicePage(iPadPro, baseUrl);
try {
await assertHidden(page, SELECTORS.SETTINGS_MOBILE);
} finally {
@@ -156,7 +156,7 @@ describe('Mobile Layout', () => {
describe('Toolbar Collision Regression', () => {
it('keeps phone toolbar controls inside the viewport', async () => {
const device = DEVICE_REGISTRY.find((d) => d.name === 'iPhone 8')!;
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
const layout = await page.evaluate(() => {
@@ -189,7 +189,7 @@ describe('Mobile Layout', () => {
it('does not render the desktop voice button on a 430px large phone', async () => {
const device = REPRESENTATIVE_DEVICES['large-phone'];
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await assertHidden(page, '#voiceInputBtn');
@@ -201,7 +201,7 @@ describe('Mobile Layout', () => {
it('uses phone upload and voice controls without toolbar overlap', async () => {
const device = REPRESENTATIVE_DEVICES['small-phone'];
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
expect(await page.locator('.toolbar-center .btn-upload').isVisible()).toBe(false);
@@ -246,7 +246,7 @@ describe('Mobile Layout', () => {
it('keeps the mobile recording mic effect inside the button bounds', async () => {
const device = REPRESENTATIVE_DEVICES['small-phone'];
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await page.evaluate(() => {
@@ -266,7 +266,7 @@ describe('Mobile Layout', () => {
});
it('keeps desktop upload and voice controls in one centered row', async () => {
const { context, page } = await createDevicePage(iPadPro, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(iPadPro, baseUrl, 'chromium');
try {
await page.setViewportSize({ width: 1280, height: 800 });
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -294,7 +294,7 @@ describe('Mobile Layout', () => {
describe('Device Classes', () => {
it('iOS user agent adds ios-device class', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
await assertHasClass(page, 'body', BODY_CLASSES.IOS);
} finally {
@@ -304,7 +304,7 @@ describe('Mobile Layout', () => {
it('Android user agent does NOT add ios-device', async () => {
const pixel = DEVICE_REGISTRY.find((d) => d.name === 'Pixel 7')!;
const { context, page } = await createDevicePage(pixel, BASE_URL);
const { context, page } = await createDevicePage(pixel, baseUrl);
try {
await assertNotHasClass(page, 'body', BODY_CLASSES.IOS);
} finally {
@@ -314,7 +314,7 @@ describe('Mobile Layout', () => {
it('Safari user agent adds safari-browser class', async () => {
// iPhone 14 Pro uses Safari UA
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
await assertHasClass(page, 'body', BODY_CLASSES.SAFARI);
} finally {
@@ -323,7 +323,7 @@ describe('Mobile Layout', () => {
});
it('touch device adds touch-device class', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
await assertHasClass(page, 'body', BODY_CLASSES.TOUCH);
} finally {
@@ -332,7 +332,7 @@ describe('Mobile Layout', () => {
});
it('width < 600 adds device-mobile', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
await assertDeviceClasses(page, iPhone14Pro.viewport.width);
} finally {
@@ -342,7 +342,7 @@ describe('Mobile Layout', () => {
it('width 600-768 adds device-tablet', async () => {
const tablet = REPRESENTATIVE_DEVICES['small-tablet'];
const { context, page } = await createDevicePage(tablet, BASE_URL);
const { context, page } = await createDevicePage(tablet, baseUrl);
try {
await assertDeviceClasses(page, tablet.viewport.width);
} finally {
@@ -351,7 +351,7 @@ describe('Mobile Layout', () => {
});
it('width >= 768 adds device-desktop', async () => {
const { context, page } = await createDevicePage(iPadPro, BASE_URL);
const { context, page } = await createDevicePage(iPadPro, baseUrl);
try {
await assertDeviceClasses(page, iPadPro.viewport.width);
} finally {
@@ -364,7 +364,7 @@ describe('Mobile Layout', () => {
describe('iOS Safe Areas', () => {
it('iOS header has extra padding for safe area', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
// On iOS devices, the header should have padding-top that accounts for safe area
const paddingTop = await getCSSNumericValue(page, SELECTORS.HEADER, 'padding-top');
@@ -378,7 +378,7 @@ describe('Mobile Layout', () => {
});
it('iOS modal content has safe area paddings', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
// Open settings modal to test modal safe areas
const settingsBtn = page.locator(SELECTORS.SETTINGS_MOBILE);
@@ -406,7 +406,7 @@ describe('Mobile Layout', () => {
describe('Touch Targets', () => {
it('all interactive elements meet 44px minimum', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
const violations = await assertAccessibleTouchTargets(page, MIN_TOUCH_TARGET);
if (violations.length > 0) {
@@ -428,7 +428,7 @@ describe('Mobile Layout', () => {
describe('Inputs', () => {
it('text inputs use font-size >= 16px', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
// Check visible text inputs (font-size < 16px causes iOS auto-zoom)
const inputs = await page.$$('input[type="text"], input[type="search"], input:not([type]), textarea');
@@ -457,7 +457,7 @@ describe('Mobile Layout', () => {
describe('Terminal', () => {
it('terminal-container has touch-action: pan-y', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
const exists = await page.$(SELECTORS.TERMINAL_CONTAINER);
if (exists) {
@@ -474,7 +474,7 @@ describe('Mobile Layout', () => {
describe('No Horizontal Overflow', () => {
it('no horizontal scroll on phone viewport', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
await assertNoHorizontalOverflow(page);
} finally {
@@ -487,7 +487,7 @@ describe('Mobile Layout', () => {
describe('Main Content', () => {
it('main has margin-top for fixed header', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
const marginTop = await getCSSNumericValue(page, SELECTORS.MAIN, 'margin-top');
// Main content should have enough margin-top to clear the fixed header (~42px)
@@ -502,7 +502,7 @@ describe('Mobile Layout', () => {
describe('Viewport Meta', () => {
it('zoom is not disabled', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
await assertZoomNotDisabled(page);
} finally {
+14 -14
View File
@@ -1,7 +1,7 @@
// Port 3203 - Settings modal mobile tests
// Ephemeral port - Settings modal mobile tests
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import type { Page, BrowserContext } from 'playwright';
import { PORTS, SELECTORS, KEYBOARD, STORAGE_KEYS, BODY_CLASSES, WAIT } from './helpers/constants.js';
import { SELECTORS, KEYBOARD, STORAGE_KEYS, BODY_CLASSES, WAIT } from './helpers/constants.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js';
@@ -9,14 +9,14 @@ import { assertVisible, assertHidden, getCSSProperty, getCSSNumericValue } from
import { DEVICE_REGISTRY, REPRESENTATIVE_DEVICES } from './devices.js';
import type { WebServer } from '../src/web/server.js';
const PORT = PORTS.SETTINGS;
const BASE_URL = `http://localhost:${PORT}`;
let baseUrl: string;
describe('Settings Modal', () => {
let server: WebServer;
beforeAll(async () => {
server = await createTestServer(PORT);
server = await createTestServer();
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -32,7 +32,7 @@ describe('Settings Modal', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
const result = await createDevicePage(device, BASE_URL, 'chromium');
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
@@ -105,7 +105,7 @@ describe('Settings Modal', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
const result = await createDevicePage(device, BASE_URL, 'chromium');
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
@@ -210,7 +210,7 @@ describe('Settings Modal', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
const result = await createDevicePage(device, BASE_URL, 'chromium');
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
@@ -295,7 +295,7 @@ describe('Settings Modal', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
it('settings survive page reload', async () => {
const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
const { page, context } = await createDevicePage(device, baseUrl, 'chromium');
try {
// Store a test setting
@@ -330,7 +330,7 @@ describe('Settings Modal', () => {
});
it('mobile notification prefs stored separately from desktop settings', async () => {
const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
const { page, context } = await createDevicePage(device, baseUrl, 'chromium');
try {
// Store both mobile and desktop settings
@@ -375,7 +375,7 @@ describe('Settings Modal', () => {
describe('Tablet vs Desktop Settings', () => {
it('tablet uses desktop settings key', async () => {
const device = REPRESENTATIVE_DEVICES['standard-tablet'];
const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
const { page, context } = await createDevicePage(device, baseUrl, 'chromium');
try {
const key = await page.evaluate(() => {
@@ -393,7 +393,7 @@ describe('Settings Modal', () => {
it('settings gear button not in toolbar on desktop', async () => {
const device = REPRESENTATIVE_DEVICES['large-tablet'];
const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
const { page, context } = await createDevicePage(device, baseUrl, 'chromium');
try {
await assertHidden(page, SELECTORS.SETTINGS_MOBILE);
@@ -411,7 +411,7 @@ describe('Settings Modal', () => {
// test/viewport-shape-change.test.ts; this drives the real resize.
const inner = DEVICE_REGISTRY.find((entry) => entry.name === 'iPhone Duo (inner)')!;
const outer = DEVICE_REGISTRY.find((entry) => entry.name === 'iPhone Duo (outer)')!;
const { page, context } = await createDevicePage(inner, BASE_URL, 'chromium');
const { page, context } = await createDevicePage(inner, baseUrl, 'chromium');
try {
await page.evaluate((key) => {
@@ -444,7 +444,7 @@ describe('Settings Modal', () => {
it('keeps handheld settings when a foldable unfolds past the desktop breakpoint', async () => {
const device = DEVICE_REGISTRY.find((entry) => entry.name === 'OPPO Find N5 (unfolded)')!;
const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
const { page, context } = await createDevicePage(device, baseUrl, 'chromium');
try {
// Seed the preferences while folded, exactly as a phone user does.
+14 -14
View File
@@ -1,7 +1,7 @@
// Port 3202 - Mobile subagent window card tests
// Ephemeral port - Mobile subagent window card tests
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Page, BrowserContext } from 'playwright';
import { PORTS, SELECTORS, SUBAGENT, KEYBOARD, WAIT } from './helpers/constants.js';
import { SELECTORS, SUBAGENT, KEYBOARD, WAIT } from './helpers/constants.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js';
@@ -9,8 +9,7 @@ import { getCSSProperty, getCSSNumericValue } from './helpers/assertions.js';
import { REPRESENTATIVE_DEVICES } from './devices.js';
import type { WebServer } from '../src/web/server.js';
const PORT = PORTS.SUBAGENT_WINDOWS;
const BASE_URL = `http://localhost:${PORT}`;
let baseUrl: string;
const standardPhone = REPRESENTATIVE_DEVICES['standard-phone']; // iPhone 14 Pro
@@ -78,7 +77,8 @@ describe('Mobile Subagent Windows', () => {
let server: WebServer;
beforeAll(async () => {
server = await createTestServer(PORT);
server = await createTestServer();
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -93,7 +93,7 @@ describe('Mobile Subagent Windows', () => {
let page: Page;
beforeAll(async () => {
({ context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium'));
({ context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium'));
await page.waitForTimeout(WAIT.PAGE_SETTLE);
});
@@ -187,7 +187,7 @@ describe('Mobile Subagent Windows', () => {
describe('Stacking', () => {
it('windows stack from top when keyboard hidden', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -240,7 +240,7 @@ describe('Mobile Subagent Windows', () => {
});
it('windows stack from bottom when keyboard visible', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -289,7 +289,7 @@ describe('Mobile Subagent Windows', () => {
});
it('stacking recalculates on keyboard toggle', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -379,7 +379,7 @@ describe('Mobile Subagent Windows', () => {
describe('Interactions', () => {
it('minimize button hides the window body', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await injectMockSubagentWindow(page, 'minimize-test', 0);
@@ -432,7 +432,7 @@ describe('Mobile Subagent Windows', () => {
});
it('minimize button toggles: second click restores window', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await injectMockSubagentWindow(page, 'toggle-test', 0);
@@ -490,7 +490,7 @@ describe('Mobile Subagent Windows', () => {
let page: Page;
beforeAll(async () => {
({ context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium'));
({ context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium'));
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await injectMockSubagentWindow(page, 'content-test-agent', 0);
});
@@ -556,7 +556,7 @@ describe('Mobile Subagent Windows', () => {
describe('Multiple Windows', () => {
it('can render multiple subagent windows simultaneously', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -583,7 +583,7 @@ describe('Mobile Subagent Windows', () => {
});
it('windows do not overlap when properly stacked', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
+24 -24
View File
@@ -1,7 +1,7 @@
// Port 3201 - Tab switching and navigation tests
// Ephemeral port - Tab switching and navigation tests
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Page, BrowserContext } from 'playwright';
import { PORTS, SELECTORS, SWIPE, BODY_CLASSES, WAIT } from './helpers/constants.js';
import { SELECTORS, SWIPE, BODY_CLASSES, WAIT } from './helpers/constants.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { swipe, swipeViaCDP, swipeViaSynthetic } from './helpers/touch-sim.js';
@@ -9,8 +9,7 @@ import { assertHidden, assertVisible, getCSSProperty, getCSSNumericValue } from
import { REPRESENTATIVE_DEVICES } from './devices.js';
import type { WebServer } from '../src/web/server.js';
const PORT = PORTS.TABS;
const BASE_URL = `http://localhost:${PORT}`;
let baseUrl: string;
const standardPhone = REPRESENTATIVE_DEVICES['standard-phone']; // iPhone 14 Pro
@@ -18,7 +17,8 @@ describe('Tab Navigation', () => {
let server: WebServer;
beforeAll(async () => {
server = await createTestServer(PORT);
server = await createTestServer();
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -33,7 +33,7 @@ describe('Tab Navigation', () => {
let page: Page;
beforeAll(async () => {
({ context, page } = await createDevicePage(standardPhone, BASE_URL));
({ context, page } = await createDevicePage(standardPhone, baseUrl));
// Wait for SSE and JS to fully initialize
await page.waitForTimeout(WAIT.PAGE_SETTLE);
});
@@ -157,7 +157,7 @@ describe('Tab Navigation', () => {
it('tabs remain visible on large phone and tablet headers', async () => {
for (const device of [REPRESENTATIVE_DEVICES['large-phone'], REPRESENTATIVE_DEVICES['small-tablet']]) {
const { context: deviceContext, page: devicePage } = await createDevicePage(device, BASE_URL, 'chromium');
const { context: deviceContext, page: devicePage } = await createDevicePage(device, baseUrl, 'chromium');
try {
await devicePage.waitForTimeout(WAIT.PAGE_SETTLE);
await devicePage.evaluate(() => {
@@ -224,7 +224,7 @@ describe('Tab Navigation', () => {
}
it('reveals a rightmost tab that selection would otherwise leave off-screen', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5);
@@ -250,7 +250,7 @@ describe('Tab Navigation', () => {
});
it('scrolls back to reveal a leftmost tab', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5);
@@ -271,7 +271,7 @@ describe('Tab Navigation', () => {
});
it('keeps the scroll position across an ambient full re-render', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5);
@@ -296,7 +296,7 @@ describe('Tab Navigation', () => {
});
it('renders tabs in sessionOrder on phones instead of hoisting the active one', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5, 3); // 4th tab active
@@ -317,7 +317,7 @@ describe('Tab Navigation', () => {
});
it('reaches the last tab with a horizontal touch drag', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5);
@@ -356,7 +356,7 @@ describe('Tab Navigation', () => {
let page: Page;
beforeAll(async () => {
({ context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium'));
({ context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium'));
await page.waitForTimeout(WAIT.PAGE_SETTLE);
});
@@ -471,7 +471,7 @@ describe('Tab Navigation', () => {
it('synthetic swipe left triggers navigation on WebKit', async () => {
let context: BrowserContext | undefined;
try {
const result = await createDevicePage(standardPhone, BASE_URL, 'webkit');
const result = await createDevicePage(standardPhone, baseUrl, 'webkit');
context = result.context;
await result.page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -510,7 +510,7 @@ describe('Tab Navigation', () => {
it('synthetic swipe right triggers prevSession on WebKit', async () => {
let context: BrowserContext | undefined;
try {
const result = await createDevicePage(standardPhone, BASE_URL, 'webkit');
const result = await createDevicePage(standardPhone, baseUrl, 'webkit');
context = result.context;
await result.page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -578,7 +578,7 @@ describe('Tab Navigation', () => {
}
it('ArrowRight moves focus to next tab', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await injectMockTabs(page, 3);
@@ -602,7 +602,7 @@ describe('Tab Navigation', () => {
});
it('ArrowLeft moves focus to previous tab', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await injectMockTabs(page, 3);
@@ -627,7 +627,7 @@ describe('Tab Navigation', () => {
});
it('Home key focuses first tab', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await injectMockTabs(page, 4);
@@ -651,7 +651,7 @@ describe('Tab Navigation', () => {
});
it('End key focuses last tab', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await injectMockTabs(page, 4);
@@ -675,7 +675,7 @@ describe('Tab Navigation', () => {
});
it('Enter activates focused tab', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await injectMockTabs(page, 3);
@@ -711,7 +711,7 @@ describe('Tab Navigation', () => {
describe('Tab Touch Focus', () => {
it('switching tabs with the keyboard closed does not leave the terminal textarea focused', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -758,7 +758,7 @@ describe('Tab Navigation', () => {
describe('Tab Close Button Visibility', () => {
it('close button hidden on non-active tabs', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -794,7 +794,7 @@ describe('Tab Navigation', () => {
});
it('gear icon visible only on active tab', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -850,7 +850,7 @@ describe('Tab Navigation', () => {
describe('Subagent Badge on Tab', () => {
it('subagent badge has correct dimensions', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
+9 -9
View File
@@ -1,16 +1,15 @@
// Port 3206 - Screenshot comparison tests
// Ephemeral port - Screenshot comparison tests
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { BrowserContext, Page } from 'playwright';
import type { WebServer } from '../src/web/server.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { PORTS, SELECTORS, VISUAL, VISUAL_BREAKPOINTS, WAIT } from './helpers/constants.js';
import { SELECTORS, VISUAL, VISUAL_BREAKPOINTS, WAIT } from './helpers/constants.js';
import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js';
import { compareScreenshot, assertScreenshotMatch } from './helpers/visual.js';
import { REPRESENTATIVE_DEVICES } from './devices.js';
const PORT = PORTS.VISUAL_REGRESSION;
const BASE_URL = `http://localhost:${PORT}`;
let baseUrl: string;
let server: WebServer;
@@ -20,7 +19,8 @@ const KEY_BREAKPOINTS = [320, 375, 393, 430, 768, 1024] as const;
describe('Visual Regression', () => {
beforeAll(async () => {
server = await createTestServer(PORT);
server = await createTestServer();
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -49,7 +49,7 @@ describe('Visual Regression', () => {
defaultBrowserType: 'chromium' as const,
};
const { context, page } = await createDevicePage(device, BASE_URL);
const { context, page } = await createDevicePage(device, baseUrl);
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
const result = await compareScreenshot(page, `landing-${width}w`);
@@ -76,7 +76,7 @@ describe('Visual Regression', () => {
defaultBrowserType: 'chromium' as const,
};
const { context, page } = await createDevicePage(device, BASE_URL);
const { context, page } = await createDevicePage(device, baseUrl);
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
// Show keyboard via DOM manipulation (works on all engines)
@@ -109,7 +109,7 @@ describe('Visual Regression', () => {
defaultBrowserType: 'chromium' as const,
};
const { context, page } = await createDevicePage(device, BASE_URL);
const { context, page } = await createDevicePage(device, baseUrl);
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -143,7 +143,7 @@ describe('Visual Regression', () => {
describe('Representative Devices', () => {
for (const [category, device] of Object.entries(REPRESENTATIVE_DEVICES)) {
it(`${device.name} (${category}) landing page`, async () => {
const { context, page } = await createDevicePage(device, BASE_URL);
const { context, page } = await createDevicePage(device, baseUrl);
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
const name = `device-${device.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
+5 -5
View File
@@ -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<void> {
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);
+2 -3
View File
@@ -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 () => {
+3 -4
View File
@@ -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<string, string | undefined> = {};
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<string, string>): Promise<unknown> {
@@ -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();
});
+4 -5
View File
@@ -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<string, unknown> }).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<string, unknown> }).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<string, Session> }).sessions;
+4 -5
View File
@@ -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<string>();
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 () => {
+8 -8
View File
@@ -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<void> {
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<number> {
// ─── 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();
+10 -11
View File
@@ -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<typeof import('../src/web/server.js')> | undefined;
process.env.HOME = TEST_HOME;
async function createTestServer(port: number): Promise<WebServer> {
async function createTestServer(): Promise<WebServer> {
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 () => {
+3 -4
View File
@@ -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(() => {
+2 -3
View File
@@ -451,13 +451,12 @@ describe('POST /api/sessions/:id/input: delivered reports the write, not just th
* exactly why this regression needs a POST and a real socket to catch.
*/
describe('POST /api/sessions/:id/input over real HTTP: hang-up handling', () => {
const PORT = 3181;
const base = `http://127.0.0.1:${PORT}`;
let base: string;
let app: FastifyInstance;
beforeAll(async () => {
app = (await harness()).app;
await app.listen({ port: PORT, host: '127.0.0.1' });
base = await app.listen({ port: 0, host: '127.0.0.1' });
});
afterAll(async () => {
+4 -6
View File
@@ -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 () => {
+6 -8
View File
@@ -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<string, string> = {}, body?: string): Promise<RawResponse> {
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<string, string> = {},
/** Open a WS to `path` with optional Origin and resolve with the close code the server sends. */
function wsCloseCode(path: string, origin?: string): Promise<number> {
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);
});
+4 -5
View File
@@ -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 () => {
+2 -4
View File
@@ -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<string, unknown> }).mux;
const session = localMuxSession();
(web as unknown as { sessions: Map<string, Session> }).sessions.set(session.id, session);
+2 -4
View File
@@ -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 () => {
+5 -5
View File
@@ -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 }) => {
@@ -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);
@@ -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);
+4 -4
View File
@@ -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);
+2 -3
View File
@@ -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 () => {
+4 -6
View File
@@ -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 () => {
+1 -1
View File
@@ -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<string, unknown>, owners: Record<string, string> = {}): Hint {
const server = new WebServer(3999, false, true) as unknown as {
const server = new WebServer(0, false, true) as unknown as {
sessions: Map<string, { owner?: string }>;
deriveSseHint(event: string, data: unknown): Hint;
};
+2 -4
View File
@@ -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 () => {
+2 -4
View File
@@ -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 () => {
+6 -6
View File
@@ -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 {
@@ -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);
+51 -71
View File
@@ -1,84 +1,33 @@
/**
* @fileoverview Static guard: a test builds `WebServer` on an ephemeral port.
* @fileoverview Static guard: a test binds an ephemeral port.
*
* A fixed port is a red suite on any machine where something else holds it, and a
* collision between two runs on one host (two worktrees, or CI plus a local run): the
* suite runs files serially (`fileParallelism: false`), so the four port pairs #440
* found never met inside one run, only across runs. `new WebServer(0, …)` binds
* whatever the OS hands out and `boundPort` reads it back, so there is nothing left
* to collide on.
* suite runs files serially (`fileParallelism: false`), so the port pairs #440 found
* never met inside one run, only across runs. Binding port 0 takes whatever the OS
* hands out, so there is nothing left to collide on.
*
* A WebServer built under test/ whose port argument is not the literal `0` fails —
* `new WebServer(…)`, a class declared `extends WebServer`, or a destructured alias
* (`{ WebServer: T }`) — unless the file is in LEGACY_FIXED_PORT_FILES, the files that
* construct one with a non-zero port today (a few never call `start()`); the follow-up
* sweep converts them. A converted file cannot stay listed: an entry whose file no
* longer matches fails too.
* Two rules, both over every file under test/:
* - A `WebServer` is built with the literal `0` as its port — `new WebServer(…)`, a
* class declared `extends WebServer`, or a destructured alias (`{ WebServer: T }`) —
* and the test reads `server.boundPort`.
* - A raw server (`http`/`net`/Fastify `listen`, `new WebSocketServer`) never listens
* on a number or a `…PORT` constant: `listen(0, …)` / `{ port: 0 }`, then
* `address().port`.
*
* Not covered: a helper that takes the port as a parameter is checked at the helper,
* not at its callers (test/mobile/helpers/server.ts is listed, so the mobile tests
* calling `createTestServer(PORT)` are not checked), `import { WebServer as X }`, and
* `new mod.WebServer(…)`. Raw `listen({ port: N })` and `new WebSocketServer({ port: N })`
* belong to the sweep.
* not at its callers; `import { WebServer as X }`; `new mod.WebServer(…)`; a port held
* in a variable that is not named `…PORT`.
*
* Port: N/A (pure static analysis).
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const TEST_ROOT = fileURLToPath(new URL('.', import.meta.url));
/** Predates the guard; converted in the follow-up sweep. Shrink only. */
const LEGACY_FIXED_PORT_FILES = new Set(
[
'admin-routes.test.ts',
'base-path-server.test.ts',
'capture-geometry-retry.browser.test.ts',
'capture-load-window.browser.test.ts',
'case-custom-path.browser.test.ts',
'doctor-settings.browser.test.ts',
'edge-cases.test.ts',
'file-link-click.test.ts',
'git-status.browser.test.ts',
'hooks-config.test.ts',
'http-contract.test.ts',
'inline-rename.test.ts',
'integration-flows.test.ts',
'key-tester.browser.test.ts',
'mobile/helpers/server.ts',
'opencode-resize.test.ts',
'operation-lightspeed.test.ts',
'ownership-scoping.test.ts',
'pane-exit-sweep.test.ts',
'paste-image-dir-shared.test.ts',
'perf-browser.test.ts',
'quick-start.test.ts',
'ralph-integration.test.ts',
'scheduled-runs.test.ts',
'security-regression.test.ts',
'session-cleanup.test.ts',
'session-pane-exit.test.ts',
'session.test.ts',
'shift-enter-keypress.browser.test.ts',
'split-pane-auto-collapse.browser.test.ts',
'split-pane-orchestration.browser.test.ts',
'split-pane-terminal.browser.test.ts',
'sse-cors-headers.test.ts',
'sse-events.test.ts',
'sse-routing-remote.test.ts',
'sse-subscription-filter.test.ts',
'static-cache-headers.test.ts',
'terminal-copy-shortcut.test.ts',
'terminal-keycode229-recovery.browser.test.ts',
'webgl-fallback.test.ts',
'webhook-settings.browser.test.ts',
'webview-lost-root-frame.test.ts',
'webview-sse.test.ts',
].map((p) => p.split('/').join(sep))
);
function testFiles(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir)) {
@@ -108,10 +57,29 @@ function webServerPortArgs(source: string): string[] {
);
}
/** A fixed port as a reader would see it: a non-zero number, or a constant named `…PORT`. */
const FIXED_PORT = /^(?:[1-9]\d*|[A-Z_]*PORT)$/;
/**
* Fixed ports handed to a raw server in `source`: the first argument of `.listen(…)`,
* and `port:` inside `.listen({ … })` or `new WebSocketServer({ … })`. A socket path or
* `0` is fine; so is anything held in a lower-case variable (see the header).
*/
function rawListenerFixedPorts(source: string): string[] {
const firstArgs = [...source.matchAll(/\.listen\(\s*([^,){\s]+)/g)].map((m) => m[1]);
const options = [...source.matchAll(/(?:\.listen|new WebSocketServer)\(\s*\{[^}]*?\bport\s*:\s*([^,}\s]+)/g)].map(
(m) => m[1]
);
return [...firstArgs, ...options].filter((arg) => FIXED_PORT.test(arg));
}
const SELF = fileURLToPath(import.meta.url);
const scanned = testFiles(TEST_ROOT)
.filter((f) => f !== SELF)
.map((file) => ({ rel: relative(TEST_ROOT, file), args: webServerPortArgs(readFileSync(file, 'utf8')) }));
.map((file) => {
const source = readFileSync(file, 'utf8');
return { rel: relative(TEST_ROOT, file), args: webServerPortArgs(source), raw: rawListenerFixedPorts(source) };
});
const fixed = (args: string[]) => args.some((a) => a !== '0');
describe('test servers bind an ephemeral port', () => {
@@ -124,9 +92,19 @@ describe('test servers bind an ephemeral port', () => {
expect(webServerPortArgs('const { WebServer: T } = mod;\nreturn new T(port, false);')).toEqual(['port']);
});
it('no test outside the legacy list builds WebServer on a fixed port', () => {
it('reads raw listeners the way a reader would', () => {
expect(rawListenerFixedPorts("server.listen(0, '127.0.0.1', done)")).toEqual([]);
expect(rawListenerFixedPorts("server.listen(PORT, '127.0.0.1', done)")).toEqual(['PORT']);
expect(rawListenerFixedPorts('srv.listen(3216)')).toEqual(['3216']);
expect(rawListenerFixedPorts("await app.listen({ port: TEST_PORT, host: '127.0.0.1' })")).toEqual(['TEST_PORT']);
expect(rawListenerFixedPorts("await app.listen({ port: 0, host: '127.0.0.1' })")).toEqual([]);
expect(rawListenerFixedPorts('new WebSocketServer({ port: 8081 })')).toEqual(['8081']);
expect(rawListenerFixedPorts('net.createServer().listen(socketPath)')).toEqual([]);
});
it('no test builds WebServer on a fixed port', () => {
const offenders = scanned
.filter((f) => fixed(f.args) && !LEGACY_FIXED_PORT_FILES.has(f.rel))
.filter((f) => fixed(f.args))
.map(
(f) =>
`${f.rel}: new WebServer(${f.args.find((a) => a !== '0')}, …) — use new WebServer(0, …) and server.boundPort`
@@ -134,8 +112,10 @@ describe('test servers bind an ephemeral port', () => {
expect(offenders).toEqual([]);
});
it('the legacy list only names files that still need converting', () => {
const stale = [...LEGACY_FIXED_PORT_FILES].filter((rel) => !scanned.some((f) => f.rel === rel && fixed(f.args)));
expect(stale).toEqual([]);
it('no test listens on a fixed port', () => {
const offenders = scanned
.filter((f) => f.raw.length > 0)
.map((f) => `${f.rel}: listens on ${f.raw.join(', ')} — listen on 0 and read address().port`);
expect(offenders).toEqual([]);
});
});
+15 -12
View File
@@ -9,10 +9,11 @@
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import { TuiClient, type TuiApprovalEvent, type TuiSseStatusDetail } from '../../src/tui/tui-client.js';
const PORT = 3242;
const BASE_URL = `http://127.0.0.1:${PORT}`;
let port: number;
let baseUrl: string;
interface Connection {
url: string;
@@ -57,7 +58,9 @@ beforeAll(async () => {
res.flushHeaders();
connections.push({ url: req.url, headers: req.headers, res });
});
await new Promise<void>((resolve) => server.listen(PORT, '127.0.0.1', resolve));
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
port = (server.address() as AddressInfo).port;
baseUrl = `http://127.0.0.1:${port}`;
});
afterAll(async () => {
@@ -82,7 +85,7 @@ describe('subscribeEvents', () => {
let planUsage: unknown = null;
let init: unknown = null;
client = new TuiClient({ baseUrl: BASE_URL, password: 's3cret' });
client = new TuiClient({ baseUrl, password: 's3cret' });
client.subscribeEvents({
onInit: (state) => {
init = state;
@@ -117,7 +120,7 @@ describe('subscribeEvents', () => {
});
it('suppresses the terminal firehose by default and carries the auth header', async () => {
client = new TuiClient({ baseUrl: BASE_URL, password: 's3cret' });
client = new TuiClient({ baseUrl, password: 's3cret' });
client.subscribeEvents({});
await until(() => connections.length === 1);
expect(connections[0].url).toBe('/api/events?sessions=tui-no-terminal');
@@ -126,7 +129,7 @@ describe('subscribeEvents', () => {
});
it('subscribes to the terminal stream of named sessions when asked', async () => {
client = new TuiClient({ baseUrl: BASE_URL });
client = new TuiClient({ baseUrl });
client.subscribeEvents({}, { sessionIds: ['a', 'b'] });
await until(() => connections.length === 1);
expect(connections[0].url).toBe('/api/events?sessions=a%2Cb');
@@ -134,7 +137,7 @@ describe('subscribeEvents', () => {
it('reconnects when the stream ends', async () => {
const statuses: Array<[string, TuiSseStatusDetail]> = [];
client = new TuiClient({ baseUrl: BASE_URL });
client = new TuiClient({ baseUrl });
const stream = client.subscribeEvents(
{ onStatus: (status, detail) => statuses.push([status, detail]) },
{ baseBackoffMs: 10, maxBackoffMs: 20 }
@@ -148,7 +151,7 @@ describe('subscribeEvents', () => {
});
it('reconnects when a live stream goes silent, which no socket error reports', async () => {
client = new TuiClient({ baseUrl: BASE_URL });
client = new TuiClient({ baseUrl });
client.subscribeEvents({}, { staleTimeoutMs: 150, checkIntervalMs: 25, baseBackoffMs: 10, maxBackoffMs: 20 });
await until(() => connections.length === 1);
@@ -161,7 +164,7 @@ describe('subscribeEvents', () => {
it('recommends polling once connecting keeps failing', async () => {
refuse = true;
const details: TuiSseStatusDetail[] = [];
client = new TuiClient({ baseUrl: BASE_URL });
client = new TuiClient({ baseUrl });
const stream = client.subscribeEvents(
{ onStatus: (_status, detail) => details.push(detail) },
{ baseBackoffMs: 10, maxBackoffMs: 20, pollingAfterFailures: 2 }
@@ -175,7 +178,7 @@ describe('subscribeEvents', () => {
});
it('stops reconnecting after close, so the process can exit', async () => {
client = new TuiClient({ baseUrl: BASE_URL });
client = new TuiClient({ baseUrl });
const stream = client.subscribeEvents({}, { baseBackoffMs: 10, maxBackoffMs: 20 });
await until(() => connections.length === 1);
@@ -187,7 +190,7 @@ describe('subscribeEvents', () => {
});
it('closes every stream the client opened', async () => {
client = new TuiClient({ baseUrl: BASE_URL });
client = new TuiClient({ baseUrl });
client.subscribeEvents({});
client.subscribeEvents({});
await until(() => connections.length === 2);
@@ -200,7 +203,7 @@ describe('subscribeEvents', () => {
});
it('refuses to subscribe before the client knows where the server is', () => {
const disconnected = new TuiClient({ port: 3999 });
const disconnected = new TuiClient({ port });
expect(() => disconnected.subscribeEvents({})).toThrow(/connect\(\)/);
});
});
+33 -17
View File
@@ -11,6 +11,7 @@
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { dataPath } from '../../src/config/instance.js';
@@ -32,10 +33,22 @@ import {
type TuiExecFile,
} from '../../src/tui/tui-client.js';
const PORT = 3241;
/** Nothing ever listens here: the "no server" path. */
const DEAD_PORT = 3243;
const BASE_URL = `http://127.0.0.1:${PORT}`;
/**
* A port nothing listens on: bind 0, read what the OS handed out, close. Free at the
* moment of use, unlike "the server's port + 1", which anything may hold.
*/
async function closedPort(): Promise<number> {
const probe = http.createServer();
await new Promise<void>((resolve) => probe.listen(0, '127.0.0.1', resolve));
const { port: free } = probe.address() as AddressInfo;
await new Promise<void>((resolve) => probe.close(() => resolve()));
return free;
}
let port: number;
/** Nothing listens one above the bound port: the "no server" path. */
let deadPort: number;
let baseUrl: string;
interface Recorded {
method: string;
@@ -115,7 +128,7 @@ const defaultResponder: Responder = (req, res) => {
};
function client(overrides: Record<string, unknown> = {}): TuiClient {
return new TuiClient({ baseUrl: BASE_URL, timeoutMs: 4000, ...overrides });
return new TuiClient({ baseUrl, timeoutMs: 4000, ...overrides });
}
let server: http.Server;
@@ -140,7 +153,10 @@ beforeAll(async () => {
(responder ?? defaultResponder)(req, res, body);
});
});
await new Promise<void>((resolve) => server.listen(PORT, '127.0.0.1', resolve));
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
port = (server.address() as AddressInfo).port;
deadPort = await closedPort();
baseUrl = `http://127.0.0.1:${port}`;
});
afterAll(async () => {
@@ -391,8 +407,8 @@ describe('TuiClient remaining API surface', () => {
describe('TuiClient.connect', () => {
it('discovers the loopback server and reports its identity', async () => {
const info = await new TuiClient({ port: PORT, probeTimeoutMs: 1000 }).connect();
expect(info?.baseUrl).toBe(BASE_URL);
const info = await new TuiClient({ port, probeTimeoutMs: 1000 }).connect();
expect(info?.baseUrl).toBe(baseUrl);
expect(info?.version).toBe('9.9.9');
expect(info?.hostname).toBeTruthy();
expect(info?.authRequired).toBeUndefined();
@@ -403,17 +419,17 @@ describe('TuiClient.connect', () => {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="codeman"' });
res.end('Unauthorized');
};
const info = await new TuiClient({ port: PORT, probeTimeoutMs: 1000 }).connect();
expect(info?.baseUrl).toBe(BASE_URL);
const info = await new TuiClient({ port, probeTimeoutMs: 1000 }).connect();
expect(info?.baseUrl).toBe(baseUrl);
expect(info?.authRequired).toBe(true);
});
it('returns null when nothing answers', async () => {
await expect(new TuiClient({ port: DEAD_PORT, probeTimeoutMs: 500 }).connect()).resolves.toBeNull();
await expect(new TuiClient({ port: deadPort, probeTimeoutMs: 500 }).connect()).resolves.toBeNull();
});
it('refuses to talk to an unconnected client', async () => {
await expect(new TuiClient({ port: DEAD_PORT }).fetchApprovals()).rejects.toThrow(/not connected/);
await expect(new TuiClient({ port: deadPort }).fetchApprovals()).rejects.toThrow(/not connected/);
});
});
@@ -501,7 +517,7 @@ describe('degraded-mode tmux enumeration', () => {
describe('attach window sizing', () => {
/** A client that only ever needs its injected exec: none of this talks to a server. */
function sizingClient(exec: TuiExecFile): TuiClient {
return new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec });
return new TuiClient({ baseUrl, socket: 'codeman-beta', exec });
}
it('parses the sizing format, and rejects a window tmux could not measure', () => {
@@ -615,7 +631,7 @@ describe('TuiClient.bindSwitchKey', () => {
calls.push([...args]);
return { stdout: '', stderr: '' };
};
const client = new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec });
const client = new TuiClient({ baseUrl, socket: 'codeman-beta', exec });
return client.bindSwitchKey('M-2', 'codeman-aaaa1111').then(() => {
const bind = calls.find((args) => args.includes('bind-key'));
expect(bind).toBeDefined();
@@ -672,7 +688,7 @@ describe('TuiClient.clearLeakedAttachBanners', () => {
}
return { stdout: '', stderr: '' };
};
return { client: new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }), calls };
return { client: new TuiClient({ baseUrl, socket: 'codeman-beta', exec }), calls };
}
it('takes down a bar a killed TUI left behind, and puts status back off', async () => {
@@ -713,7 +729,7 @@ describe('TuiClient.clearLeakedAttachBanners', () => {
const exec: TuiExecFile = async () => {
throw new Error('no server running on /tmp/tmux-1000/codeman-beta');
};
const client = new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec });
const client = new TuiClient({ baseUrl, socket: 'codeman-beta', exec });
await expect(client.clearLeakedAttachBanners()).resolves.toBe(0);
});
});
@@ -761,7 +777,7 @@ describe('parseDetachKey', () => {
describe('attach status bar options', () => {
function optionsClient(exec: TuiExecFile): TuiClient {
return new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec });
return new TuiClient({ baseUrl, socket: 'codeman-beta', exec });
}
const SHOW = [
+5 -4
View File
@@ -25,6 +25,7 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { spawn } from 'node:child_process';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
@@ -35,8 +36,7 @@ import type { SearchResponseData } from '../../src/types/search.js';
import type { ApprovalItem } from '../../src/web/approval-inbox.js';
import type { AwayDigestResponse } from '../../src/web/away-digest.js';
const PORT = 3244;
const BASE_URL = `http://127.0.0.1:${PORT}`;
let baseUrl: string;
const ROOT = resolve(import.meta.dirname, '..', '..');
const COLS = 100;
const ROWS = 30;
@@ -234,7 +234,7 @@ function childEnv(): Record<string, string> {
delete env.CODEMAN_PORT;
return {
...env,
CODEMAN_API_URL: BASE_URL,
CODEMAN_API_URL: baseUrl,
CODEMAN_DATA_DIR: dataDir,
CODEMAN_TMUX_SOCKET: 'codeman-tui-e2e',
CODEMAN_TUI_GLYPHS: 'ascii',
@@ -347,7 +347,8 @@ beforeAll(async () => {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'no route', errorCode: 'NOT_FOUND' }));
});
await new Promise<void>((done) => server.listen(PORT, '127.0.0.1', done));
await new Promise<void>((done) => server.listen(0, '127.0.0.1', done));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
+5 -5
View File
@@ -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(
() =>
+2 -3
View File
@@ -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<void>((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' });
+2 -3
View File
@@ -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;
+1 -1
View File
@@ -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);