Files
cc-web/scripts/composer-file-display-browser.js

235 lines
8.6 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 STYLE_PATH = path.join(REPO_DIR, 'public', 'style.css');
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(`等待 geckodriver 端口 ${port} 超时`);
}
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',
'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;
}
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 screenshot(targetPath) {
const base64 = await this.request('GET', `/session/${this.sessionId}/screenshot`);
fs.writeFileSync(targetPath, Buffer.from(base64, 'base64'));
}
async close() {
if (!this.sessionId) return;
try {
await this.request('DELETE', `/session/${this.sessionId}`);
} finally {
this.sessionId = '';
}
}
}
function fixtureHtml() {
const style = fs.readFileSync(STYLE_PATH, 'utf8');
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>${style}</style>
<style>
:root { --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
html, body { min-height: 100%; }
body { margin: 0; padding: 24px; background: var(--bg-primary); color: var(--text-primary); }
.fixture-stage { min-height: 460px; position: relative; }
#cmd-menu { position: relative; inset: auto; transform: none; display: block; margin: 0 auto; }
</style>
</head>
<body>
<main class="fixture-stage">
<div id="cmd-menu" class="cmd-menu">
<div class="cmd-item file-suggestion active" data-index="0" title="@01_设计书说明文档/wayfinder/README.md">
<span class="cmd-item-kind">File</span>
<span class="cmd-item-main">
<span class="cmd-item-cmd">README.md</span>
<span class="cmd-item-desc">@01_设计书说明文档/wayfinder/</span>
</span>
</div>
<div class="cmd-item file-suggestion" data-index="1" title="@01_设计书说明文档/wayfinder/hooks/">
<span class="cmd-item-kind">Dir</span>
<span class="cmd-item-main">
<span class="cmd-item-cmd">hooks/</span>
<span class="cmd-item-desc">@01_设计书说明文档/wayfinder/</span>
</span>
</div>
</div>
</main>
</body>
</html>`;
}
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-composer-file-browser-'));
const evidenceRoot = path.join(runtimeRoot, 'evidence');
fs.mkdirSync(evidenceRoot, { recursive: true });
const fixturePath = path.join(runtimeRoot, 'fixture.html');
fs.writeFileSync(fixturePath, fixtureHtml());
const driverPort = await freePort();
const geckodriver = spawn(GECKODRIVER, ['--port', String(driverPort), '-b', FIREFOX], {
cwd: runtimeRoot,
stdio: ['ignore', 'pipe', 'pipe'],
});
let driverStderr = '';
geckodriver.stderr.on('data', (chunk) => { driverStderr += chunk.toString(); });
const driver = new FirefoxDriver(driverPort);
const evidence = { screenshots: {}, audits: {} };
try {
await waitForPort(driverPort);
await driver.start();
await driver.navigate(`file://${fixturePath}`);
for (const viewport of [{ name: 'desktop', width: 1440, height: 900 }, { name: 'narrow', width: 375, height: 667 }]) {
const rect = await driver.setWindow(viewport.width, viewport.height);
const audit = await driver.execute(`
const menu = document.querySelector('#cmd-menu');
const menuRect = menu.getBoundingClientRect();
const items = Array.from(menu.querySelectorAll('.file-suggestion')).map((item) => {
const itemRect = item.getBoundingClientRect();
const main = item.querySelector('.cmd-item-main');
return {
primary: item.querySelector('.cmd-item-cmd')?.textContent.trim() || '',
secondary: item.querySelector('.cmd-item-desc')?.textContent.trim() || '',
right: itemRect.right,
fullyInsideMenu: itemRect.left >= menuRect.left && itemRect.right <= menuRect.right,
mainOverflow: main ? main.scrollWidth > main.clientWidth + 1 : false,
};
});
return {
viewport: { width: innerWidth, height: innerHeight },
window: arguments[0],
menu: { left: menuRect.left, right: menuRect.right, width: menuRect.width, scrollWidth: menu.scrollWidth },
items,
};
`, [rect]);
assert(audit.menu.right <= audit.viewport.width + 1, `${viewport.name} 菜单超出视口:${JSON.stringify(audit)}`);
assert(audit.menu.scrollWidth <= audit.menu.width + 1, `${viewport.name} 菜单出现横向溢出:${JSON.stringify(audit)}`);
assert.deepEqual(audit.items.map((item) => item.primary), ['README.md', 'hooks/']);
assert(audit.items.every((item) => item.secondary.includes('@01_设计书说明文档/wayfinder/')), `${viewport.name} 父路径不可见`);
assert(audit.items.every((item) => item.fullyInsideMenu && !item.mainOverflow), `${viewport.name} 候选内容溢出`);
evidence.audits[viewport.name] = audit;
const screenshotPath = path.join(evidenceRoot, `${viewport.name}-1440x900-or-375x667.png`);
await driver.screenshot(screenshotPath);
evidence.screenshots[viewport.name] = screenshotPath;
}
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 {
try { await driver.close(); } catch {}
await stopChild(geckodriver);
}
}
main().catch((error) => {
console.error(error.stack || error.message || String(error));
process.exitCode = 1;
});