Files
RustProxy/tools/checks/dashboard_startup_0310_ui_test.mjs
T
2026-10-11 12:21:26 +02:00

96 lines
4.9 KiB
JavaScript

#!/usr/bin/env node
// Browser bootstrap contract: visible recovery and early shell.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const read = path => readFileSync(resolve(root, path), 'utf8');
const html = read('src/admin/web/index.html');
const css = read('src/admin/web/style.css');
const core = read('src/admin/web/app/core.js');
const runtime = read('src/admin/web/app/runtime.js');
const resume = read('src/admin/web/app/session-resume.js');
assert.match(html, /<html class="app-loading"/);
assert.match(html, /id="app-startup"[^>]*role="status"/);
assert.match(html, /id="app-startup-title"/);
assert.match(html, /class="app-startup-spinner"/);
assert.match(html, /class="app-startup-recovery"/);
assert.match(html, /class="outline" id="app-startup-reload" href="" data-i18n="startupRetry"/);
assert.match(html, /<noscript>/);
assert.doesNotMatch(css, /html\.app-loading\s+body\s*\{\s*visibility:\s*hidden/);
assert.match(css, /html\.app-loading \.shell/);
assert.match(css, /app-startup-reveal 0s linear 12s forwards/);
assert.match(css, /prefers-reduced-motion: reduce/);
assert.match(runtime, /showStartupFailure\(e\)/);
const titleRow = html.match(/<div class="page-intro-title-row">([\s\S]*?)<\/div>/)?.[1];
assert(titleRow, 'title row exists');
assert.match(titleRow, /id="page-title"/);
assert.match(titleRow, /id="overview-refresh"[^>]*hidden/);
assert.equal((html.match(/id="overview-refresh"/g) || []).length, 1);
assert.match(core, /\$\("overview-refresh"\)\.hidden = name !== "overview"/);
assert.match(css, /\.page-intro-title-row\s*\{\s*display:\s*flex/);
assert.match(css, /\.page-intro-title-row \.overview-refresh/);
// Exercise the real activate() and failure handler against a small DOM fake.
const activateSource = core.slice(core.indexOf('async function activate(info) {'), core.indexOf('async function loadAudit()'));
const failureSource = resume.slice(resume.indexOf('function displayStartupError(error, sessionResume = false)'));
assert(activateSource.startsWith('async function activate(info)'));
assert(failureSource.startsWith('function displayStartupError(error, sessionResume = false)'));
const nodes = new Map();
const node = key => {
if (!nodes.has(key)) nodes.set(key, { hidden: true, value: 'x', textContent: '', addEventListener() {} });
return nodes.get(key);
};
const classes = new Set(['app-loading']);
let startCount = 0, configCount = 0, statusCount = 0, prefResolve, configResolve;
const preferencesPromise = new Promise(resolve => { prefResolve = resolve; });
const configPromise = new Promise(resolve => { configResolve = resolve; });
const context = {
$: node,
document: { documentElement: { classList: {
add(...keys) { for (const key of keys) classes.add(key); },
remove(...keys) { for (const key of keys) classes.delete(key); },
toggle(key, force) { if (force) classes.add(key); else classes.delete(key); },
contains(key) { return classes.has(key); },
} } },
pendingAuthDestination: null,
authMessage() {},
authEpoch: 0, authenticated: false, csrf: '', sessionInfo: null,
renderSession() {},
rustProxyRefresh: { start() { startCount++; } },
loadAdminPreferences: () => preferencesPromise,
loadConfig: () => { configCount++; return configPromise; },
refresh: async () => { statusCount++; },
toast() { throw new Error('Unexpected toast'); },
window: { rustProxyRefresh: { stop() {} } }, languagePacks: new Map(), t: key => key,
};
runInNewContext(`${activateSource}\n${failureSource}`, context);
const activating = context.activate({ username: 'admin', csrf_token: 'CSRF' });
assert.equal(startCount, 1, 'SSE/polling starts without waiting for preferences');
assert.equal(context.authenticated, true);
assert.equal(node('workspace').hidden, false, 'shell is shown immediately');
assert(!classes.has('app-loading'), 'spinner is cleared before preferences/config/status');
assert.equal(configCount, 0, 'preferences request is still pending');
assert.equal(statusCount, 0);
prefResolve({ refresh: {} });
for (let i = 0; i < 5; i++) await Promise.resolve();
assert.equal(configCount, 1);
assert.equal(node('workspace').hidden, false, 'slow configuration does not hide shell');
configResolve({});
await activating;
for (let i = 0; i < 5; i++) await Promise.resolve();
assert.equal(statusCount, 1);
context.showStartupFailure(new Error('Offline'));
assert(classes.has('app-loading') && classes.has('app-startup-failed'));
assert.equal(node('app-startup-detail').textContent, 'Offline');
assert.equal(node('app-startup-detail').hidden, false);
assert.equal(node('workspace').hidden, true, 'failed shell cannot obscure retry UI');
console.log('PASS: startup spinner/retry, shell before slow API, error recovery and compact Overview title row.');