235 lines
8.6 KiB
JavaScript
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;
|
|
});
|
|
|