96 lines
4.9 KiB
JavaScript
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.');
|