'use strict'; const assert = require('node:assert'); const fs = require('node:fs'); const net = require('node:net'); const os = require('node:os'); const path = require('node:path'); const { spawn } = require('node:child_process'); const REPO_DIR = path.resolve(__dirname, '..'); const SERVER_PATH = path.join(REPO_DIR, 'server.js'); const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js'); const GECKODRIVER = '/snap/firefox/current/usr/lib/firefox/geckodriver'; const FIREFOX = '/snap/firefox/current/usr/lib/firefox/firefox'; const PASSWORD = 'TaskBoardBrowser!234'; const INTERNAL_TOKEN = 'TaskBoardBrowserInternal!234'; const SESSION_ID = 'browser-task-existing'; const STATUS_ID = 'waiting-release'; const INITIAL_PROMPT = '当实现和验证已完成,但仍需等待部署环境或发布窗口时选择此列。'; const EDITED_PROMPT = '当实现与验证完成,但仍在等待发布窗口、部署条件或最终放行时选择此列。'; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function freePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); server.close(() => resolve(address.port)); }); }); } async function waitForPort(port, timeoutMs = 10_000) { const started = Date.now(); while (Date.now() - started < timeoutMs) { const ready = await new Promise((resolve) => { const socket = net.createConnection({ host: '127.0.0.1', port }); socket.once('connect', () => { socket.destroy(); resolve(true); }); socket.once('error', () => resolve(false)); }); if (ready) return; await sleep(50); } throw new Error(`等待端口 ${port} 超时`); } async function waitFor(predicate, message, timeoutMs = 10_000) { const started = Date.now(); let lastError = null; while (Date.now() - started < timeoutMs) { try { const value = await predicate(); if (value) return value; } catch (error) { lastError = error; } await sleep(80); } throw new Error(`${message}${lastError ? `: ${lastError.message}` : ''}`); } async function stopChild(child) { if (!child || child.exitCode !== null) return; child.kill('SIGTERM'); await Promise.race([ new Promise((resolve) => child.once('exit', resolve)), sleep(1_000), ]); if (child.exitCode === null) child.kill('SIGKILL'); } class FirefoxDriver { constructor(port) { this.port = port; this.sessionId = ''; } async request(method, endpoint, body) { const response = await fetch(`http://127.0.0.1:${this.port}${endpoint}`, { method, headers: body === undefined ? {} : { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); const payload = await response.json(); if (!response.ok || payload?.value?.error) { throw new Error(`WebDriver ${method} ${endpoint}: ${JSON.stringify(payload.value || payload)}`); } return payload.value; } async start() { const value = await this.request('POST', '/session', { capabilities: { alwaysMatch: { browserName: 'firefox', acceptInsecureCerts: true, 'moz:firefoxOptions': { binary: FIREFOX, args: ['-headless'], prefs: { 'browser.shell.checkDefaultBrowser': false, 'browser.startup.homepage_override.mstone': 'ignore', 'media.prefers-reduced-motion': 1, }, }, }, }, }); this.sessionId = value.sessionId; return value.capabilities; } async execute(script, args = []) { return this.request('POST', `/session/${this.sessionId}/execute/sync`, { script, args }); } async navigate(url) { return this.request('POST', `/session/${this.sessionId}/url`, { url }); } async setWindow(width, height) { await this.request('POST', `/session/${this.sessionId}/window/rect`, { width, height }); return this.request('GET', `/session/${this.sessionId}/window/rect`); } async click(selector) { const clicked = await this.execute(` const node = document.querySelector(arguments[0]); if (!node) return false; node.click(); return true; `, [selector]); assert(clicked, `找不到可点击节点: ${selector}`); } async screenshot(targetPath) { const base64 = await this.request('GET', `/session/${this.sessionId}/screenshot`); fs.writeFileSync(targetPath, Buffer.from(base64, 'base64')); return targetPath; } async close() { if (!this.sessionId) return; try { await this.request('DELETE', `/session/${this.sessionId}`); } finally { this.sessionId = ''; } } } function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function statusDefinition(configPath) { if (!fs.existsSync(configPath)) return null; return readJson(configPath).definitions?.find((definition) => definition.id === STATUS_ID) || null; } async function setTheme(driver, theme) { await driver.execute(` document.documentElement.dataset.theme = arguments[0]; localStorage.setItem('cc-web-theme', arguments[0]); return document.documentElement.dataset.theme; `, [theme]); await sleep(120); } async function auditBoard(driver) { return driver.execute(` const shell = document.querySelector('.ccweb-task-board'); const toolbar = document.querySelector('.ccweb-task-board__toolbar'); const lanes = document.querySelector('.ccweb-task-board__lanes'); const workspace = document.querySelector('.ccweb-task-board__workspace'); const column = document.querySelector('.ccweb-task-board__column'); const text = shell?.innerText || ''; const controlsText = document.querySelector('.ccweb-task-board__controls')?.innerText || ''; const styleOf = (node) => node ? getComputedStyle(node).backgroundColor : ''; return { theme: document.documentElement.dataset.theme, viewport: { width: innerWidth, height: innerHeight }, shellBackground: styleOf(shell), toolbarBackground: styleOf(toolbar), columnBackground: styleOf(column), controlsText, hasAgentFilter: /Agent|代理筛选/.test(controlsText), hasPriorityFilter: /优先级|priority/i.test(controlsText), hasCreateTask: /新建任务/.test(controlsText), hasRefreshControl: Array.from(document.querySelectorAll('.ccweb-task-board__controls button')).some((button) => /刷新|refresh/i.test(button.textContent + ' ' + button.title + ' ' + button.getAttribute('aria-label'))), hasPercentage: /(?:^|\\s)\\d+(?:\\.\\d+)?%/.test(text), hasProgressElement: !!shell?.querySelector('[class*="progress"], [data-progress]'), hasRuntimeState: /运行:(?:运行中|未运行|等待子任务)/.test(text), hasWaitingReleaseLane: !!lanes?.querySelector('[data-status-id="waiting-release"]'), laneScrollWidth: lanes?.scrollWidth || 0, laneClientWidth: lanes?.clientWidth || 0, workspaceScrollWidth: workspace?.scrollWidth || 0, workspaceClientWidth: workspace?.clientWidth || 0, }; `); } async function main() { assert(fs.existsSync(GECKODRIVER), `缺少 geckodriver: ${GECKODRIVER}`); assert(fs.existsSync(FIREFOX), `缺少 Firefox: ${FIREFOX}`); const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-task-board-browser-runtime-')); const evidenceRoot = path.join(os.tmpdir(), `cc-web-task-board-browser-evidence-${Date.now()}`); const configDir = path.join(runtimeRoot, 'config'); const sessionsDir = path.join(runtimeRoot, 'sessions'); const logsDir = path.join(runtimeRoot, 'logs'); const homeDir = path.join(runtimeRoot, 'home'); for (const directory of [evidenceRoot, configDir, sessionsDir, logsDir, homeDir]) { fs.mkdirSync(directory, { recursive: true }); } const now = new Date().toISOString(); const sessionPath = path.join(sessionsDir, `${SESSION_ID}.json`); fs.writeFileSync(sessionPath, JSON.stringify({ id: SESSION_ID, title: '真实浏览器任务', agent: 'codexapp', cwd: homeDir, created: now, updated: now, messages: [], taskTracking: { enabled: true, statusId: 'in_progress', source: 'user', reason: '浏览器隔离验收', summary: '验证真实新增列链路', version: 1, enabledAt: now, disabledAt: null, statusUpdatedAt: now, archivedAt: null, archivedBy: null, }, }, null, 2)); const [serverPort, driverPort] = await Promise.all([freePort(), freePort()]); const server = spawn(process.execPath, [SERVER_PATH], { cwd: REPO_DIR, env: { ...process.env, PORT: String(serverPort), CC_WEB_PASSWORD: PASSWORD, CC_WEB_INTERNAL_MCP_TOKEN: INTERNAL_TOKEN, CC_WEB_CONFIG_DIR: configDir, CC_WEB_SESSIONS_DIR: sessionsDir, CC_WEB_LOGS_DIR: logsDir, HOME: homeDir, CLAUDE_PATH: MOCK_CLAUDE, }, stdio: ['ignore', 'pipe', 'pipe'], }); const geckodriver = spawn(GECKODRIVER, ['--port', String(driverPort), '-b', FIREFOX], { cwd: runtimeRoot, stdio: ['ignore', 'pipe', 'pipe'], }); let serverStderr = ''; let driverStderr = ''; server.stderr.on('data', (chunk) => { serverStderr += chunk.toString(); }); geckodriver.stderr.on('data', (chunk) => { driverStderr += chunk.toString(); }); const driver = new FirefoxDriver(driverPort); const evidence = { screenshots: {}, geometry: {}, audits: {}, runtime: {} }; try { await Promise.all([waitForPort(serverPort), waitForPort(driverPort)]); evidence.runtime.capabilities = await driver.start(); evidence.geometry.desktop = await driver.setWindow(1440, 900); await driver.navigate(`http://127.0.0.1:${serverPort}/`); await waitFor(() => driver.execute('return !!document.querySelector("#login-form")'), '登录页未加载'); await driver.execute(` const input = document.querySelector('#login-password'); input.value = arguments[0]; document.querySelector('#login-form').requestSubmit(); return true; `, [PASSWORD]); await waitFor(() => driver.execute('return !document.querySelector("#app")?.hidden && document.querySelector("#login-overlay")?.hidden'), '浏览器登录失败'); await waitFor(() => driver.execute(`return !!document.querySelector('.session-item[data-id="${SESSION_ID}"]')`), '隔离会话未出现在列表'); await driver.click(`.session-item[data-id="${SESSION_ID}"]`); await waitFor(() => driver.execute(`return document.querySelector('.session-item[data-id="${SESSION_ID}"]')?.classList.contains('active')`), '隔离会话未打开'); await waitFor(() => driver.execute('return !!document.querySelector("#task-tracking-control .ccweb-task-tracking__input")'), '任务跟踪开关未渲染'); await setTheme(driver, 'washi'); await driver.execute(` const input = document.querySelector('#msg-input'); input.focus(); input.value = '/'; input.setSelectionRange(1, 1); input.dispatchEvent(new Event('input', { bubbles: true })); return true; `); await waitFor(() => driver.execute(` const labels = Array.from(document.querySelectorAll('#cmd-menu .cmd-item-cmd')).map((node) => node.textContent.trim()); return labels[0] === 'mcp:ccweb/ccweb_prompt_user' && labels[1] === 'mcp:ccweb/ccweb_task_update' && !labels.includes('mcp:ccweb/ccweb_task_status_list'); `), 'tracked 空 / 未显示唯一任务 update 工具'); evidence.geometry.trackedSlash = await driver.execute(` const menu = document.querySelector('#cmd-menu'); const menuRect = menu.getBoundingClientRect(); const items = Array.from(menu.querySelectorAll('.cmd-item')).slice(0, 5).map((item, index) => { const rect = item.getBoundingClientRect(); return { index, label: item.querySelector('.cmd-item-cmd')?.textContent.trim() || '', top: rect.top, bottom: rect.bottom, height: rect.height, fullyVisible: rect.top >= menuRect.top && rect.bottom <= menuRect.top + menu.clientHeight, }; }); return { viewport: { width: innerWidth, height: innerHeight }, menu: { top: menuRect.top, bottom: menuRect.bottom, height: menuRect.height, clientHeight: menu.clientHeight, scrollHeight: menu.scrollHeight }, items, }; `); assert.deepEqual( evidence.geometry.trackedSlash.items.slice(0, 2).map((item) => item.label), ['mcp:ccweb/ccweb_prompt_user', 'mcp:ccweb/ccweb_task_update'], ); assert(evidence.geometry.trackedSlash.items.slice(0, 2).every((item) => item.fullyVisible), 'tracked 的动态任务工具未出现在菜单初始可视区域'); evidence.screenshots.trackedSlash = await driver.screenshot(path.join(evidenceRoot, 'tracked-empty-slash-washi-1440x900.png')); await driver.execute(` const input = document.querySelector('#msg-input'); input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); return true; `); await driver.click('#task-tracking-control .ccweb-task-tracking__input'); await waitFor(() => readJson(sessionPath).taskTracking?.enabled === false, '真实开关未将会话移出任务看板'); await waitFor(() => driver.execute(`return document.querySelector('#task-tracking-control .ccweb-task-tracking')?.dataset.enabled === 'false'`), '跟踪开关 UI 未显示未加入'); await driver.execute(` const input = document.querySelector('#msg-input'); input.focus(); input.value = '/'; input.setSelectionRange(1, 1); input.dispatchEvent(new Event('input', { bubbles: true })); return true; `); await waitFor(() => driver.execute(` const labels = Array.from(document.querySelectorAll('#cmd-menu .cmd-item-cmd')).map((node) => node.textContent.trim()); return labels[0] === 'mcp:ccweb/ccweb_prompt_user' && !labels.includes('mcp:ccweb/ccweb_task_status_list') && !labels.includes('mcp:ccweb/ccweb_task_update'); `), 'untracked 空 / 仍泄漏任务工具'); evidence.geometry.untrackedSlash = await driver.execute(` const menu = document.querySelector('#cmd-menu'); const menuRect = menu.getBoundingClientRect(); const labels = Array.from(menu.querySelectorAll('.cmd-item-cmd')).map((node) => node.textContent.trim()); return { viewport: { width: innerWidth, height: innerHeight }, menu: { top: menuRect.top, bottom: menuRect.bottom, height: menuRect.height }, labels, trackingText: document.querySelector('#task-tracking-control')?.innerText || '', }; `); assert.equal(evidence.geometry.untrackedSlash.labels.includes('mcp:ccweb/ccweb_task_update'), false); evidence.screenshots.untrackedSlash = await driver.screenshot(path.join(evidenceRoot, 'untracked-empty-slash-washi-1440x900.png')); await driver.execute(` const input = document.querySelector('#msg-input'); input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); return true; `); await driver.click('#task-tracking-control .ccweb-task-tracking__input'); await waitFor(() => readJson(sessionPath).taskTracking?.enabled === true, '真实开关未重新加入任务看板'); await waitFor(() => driver.execute(`return document.querySelector('#task-tracking-control .ccweb-task-tracking')?.dataset.enabled === 'true'`), '跟踪开关 UI 未显示已加入'); assert.equal(readJson(sessionPath).taskTracking.statusId, 'in_progress', '开关跟踪不得改变任务列'); await driver.execute(` const input = document.querySelector('#msg-input'); input.value = '/mo'; input.setSelectionRange(3, 3); input.dispatchEvent(new Event('input', { bubbles: true })); return true; `); await waitFor(() => driver.execute(`return Array.from(document.querySelectorAll('#cmd-menu .cmd-item-cmd')).some((node) => node.textContent.trim() === '/model')`), '保留组前置后 /model 不可搜索'); await driver.execute(` const input = document.querySelector('#msg-input'); input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); return true; `); await driver.click('#task-board-open'); await waitFor(() => driver.execute('return !document.querySelector("#task-board-panel")?.hidden && !!document.querySelector(".ccweb-task-board__column")'), '任务看板未打开'); const initialAudit = await auditBoard(driver); assert.equal(initialAudit.hasAgentFilter, false); assert.equal(initialAudit.hasPriorityFilter, false); assert.equal(initialAudit.hasCreateTask, false); assert.equal(initialAudit.hasRefreshControl, false); assert.equal(initialAudit.hasPercentage, false); assert.equal(initialAudit.hasProgressElement, false); assert.equal(initialAudit.hasRuntimeState, true); await driver.click('[data-action="manage-statuses"]'); await waitFor(() => driver.execute('return !document.querySelector(".ccweb-task-board__status-layer")?.hidden'), '列管理未打开'); const submittedShape = await driver.execute(` const form = Array.from(document.querySelectorAll('form[data-form-action="upsert-definition"]')) .find((candidate) => !candidate.dataset.definitionId); if (!form) return null; const values = { id: 'waiting-release', label: '等待发布', color: '#f97316', order: '38', enabled: true, prompt: arguments[0], }; for (const [name, value] of Object.entries(values)) { const control = form.querySelector('[name="' + name + '"]'); if (!control) return { missing: name }; if (name === 'enabled') control.checked = value; else control.value = value; control.dispatchEvent(new Event('input', { bubbles: true })); control.dispatchEvent(new Event('change', { bubbles: true })); } form.requestSubmit(); return ['id', 'label', 'color', 'order', 'enabled', 'system', 'prompt']; `, [INITIAL_PROMPT]); assert.deepEqual(submittedShape, ['id', 'label', 'color', 'order', 'enabled', 'system', 'prompt']); const statusConfigPath = path.join(configDir, 'task-board-statuses.json'); const persistedDefinition = await waitFor(() => statusDefinition(statusConfigPath), '真实前端新增列未持久化到隔离服务'); assert.equal(persistedDefinition.prompt, INITIAL_PROMPT); assert.equal(persistedDefinition.description || '', ''); assert.equal(Object.hasOwn(persistedDefinition, 'baseStatus'), false); assert.equal(Object.hasOwn(persistedDefinition, 'progress'), false); await waitFor(() => driver.execute('return !!document.querySelector(".ccweb-task-board__column[data-status-id=\\"waiting-release\\"]")'), '新增列未真实渲染'); await waitFor(() => driver.execute('return !!document.querySelector("form[data-definition-id=\\"waiting-release\\"] textarea[name=\\"prompt\\"]")'), '新增列编辑表单未渲染'); await driver.execute(` const form = document.querySelector('form[data-definition-id="waiting-release"]'); const prompt = form.querySelector('textarea[name="prompt"]'); prompt.value = arguments[0]; prompt.dispatchEvent(new Event('input', { bubbles: true })); form.requestSubmit(); return true; `, [EDITED_PROMPT]); await waitFor(() => statusDefinition(statusConfigPath)?.prompt === EDITED_PROMPT, '分类提示词编辑未持久化'); await driver.execute(` document.querySelector('form[data-definition-id="waiting-release"]') ?.scrollIntoView({ block: 'center', inline: 'nearest' }); return true; `); await sleep(160); evidence.geometry.waitingReleaseForm = await driver.execute(` const form = document.querySelector('form[data-definition-id="waiting-release"]'); const rect = form.getBoundingClientRect(); return { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right, id: form.querySelector('[name="id"]')?.value || '', label: form.querySelector('[name="label"]')?.value || '', prompt: form.querySelector('[name="prompt"]')?.value || '', }; `); assert.equal(evidence.geometry.waitingReleaseForm.id, STATUS_ID); assert.equal(evidence.geometry.waitingReleaseForm.prompt, EDITED_PROMPT); evidence.screenshots.statusManager = await driver.screenshot(path.join(evidenceRoot, 'status-manager-washi-1440x900.png')); await driver.click('[data-action="close-statuses"]'); await waitFor(() => driver.execute('return document.querySelector(".ccweb-task-board__status-layer")?.hidden'), '列管理未关闭'); await driver.execute(` const select = document.querySelector('[data-action="set-status"][data-session-id="${SESSION_ID}"]'); if (!select) return false; select.value = 'waiting-release'; select.dispatchEvent(new Event('change', { bubbles: true })); return true; `); await waitFor(() => readJson(sessionPath).taskTracking?.statusId === STATUS_ID, '自定义列状态未由真实 UI 写入会话'); await waitFor(() => driver.execute(`return document.querySelector('[data-session-id="${SESSION_ID}"]')?.dataset.statusId === 'waiting-release'`), '任务卡片未移动到新增列'); await driver.execute(` document.querySelector('.ccweb-task-board__column[data-status-id="waiting-release"]') ?.scrollIntoView({ block: 'nearest', inline: 'center' }); return true; `); await sleep(160); evidence.audits.washiDesktop = await auditBoard(driver); assert.equal(evidence.audits.washiDesktop.hasWaitingReleaseLane, true); evidence.screenshots.washiDesktop = await driver.screenshot(path.join(evidenceRoot, 'board-washi-1440x900.png')); await setTheme(driver, 'carbon'); evidence.audits.carbonDesktop = await auditBoard(driver); assert.notEqual(evidence.audits.washiDesktop.shellBackground, evidence.audits.carbonDesktop.shellBackground, '看板背景未继承 washi/carbon 主题变量'); evidence.screenshots.carbonDesktop = await driver.screenshot(path.join(evidenceRoot, 'board-carbon-1440x900.png')); await driver.click(`[data-action="set-archive"][data-session-id="${SESSION_ID}"]`); await waitFor(() => !!readJson(sessionPath).taskTracking?.archivedAt, '归档未写入隔离会话'); await driver.click('[data-action="toggle-archive"]'); await waitFor(() => driver.execute(`return !!document.querySelector('[data-action="set-archive"][data-session-id="${SESSION_ID}"][data-archived="false"]')`), '归档视图未显示任务'); await driver.click(`[data-action="set-archive"][data-session-id="${SESSION_ID}"]`); await waitFor(() => readJson(sessionPath).taskTracking?.archivedAt === null, '恢复未清除隔离会话归档状态'); await driver.click('[data-action="toggle-archive"]'); evidence.geometry.narrow = await driver.setWindow(500, 900); assert.equal(evidence.geometry.narrow.width, 500, 'Firefox 未提供要求的 500px 视口'); await setTheme(driver, 'washi'); evidence.audits.washiNarrow = await auditBoard(driver); assert(evidence.audits.washiNarrow.laneScrollWidth > evidence.audits.washiNarrow.laneClientWidth, '500px 看板 lanes 未形成横向滚动区域'); evidence.screenshots.washiNarrow = await driver.screenshot(path.join(evidenceRoot, 'board-washi-500x900.png')); await setTheme(driver, 'carbon'); evidence.audits.carbonNarrow = await auditBoard(driver); evidence.screenshots.carbonNarrow = await driver.screenshot(path.join(evidenceRoot, 'board-carbon-500x900.png')); evidence.geometry.requested390 = await driver.setWindow(390, 900); evidence.geometry.covered390 = evidence.geometry.requested390.width === 390; if (evidence.geometry.covered390) { evidence.screenshots.washi390 = await driver.screenshot(path.join(evidenceRoot, 'board-carbon-390x900.png')); } evidence.persistedDefinition = statusDefinition(statusConfigPath); evidence.persistedSessionStatus = readJson(sessionPath).taskTracking?.statusId; fs.writeFileSync(path.join(evidenceRoot, 'evidence.json'), JSON.stringify(evidence, null, 2)); console.log(JSON.stringify({ ok: true, evidenceRoot, ...evidence }, null, 2)); } catch (error) { error.message += `\nserver stderr:\n${serverStderr.slice(-4_000)}\ngeckodriver stderr:\n${driverStderr.slice(-4_000)}`; throw error; } finally { try { await driver.close(); } catch {} await stopChild(geckodriver); await stopChild(server); fs.rmSync(runtimeRoot, { recursive: true, force: true }); } } main().catch((error) => { console.error(error); process.exitCode = 1; });