411 lines
23 KiB
JavaScript
411 lines
23 KiB
JavaScript
'use strict';
|
||
/* Браузерный плейтест игры через Orca CLI (вкладка встроенного браузера Orca app).
|
||
*
|
||
* Запуск из каталога репо, при ЗАПУЩЕННОМ Orca app:
|
||
* node tests/orca-playtest.js
|
||
*
|
||
* Отличия от puppeteer-теста (tests/test.js):
|
||
* - CLI-roundtrip (~0.3-0.5 c) длиннее окна подсечки (0.35-0.8 c) →
|
||
* перед тапом biteT расширяется до 5 c (СНАЧАЛА скриншот, потом тап);
|
||
* - вкладка Orca в фоне → requestAnimationFrame троттлится → игровое время
|
||
* идёт МЕДЛЕННЕЕ реального. Фиксированные sleep НЕ ждут состояния: все
|
||
* ожидания — polling с таймаутом (шаг POLL_MS). Фиксированные sleep
|
||
* используются только как «удержание кнопки»;
|
||
* - JS-выражения передаются ТОЛЬКО через child_process.execFile с массивом
|
||
* аргументов (БЕЗ shell) — иначе кавычки ломаются в PTY;
|
||
* - ошибки страницы (pageerror) ловятся хуками window error/unhandledrejection,
|
||
* установленными сразу после открытия вкладки, и проверяются в конце прогона;
|
||
* - ретраи CLI-вызовов — с линейным бэкоффом; повторы пишутся в stderr,
|
||
* чтобы не засорять stdout с PASS/FAIL.
|
||
*
|
||
* Только встроенные модули Node: child_process, fs, path.
|
||
*/
|
||
const { execFile } = require('child_process');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const ORCA = process.env.ORCA_CLI_COMMAND || 'orca-ide';
|
||
const GAME_URL = 'file://' + path.resolve(__dirname, '..', 'index.html');
|
||
const POLL_MS = 300; // шаг polling'а состояний
|
||
const CALL_TIMEOUT_MS = 20000; // таймаут одного CLI-вызова
|
||
const GLOBAL_TIMEOUT_MS = 240000; // ~4 минуты на весь прогон
|
||
|
||
const results = [];
|
||
function ok(name, cond, extra = '') {
|
||
results.push({ name, pass: !!cond, extra });
|
||
console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name + (extra ? ' | ' + extra : ''));
|
||
}
|
||
function printSummary() {
|
||
const failed = results.filter(r => !r.pass);
|
||
console.log('\n===== ИТОГ: ' + (results.length - failed.length) + '/' + results.length + ' =====');
|
||
return failed.length;
|
||
}
|
||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||
|
||
/* ---------- Orca CLI ---------- */
|
||
let pageId = null;
|
||
|
||
// Один вызов orca-ide: args БЕЗ --json (флаг добавляется здесь), ответ — распарсенный JSON.
|
||
// Всегда execFile + массив аргументов, без shell.
|
||
function orcaCall(args) {
|
||
return new Promise((resolve, reject) => {
|
||
execFile(ORCA, args.concat(['--json']), { timeout: CALL_TIMEOUT_MS }, (err, stdout, stderr) => {
|
||
if (err) {
|
||
const why = err.killed ? 'timeout ' + CALL_TIMEOUT_MS + 'ms' : String(err.message || '').slice(0, 150);
|
||
return reject(new Error(ORCA + ' ' + args.join(' ') + ': ' + why + (stderr ? ' ' + String(stderr).slice(0, 150) : '')));
|
||
}
|
||
try { resolve(JSON.parse(stdout)); }
|
||
catch (e) { reject(new Error('bad JSON from orca: ' + String(stdout).slice(0, 150))); }
|
||
});
|
||
});
|
||
}
|
||
|
||
// То же, но с ретраями: CLI может transient-повиснуть (hang ловится таймаутом execFile).
|
||
// eval'ы в нашем сценарии идемпотентны, поэтому повтор безопасен.
|
||
// ВНИМАНИЕ: tab create ретраить нельзя (получится дубль вкладки) — вызовите orcaCall.
|
||
// Повторы идут с линейным бэкоффом (0.5 c, 1 c, …) и пишутся в stderr.
|
||
async function orca(args, attempts) {
|
||
const n = attempts || 3;
|
||
let lastErr;
|
||
for (let i = 0; i < n; i++) {
|
||
try { return await orcaCall(args); }
|
||
catch (e) {
|
||
lastErr = e;
|
||
if (i < n - 1) {
|
||
console.error('[retry ' + (i + 1) + '/' + (n - 1) + '] ' + args.join(' ') + ': '
|
||
+ String(e.message || e).slice(0, 100));
|
||
await sleep(500 * (i + 1));
|
||
}
|
||
}
|
||
}
|
||
throw lastErr;
|
||
}
|
||
|
||
// eval в странице. Значение берётся из result.result;
|
||
// объекты/массивы CLI возвращает JSON-строкой → декодируем.
|
||
async function evalPage(expr) {
|
||
const r = await orca(['eval', '--expression', expr, '--page', String(pageId)]);
|
||
const raw = r && r.result ? r.result.result : undefined;
|
||
if (typeof raw === 'string' && (raw[0] === '{' || raw[0] === '[')) {
|
||
try { return JSON.parse(raw); } catch (e) { return raw; }
|
||
}
|
||
return raw;
|
||
}
|
||
|
||
// Polling условия: expr обязан вернуть "yes" (иначе — любой диагностический текст).
|
||
async function waitCond(expr, timeout, label) {
|
||
const t0 = Date.now();
|
||
let last = '';
|
||
while (Date.now() - t0 < (timeout || 30000)) {
|
||
try { last = String(await evalPage(expr)); }
|
||
catch (e) { last = '<eval: ' + String(e.message).slice(0, 60) + '>'; }
|
||
if (last === 'yes') return;
|
||
await sleep(POLL_MS);
|
||
}
|
||
throw new Error('timeout: ' + (label || 'условие') + ' (last: ' + last + ')');
|
||
}
|
||
const waitState = (st, timeout) =>
|
||
waitCond('state === "' + st + '" ? "yes" : "state=" + state', timeout, 'state="' + st + '"');
|
||
|
||
/* ---------- Ввод игры (Pointer Events, как слушает игра) ---------- */
|
||
let pointerSeq = 0;
|
||
// pointerdown — на canvas, pointerup — на window; уникальный pointerId на каждое взаимодействие.
|
||
async function pointerDown() {
|
||
const id = ++pointerSeq;
|
||
await evalPage('document.getElementById("game").dispatchEvent(new PointerEvent("pointerdown",{pointerId:' + id + ',isPrimary:true,bubbles:true})); "down"');
|
||
return id;
|
||
}
|
||
async function pointerUp(id) {
|
||
await evalPage('window.dispatchEvent(new PointerEvent("pointerup",{pointerId:' + id + ',isPrimary:true,bubbles:true})); "up"');
|
||
}
|
||
|
||
// Клик по кнопке по ТОЧНОМУ тексту (trim). Если кнопки нет — в ошибку идут
|
||
// тексты доступных кнопок: сразу видно, на каком экране мы застряли.
|
||
async function clickByText(text) {
|
||
const expr = '(function(){var b=Array.from(document.querySelectorAll("button")).find(function(x){return x.textContent.trim()==="' + text + '";});if(b){b.click();return "yes";}return JSON.stringify(Array.from(document.querySelectorAll("button")).map(function(x){return x.textContent.trim();}).slice(0,12));})()';
|
||
const r = await evalPage(expr);
|
||
if (r !== 'yes') throw new Error('кнопка не найдена: «' + text + '»; есть: ' + JSON.stringify(r).slice(0, 160));
|
||
}
|
||
|
||
// Клик по кнопке по id.
|
||
async function clickByID(id) {
|
||
await evalPage('document.getElementById("' + id + '").click()');
|
||
}
|
||
|
||
// Скриншот вкладки → CWD/orca-*.png (result.data = base64 PNG).
|
||
async function shot(name) {
|
||
try {
|
||
const r = await orca(['screenshot', '--page', String(pageId)]);
|
||
const data = r && r.result ? r.result.data : null;
|
||
if (!data) throw new Error('нет result.data в ответе screenshot');
|
||
fs.writeFileSync(path.resolve(process.cwd(), name), Buffer.from(data, 'base64'));
|
||
ok('скриншот ' + name, true);
|
||
} catch (e) {
|
||
ok('скриншот ' + name, false, String(e.message || e).slice(0, 120));
|
||
}
|
||
}
|
||
|
||
/* ---------- Игровые циклы (хелперы, переиспользуются в шагах 4-6, 8 и 9) ---------- */
|
||
async function castToWait() {
|
||
const pid = await pointerDown();
|
||
await waitState('CHARGE', 20000);
|
||
ok('зарядка: state=CHARGE', true);
|
||
await sleep(700); // удержание ~0.7 c реального времени
|
||
await pointerUp(pid);
|
||
await waitState('WAIT', 30000); // анимация броска (0.65 c игрового) в троттлинге — дольше
|
||
ok('бросок: state=WAIT', true);
|
||
}
|
||
// Ускоряем клёв и расширяем окно подсечки. ВАЖНО: biteT=5 ставится в ТОМ ЖЕ
|
||
// eval, что и детекция BITE — отдельный eval не успевает: CLI-roundtrip
|
||
// (~0.3-0.5 c) длиннее окна подсечки (0.35-0.8 c), а на ПЕРВОПЛАНАРНОЙ вкладке
|
||
// игра идёт в реальном времени.
|
||
async function forceBite() {
|
||
await evalPage('forceBite = true; biteTimer = 0.05; "ok"');
|
||
const t0 = Date.now();
|
||
let last = '';
|
||
while (Date.now() - t0 < 30000) {
|
||
try { last = String(await evalPage('state === "BITE" ? (biteT = 5, "yes") : "state=" + state')); }
|
||
catch (e) { last = '<eval: ' + String(e.message).slice(0, 60) + '>'; }
|
||
if (last === 'yes') break;
|
||
await sleep(POLL_MS);
|
||
}
|
||
if (last !== 'yes') throw new Error('timeout: state="BITE" (last: ' + last + ')');
|
||
ok('клёв: state=BITE (через APPROACH)', true);
|
||
}
|
||
async function hookIt() {
|
||
const pid = await pointerDown();
|
||
await sleep(100);
|
||
await pointerUp(pid);
|
||
await waitState('REEL', 20000);
|
||
ok('подсечка: state=REEL', true);
|
||
}
|
||
// Заставляем улов: d почти ноль, удержание ~2 c, polling CARD с таймаутом 30 c.
|
||
// Т2b: сброс и удержание в ОДНОМ eval — гэп между отдельными eval'ами на
|
||
// реалтайм-вкладке успевает заполнить escape-бар (1.67 c) и рыба сбегает; фазу
|
||
// тоже сбрасываем в затишье (в рывке держать = трение + нагрузка, до обрыва).
|
||
async function forceCatch() {
|
||
const pid = ++pointerSeq;
|
||
await evalPage('reel.d = 0.5; reel.phase = "calm"; reel.phaseT = 5; reel.escape = 0;'
|
||
+ 'document.getElementById("game").dispatchEvent(new PointerEvent("pointerdown",{pointerId:' + pid + ',isPrimary:true,bubbles:true})); "ok"');
|
||
try {
|
||
await sleep(2000);
|
||
await waitState('CARD', 30000);
|
||
} finally {
|
||
await pointerUp(pid);
|
||
}
|
||
}
|
||
|
||
/* ---------- Прогон ---------- */
|
||
(async () => {
|
||
// Глобальный таймаут: даже если что-то повисло — закрываем вкладку и выходим.
|
||
const killer = setTimeout(async () => {
|
||
console.error('\nGLOBAL TIMEOUT: прогон длиннее 4 минут, останавливаюсь.');
|
||
if (pageId) { try { await orcaCall(['tab', 'close', '--page', String(pageId)]); } catch (e) {} }
|
||
printSummary();
|
||
process.exit(3);
|
||
}, GLOBAL_TIMEOUT_MS);
|
||
|
||
let crashed = null;
|
||
try {
|
||
// 0. Orca runtime готов (иначе — понятная ошибка и exit 1)
|
||
const st = await orca(['status']);
|
||
const ready = !!(st && st.result && st.result.runtime && st.result.runtime.state === 'ready');
|
||
ok('Orca runtime готов', ready,
|
||
ready ? '' : 'запусти Orca app (result: ' + JSON.stringify(st && st.result).slice(0, 150) + ')');
|
||
if (!ready) { printSummary(); process.exit(1); }
|
||
|
||
// 1. Вкладка с игрой (без ретраев — иначе дубль вкладки)
|
||
const t = await orcaCall(['tab', 'create', '--url', GAME_URL]);
|
||
pageId = t && t.result ? t.result.browserPageId : null;
|
||
ok('вкладка с игрой открыта', !!pageId, 'pageId=' + String(pageId).slice(0, 8));
|
||
if (!pageId) throw new Error('tab create: нет browserPageId');
|
||
|
||
// 1b. Хуки ошибок страницы (pageerror): window error + unhandledrejection,
|
||
// ошибки копятся в window.__errs до конца прогона. waitCond переживает
|
||
// eval-ошибки ещё грузящейся страницы (повторит, пока не вернётся "yes").
|
||
await waitCond('(function(){if(window.__errs)return "yes";'
|
||
+ 'window.__errs=[];'
|
||
+ 'window.addEventListener("error",function(e){if(e.message)window.__errs.push("error: "+String(e.message));},true);'
|
||
+ 'window.addEventListener("unhandledrejection",function(e){window.__errs.push("promise: "+String(e.reason));});'
|
||
+ 'return "yes";})()', 15000, 'установка error-хуков');
|
||
|
||
// 2. Меню показано (page может ещё грузиться — polling переживёт eval-ошибки)
|
||
await waitCond('!document.getElementById("menu").classList.contains("hidden") ? "yes" : "loading"', 15000, 'меню показано');
|
||
ok('меню показано', true);
|
||
|
||
// 3. Старт
|
||
await clickByText('Играть');
|
||
await waitState('IDLE');
|
||
ok('старт: state=IDLE', true);
|
||
|
||
// 4. Зарядка и бросок
|
||
await castToWait();
|
||
|
||
// 5. Клёв и подсечка (скриншот СНАЧАЛА, потом тап)
|
||
await forceBite();
|
||
await shot('orca-bite.png');
|
||
await hookIt();
|
||
|
||
// 6. Улов и карточка
|
||
await forceCatch();
|
||
ok('улов: state=CARD', true);
|
||
await shot('orca-card.png');
|
||
const card = await evalPage('({name: document.getElementById("cardName").textContent, val: document.getElementById("cardVal").textContent, hidden: document.getElementById("card").classList.contains("hidden")})');
|
||
ok('карточка улова: имя вида и «+N»',
|
||
!!(card && card.hidden === false && card.name && String(card.val).indexOf('+') === 0),
|
||
JSON.stringify(card).slice(0, 120));
|
||
|
||
// 7. Лавка: снасти, дневник, закрытие
|
||
await clickByText('Отлично!');
|
||
await waitState('IDLE');
|
||
await clickByText('🛒');
|
||
await waitCond('!document.getElementById("shop").classList.contains("hidden") ? "yes" : "shop hidden"', 15000, 'лавка открыта');
|
||
ok('лавка открыта (вкладка Снасти)', true);
|
||
await shot('orca-shop.png');
|
||
await clickByText('Дневник');
|
||
await waitCond('document.getElementById("shopBody").textContent.indexOf("Бросков") >= 0 ? "yes" : "no journal"', 15000, 'дневник рендерится');
|
||
ok('дневник показан', true);
|
||
await shot('orca-journal.png');
|
||
|
||
// 7b. M2: вкладки «🎯» (стрик + квесты) и «🏅» (достижения)
|
||
await clickByID('tabRewards');
|
||
await waitCond('document.getElementById("shopBody").textContent.indexOf("Квесты дня") >= 0 ? "yes" : "no quests"', 15000, '🎯 вкладка: квесты');
|
||
ok('🎯 вкладка: стрик и квесты дня', true);
|
||
await shot('orca-rewards.png');
|
||
await clickByID('tabAch');
|
||
await waitCond('document.getElementById("shopBody").textContent.indexOf("Достижения") >= 0 ? "yes" : "no ach"', 15000, '🏅 вкладка: достижения');
|
||
ok('🏅 вкладка: список достижений', true);
|
||
await shot('orca-ach.png');
|
||
|
||
await clickByText('×');
|
||
await waitCond('document.getElementById("shop").classList.contains("hidden") ? "yes" : "shop open"', 15000, 'лавка закрыта');
|
||
ok('лавка закрыта', true);
|
||
|
||
// 8. Shiny: форсируем шанс, полный цикл, бейдж «ШИНИ» в DOM
|
||
await evalPage('SHINY_CHANCE = 1; "ok"');
|
||
try {
|
||
await castToWait();
|
||
await forceBite();
|
||
await hookIt();
|
||
await forceCatch();
|
||
ok('шини: улов получен (state=CARD)', true);
|
||
await shot('orca-shiny.png');
|
||
const shiny = await evalPage('({badge: document.getElementById("cardShiny").textContent, hidden: document.getElementById("cardShiny").classList.contains("hidden"), gold: document.getElementById("card").classList.contains("shiny-card")})');
|
||
ok('шини: бейдж «ШИНИ» и золотая карточка',
|
||
!!(shiny && shiny.hidden === false && String(shiny.badge).indexOf('ШИНИ') >= 0 && shiny.gold === true),
|
||
JSON.stringify(shiny).slice(0, 120));
|
||
} finally {
|
||
await evalPage('SHINY_CHANCE = 0.03; "ok"');
|
||
}
|
||
await clickByText('Отлично!');
|
||
await waitState('IDLE');
|
||
|
||
// 9. Near-miss: сход с утешением. Тост живёт недолго → текст читаем в ТОМ ЖЕ
|
||
// eval, что и ожидание WAIT (не отдельным вызовом).
|
||
await castToWait();
|
||
await forceBite();
|
||
await hookIt();
|
||
await evalPage('reel.d = reel.d0 * 1.45 + 2; "ok"'); // T2b: near-miss порог 1.45·d0
|
||
const t0 = Date.now();
|
||
let toast = '';
|
||
while (Date.now() - t0 < 30000) {
|
||
try {
|
||
toast = String(await evalPage('state === "WAIT" ? document.getElementById("toast").textContent : ""'));
|
||
if (toast) break;
|
||
} catch (e) { /* вкладка/CLI в миг сбоя — повторяем */ }
|
||
await sleep(POLL_MS);
|
||
}
|
||
ok('near-miss: тост «Сбежала» после схода', toast.indexOf('Сбежала') >= 0, toast.slice(0, 80));
|
||
await shot('orca-escape.png'); // best-effort: тост мог уже погаснуть, ok() — по тексту
|
||
|
||
// 9b. M3: ночь — dayTime 0.85 → фаза night. Фазу проверяем в ТОМ ЖЕ eval, что
|
||
// и setDayTime (кадры идут, время суток ползёт). Ночью без фонарика клёв не
|
||
// стартует — поплавок просто ждёт, скриншот показывает ночной визуал.
|
||
const night = await evalPage('(function(){ setDayTime(0.85); return dayPhase; })()');
|
||
ok('ночь: dayPhase=night при dayTime 0.85', night === 'night', String(night));
|
||
await shot('orca-night.png');
|
||
|
||
// 9c. M3: фонарик в лавке (id=4, пометка «ночь»). Лавка открыта на вкладке,
|
||
// где её закрыли в шаге 7 (🏅) — переключаем на Снасти.
|
||
await clickByText('🛒');
|
||
await waitCond('!document.getElementById("shop").classList.contains("hidden") ? "yes" : "shop hidden"', 15000, 'лавка открыта');
|
||
await clickByID('tabTackle');
|
||
await waitCond('document.getElementById("shopBody").textContent.indexOf("Фонарик") >= 0 ? "yes" : "no flask"', 15000, 'фонарик в снастях');
|
||
ok('лавка: «Фонарик» в снастях (id=4, пометка «ночь»)', true);
|
||
await clickByText('×');
|
||
await waitCond('document.getElementById("shop").classList.contains("hidden") ? "yes" : "shop open"', 15000, 'лавка закрыта');
|
||
|
||
// 9d. M4: босс-рыба — forceBoss + осётр, 3 фазы «рвёт и уходит» до босс-карточки.
|
||
// Из WAIT (ночь) — отзываем поплавок и возвращаем день; biteTimer=30 держит
|
||
// поклёвку, пока не сделаем новый заброс.
|
||
await evalPage('setDayTime(0.3); biteTimer = 30; "ok"');
|
||
await clickByID('btnRecall');
|
||
await waitState('IDLE');
|
||
await evalPage('window.__pick = pickSpecies; pickSpecies = function(){ return SPECIES.find(function(s){ return s.id === "sturge"; }); }; forceBoss = true; "ok"');
|
||
try {
|
||
await castToWait();
|
||
await forceBite();
|
||
await hookIt();
|
||
// Т2b: за гэп между подсечкой и этой проверкой escape-бар успел подрасти —
|
||
// гасим, чтобы гонка «CLI-roundtrip vs пассивный сход» не решалась против бота.
|
||
const bs = await evalPage('if (reel) reel.escape = 0; ({b: stateBoss, ph: bossPhase, rb: !!(reel && reel.boss)})');
|
||
ok('босс: старт боя (stateBoss=true, bossPhase=1, reel.boss)',
|
||
bs && bs.b === true && bs.ph === 1 && bs.rb === true, JSON.stringify(bs).slice(0, 80));
|
||
// Фазы 1-2: d→0 — босс-обрыв (SNAP без штрафа) → авто-REEL в следующую (глубже)
|
||
// фазу. В SNAP holding сбрасываем в ТОМ ЖЕ eval, что и детекцию: onUp в
|
||
// состоянии SNAP reel.holding не чистит (см. п.9 «Подводных камней»).
|
||
// Т2b: сброс d/phase/escape и pointerdown — в ОДНОМ eval (тот же паттерн, что
|
||
// в forceBite): CLI-roundtrip между отдельными eval'ами — это «пассив в
|
||
// затишье», а бар схода за 1.67 c игрового времени заполняется → босс сбежал.
|
||
for (let phase = 1; phase <= 3; phase++) {
|
||
const pid = ++pointerSeq;
|
||
await evalPage('reel.d = 0.4; reel.phase = "calm"; reel.phaseT = 5; reel.escape = 0;'
|
||
+ 'document.getElementById("game").dispatchEvent(new PointerEvent("pointerdown",{pointerId:' + pid + ',isPrimary:true,bubbles:true})); "ok"');
|
||
if (phase < 3) {
|
||
await waitCond('state === "SNAP" ? (reel && (reel.holding = false, reel.escape = 0), "yes") : "state=" + state', 30000, 'SNAP фазы ' + phase);
|
||
await pointerUp(pid);
|
||
await waitState('REEL', 30000);
|
||
} else {
|
||
await waitState('CARD', 30000);
|
||
await pointerUp(pid);
|
||
}
|
||
}
|
||
ok('босс: карточка после 3 фаз (stateBoss=false)',
|
||
(await evalPage('state === "CARD" && !stateBoss ? "yes" : "no"')) === 'yes');
|
||
await shot('orca-boss.png');
|
||
const nm = await evalPage('document.getElementById("cardName").textContent + " | " + document.getElementById("cardBoss").textContent');
|
||
ok('босс: карточка «Осётр-БОСС» с бейджем 🏆',
|
||
String(nm).indexOf('Осётр') >= 0 && String(nm).indexOf('БОСС') >= 0, String(nm).slice(0, 60));
|
||
} finally {
|
||
await evalPage('forceBoss = false; if (window.__pick) pickSpecies = window.__pick; "ok"');
|
||
}
|
||
await clickByText('Отлично!');
|
||
await waitState('IDLE');
|
||
|
||
// 10. Консоль: нет ошибок уровня error
|
||
const c = await orca(['console', '--limit', '100', '--page', String(pageId)]);
|
||
const msgs = (c && c.result && c.result.messages) || [];
|
||
const errs = msgs.filter(m => m.type === 'error');
|
||
ok('нет ошибок в консоли (console --limit 100)', errs.length === 0,
|
||
errs.slice(0, 3).map(m => m.text).join(' | ').slice(0, 200));
|
||
|
||
// 10b. Pageerror: JS-ошибки и unhandledrejection, пойманные хуками за прогон
|
||
const pe = await evalPage('(window.__errs || []).slice(0, 5)');
|
||
ok('нет pageerror за прогон (window.onerror / unhandledrejection)',
|
||
Array.isArray(pe) && pe.length === 0, JSON.stringify(pe).slice(0, 200));
|
||
} catch (e) {
|
||
crashed = e;
|
||
} finally {
|
||
// 11. Вкладка закрывается при ЛЮБОМ исходе (2 попытки — close идемпотентен)
|
||
clearTimeout(killer);
|
||
if (pageId) { try { await orca(['tab', 'close', '--page', String(pageId)], 2); } catch (e) {} }
|
||
}
|
||
|
||
if (crashed) {
|
||
console.error('\nPLAYTEST CRASH: ' + (crashed && crashed.message || String(crashed)));
|
||
printSummary();
|
||
process.exit(2);
|
||
}
|
||
process.exit(printSummary() ? 1 : 0);
|
||
})();
|