/**
* Агентная обвязка v2 — браузерный каркас проверок (часть ядра, решение v2).
* Жанронезависимая: порт/корень/приложение — параметры вызывающей стороны.
* Образец — agent-lib.mjs v1; урок v1 №2: визуальная подсистема готова
* только со скриншот-гейтом, поэтому обвязка живёт здесь с первого дня.
*
* Экспорт: launchBrowser, startDevServer, openGame, AgentClient, Checks, parseArgs.
* Запуск в headless Chromium (swiftshader) — WebGL работает без GPU.
*/
import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
import puppeteer from 'puppeteer-core';
export { sleep };
const CHROMIUM = process.env.CHROMIUM || '/usr/bin/chromium';
const BROWSER_FLAGS = [
'--no-sandbox',
'--enable-unsafe-swiftshader',
'--use-angle=swiftshader',
'--autoplay-policy=no-user-gesture-required',
];
export async function launchBrowser({ viewport = { width: 960, height: 540 } } = {}) {
return puppeteer.launch({
executablePath: CHROMIUM,
headless: true,
args: [...BROWSER_FLAGS, `--window-size=${viewport.width},${viewport.height}`],
defaultViewport: viewport,
});
}
/**
* Vite dev-сервер приложения (cwd — каталог приложения). Отдельная группа
* процессов, SIGTERM по завершении; если порт занят — сразу выходим
* (значит, сервер уже поднят снаружи).
*/
export function startDevServer({ port = 5299, cwd } = {}) {
const child = spawn('npx', ['vite', '--port', String(port), '--strictPort'], {
cwd,
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
const waitReady = new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`vite не поднялся за 30 с (${cwd})`)), 30000);
child.stdout.on('data', (d) => {
if (String(d).includes('Local:')) { clearTimeout(timer); resolve(); }
});
child.on('exit', (code) => { clearTimeout(timer); reject(new Error(`vite упал (code ${code})`)); });
});
return {
child,
waitReady,
stop() {
try { process.kill(-child.pid, 'SIGTERM'); } catch { /* уже умер */ }
},
};
}
/** Открывает страницу, ждёт маяк загрузки (window.__beacon = true) и собирает консоль. */
export async function openGame(browser, { url, beacon = '__beacon', timeoutMs = 30000 } = {}) {
const page = await browser.newPage();
const client = new AgentClient(page);
client.pageErrors = [];
page.on('pageerror', (e) => client.pageErrors.push(String(e)));
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
await page.waitForFunction(`window.${beacon} === true`, { timeout: timeoutMs });
await sleep(1200); // первый кадр, прогрев шейдеров
return client;
}
/** Тонкий клиент страницы: вызовы моста, скриншоты, консоль. */
export class AgentClient {
constructor(page) {
this.page = page;
this.consoleLogs = [];
page.on('console', (msg) => this.consoleLogs.push(`[${msg.type()}] ${msg.text()}`));
}
/** Вызов метода window.__agent (если он есть). */
async call(method, ...args) {
return this.page.evaluate((m, a) => window.__agent?.[m]?.(...a), method, args);
}
/** Произвольное выражение в контексте страницы. */
evaluate(fn, ...args) {
return this.page.evaluate(fn, ...args);
}
/** Скриншот в файл (по умолчанию /tmp) — визуальный гейт. */
async screenshot(path) {
await this.page.screenshot({ path });
return path;
}
/** Ошибки страницы, накопленные с открытия. */
errors() {
return this.pageErrors;
}
}
/** Мини-раннер проверок с JSON-отчётом (образец Checks из v1). */
export class Checks {
constructor(name) {
this.name = name;
this.results = [];
this.started = Date.now();
}
run(title, fn) {
this.results.push({ title, fn });
return this;
}
async expect(title, actual, expected) {
const ok = Object.is(actual, expected) ||
(typeof actual === 'object' && JSON.stringify(actual) === JSON.stringify(expected));
this.results.push({ title, ok, actual, expected });
if (!ok) this.failed = true;
return ok;
}
finish() {
const ok = !this.failed;
return { name: this.name, ok, ms: Date.now() - this.started, results: this.results };
}
}
/** Простой парсер аргументов: --ключ значение / --флаг. */
export function parseArgs(argv) {
const out = { _: [] };
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2);
if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) out[key] = argv[++i];
else out[key] = true;
} else out._.push(argv[i]);
}
return out;
}