import { existsSync } from 'node:fs'; import { chromium } from 'playwright'; export function createBrowserChecks(config) { let browser = null, launching = null, active = 0; const queue = []; async function getBrowser() { if (browser?.isConnected()) return browser; if (!launching) { const installedChrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; const executablePath = config.browser.executablePath || (process.platform === 'darwin' && existsSync(installedChrome) ? installedChrome : undefined); launching = chromium.launch({ headless: true, chromiumSandbox: true, executablePath, timeout: 15000 }).then(value => { browser = value; return value; }).finally(() => { launching = null; }); } return launching; } async function run(spec, target) { if (active >= 2) await new Promise(resolve => queue.push(resolve)); else active++; let context; const started = performance.now(); try { let currentBrowser; try { currentBrowser = await getBrowser(); } catch { return { ok: null, reason: 'browser_unavailable', latencyMs: null }; } context = await currentBrowser.newContext({ viewport: { width: 1280, height: 800 }, locale: 'ru-RU', timezoneId: 'Europe/Minsk', reducedMotion: 'reduce', serviceWorkers: 'block' }); const origin = new URL(spec.baseUrl).origin; if (spec.cookie) { const cookies = spec.cookie.split(';').map(item => { const separator = item.indexOf('='); if (separator <= 0) return null; return { name: item.slice(0, separator).trim(), value: item.slice(separator + 1).trim(), url: `${origin}/`, httpOnly: true, secure: origin.startsWith('https:'), sameSite: 'Strict' }; }); if (cookies.some(item => !item || !item.name || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(item.name))) return { ok: null, reason: 'auth_required', latencyMs: null }; // Chromium ignores a Cookie override in route.continue(). Load the // existing test session into this fresh browser context instead. await context.addCookies(cookies); } // Probes run in fresh contexts, with no offline cache or write requests. await context.route('**/*', async route => { const request = route.request(); if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) { await route.abort(); return; } if (spec.cookie && new URL(request.url()).origin !== origin) { await route.abort(); return; } await route.continue(); }); if (spec.type === 'board') await context.addInitScript(({ stopId }) => { localStorage.setItem('ridebus-board-settings-v1', JSON.stringify({ stopId, demo: false, nightMode: 'off' })); }, target); const page = await context.newPage(); page.setDefaultTimeout(config.scenarioTimeoutMs); const path = spec.type === 'pwa' ? `/?city=${target.cityId}&stop=${target.stopId}` : '/'; const dataResponse = page.waitForResponse(response => { const url = new URL(response.url()); return url.origin === new URL(spec.baseUrl).origin && (spec.type === 'pwa' ? url.pathname === '/api/data' && url.searchParams.get('resource') === 'departures' && url.searchParams.get('stopId') === String(target.stopId) : url.pathname === '/api/board' && url.searchParams.get('stopId') === String(target.stopId)); }); // Consume early rejections even if navigation itself fails. void dataResponse.catch(() => {}); const navigation = await page.goto(new URL(path, spec.baseUrl).toString(), { waitUntil: 'domcontentloaded', timeout: config.scenarioTimeoutMs }); if (!navigation || navigation.status() !== 200) return { ok: false, reason: 'render', httpCode: navigation?.status() ?? null, latencyMs: null }; const response = await dataResponse; if (!response.ok()) return { ok: [401, 403].includes(response.status()) ? null : false, reason: [401, 403].includes(response.status()) ? 'auth_required' : 'render', httpCode: response.status(), latencyMs: null }; if (spec.type === 'pwa') { await page.waitForFunction(() => Boolean(document.querySelector('.boardCard .departureList')) || /Пока нет отправлений|Нет ближайших отправлений/.test(document.querySelector('.boardCard .boardEmpty h3')?.textContent || '')); if (await page.locator('.boardCard .savedNotice, .boardCard .partialNotice').count()) return { ok: false, reason: 'render', httpCode: 200, latencyMs: null }; } else { await page.waitForFunction(() => Boolean(document.querySelector('.board .scene-container')) && !document.querySelector('.offline-banner') && !document.querySelector('.device-setup')); if (await page.locator('.board-header h1').count() !== 1) return { ok: false, reason: 'render', httpCode: 200, latencyMs: null }; } return { ok: true, reason: null, httpCode: 200, latencyMs: Math.round(performance.now() - started) }; } catch { return { ok: false, reason: 'render', latencyMs: null }; } finally { await context?.close().catch(() => {}); const next = queue.shift(); if (next) next(); else active--; } } return { run, async close() { const current = browser || await launching?.catch(() => null); await current?.close(); browser = null; } }; }