387 lines
14 KiB
JavaScript
387 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert');
|
|
const fs = require('node:fs');
|
|
const http = require('node:http');
|
|
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 PUBLIC_DIR = path.join(REPO_DIR, 'public');
|
|
const GECKODRIVER = '/snap/firefox/current/usr/lib/firefox/geckodriver';
|
|
const FIREFOX = '/snap/firefox/current/usr/lib/firefox/firefox';
|
|
|
|
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(options = {}) {
|
|
const reducedMotion = options.reducedMotion === true;
|
|
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',
|
|
'ui.prefersReducedMotion': reducedMotion ? 1 : 0,
|
|
'media.prefers-reduced-motion': reducedMotion ? 1 : 0,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
this.sessionId = value.sessionId;
|
|
return value.capabilities;
|
|
}
|
|
|
|
execute(script, args = []) {
|
|
return this.request('POST', `/session/${this.sessionId}/execute/sync`, { script, args });
|
|
}
|
|
|
|
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 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 fixtureHtml() {
|
|
return `<!doctype html>
|
|
<html lang="zh-CN" data-theme="wasteland">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<link rel="stylesheet" href="/style.css">
|
|
<link rel="stylesheet" href="/task-board.css">
|
|
<style>html,body,#root{width:100%;height:100%;margin:0;overflow:hidden}</style>
|
|
</head>
|
|
<body>
|
|
<main id="root"></main>
|
|
<script src="/task-board.js"></script>
|
|
<script>
|
|
window.runtimeBoard = window.CcwebTaskBoard.mount(document.querySelector('#root'), {
|
|
document,
|
|
autoLoad: false,
|
|
initialData: {
|
|
statusDefinitions: [
|
|
{ id: 'in_progress', label: '处理中', prompt: '任务正在实际推进时选择此列。', color: '#c49a5a', order: 10, enabled: true, system: true }
|
|
],
|
|
definitionsVersion: 1,
|
|
tasks: [
|
|
{
|
|
sessionId: 'runtime-running',
|
|
title: '正在执行的对话任务',
|
|
isRunning: true,
|
|
runtimeState: '运行中',
|
|
taskTracking: { enabled: true, statusId: 'in_progress', summary: '卡片边缘展示活动轨迹', version: 2 }
|
|
},
|
|
{
|
|
sessionId: 'runtime-idle',
|
|
title: '未运行的对话任务',
|
|
isRunning: false,
|
|
runtimeState: '未运行',
|
|
taskTracking: { enabled: true, statusId: 'in_progress', summary: '保持静态卡片样式', version: 1 }
|
|
}
|
|
]
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function startStaticServer(port) {
|
|
const allowed = new Map([
|
|
['/style.css', ['style.css', 'text/css; charset=utf-8']],
|
|
['/task-board.css', ['task-board.css', 'text/css; charset=utf-8']],
|
|
['/task-board.js', ['task-board.js', 'text/javascript; charset=utf-8']],
|
|
]);
|
|
const server = http.createServer((request, response) => {
|
|
const pathname = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`).pathname;
|
|
if (pathname === '/') {
|
|
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
response.end(fixtureHtml());
|
|
return;
|
|
}
|
|
const asset = allowed.get(pathname);
|
|
if (!asset) {
|
|
response.writeHead(404);
|
|
response.end('Not found');
|
|
return;
|
|
}
|
|
response.writeHead(200, { 'content-type': asset[1], 'cache-control': 'no-store' });
|
|
response.end(fs.readFileSync(path.join(PUBLIC_DIR, asset[0])));
|
|
});
|
|
return new Promise((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(port, '127.0.0.1', () => resolve(server));
|
|
});
|
|
}
|
|
|
|
async function audit(driver) {
|
|
return driver.execute(`
|
|
const running = document.querySelector('[data-session-id="runtime-running"]');
|
|
const idle = document.querySelector('[data-session-id="runtime-idle"]');
|
|
const rect = (node) => {
|
|
const value = node.getBoundingClientRect();
|
|
return { x: value.x, y: value.y, width: value.width, height: value.height };
|
|
};
|
|
const style = (node, pseudo) => {
|
|
const value = getComputedStyle(node, pseudo || null);
|
|
return {
|
|
content: value.content,
|
|
animationName: value.animationName,
|
|
animationDuration: value.animationDuration,
|
|
borderColor: value.borderColor,
|
|
backgroundColor: value.backgroundColor,
|
|
boxShadow: value.boxShadow,
|
|
left: value.left,
|
|
top: value.top,
|
|
width: value.width,
|
|
height: value.height,
|
|
opacity: value.opacity,
|
|
};
|
|
};
|
|
return {
|
|
theme: document.documentElement.dataset.theme,
|
|
reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
|
|
viewport: { width: innerWidth, height: innerHeight, scrollWidth: document.documentElement.scrollWidth },
|
|
running: {
|
|
runtimeState: running?.dataset.runtimeState || '',
|
|
statusId: running?.dataset.statusId || '',
|
|
rect: rect(running),
|
|
style: style(running),
|
|
rail: style(running, '::before'),
|
|
tracer: style(running, '::after'),
|
|
badge: style(running.querySelector('.ccweb-task-board__runtime-state')),
|
|
badgeDot: style(running.querySelector('.ccweb-task-board__runtime-state'), '::before'),
|
|
},
|
|
idle: {
|
|
runtimeState: idle?.dataset.runtimeState || '',
|
|
statusId: idle?.dataset.statusId || '',
|
|
rect: rect(idle),
|
|
style: style(idle),
|
|
tracer: style(idle, '::after'),
|
|
},
|
|
};
|
|
`);
|
|
}
|
|
|
|
function rectDelta(left, right) {
|
|
return Math.max(...['x', 'y', 'width', 'height'].map((key) => Math.abs(left[key] - right[key])));
|
|
}
|
|
|
|
async function runBrowserCase(driver, url, evidenceRoot, options = {}) {
|
|
const reducedMotion = options.reducedMotion === true;
|
|
await driver.start({ reducedMotion });
|
|
const desktop = await driver.setWindow(1024, 760);
|
|
await driver.navigate(url);
|
|
await waitFor(() => driver.execute(`
|
|
return document.querySelector('[data-session-id="runtime-running"]')?.dataset.runtimeState === 'running'
|
|
&& document.querySelector('[data-session-id="runtime-idle"]')?.dataset.runtimeState === 'idle';
|
|
`), '运行态卡片未完成真实挂载');
|
|
|
|
const first = await audit(driver);
|
|
const prefix = reducedMotion ? 'reduced-motion' : 'animated';
|
|
const screenshots = {
|
|
first: await driver.screenshot(path.join(evidenceRoot, `${prefix}-desktop-first.png`)),
|
|
};
|
|
const samples = [first];
|
|
if (!reducedMotion) {
|
|
for (let index = 0; index < 6; index += 1) {
|
|
await sleep(240);
|
|
samples.push(await audit(driver));
|
|
}
|
|
screenshots.second = await driver.screenshot(path.join(evidenceRoot, `${prefix}-desktop-second.png`));
|
|
}
|
|
|
|
assert.equal(first.theme, 'wasteland');
|
|
assert.equal(first.reducedMotion, reducedMotion);
|
|
assert.equal(first.running.runtimeState, 'running');
|
|
assert.equal(first.idle.runtimeState, 'idle');
|
|
assert.equal(first.running.statusId, 'in_progress');
|
|
assert.equal(first.idle.statusId, 'in_progress');
|
|
assert.equal(first.running.style.animationName, 'none');
|
|
assert.equal(first.idle.style.animationName, 'none');
|
|
assert.equal(first.running.style.backgroundColor, first.idle.style.backgroundColor);
|
|
assert.equal(first.running.style.borderColor, first.idle.style.borderColor);
|
|
assert.equal(first.running.style.boxShadow, first.idle.style.boxShadow);
|
|
assert.notEqual(first.running.rail.content, 'none');
|
|
assert(Number.parseFloat(first.running.rail.width) > 0, '左侧运行指示条没有可见宽度');
|
|
assert(Number.parseFloat(first.running.rail.opacity) > 0, '左侧运行指示条不可见');
|
|
assert.equal(first.running.rail.animationName, 'none');
|
|
assert.equal(first.running.badgeDot.animationName, 'none');
|
|
|
|
if (reducedMotion) {
|
|
assert.equal(first.running.tracer.animationName, 'none');
|
|
} else {
|
|
assert.match(first.running.tracer.animationName, /ccweb-task-board-wasteland-runtime-tracer/);
|
|
const tracerPositions = samples.map((sample) => Number.parseFloat(sample.running.tracer.top)).filter(Number.isFinite);
|
|
assert(Math.max(...tracerPositions) - Math.min(...tracerPositions) > 8, '左侧活动轨迹没有形成可见位移');
|
|
assert(samples.every((sample) => rectDelta(first.running.rect, sample.running.rect) < 0.5), '运行动画导致卡片几何抖动');
|
|
}
|
|
|
|
const narrow = await driver.setWindow(500, 760);
|
|
await sleep(120);
|
|
const narrowAudit = await audit(driver);
|
|
assert.equal(narrow.width, 500);
|
|
assert(narrowAudit.viewport.scrollWidth <= narrowAudit.viewport.width, '窄屏产生页面级横向溢出');
|
|
screenshots.narrow = await driver.screenshot(path.join(evidenceRoot, `${prefix}-500x760.png`));
|
|
return { desktop, first, samples, narrow, narrowAudit, screenshots };
|
|
}
|
|
|
|
async function main() {
|
|
assert(fs.existsSync(GECKODRIVER), `缺少 geckodriver: ${GECKODRIVER}`);
|
|
assert(fs.existsSync(FIREFOX), `缺少 Firefox: ${FIREFOX}`);
|
|
|
|
const evidenceRoot = path.join(os.tmpdir(), `cc-web-wasteland-runtime-animation-${Date.now()}`);
|
|
fs.mkdirSync(evidenceRoot, { recursive: true });
|
|
const [serverPort, driverPort] = await Promise.all([freePort(), freePort()]);
|
|
const staticServer = await startStaticServer(serverPort);
|
|
const geckodriver = spawn(GECKODRIVER, ['--port', String(driverPort), '-b', FIREFOX], {
|
|
cwd: REPO_DIR,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let driverStderr = '';
|
|
geckodriver.stderr.on('data', (chunk) => { driverStderr += chunk.toString(); });
|
|
|
|
const evidence = { evidenceRoot, animated: null, reducedMotion: null };
|
|
try {
|
|
await waitForPort(driverPort);
|
|
const animatedDriver = new FirefoxDriver(driverPort);
|
|
try {
|
|
evidence.animated = await runBrowserCase(
|
|
animatedDriver,
|
|
`http://127.0.0.1:${serverPort}/`,
|
|
evidenceRoot,
|
|
{ reducedMotion: false },
|
|
);
|
|
} finally {
|
|
await animatedDriver.close();
|
|
}
|
|
|
|
const reducedDriver = new FirefoxDriver(driverPort);
|
|
try {
|
|
evidence.reducedMotion = await runBrowserCase(
|
|
reducedDriver,
|
|
`http://127.0.0.1:${serverPort}/`,
|
|
evidenceRoot,
|
|
{ reducedMotion: true },
|
|
);
|
|
} finally {
|
|
await reducedDriver.close();
|
|
}
|
|
|
|
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 += `\ngeckodriver stderr:\n${driverStderr.slice(-4_000)}`;
|
|
throw error;
|
|
} finally {
|
|
await new Promise((resolve) => staticServer.close(resolve));
|
|
await stopChild(geckodriver);
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|