Newer
Older
rpg / v2 / packages / engine / tools / agent-lib.mjs
/**
 * Агентная обвязка 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;
}