- Implemented StatusWidget component for embedding RideBus service status. - Created widget-client.js for client-side interaction with the status widget. - Developed install-widget-integrations script to automate integration into PWA, website, and admin projects. - Introduced server-side widget rendering in widget.mjs for generating status widget HTML. - Added comprehensive tests for widget functionality and integration.
146 lines
9.3 KiB
JavaScript
146 lines
9.3 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { loadConfig } from '../server/config.mjs';
|
|
import { openStore } from '../server/store.mjs';
|
|
import { nextState } from '../server/probe.mjs';
|
|
import { makeSnapshot } from '../server/snapshot.mjs';
|
|
import { widgetOptions, widgetSummary } from '../server/widget.mjs';
|
|
import { createStatusServer } from '../server/index.mjs';
|
|
import { readFileSync } from 'node:fs';
|
|
import { runInNewContext } from 'node:vm';
|
|
import { widgetCopy } from '../server/widget.mjs';
|
|
|
|
const config = loadConfig({ RIDEBUS_API_KEY: 'private-api-key', STATUS_BOARD_TEST_COOKIE: 'private-cookie', STATUS_FAILURE_THRESHOLD: '1' });
|
|
const website = config.services.find(service => service.id === 'website');
|
|
const api = config.services.find(service => service.id === 'api');
|
|
const good = { ok: true, latencyMs: 100, httpCode: 200, reason: null };
|
|
const bad = { ok: false, latencyMs: null, httpCode: 503, reason: 'http' };
|
|
const now = Date.now();
|
|
function record(store, service, result, time = now) { store.record(service, result, nextState(store.state(service), result, config), time); }
|
|
|
|
test('widget uses only selected services and never publishes credentials or transport data', () => {
|
|
const store = openStore(':memory:');
|
|
try {
|
|
record(store, website, good);
|
|
record(store, config.services.find(service => service.id === 'board'), bad);
|
|
const result = widgetSummary(store, config, ['website'], now);
|
|
assert.equal(result.overall, 'operational');
|
|
assert.deepEqual(result.services, [{ id: 'website', status: 'operational' }]);
|
|
assert.doesNotMatch(JSON.stringify(result), /private-api-key|private-cookie|signature|baseUrl|stopId|latencyMs|httpCode/);
|
|
assert.equal(widgetSummary(store, config, ['board'], now).overall, 'down');
|
|
} finally { store.close(); }
|
|
});
|
|
|
|
test('widget cannot keep a stale successful check green and advertises its next expiry', () => {
|
|
const store = openStore(':memory:');
|
|
try {
|
|
record(store, website, good);
|
|
const deadline = now + config.staleAfterSeconds * 1000;
|
|
assert.equal(widgetSummary(store, config, ['website'], deadline).overall, 'operational');
|
|
assert.equal(widgetSummary(store, config, ['website'], deadline).expiresAt, deadline + 1);
|
|
assert.equal(widgetSummary(store, config, ['website'], deadline + 1).overall, 'unknown');
|
|
assert.equal(widgetSummary(store, config, ['website'], now).expiresAt, now + 90000);
|
|
} finally { store.close(); }
|
|
});
|
|
|
|
test('journey failure and maintenance in the widget agree with the full status page', () => {
|
|
const store = openStore(':memory:');
|
|
try {
|
|
record(store, api, good);
|
|
store.recordScenario(api, { ...bad, steps: [], mode: 'http' }, nextState(null, bad, config), now);
|
|
assert.equal(widgetSummary(store, config, ['api'], now).overall, 'degraded');
|
|
assert.equal(widgetSummary(store, config, ['api'], now).services[0].status, makeSnapshot(store, config, 7, now).services.find(service => service.id === 'api').status);
|
|
store.createMaintenance({ serviceIds: ['api'], titleRu: 'Работы', titleEn: 'Work', messageRu: 'Текст', messageEn: 'Text', startsAt: now + 1000, endsAt: now + 5000 }, now);
|
|
assert.equal(widgetSummary(store, config, ['api'], now).expiresAt, now + 1000);
|
|
assert.equal(widgetSummary(store, config, ['api'], now + 1000).overall, 'maintenance');
|
|
assert.equal(widgetSummary(store, config, ['api'], now + 1000).expiresAt, now + 5000);
|
|
assert.equal(widgetSummary(store, config, ['api'], now + 5000).overall, 'degraded');
|
|
} finally { store.close(); }
|
|
});
|
|
|
|
test('widget option validation rejects unknown services, duplicates, demo and unsafe presentation', () => {
|
|
assert.deepEqual(widgetOptions(new URLSearchParams('services=website,api&lang=en&theme=dark'), config.services, { embed: true }), { serviceIds: ['website', 'api'], locale: 'en', theme: 'dark' });
|
|
for (const query of ['services=missing', 'services=', 'services=api,api', 'services=api&services=website', 'demo=1', 'lang=fr', 'theme=<script>']) assert.throws(() => widgetOptions(new URLSearchParams(query), config.services, { embed: true }));
|
|
assert.throws(() => widgetOptions(new URLSearchParams('lang=en'), config.services));
|
|
});
|
|
|
|
test('public embed and assets work independently of the host app and provide real, read-only status', async () => {
|
|
const app = await createStatusServer(loadConfig({ STATUS_DB_PATH: ':memory:', PORT: '0', HOST: '127.0.0.1' }), { monitorEnabled: false });
|
|
try {
|
|
record(app.store, website, good);
|
|
await new Promise(resolve => app.server.listen(0, '127.0.0.1', resolve));
|
|
const origin = `http://127.0.0.1:${app.server.address().port}`;
|
|
const response = await fetch(`${origin}/api/widget?services=website`);
|
|
assert.equal(response.headers.get('cache-control'), 'no-store');
|
|
const result = await response.json();
|
|
assert.equal(result.overall, 'operational');
|
|
assert.equal(result.services.length, 1);
|
|
const embed = await fetch(`${origin}/embed?services=website&lang=en&theme=dark`);
|
|
assert.equal(embed.status, 200);
|
|
assert.match(embed.headers.get('content-security-policy'), /frame-ancestors \*/);
|
|
const html = await embed.text();
|
|
assert.match(html, /lang="en" data-theme="dark"/);
|
|
assert.match(html, /vkuiIcon/);
|
|
assert.match(html, /"overall":"operational"/);
|
|
assert.doesNotMatch(html, /private-api-key|private-cookie/);
|
|
for (const asset of ['/widget-client.js', '/widget/manrope-latin.woff2', '/widget/manrope-cyrillic.woff2']) assert.equal((await fetch(origin + asset)).status, 200);
|
|
assert.equal((await fetch(`${origin}/api/widget?demo=1`)).status, 400);
|
|
assert.equal((await fetch(`${origin}/embed?services=website`, { method: 'POST' })).status, 405);
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
function widgetClientFixture({ serverNow = 1000, navigationElapsed = 0, slowSuccessfulRefresh = false } = {}) {
|
|
let clock = 1000, timerId = 0;
|
|
const events = new Map(), timers = new Map(), messages = [], label = {}, link = { dataset: {}, setAttribute() {} };
|
|
const parent = { postMessage: value => messages.push(value) };
|
|
const window = { addEventListener: (name, callback) => events.set(name, callback) };
|
|
const bootstrap = { summary: { version: 1, generatedAt: serverNow, expiresAt: serverNow + 1000, overall: 'operational' }, serviceIds: ['website'], locale: 'ru', copy: widgetCopy.ru, states: ['operational', 'degraded', 'down', 'unknown', 'maintenance'] };
|
|
const document = { hidden: false, getElementById: () => ({ textContent: JSON.stringify(bootstrap) }), querySelector: selector => selector === '.widget' ? link : label, addEventListener: (name, callback) => events.set(name, callback) };
|
|
runInNewContext(readFileSync(new URL('../public/widget-client.js', import.meta.url), 'utf8'), {
|
|
document, window, parent, location: { origin: 'https://status.example' }, Date: { now: () => clock }, performance: { now: () => clock - 1000 + navigationElapsed }, URL, AbortSignal,
|
|
setTimeout: (callback, delay) => { const id = ++timerId; timers.set(id, { callback, delay }); return id; }, clearTimeout: id => timers.delete(id),
|
|
fetch: async () => { if (slowSuccessfulRefresh) { clock += 1001; return Response.json(bootstrap.summary); } throw new Error('Monitoring unavailable'); },
|
|
});
|
|
return { events, timers, messages, parent, link, label, setClock: value => { clock = value; } };
|
|
}
|
|
|
|
test('a delayed browser timer cannot reply with an expired green status', () => {
|
|
const fixture = widgetClientFixture();
|
|
assert.equal(fixture.link.dataset.status, 'operational');
|
|
fixture.setClock(2001);
|
|
fixture.events.get('message')({ source: fixture.parent, data: { type: 'ridebus-status-widget-request' } });
|
|
assert.equal(fixture.link.dataset.status, 'unknown');
|
|
assert.equal(fixture.label.textContent, widgetCopy.ru.unknown);
|
|
assert.equal(fixture.messages.at(-1).status, 'unknown');
|
|
});
|
|
|
|
test('offline and a failed refresh replace the visible green status immediately', async () => {
|
|
const offline = widgetClientFixture();
|
|
offline.events.get('offline')();
|
|
assert.equal(offline.link.dataset.status, 'unknown');
|
|
const refresh = widgetClientFixture();
|
|
await [...refresh.timers.values()].find(timer => timer.delay === 30000).callback();
|
|
assert.equal(refresh.link.dataset.status, 'unknown');
|
|
assert.equal(refresh.messages.at(-1).status, 'unknown');
|
|
});
|
|
|
|
test('browser freshness uses remaining lifetime rather than requiring matching server clocks', () => {
|
|
const fixture = widgetClientFixture({ serverNow: 9000000 });
|
|
assert.equal(fixture.link.dataset.status, 'operational');
|
|
const count = fixture.messages.length;
|
|
fixture.events.get('message')({ source: {}, data: { type: 'ridebus-status-widget-request' } });
|
|
assert.equal(fixture.messages.length, count);
|
|
fixture.setClock(2001);
|
|
fixture.events.get('message')({ source: fixture.parent, data: { type: 'ridebus-status-widget-request' } });
|
|
assert.equal(fixture.link.dataset.status, 'unknown');
|
|
});
|
|
|
|
test('a snapshot expiring while the page or response loads cannot become green on receipt', async () => {
|
|
const navigation = widgetClientFixture({ navigationElapsed: 1001 });
|
|
assert.equal(navigation.link.dataset.status, 'unknown');
|
|
const response = widgetClientFixture({ slowSuccessfulRefresh: true });
|
|
assert.equal(response.link.dataset.status, 'operational');
|
|
await [...response.timers.values()].find(timer => timer.delay === 30000).callback();
|
|
assert.equal(response.link.dataset.status, 'unknown');
|
|
});
|