549 lines
24 KiB
JavaScript
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;
|
|
});
|