#!/usr/bin/env node const fs = require('fs'); const crypto = require('crypto'); const os = require('os'); const path = require('path'); const net = require('net'); const { spawn, spawnSync } = require('child_process'); const WebSocket = require('ws'); const REPO_DIR = path.resolve(__dirname, '..'); const SERVER_PATH = path.join(REPO_DIR, 'server.js'); const WINDOWS_START_PATH = path.join(REPO_DIR, 'start.bat'); const PUBLIC_APP_PATH = path.join(REPO_DIR, 'public', 'app.js'); const PUBLIC_INDEX_PATH = path.join(REPO_DIR, 'public', 'index.html'); const PUBLIC_STYLE_PATH = path.join(REPO_DIR, 'public', 'style.css'); const GILDED_THEME_ASSETS = [ { filename: 'gilded-wasteland.png', format: 'png', width: 1000, height: 1500, sha256: '11bf3ffed4422c08f6be217c63060ba68d63367e747a3604fc2d7b877e9c5955', }, ].map((asset) => ({ ...asset, path: path.join(REPO_DIR, 'public', 'assets', 'themes', asset.filename), })); const WASTELAND_THEME_ASSETS = { root: path.join(REPO_DIR, 'public', 'assets', 'themes', 'wasteland'), background: { filename: 'background.webp', width: 1672, height: 941, sha256: 'ee52b136294a7a31181b67e209253fefe7ace8c2b76885e45621484fecb802cb', }, manifestFilename: path.join('icons', 'manifest.json'), iconNames: [ 'new-chat', 'search', 'dropdown', 'theme', 'ai', 'status', 'attachment', 'send', 'stop', 'settings', 'user', 'terminal', 'copy', 'refresh', 'chat', 'close', 'check', ], }; const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js'); const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js'); const MOCK_CODEX_APP_SERVER = path.join(REPO_DIR, 'scripts', 'mock-codex-app-server.js'); const HAS_SQLITE3 = spawnSync('sqlite3', ['-version'], { stdio: 'ignore' }).status === 0; function mkdirp(dir) { fs.mkdirSync(dir, { recursive: true }); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function getFreePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.on('error', reject); server.listen(0, '127.0.0.1', () => { const addr = server.address(); const port = addr && typeof addr === 'object' ? addr.port : null; server.close(() => resolve(port)); }); }); } function assert(condition, message) { if (!condition) { throw new Error(message); } } function escapeRegExp(value) { return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function imageMarker(filename) { return new RegExp(`\\[image:${escapeRegExp(filename)}\\]`); } function storedAttachmentImageMarker(attachment) { const id = String(attachment?.id || '').trim(); assert(id, 'Uploaded attachment should include an id for localImage marker checks'); const mime = String(attachment?.mime || '').toLowerCase(); const ext = mime === 'image/jpeg' ? '.jpg' : mime === 'image/webp' ? '.webp' : '.png'; return imageMarker(`${id}${ext}`); } function assertPersistedMessageAttachment(message, filename, label) { const attachments = Array.isArray(message?.attachments) ? message.attachments : []; const attachment = attachments.find((item) => item?.filename === filename); assert(attachment, `${label} should persist attachment ${filename}`); assert(attachment.storageState === 'available', `${label} should persist available attachment state`); assert(!Object.prototype.hasOwnProperty.call(attachment, 'path'), `${label} should not persist local attachment paths`); } function sql(dbPath, statement) { if (!HAS_SQLITE3) throw new Error('sqlite3 is not available'); const result = spawnSync('sqlite3', [dbPath, statement], { encoding: 'utf8' }); if (result.status !== 0) throw new Error(result.stderr || `sqlite3 failed: ${statement}`); return result.stdout.trim(); } async function waitForPort(port, timeoutMs = 10000) { const started = Date.now(); while (Date.now() - started < timeoutMs) { const probe = spawnSync('bash', ['-lc', `ss -tln | grep -q ':${port} '`], { encoding: 'utf8' }); if (probe.status === 0) return; await sleep(100); } throw new Error(`Timed out waiting for port ${port}`); } async function waitForFile(filePath, timeoutMs = 10000) { const started = Date.now(); while (Date.now() - started < timeoutMs) { if (fs.existsSync(filePath)) return; await sleep(50); } throw new Error(`Timed out waiting for file: ${filePath}`); } async function waitForJsonCondition(filePath, predicate, timeoutMs = 5000) { const started = Date.now(); let lastError = null; while (Date.now() - started < timeoutMs) { try { if (fs.existsSync(filePath)) { const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); if (predicate(parsed)) return parsed; } } catch (err) { lastError = err; } await sleep(50); } throw new Error(`Timed out waiting for JSON condition: ${filePath}${lastError ? ` (${lastError.message})` : ''}`); } async function withServer(env, fn) { const child = spawn('/usr/bin/node', [SERVER_PATH], { cwd: REPO_DIR, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); try { await waitForPort(env.PORT, 10000); await fn({ child, stdout: () => stdout, stderr: () => stderr }); } finally { child.kill('SIGTERM'); await sleep(300); if (!child.killed) child.kill('SIGKILL'); } } async function startServer(env) { const child = spawn('/usr/bin/node', [SERVER_PATH], { cwd: REPO_DIR, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); await waitForPort(env.PORT, 10000); return { child, stdout: () => stdout, stderr: () => stderr, async stop(signal = 'SIGTERM') { if (child.exitCode !== null || child.signalCode) return; child.kill(signal); await sleep(300); if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL'); await sleep(200); }, }; } function connectWs(port, password, options = {}) { return new Promise((resolve, reject) => { const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); const messages = []; const receivedMessages = options.trackReceived ? [] : null; let settled = false; ws.on('open', () => { ws.send(JSON.stringify({ type: 'auth', password })); }); ws.on('message', (buf) => { const msg = JSON.parse(String(buf)); messages.push(msg); if (receivedMessages) receivedMessages.push(msg); if (msg.type === 'auth_result' && msg.success) { settled = true; resolve({ ws, messages, receivedMessages, token: msg.token }); } if (msg.type === 'auth_result' && !msg.success) { settled = true; reject(new Error('Auth failed')); } }); ws.on('error', (err) => { if (settled) return; settled = true; reject(err); }); }); } function assertWsUpgradeRejected(port, pathname) { return new Promise((resolve, reject) => { let settled = false; const ws = new WebSocket(`ws://127.0.0.1:${port}${pathname}`); const timer = setTimeout(() => finish(reject, new Error(`WebSocket upgrade was not rejected for ${pathname}`)), 5000); function finish(done, value) { if (settled) return; settled = true; clearTimeout(timer); if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { try { ws.terminate(); } catch {} } done(value); } ws.on('open', () => { finish(reject, new Error(`Unexpected WebSocket connection opened for ${pathname}`)); }); ws.on('unexpected-response', (req, res) => { res.resume(); if (res.statusCode === 404) { finish(resolve); return; } finish(reject, new Error(`Expected 404 for ${pathname}, got ${res.statusCode}`)); }); ws.on('error', (err) => { if (/Unexpected server response: 404/.test(err.message || '')) { finish(resolve); return; } finish(reject, err); }); }); } async function uploadAttachment(port, token, { filename, mime, data }) { const response = await fetch(`http://127.0.0.1:${port}/api/attachments`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': mime, 'X-Filename': encodeURIComponent(filename), }, body: data, }); const payload = await response.json(); assert(response.ok && payload.ok, `Attachment upload failed: ${payload.message || response.status}`); return payload.attachment; } async function fetchAuthedJson(port, token, pathname) { const response = await fetch(`http://127.0.0.1:${port}${pathname}`, { headers: { Authorization: `Bearer ${token}`, }, }); const payload = await response.json(); assert(response.ok && payload.ok, `Request failed for ${pathname}: ${payload.message || response.status}`); return payload; } async function postAuthedJson(port, token, pathname, body = {}) { const response = await fetch(`http://127.0.0.1:${port}${pathname}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify(body), }); const payload = await response.json(); assert(response.ok && payload.ok, `POST failed for ${pathname}: ${payload.message || response.status}`); return payload; } async function callInternalMcp(port, token, payload) { const response = await fetch(`http://127.0.0.1:${port}/api/internal/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CC-Web-MCP-Token': token, }, body: JSON.stringify(payload), }); let body = null; try { body = await response.json(); } catch {} return { status: response.status, body }; } function nextMessage(messages, ws, predicate, timeoutMs = 15000) { const callSite = (() => { const stack = String(new Error().stack || '').split('\n'); return (stack[3] || stack[2] || '').trim(); })(); return new Promise((resolve, reject) => { const started = Date.now(); const timer = setInterval(() => { const idx = messages.findIndex(predicate); if (idx !== -1) { clearInterval(timer); const found = messages.splice(idx, 1)[0]; resolve(found); return; } if (Date.now() - started > timeoutMs) { clearInterval(timer); const recentTypes = messages.slice(-12).map((m) => m?.type).join(', '); const pendingTypes = messages.slice(0, 12).map((m) => m?.type).join(', '); const recentDetails = messages.slice(-6).map((m) => JSON.stringify({ type: m?.type, sessionId: m?.sessionId, status: m?.status, clientMessageId: m?.clientMessageId, code: m?.code, text: typeof m?.text === 'string' ? m.text.slice(0, 120) : undefined, message: typeof m?.message === 'string' ? m.message.slice(0, 120) : undefined, })).join(' | '); reject(new Error(`Timed out waiting for expected WebSocket message (wsState=${ws.readyState}, callSite=${callSite}, pendingTypes=[${pendingTypes}], recentTypes=[${recentTypes}], recentDetails=[${recentDetails}])`)); } }, 50); }); } function isSessionCompletionMessage(msg, sessionId) { return (msg?.type === 'done' || msg?.type === 'background_done') && msg.sessionId === sessionId; } function createFakeClaudeHistory(homeDir) { const projectDir = path.join(homeDir, '.claude', 'projects', 'tmp-project'); mkdirp(projectDir); const sessionId = 'claude-import-test'; const filePath = path.join(projectDir, `${sessionId}.jsonl`); const lines = [ JSON.stringify({ type: 'user', cwd: '/tmp/project-a', timestamp: '2026-03-12T00:00:00.000Z', message: { content: 'Claude import prompt' }, }), JSON.stringify({ type: 'assistant', timestamp: '2026-03-12T00:00:02.000Z', message: { content: [{ type: 'text', text: 'Claude import answer' }] }, }), ]; fs.writeFileSync(filePath, `${lines.join('\n')}\n`); return { sessionId, projectDir: 'tmp-project', filePath }; } function createFakeCodexHistory(homeDir, options = {}) { const sessionsDir = path.join(homeDir, '.codex', 'sessions', '2026', '03', '12'); mkdirp(sessionsDir); const threadId = options.threadId || 'codex-import-thread'; const cwd = options.cwd || '/tmp/project-b'; const userText = options.userText || 'Codex import prompt'; const answerText = options.answerText || 'Codex import answer'; const source = options.source || 'exec'; const cliVersion = options.cliVersion || '0.114.0'; const fileStamp = options.fileStamp || '2026-03-12T00-00-00'; const rolloutPath = path.join(sessionsDir, `rollout-${fileStamp}-${threadId}.jsonl`); const rolloutLines = [ JSON.stringify({ timestamp: '2026-03-12T00:00:00.000Z', type: 'session_meta', payload: { id: threadId, cwd, cli_version: cliVersion, source }, }), JSON.stringify({ timestamp: '2026-03-12T00:00:00.100Z', type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: '# AGENTS.md wrapper should be ignored' }], }, }), JSON.stringify({ timestamp: '2026-03-12T00:00:01.000Z', type: 'event_msg', payload: { type: 'user_message', message: userText }, }), JSON.stringify({ timestamp: '2026-03-12T00:00:02.000Z', type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: answerText }], }, }), JSON.stringify({ timestamp: '2026-03-12T00:00:03.000Z', type: 'event_msg', payload: { type: 'token_count', info: { total_token_usage: { input_tokens: 20, cached_input_tokens: 5, output_tokens: 8 } }, }, }), ]; fs.writeFileSync(rolloutPath, `${rolloutLines.join('\n')}\n`); let stateDb = null; let logsDb = null; if (HAS_SQLITE3) { stateDb = path.join(homeDir, '.codex', 'state_5.sqlite'); mkdirp(path.dirname(stateDb)); sql(stateDb, ` PRAGMA journal_mode = WAL; CREATE TABLE IF NOT EXISTS threads ( id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, source TEXT NOT NULL, model_provider TEXT NOT NULL, cwd TEXT NOT NULL, title TEXT NOT NULL, sandbox_policy TEXT NOT NULL, approval_mode TEXT NOT NULL, tokens_used INTEGER NOT NULL DEFAULT 0, has_user_event INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, archived_at INTEGER, git_sha TEXT, git_branch TEXT, git_origin_url TEXT, cli_version TEXT NOT NULL DEFAULT '', first_user_message TEXT NOT NULL DEFAULT '', agent_nickname TEXT, agent_role TEXT, memory_mode TEXT NOT NULL DEFAULT 'enabled' ); CREATE TABLE IF NOT EXISTS stage1_outputs ( thread_id TEXT PRIMARY KEY, source_updated_at INTEGER NOT NULL, raw_memory TEXT NOT NULL, rollout_summary TEXT NOT NULL, generated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS thread_dynamic_tools ( thread_id TEXT NOT NULL, position INTEGER NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL, input_schema TEXT NOT NULL, PRIMARY KEY(thread_id, position) ); CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, ts_nanos INTEGER NOT NULL, level TEXT NOT NULL, target TEXT NOT NULL, message TEXT, module_path TEXT, file TEXT, line INTEGER, thread_id TEXT, process_uuid TEXT, estimated_bytes INTEGER NOT NULL DEFAULT 0 ); INSERT INTO threads (id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, sandbox_policy, approval_mode, cli_version) VALUES ('${threadId}', '${rolloutPath.replace(/'/g, "''")}', 1, 2, '${source}', 'OpenAI', '${cwd.replace(/'/g, "''")}', '${userText.replace(/'/g, "''")}', '{}', 'never', '${cliVersion}'); INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}'); `); logsDb = path.join(homeDir, '.codex', 'logs_1.sqlite'); sql(logsDb, ` CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, ts_nanos INTEGER NOT NULL, level TEXT NOT NULL, target TEXT NOT NULL, message TEXT, module_path TEXT, file TEXT, line INTEGER, thread_id TEXT, process_uuid TEXT, estimated_bytes INTEGER NOT NULL DEFAULT 0 ); INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}'); `); } return { threadId, rolloutPath, stateDb, logsDb }; } function createFakeCodexConfig(homeDir, { model = 'gpt-5.5', reasoningEffort = 'xhigh' } = {}) { const codexDir = path.join(homeDir, '.codex'); mkdirp(codexDir); fs.writeFileSync(path.join(codexDir, 'config.toml'), [ 'model_provider = "test"', `model = "${model}"`, `model_reasoning_effort = "${reasoningEffort}"`, '', '[projects."/tmp/project-b"]', 'trust_level = "trusted"', '', '[mcp_servers.reg-config]', 'command = "node"', 'args = ["regression-mcp.js"]', '', ].join('\n')); } function assertFrontendSidebarCollapseContract() { const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8'); const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8'); const buttonMarkup = indexSource.match(/