Files
cc-web/scripts/task-board-browser-integration.js

549 lines
24 KiB
JavaScript

'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;
});