Files
cc-web/scripts/task-board-runtime-animation-browser.js

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