diff --git a/docs/BROWSER-TESTING.md b/docs/BROWSER-TESTING.md index 495d008..2c8506f 100644 --- a/docs/BROWSER-TESTING.md +++ b/docs/BROWSER-TESTING.md @@ -36,6 +36,7 @@ node tests/orca-playtest.js # скриншоты orca-*.png кладутся |---|---|---| | 0 | `status` | runtime готов; иначе «запусти Orca app» и exit 1 | | 1 | `tab create --url file://…/index.html` | получился `browserPageId` | +| 1b | установка error-хуков (`window.__errs`, `error` + `unhandledrejection`) | хуки стоят до старта геймплея (polling переживает eval-ошибки грузящейся страницы) | | 2 | ожидание DOM | меню (`#menu`) показано | | 3 | клик «Играть» (`.click()` в DOM) | `state === "IDLE"` | | 4 | `pointerdown` на canvas → polling `CHARGE` → удержание ~0.7 c → `pointerup` | `CHARGE`, затем `WAIT` (бросок) | @@ -48,7 +49,8 @@ node tests/orca-playtest.js # скриншоты orca-*.png кладутся | 11 | «🛒» → вкладка «Снасти» → поиск «Фонарик» в `#shopBody` | в снастях есть «Фонарик» (500 🪙, ночь) | | 12 | `forceBoss = true` + `pickSpecies` → осётр → полный цикл: `REEL` (старт боя: `stateBoss`, `bossPhase=1`) → 3 фазы (в каждой `reel.d = 0.4` → `SNAP` **со сбросом `holding` в том же eval** → авто-`REEL` следующей фазы; фаза 3 → `CARD`) → скриншот → `forceBoss = false` в `finally` | старт босс-боя, карточка после 3 фаз (`stateBoss=false`), имя «Осётр-БОСС» с бейджем | | 13 | `console --limit 100` | нет сообщений уровня `error` | -| 14 | `tab close` (в `finally`) | вкладка закрыта при любом исходе | +| 13b | чтение `window.__errs` | нет pageerror за прогон (JS-ошибки и unhandledrejection) | +| 14 | `tab close` (в `finally`, 2 попытки — close идемпотентен) | вкладка закрыта при любом исходе | Итог — сводка `ИТОГ: N/N` и exit code: `0` все PASS, `1` есть FAIL, `2` краш сценария, `3` глобальный таймаут (~4 мин). @@ -101,8 +103,9 @@ PASS | босс: карточка после 3 фаз (stateBoss=false) PASS | скриншот orca-boss.png PASS | босс: карточка «Осётр-БОСС» с бейджем 🏆 | Осётр-БОСС | 🏆 БОСС! PASS | нет ошибок в консоли (console --limit 100) +PASS | нет pageerror за прогон (window.onerror / unhandledrejection) | [] -===== ИТОГ: 46/46 ===== +===== ИТОГ: 47/47 ===== ``` ## Подводные камни @@ -112,7 +115,7 @@ PASS | нет ошибок в консоли (console --limit 100) 3. **Цитирование: только `execFile` с массивом аргументов, без shell.** JS-выражения содержат кавычки — при передаче одной строкой через shell (PTY) они ломаются. В скрипте все вызовы — `child_process.execFile(ORCA, argsArray, …)`; никакого `exec("orca-ide …")`. 4. **Pointer Events с уникальным `pointerId`.** Игра слушает `pointerdown` на canvas и `pointerup` на window. Каждое взаимодействие (down/up-пара) получает свой `pointerId` (счётчик в скрипте) — так события не путаются с реальными. 5. **Тосты недолгие (1.6 с).** Текст тоста near-miss читается **в том же eval**, что и ожидание `WAIT` (`state === "WAIT" ? toast.textContent : ""`) — отдельный последующий вызов может опоздать. Скриншот `orca-escape.png` — best-effort (тост мог погаснуть); ok() ставится по тексту. -6. **CLI может transient-повиснуть.** Наблюдались единичные зависания `eval` (~1 мин), проходящие при повторе. Поэтому `orca()` ретраит вызовы (3 раза), а `execFile` жжёт зависший процесс таймаутом 20 с. **Исключение — `tab create`: ретраить нельзя** (получится дубль вкладки), он идёт через `orcaCall` один раз. +6. **CLI может transient-повиснуть.** Наблюдались единичные зависания `eval` (~1 мин), проходящие при повторе. Поэтому `orca()` ретраит вызовы (3 раза, линейный бэкофф 0.5/1 c; повторы пишутся в **stderr**, чтобы не мешать stdout с PASS/FAIL), а `execFile` жжёт зависший процесс таймаутом 20 с. **Исключение — `tab create`: ретраить нельзя** (получится дубль вкладки), он идёт через `orcaCall` один раз. `tab close` в `finally` ретраится 2 раза — вызов идемпотентен. 7. **Эмодзи — квадраты без шрифта.** Без `font-noto-emoji` эмодзи в скриншотах (и в окне Orca) рендерятся тофу. На скринах это ОК — проверка не по пикселям; для красивых скриншотов поставьте шрифт. 8. **`eval` возвращает объекты JSON-строкой.** `result.result` для объекта/массива — двойная сериализация (строка-JSON); `evalPage` в скрипте декодирует. Притом строка-«MENU» парсится как есть. 9. **Босс-фазы: `SNAP` не сбрасывает `reel.holding`.** `onUp` слушает только `CHARGE`/`REEL` — отпускание пальца в состоянии `SNAP` (босс-обрыв фазы) оставляет `holding = true`, и после авто-перехода в следующую фазу игра «втягивает» сама. Поэтому в шаге 12 сброс `reel.holding = false` делается **в том же eval, что детекция `SNAP`** (тот же приём, что `biteT = 5` в п. 2). Сам босс-бой — цикл из 3 фаз с polling'ом (`SNAP` → авто-`REEL` → снова), `forceCatch()` тут не подходит: на фазах 1–2 вместо `CARD` будет босс-обрыв. diff --git a/tests/orca-playtest.js b/tests/orca-playtest.js index d2bc951..d60e1ac 100644 --- a/tests/orca-playtest.js +++ b/tests/orca-playtest.js @@ -12,7 +12,11 @@ * ожидания — polling с таймаутом (шаг POLL_MS). Фиксированные sleep * используются только как «удержание кнопки»; * - JS-выражения передаются ТОЛЬКО через child_process.execFile с массивом - * аргументов (БЕЗ shell) — иначе кавычки ломаются в PTY. + * аргументов (БЕЗ shell) — иначе кавычки ломаются в PTY; + * - ошибки страницы (pageerror) ловятся хуками window error/unhandledrejection, + * установленными сразу после открытия вкладки, и проверяются в конце прогона; + * - ретраи CLI-вызовов — с линейным бэкоффом; повторы пишутся в stderr, + * чтобы не засорять stdout с PASS/FAIL. * * Только встроенные модули Node: child_process, fs, path. */ @@ -59,12 +63,20 @@ function orcaCall(args) { // То же, но с ретраями: 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; await sleep(500); } + 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; } @@ -107,10 +119,12 @@ async function pointerUp(id) { await evalPage('window.dispatchEvent(new PointerEvent("pointerup",{pointerId:' + id + ',isPrimary:true,bubbles:true})); "up"'); } -// Клик по кнопке по ТОЧНОМУ тексту (trim). +// Клик по кнопке по ТОЧНОМУ тексту (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)return "nope";b.click();return "yes";})()'; - if ((await evalPage(expr)) !== 'yes') throw new Error('кнопка не найдена: «' + 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. @@ -202,6 +216,15 @@ async function forceCatch() { 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); @@ -355,12 +378,17 @@ async function forceCatch() { 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. Вкладка закрывается при ЛЮБОМ исходе + // 11. Вкладка закрывается при ЛЮБОМ исходе (2 попытки — close идемпотентен) clearTimeout(killer); - if (pageId) { try { await orcaCall(['tab', 'close', '--page', String(pageId)]); } catch (e) {} } + if (pageId) { try { await orca(['tab', 'close', '--page', String(pageId)], 2); } catch (e) {} } } if (crashed) {