'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 `
File README.md @01_设计书说明文档/wayfinder/
Dir hooks/ @01_设计书说明文档/wayfinder/
`; } 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; });