feat(task-board): refine running state indicator
This commit is contained in:
386
scripts/task-board-runtime-animation-browser.js
Normal file
386
scripts/task-board-runtime-animation-browser.js
Normal file
@@ -0,0 +1,386 @@
|
||||
'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;
|
||||
});
|
||||
Reference in New Issue
Block a user