import { runProbe } from './probe.mjs'; import { createBrowserChecks } from './browser-checks.mjs'; const validId = id => Number.isSafeInteger(Number(id)) && Number(id) > 0; const validBoardConfig = value => validId(value?.cityId) && validId(value?.defaultStopId) && value.configured === true; const validDeviceState = value => value?.enabled === false || value?.enabled === true && (['unpaired', 'pending', 'expired'].includes(value.state) || value.state === 'paired' && validBoardConfig(value.config)); export const validCatalog = value => Array.isArray(value) && value.length > 0 && value.every(item => validId(item?.id) && typeof item.name === 'string' && Boolean(item.name.trim())); export const validDepartures = value => value && Array.isArray(value.routes) && Array.isArray(value.departures) && value.departures.every(item => validId(item.routeId) && typeof item.scheduledTime === 'string' && Number.isFinite(Date.parse(item.scheduledAt))) && value.partial !== true; export const validEta = value => value && Array.isArray(value.routes) && value.routes.every(item => !item.error && validId(item.id) && Array.isArray(item.arrivals)); export const validBoard = value => value && validId(value.stop?.id) && value.mode === 'live' && Array.isArray(value.departures) && value.departures.every(item => validId(item.routeId) && Number.isFinite(Date.parse(item.scheduledAt))) && value.partial !== true && value.demo !== true; function selectCity(cities, configuredId) { const city = configuredId ? cities.find(item => Number(item.id) === configuredId) : cities.find(item => item.slug === 'polotsk') || cities[0]; if (!city) throw { reason: 'content', httpCode: 200 }; return Number(city.id); } function selectStop(stops, configuredId) { const stop = configuredId ? stops.find(item => Number(item.id) === configuredId) : stops.find(item => Number(item.routesCount ?? item.routes_count) > 0) || stops[0]; if (!stop) throw { reason: 'content', httpCode: 200 }; return Number(stop.id); } export function createScenarioRunner(config, { fetchImpl = fetch, browserChecks = null } = {}) { const browser = browserChecks || createBrowserChecks(config); async function run(service) { const spec = service.scenario; const steps = []; const started = performance.now(); let mode = 'http', current = 'cities'; const target = {}; const result = (ok, reason = null) => ({ ok, reason, latencyMs: ok === true ? Math.round(performance.now() - started) : null, httpCode: steps.find(step => step.status === 'failed')?.httpCode ?? (ok === true ? 200 : null), mode, steps }); async function get(step, path, validator, headers = {}) { current = step; let data; const response = await runProbe({ url: new URL(path, `${spec.baseUrl.replace(/\/+$/, '')}/`).toString(), kind: 'json', headers, validateJson: json => { data = json; return validator(json); } }, { timeoutMs: config.timeoutMs, fetchImpl }); steps.push({ id: step, status: response.ok ? 'passed' : 'failed', latencyMs: response.latencyMs, httpCode: response.httpCode }); if (!response.ok) throw { reason: [401, 403].includes(response.httpCode) || spec.type === 'board' && response.httpCode === 410 ? 'auth_required' : response.reason, httpCode: response.httpCode }; return data; } if (!spec || spec.type === 'api' && !spec.apiKey) return result(null, 'unconfigured'); try { if (spec.type === 'api') { const headers = { 'X-API-Key': spec.apiKey }; const cities = await get('cities', '/api/v1/cities', validCatalog, headers); target.cityId = selectCity(cities, spec.cityId); const scoped = { ...headers, 'X-City-Id': String(target.cityId) }; const stops = await get('stops', '/api/v1/stops', validCatalog, scoped); target.stopId = selectStop(stops, spec.stopId); await get('departures', `/api/v1/eta/stop/${target.stopId}`, validEta, scoped); } else if (spec.type === 'pwa') { const cities = await get('cities', '/api/data?resource=cities', validCatalog); target.cityId = selectCity(cities, spec.cityId); const stops = await get('stops', `/api/data?resource=stops&cityId=${target.cityId}`, validCatalog); target.stopId = selectStop(stops, spec.stopId); await get('departures', `/api/data?resource=departures&cityId=${target.cityId}&stopId=${target.stopId}`, validDepartures); } else { const headers = spec.cookie ? { Cookie: spec.cookie } : {}; let settings = await get('config', '/api/config', validBoardConfig, headers); let managed = false; if (spec.cookie) { const device = await get('device', '/api/device/status', validDeviceState, headers); if (device.enabled) { if (device.state !== 'paired') { steps.at(-1).status = 'failed'; throw { reason: 'auth_required', httpCode: 200 }; } // A paired board can only request the stop assigned by the admin. // /api/config describes the standalone defaults, not this device. settings = device.config; managed = true; } } target.cityId = Number(settings.cityId); const stops = await get('stops', '/api/stops', validCatalog, headers); target.stopId = managed ? Number(settings.defaultStopId) : spec.stopId || Number(settings.defaultStopId); if (!stops.some(stop => Number(stop.id) === target.stopId)) throw { reason: 'content' }; await get('departures', `/api/board?stopId=${target.stopId}`, value => validBoard(value) && Number(value.stop.id) === target.stopId, headers); } if (spec.type !== 'api') { current = 'render'; if (!config.browser.enabled) steps.push({ id: 'render', status: 'waiting', latencyMs: null, httpCode: null }); else { const rendered = await browser.run(spec, target); steps.push({ id: 'render', status: rendered.ok === null ? 'waiting' : rendered.ok ? 'passed' : 'failed', latencyMs: rendered.latencyMs ?? null, httpCode: rendered.httpCode ?? null }); if (rendered.ok !== true) return result(rendered.ok, rendered.reason); mode = 'browser'; } } return result(true); } catch (error) { if (steps.at(-1)?.status !== 'failed') steps.push({ id: current, status: 'failed', latencyMs: null, httpCode: error.httpCode ?? null }); return result(error.reason === 'auth_required' ? null : false, error.reason || 'content'); } } return { run, close: () => browser.close() }; }