Y1 (SDK, треб. 1.1/1.3/1.19): секция «Y1: SDK Яндекс Игр» — обёртка YG: стаб при file:// без ?sdk=1 (скрипт не создаётся — чистая консоль), http(s) — динамический /sdk.js (retry/slow-warn 3 с, апгрейд стаб→SDK), LoadingAPI.ready() первым кадром + catch-up в init, GameplayAPI.start/stop (btnPlay/openShop/closeShop/btnReset/btnResetNo; карточка улова — без stop, осознанно: часть цикла улова), game_api_pause/resume + visibilitychange → YG.onPause/onResume (AC.suspend; при YG.paused в frame() заморожено ВСЁ игровое время; guard adShowing в резюме).
Y2 (монетизация, треб. 1.12/4.x): interstitial через showFullscreenAdv на закрытии карточки улова (логическая пауза 4.4; гейты: первый ≥2 мин сессии, кулдаун 3 мин; lastAdTs — осознанно «попытка», не показ — при троттлинге wasShown=false кулдаун сгорает; persist() ДО показа 4.2; YG.onPause — звук+процесс 4.7; watchdog 90 с 1.14, дедуп в YG._startAdWatchdog). RV-кнопка «📺 Реклама: ×2 за этот улов» (4.5.1 честная формулировка, title) через showRewardedVideo: onRewarded → +val (итого ×2), раз на улов (rvDone), бонус не блокирует игру 4.5.2, честная игровая кнопка — не имитация рекламного блока 1.16, .rvbtn min-height:44px (тап-таргет). Стаб: fullscreen → false (мгновенный resume), RV — синхронная симуляция (локальная разработка).
Y3 (полировка): -webkit-touch-callout:none в универсальном селекторе (1.6.1.8 лонгтап iOS).
Y4 (упаковка, треб. 1.21/1.22): tools/pack.mjs — сборка dist/fishing-yandex.zip (только index.html в корне; сборщики zip→python3→python→py) + самопроверка 5 пунктов (структура, <100MB, имена, sha256 byte-идентичность, node --check распакованного JS; встроенный zip-ридер). tests/pack-smoke.js — smoke играбельности КОПИИ из архива (6 чек: MENU, стаб, IDLE, shop, console/pageerror 0; авто-откат /tmp→$HOME — в этом чекауте /tmp невидим Chromium'у). .gitignore: dist/ + /*.pdf.
Тесты: 129→146 — §Y1 +7 (стаб, ready, gameplay-разметка, pause/visibilitychange), §Y2 +9 (RV-бонус ×2/no-op/закрытие, гейт 2 мин, вызов после, кулдаун, после кулдауна; дельты счётчиков, forceVariant=normal), §Y3 +1 (.rvbtn min-height). tests/shots.js: кадр card-rv (13→14). check.mjs OK; pack 5/5 (40.6 KB); pack-smoke 6/6.
Ревью: 4 gate-ревью по этапам (PASS, все findings F1–F7/F1–F5/low+nit/F1–F8 закрыты в лэйнах ревью) + визуальные: мобильные 13 кадров + card-rv, десктоп 1920×1080 (full-bleed, 1.6.2/1.10), рендер из zip-копии (меню/сцена/карточка, 0 ошибок) — все PASS.
Доки: README «Публикация в Яндекс Играх» (pack → smoke → консоль; ?sdk=1), AGENTS.md (п.5 «Проверка», строки Y1/Y2 в «Где крутить», YG/YG.counts в тест-глобалах, 146), DEVLOG этапы 24–27. screenshots/: logo.png (512×512, иконка для консоли), 1.png (1280×720, обложка).
Осознанные решения/риски: 1.20/1.23 — emoji-шрифт на старых Win ОС (квадраты, зафиксировано); 2.14 — игра одноязычная RU (язык черновика — ru); облачные сейвы НЕ используются → опция в консоли выключена; платформы — без Android TV.
181 lines
9.5 KiB
JavaScript
181 lines
9.5 KiB
JavaScript
'use strict';
|
||
/* Smoke-тест «упакованная игра работает» (этап Y4).
|
||
*
|
||
* Игра запускается НЕ из index.html в репо, а из СВЕЖЕЙ КОПИИ, распакованной
|
||
* из dist/fishing-yandex.zip во временный каталог (fs.mkdtemp, распаковка —
|
||
* python3 -m zipfile -e, fallback unzip). Это доказательство, что именно
|
||
* публикуемый артефакт (архив из dist/) запускается и прогоняет базовый
|
||
* сценарий:
|
||
* (1) после загрузки state==='MENU',
|
||
* (2) YG-стаб (file://, без ?sdk=1): YG.active===false и YG.readySent===true
|
||
* (LoadingAPI.ready() уходит на первом кадре),
|
||
* (3) клик #btnPlay → state==='IDLE' и меню скрыто,
|
||
* (4) клик #btnShop → #shop без hidden (магазин открыт),
|
||
* (5) клик #btnCloseShop → #shop с hidden (магазин закрыт),
|
||
* (6) ни одной JS-ошибки за прогон (console 'error' + pageerror).
|
||
*
|
||
* Копия размещается сначала в каталоге под /tmp (os.tmpdir). Если браузер по
|
||
* file:// не видит файл (в некоторых контейнерах /tmp — отдельный tmpfs,
|
||
* Chromium отвечает ERR_FILE_NOT_FOUND), автоматически перекладывается в
|
||
* каталог под $HOME — проверка «архив работает» от этого не меняется.
|
||
*
|
||
* Запуск (из каталога репо):
|
||
* node tools/pack.mjs # сначала собрать архив
|
||
* node tests/pack-smoke.js # smoke упакованной копии
|
||
*
|
||
* Коды выхода: 0 — все 6 пунктов PASS; 1 — есть FAIL (в т.ч. отсутствует архив);
|
||
* 2 — краш (в т.ч. не удалось распаковать архив или браузер не видит ни /tmp, ни $HOME).
|
||
*/
|
||
const puppeteer = require('puppeteer-core');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const { spawnSync } = require('child_process');
|
||
const CHROMIUM = process.env.CHROMIUM || '/usr/bin/chromium-browser';
|
||
const ZIP_PATH = path.resolve(__dirname, '..', 'dist', 'fishing-yandex.zip');
|
||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||
|
||
const results = [];
|
||
function ok(name, cond, extra = '') {
|
||
results.push({ name, pass: !!cond, extra });
|
||
console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name + (extra ? ' | ' + extra : ''));
|
||
}
|
||
|
||
// Polling-проверка условия в странице: ~10 с, шаг 300 мс. Шаг грубее, чем
|
||
// в test.js (40 мс) — smoke проверяет «игра жива», а не точность кадров.
|
||
// Последняя ошибка evaluate в poll (для диагностики при таймауте проверки).
|
||
let lastEvalErr = null;
|
||
async function poll(page, expr, timeout = 10000, step = 300) {
|
||
const t0 = Date.now();
|
||
let cur;
|
||
while (Date.now() - t0 < timeout) {
|
||
try {
|
||
cur = await page.evaluate(expr);
|
||
lastEvalErr = null;
|
||
} catch (e) {
|
||
// evaluate мог упасть, пока браузер ещё добрасывает навигацию/контекст
|
||
// (ReferenceError «state is not defined» в первую сотню мс после load) —
|
||
// трактуем как «условие пока не выполнено» и продолжаем polling, а не падаем.
|
||
lastEvalErr = e;
|
||
cur = false;
|
||
}
|
||
if (cur) return cur;
|
||
await sleep(step);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Безопасное чтение state (только для диагностики при сбое проверки).
|
||
async function safeState(page) {
|
||
try { return await page.evaluate(() => state); } catch (e) { return '<ошибка: ' + String(e.message || e) + '>'; }
|
||
}
|
||
|
||
// Распаковка архива в каталог: python3 -m zipfile -e, при отсутствии/ошибке — unzip.
|
||
function unzipTo(dir) {
|
||
const attempts = [
|
||
['python3', ['-m', 'zipfile', '-e', ZIP_PATH, dir]],
|
||
['unzip', ['-o', '-q', ZIP_PATH, '-d', dir]],
|
||
];
|
||
let lastErr = '';
|
||
for (const [cmd, args] of attempts) {
|
||
const r = spawnSync(cmd, args, { stdio: 'pipe' });
|
||
if (!r.error && r.status === 0) return;
|
||
lastErr = r.error ? (r.error.code || String(r.error)) : 'exit ' + r.status + (r.stderr ? ': ' + r.stderr.toString().trim() : '');
|
||
}
|
||
throw new Error('не удалось распаковать ' + ZIP_PATH + ' (python3/unzip: ' + lastErr + ')');
|
||
}
|
||
|
||
// Свежая распакованная копия в tmp-каталоге, ДОСТУПНОМ браузеру по file://.
|
||
// Пробуем базовые каталоги по очереди: /tmp, затем $HOME; доступность
|
||
// подтверждаем реальным goto (заодно это и есть шаг «загрузить игру»).
|
||
async function placeUnpackedCopy(page) {
|
||
const bases = [os.tmpdir(), os.homedir()];
|
||
const errs = [];
|
||
for (const base of bases) {
|
||
const tmp = fs.mkdtempSync(path.join(base, 'pack-smoke-'));
|
||
try {
|
||
unzipTo(tmp);
|
||
const gameFile = path.join(tmp, 'index.html');
|
||
if (!fs.existsSync(gameFile)) throw new Error('в распакованном архиве нет index.html');
|
||
const url = 'file://' + gameFile;
|
||
await page.goto(url, { waitUntil: 'load', timeout: 15000 });
|
||
if (base !== os.tmpdir()) console.log('pack-smoke: /tmp невидим браузеру (file://) — копия в ' + tmp);
|
||
return { tmp, url };
|
||
} catch (e) {
|
||
// Каталог невидим браузеру (отдельный tmpfs) или ошибка — пробовать дальше.
|
||
fs.rmSync(tmp, { recursive: true, force: true });
|
||
errs.push(e);
|
||
}
|
||
}
|
||
// Ни /tmp, ни $HOME браузер не видит (ERR_FILE_NOT_FOUND в обоих) — это проблема
|
||
// окружения (mount/права), а не архива: отдельное финальное сообщение, exit 2.
|
||
if (errs.length === bases.length &&
|
||
errs.every(e => /ERR_FILE_NOT_FOUND/.test(String((e && e.message) || e)))) {
|
||
throw new Error('браузер не видит ни /tmp, ни $HOME (ERR_FILE_NOT_FOUND) — проверь mount/права (tmpfs?)');
|
||
}
|
||
throw errs[errs.length - 1];
|
||
}
|
||
|
||
(async () => {
|
||
if (!fs.existsSync(ZIP_PATH)) {
|
||
console.error('pack-smoke: архив ' + ZIP_PATH + ' отсутствует — сначала node tools/pack.mjs');
|
||
process.exit(1);
|
||
}
|
||
let browser = null;
|
||
let tmp = null;
|
||
try {
|
||
browser = await puppeteer.launch({
|
||
executablePath: CHROMIUM, headless: true,
|
||
args: ['--no-sandbox', '--disable-gpu', '--hide-scrollbars', '--mute-audio'],
|
||
defaultViewport: { width: 390, height: 844, deviceScaleFactor: 2 },
|
||
});
|
||
const page = await browser.newPage();
|
||
const consoleErrors = [];
|
||
const pageErrors = [];
|
||
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
|
||
page.on('pageerror', e => pageErrors.push(String(e)));
|
||
|
||
const placed = await placeUnpackedCopy(page);
|
||
tmp = placed.tmp;
|
||
console.log('pack-smoke: ' + ZIP_PATH + ' → ' + tmp + ' (играем на упакованной копии)');
|
||
|
||
// (1) после загрузки — меню
|
||
const st1 = await poll(page, () => state === 'MENU');
|
||
ok('state==="MENU" после загрузки', !!st1,
|
||
st1 ? '' : (lastEvalErr ? String(lastEvalErr.message || lastEvalErr) : 'timeout, state=' + (await safeState(page))));
|
||
|
||
// (2) YG-стаб (file://): SDK не загружен, но ready() ушёл на первом кадре
|
||
const y1 = await poll(page, () => !!YG && YG.active === false && YG.readySent === true);
|
||
ok('YG-стаб: active===false, readySent===true (file://)', !!y1);
|
||
|
||
// (3) старт игры: IDLE + меню скрыто
|
||
await page.click('#btnPlay');
|
||
const st3 = await poll(page, () => state === 'IDLE' && document.getElementById('menu').classList.contains('hidden'));
|
||
ok('#btnPlay: state==="IDLE", меню скрыто', !!st3);
|
||
|
||
// (4) магазин открывается
|
||
await page.click('#btnShop');
|
||
const st4 = await poll(page, () => !document.getElementById('shop').classList.contains('hidden'));
|
||
ok('#btnShop: #shop открыт (без hidden)', !!st4);
|
||
|
||
// (5) магазин закрывается
|
||
await page.click('#btnCloseShop');
|
||
const st5 = await poll(page, () => document.getElementById('shop').classList.contains('hidden'));
|
||
ok('#btnCloseShop: #shop закрыт (с hidden)', !!st5);
|
||
|
||
// Небольшой запас на «поздние» ошибки из игрового цикла после закрытия магазина.
|
||
await sleep(500);
|
||
|
||
// (6) чистая консоль
|
||
ok('нет ошибок JS (console error + pageerror)', consoleErrors.length === 0 && pageErrors.length === 0,
|
||
[...consoleErrors, ...pageErrors].join(' | '));
|
||
} finally {
|
||
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
|
||
if (browser) { try { await browser.close(); } catch (_) { /* уже закрыт */ } }
|
||
}
|
||
|
||
const passed = results.filter(r => r.pass).length;
|
||
console.log('\n===== PACK-SMOKE: ' + passed + '/' + results.length + ' =====');
|
||
process.exit(passed === results.length ? 0 : 1);
|
||
})().catch(e => { console.error('SMOKE CRASH:', e.message); process.exit(2); });
|