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