2026-09-09 07:32:46 +00:00
|
|
|
#!/usr/bin/env node
|
|
|
|
|
// Capture README hero screenshots from a running LazyBoy instance.
|
|
|
|
|
//
|
|
|
|
|
// The banner must show the real app, so this drives a real browser against a
|
|
|
|
|
// real stack instead of painting a mock-up. No npm dependencies: it speaks
|
|
|
|
|
// Chrome DevTools Protocol over the browser WebSocket (Node 22+).
|
|
|
|
|
//
|
|
|
|
|
// node scripts/capture-hero.mjs --out docs/hero/agent.png
|
|
|
|
|
// node scripts/capture-hero.mjs --out docs/hero/room.png --pick 測試聊天
|
|
|
|
|
// node scripts/capture-hero.mjs --out docs/hero/desktop.png --selector .side-card
|
|
|
|
|
//
|
2026-09-09 07:55:39 +00:00
|
|
|
// `--lang` pins the UI language so each README gets a banner and a screenshot
|
|
|
|
|
// in the same language; the app itself defaults to zh-TW.
|
|
|
|
|
//
|
|
|
|
|
// node scripts/capture-hero.mjs --lang en --out docs/hero/room-en.png --pick 測試聊天
|
|
|
|
|
//
|
2026-09-09 07:32:46 +00:00
|
|
|
// A capture that goes into the repository must not photograph whatever the
|
|
|
|
|
// agent's browser happens to have open. `--hide` blanks a region (its layout
|
|
|
|
|
// box stays, so nothing reflows) and `--anchor` prints where that region sits
|
|
|
|
|
// inside the shot, which is what docs/hero.html uses to overlay a clean panel.
|
|
|
|
|
//
|
|
|
|
|
// node scripts/capture-hero.mjs --pick 測試聊天 --hide .side-card --anchor .side-card \
|
|
|
|
|
// --out docs/hero/room.png
|
|
|
|
|
// node scripts/capture-hero.mjs --pick 阿狗 --selector .side-card --out docs/hero/desktop.png
|
|
|
|
|
//
|
|
|
|
|
// Environment:
|
|
|
|
|
// LB_URL stack to photograph (default http://127.0.0.1:3101)
|
2026-09-09 16:59:32 +00:00
|
|
|
// LB_USER account to sign in as (default: hero)
|
|
|
|
|
// LB_PASS that account's password (default below; registered on demand)
|
2026-09-09 07:32:46 +00:00
|
|
|
// CHROME browser binary (default: discovered, see findChrome)
|
|
|
|
|
import { spawn } from 'node:child_process';
|
|
|
|
|
import fs from 'node:fs';
|
|
|
|
|
import os from 'node:os';
|
|
|
|
|
import path from 'node:path';
|
|
|
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
|
|
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
|
|
|
|
|
|
const option = (name, fallback) => {
|
|
|
|
|
const index = process.argv.indexOf(`--${name}`);
|
|
|
|
|
return index > -1 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const config = {
|
|
|
|
|
base: (process.env.LB_URL || 'http://127.0.0.1:3101').replace(/\/$/, ''),
|
|
|
|
|
out: option('out', 'docs/hero/app.png'),
|
|
|
|
|
pick: option('pick', ''),
|
|
|
|
|
selector: option('selector', '.app-shell'),
|
2026-09-09 07:55:39 +00:00
|
|
|
lang: option('lang', 'zh-TW'),
|
2026-09-09 07:32:46 +00:00
|
|
|
hide: option('hide', ''),
|
|
|
|
|
anchor: option('anchor', ''),
|
|
|
|
|
width: Number(option('width', 1480)),
|
|
|
|
|
// 1480x650 keeps the 1184x520 image box in docs/hero.html free of cropping.
|
|
|
|
|
height: Number(option('height', 650)),
|
|
|
|
|
scale: Number(option('scale', 2)),
|
|
|
|
|
settle: Number(option('settle', 6000)),
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-09 07:55:39 +00:00
|
|
|
if (!['zh-TW', 'en'].includes(config.lang)) {
|
|
|
|
|
throw new Error(`--lang must be zh-TW or en, got "${config.lang}"`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 07:32:46 +00:00
|
|
|
function findChrome() {
|
|
|
|
|
const candidates = [
|
|
|
|
|
process.env.CHROME,
|
|
|
|
|
...['chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable', 'brave', 'microsoft-edge']
|
|
|
|
|
.map((name) => process.env.PATH.split(path.delimiter).map((dir) => path.join(dir, name)).find((bin) => fs.existsSync(bin))),
|
|
|
|
|
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
|
|
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
|
|
|
].filter(Boolean);
|
|
|
|
|
for (const candidate of candidates) if (fs.existsSync(candidate)) return candidate;
|
|
|
|
|
const playwright = path.join(os.homedir(), '.cache/ms-playwright');
|
|
|
|
|
if (fs.existsSync(playwright)) {
|
|
|
|
|
for (const build of fs.readdirSync(playwright).sort().reverse()) {
|
|
|
|
|
for (const dir of ['chrome-linux64', 'chrome-mac']) {
|
|
|
|
|
const bin = path.join(playwright, build, dir, 'chrome');
|
|
|
|
|
if (fs.existsSync(bin)) return bin;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
throw new Error('no Chromium found; set CHROME=/path/to/chrome');
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 16:59:32 +00:00
|
|
|
// Screenshots are taken against a throwaway stack, so the script is allowed to
|
|
|
|
|
// create the account it photographs; LB_USER/LB_PASS override both halves.
|
|
|
|
|
function credentials() {
|
|
|
|
|
return {
|
|
|
|
|
username: (process.env.LB_USER || 'hero').trim().toLowerCase(),
|
|
|
|
|
password: process.env.LB_PASS || 'lazyboy-hero-2026',
|
|
|
|
|
};
|
2026-09-09 07:32:46 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-09 16:59:32 +00:00
|
|
|
// Evaluated inside the page so the session cookie lands in the browser that
|
|
|
|
|
// takes the screenshot. An unknown account is registered instead of failing,
|
|
|
|
|
// which keeps a freshly started stack photographable without setup.
|
|
|
|
|
const SIGN_IN_HELPER = String.raw`async function signIn({ username, password }) {
|
|
|
|
|
const call = async (path) => {
|
|
|
|
|
const response = await fetch(path, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
body: JSON.stringify({ username, password }),
|
|
|
|
|
});
|
|
|
|
|
return response.status;
|
|
|
|
|
};
|
|
|
|
|
const status = await call('/api/auth/login');
|
|
|
|
|
return status === 401 ? call('/api/auth/register') : status;
|
|
|
|
|
}`;
|
|
|
|
|
|
2026-09-09 07:32:46 +00:00
|
|
|
async function waitForVersion(port) {
|
|
|
|
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
|
|
|
|
|
if (response.ok) return await response.json();
|
|
|
|
|
} catch {
|
|
|
|
|
// Chrome is still coming up.
|
|
|
|
|
}
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
|
|
|
}
|
|
|
|
|
throw new Error('Chrome did not expose a DevTools endpoint');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Minimal flat CDP client: one WebSocket, promise-per-id, plus event waiting.
|
|
|
|
|
class Connection {
|
|
|
|
|
constructor(socket) {
|
|
|
|
|
this.socket = socket;
|
|
|
|
|
this.nextId = 1;
|
|
|
|
|
this.pending = new Map();
|
|
|
|
|
this.listeners = new Map();
|
|
|
|
|
socket.addEventListener('message', (event) => {
|
|
|
|
|
const message = JSON.parse(event.data);
|
|
|
|
|
if (message.id) {
|
|
|
|
|
const waiter = this.pending.get(message.id);
|
|
|
|
|
if (!waiter) return;
|
|
|
|
|
this.pending.delete(message.id);
|
|
|
|
|
if (message.error) waiter.reject(new Error(`${message.method}: ${message.error.message}`));
|
|
|
|
|
else waiter.resolve(message.result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const handlers = this.listeners.get(message.method) || [];
|
|
|
|
|
this.listeners.set(message.method, handlers.filter((handler) => !handler.once));
|
|
|
|
|
handlers.forEach((handler) => handler.resolve(message.params));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
send(method, params = {}, sessionId) {
|
|
|
|
|
const id = this.nextId;
|
|
|
|
|
this.nextId += 1;
|
|
|
|
|
this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
|
|
|
|
return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
waitFor(method, sessionId) {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const handlers = this.listeners.get(method) || [];
|
|
|
|
|
handlers.push({ resolve, once: true });
|
|
|
|
|
this.listeners.set(method, handlers);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Retry an evaluate call until it returns something other than null. The app
|
|
|
|
|
// loads its lists over the API, so a single shot races the first render.
|
|
|
|
|
const retry = async (attempt) => {
|
|
|
|
|
for (let pass = 0; pass < 40; pass += 1) {
|
|
|
|
|
const value = await attempt();
|
|
|
|
|
if (value) return value;
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const launch = async () => {
|
|
|
|
|
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'lazyboy-hero-'));
|
|
|
|
|
const port = 9400 + Math.floor(Math.random() * 200);
|
|
|
|
|
const args = [
|
|
|
|
|
'--headless=new',
|
|
|
|
|
`--remote-debugging-port=${port}`,
|
|
|
|
|
`--user-data-dir=${profile}`,
|
|
|
|
|
'--no-first-run',
|
|
|
|
|
'--no-default-browser-check',
|
|
|
|
|
'--disable-gpu',
|
|
|
|
|
'--hide-scrollbars',
|
|
|
|
|
'--force-color-profile=srgb',
|
|
|
|
|
`--window-size=${config.width},${config.height}`,
|
|
|
|
|
...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
|
|
|
|
|
'about:blank',
|
|
|
|
|
];
|
|
|
|
|
const chrome = spawn(findChrome(), args, { stdio: 'ignore' });
|
|
|
|
|
chrome.on('exit', (code) => {
|
|
|
|
|
if (code && code !== 0) console.error(`chrome exited early with code ${code}`);
|
|
|
|
|
});
|
|
|
|
|
const cleanup = () => {
|
|
|
|
|
chrome.kill('SIGKILL');
|
|
|
|
|
fs.rmSync(profile, { recursive: true, force: true });
|
|
|
|
|
};
|
|
|
|
|
const version = await waitForVersion(port);
|
|
|
|
|
return { cleanup, version };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const { cleanup, version } = await launch();
|
|
|
|
|
process.on('exit', cleanup);
|
|
|
|
|
try {
|
|
|
|
|
const socket = new WebSocket(version.webSocketDebuggerUrl);
|
|
|
|
|
await new Promise((resolve, reject) => {
|
|
|
|
|
socket.addEventListener('open', resolve, { once: true });
|
|
|
|
|
socket.addEventListener('error', () => reject(new Error('DevTools WebSocket failed')), { once: true });
|
|
|
|
|
});
|
|
|
|
|
const connection = new Connection(socket);
|
|
|
|
|
const { targetId } = await connection.send('Target.createTarget', { url: 'about:blank' });
|
|
|
|
|
const { sessionId } = await connection.send('Target.attachToTarget', { targetId, flatten: true });
|
|
|
|
|
await connection.send('Page.enable', {}, sessionId);
|
|
|
|
|
await connection.send(
|
|
|
|
|
'Emulation.setDeviceMetricsOverride',
|
|
|
|
|
{ width: config.width, height: config.height, deviceScaleFactor: config.scale, mobile: false },
|
|
|
|
|
sessionId,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const goto = async (url) => {
|
|
|
|
|
const loaded = connection.waitFor('Page.loadEventFired', sessionId);
|
|
|
|
|
await connection.send('Page.navigate', { url }, sessionId);
|
|
|
|
|
await loaded;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await goto(`${config.base}/`);
|
|
|
|
|
const status = await connection.send(
|
|
|
|
|
'Runtime.evaluate',
|
|
|
|
|
{
|
2026-09-09 16:59:32 +00:00
|
|
|
expression: `${SIGN_IN_HELPER}\nsignIn(${JSON.stringify(credentials())})`,
|
2026-09-09 07:32:46 +00:00
|
|
|
awaitPromise: true,
|
|
|
|
|
returnByValue: true,
|
|
|
|
|
},
|
|
|
|
|
sessionId,
|
|
|
|
|
).then((result) => result.result?.value);
|
|
|
|
|
if (status !== 200) throw new Error(`login failed with HTTP ${status}`);
|
2026-09-09 07:55:39 +00:00
|
|
|
// The app reads its locale once, at module init, so pin it before the reload
|
|
|
|
|
// that mounts the real view.
|
|
|
|
|
await connection.send(
|
|
|
|
|
'Runtime.evaluate',
|
|
|
|
|
{ expression: `localStorage.setItem('lazyboy.locale', ${JSON.stringify(config.lang)})` },
|
|
|
|
|
sessionId,
|
|
|
|
|
);
|
2026-09-09 07:32:46 +00:00
|
|
|
await goto(`${config.base}/`);
|
|
|
|
|
|
|
|
|
|
if (config.pick) {
|
|
|
|
|
// The sidebar fills in after the first API responses, so retry until the
|
|
|
|
|
// entry is really there before clicking.
|
|
|
|
|
const clicked = await retry(async () => connection.send(
|
|
|
|
|
'Runtime.evaluate',
|
|
|
|
|
{
|
|
|
|
|
expression: `(() => {
|
|
|
|
|
const label = ${JSON.stringify(config.pick)};
|
|
|
|
|
const hits = [...document.querySelectorAll('button,[role="button"],li,div')].filter((el) => {
|
|
|
|
|
const text = (el.innerText || '').trim();
|
|
|
|
|
return text.startsWith(label) && text.length < 90 && el.offsetParent !== null
|
|
|
|
|
&& el.getBoundingClientRect().width < 420;
|
|
|
|
|
});
|
|
|
|
|
if (!hits.length) return null;
|
|
|
|
|
hits.sort((a, b) => a.innerText.length - b.innerText.length)[0].click();
|
|
|
|
|
return 'clicked';
|
|
|
|
|
})()`,
|
|
|
|
|
returnByValue: true,
|
|
|
|
|
},
|
|
|
|
|
sessionId,
|
|
|
|
|
).then((result) => result.result?.value));
|
|
|
|
|
if (clicked !== 'clicked') throw new Error(`sidebar entry "${config.pick}" not found`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The noVNC panel is a live stream; give it time to paint a real desktop.
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, config.settle));
|
|
|
|
|
|
|
|
|
|
if (config.hide) {
|
|
|
|
|
const hidden = await connection.send(
|
|
|
|
|
'Runtime.evaluate',
|
|
|
|
|
{
|
|
|
|
|
expression: `document.querySelectorAll(${JSON.stringify(config.hide)}).length`,
|
|
|
|
|
returnByValue: true,
|
|
|
|
|
},
|
|
|
|
|
sessionId,
|
|
|
|
|
).then((result) => result.result?.value);
|
|
|
|
|
// A typo in --hide would silently ship a photograph of the live desktop.
|
|
|
|
|
if (!hidden) throw new Error(`--hide ${config.hide} matched nothing; refusing to capture`);
|
|
|
|
|
// A stylesheet survives React re-renders; an inline style does not.
|
|
|
|
|
await connection.send(
|
|
|
|
|
'Runtime.evaluate',
|
|
|
|
|
{
|
|
|
|
|
expression: `(() => {
|
|
|
|
|
const style = document.createElement('style');
|
|
|
|
|
const rules = ${JSON.stringify(config.hide)}.split(',')
|
|
|
|
|
.map((s) => s.trim()).filter(Boolean)
|
|
|
|
|
.map((s) => s + ' { visibility: hidden !important }').join('\\n');
|
|
|
|
|
style.textContent = rules;
|
|
|
|
|
document.head.appendChild(style);
|
|
|
|
|
return document.querySelectorAll(${JSON.stringify(config.hide)}).length;
|
|
|
|
|
})()`,
|
|
|
|
|
returnByValue: true,
|
|
|
|
|
},
|
|
|
|
|
sessionId,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rect = await connection.send(
|
|
|
|
|
'Runtime.evaluate',
|
|
|
|
|
{
|
|
|
|
|
expression: `(() => {
|
|
|
|
|
const el = document.querySelector(${JSON.stringify(config.selector)});
|
|
|
|
|
if (!el) return null;
|
|
|
|
|
const box = el.getBoundingClientRect();
|
|
|
|
|
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
|
|
|
|
})()`,
|
|
|
|
|
returnByValue: true,
|
|
|
|
|
},
|
|
|
|
|
sessionId,
|
|
|
|
|
).then((result) => result.result?.value);
|
|
|
|
|
if (!rect) throw new Error(`selector ${config.selector} not found`);
|
|
|
|
|
|
|
|
|
|
let placed = '';
|
|
|
|
|
if (config.anchor) {
|
|
|
|
|
const box = await connection.send(
|
|
|
|
|
'Runtime.evaluate',
|
|
|
|
|
{
|
|
|
|
|
expression: `(() => {
|
|
|
|
|
const host = document.querySelector(${JSON.stringify(config.selector)});
|
|
|
|
|
const el = document.querySelector(${JSON.stringify(config.anchor)});
|
|
|
|
|
if (!host || !el) return null;
|
|
|
|
|
const a = el.getBoundingClientRect();
|
|
|
|
|
const b = host.getBoundingClientRect();
|
|
|
|
|
return { x: a.x - b.x, y: a.y - b.y, width: a.width, height: a.height };
|
|
|
|
|
})()`,
|
|
|
|
|
returnByValue: true,
|
|
|
|
|
},
|
|
|
|
|
sessionId,
|
|
|
|
|
).then((result) => result.result?.value);
|
|
|
|
|
if (!box) throw new Error(`anchor ${config.anchor} not found`);
|
|
|
|
|
placed = ` anchor ${box.x.toFixed(1)},${box.y.toFixed(1)} ${box.width.toFixed(1)}x${box.height.toFixed(1)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const shot = await connection.send(
|
|
|
|
|
'Page.captureScreenshot',
|
|
|
|
|
{ format: 'png', clip: { ...rect, scale: 1 }, captureBeyondViewport: true },
|
|
|
|
|
sessionId,
|
|
|
|
|
);
|
|
|
|
|
const file = path.resolve(root, config.out);
|
|
|
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
|
|
|
fs.writeFileSync(file, Buffer.from(shot.data, 'base64'));
|
|
|
|
|
const bytes = fs.statSync(file).size;
|
|
|
|
|
console.log(`wrote ${path.relative(root, file)} ${Math.round(rect.width * config.scale)}x${Math.round(rect.height * config.scale)} (${bytes} bytes)${placed}`);
|
|
|
|
|
} finally {
|
|
|
|
|
cleanup();
|
|
|
|
|
}
|