'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 = ''; } 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 = ''; } 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); })();