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