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