Files
ridebus-status/server/browser-checks.mjs
2026-10-13 02:24:39 +03:00

70 lines
5.2 KiB
JavaScript

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