/** * The public demo's two hooks into this site (D82, D83). * * `demoLogins.mjs` decides what `/beta` prints from an answer it does not control, so its * shape checks and its failure path are tested here rather than trusted. `applyBrand.mjs` * decides which "See it running" links point anywhere, and the failure it guards against * (a Rust demo linked to `/uo/` pages) is invisible in a build that has no demo mounted, so * it is run here against a scratch build that does. */ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { after, beforeEach, describe, it } from 'node:test'; import { demoLogins, readDemo, resetDemoLoginsCache, resetTime } from '../src/lib/demoLogins.mjs'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); const LOGINS = [ { username: 'demo-admin', password: 'pw-a', role: 'admin' }, { username: 'demo-player', password: 'pw-p', role: 'player' }, ]; const answer = (body, status = 200) => async () => ({ ok: status >= 200 && status < 300, status, json: async () => body, }); describe('readDemo', () => { it('keeps the logins and normalises the reset time', () => { const read = readDemo({ demo: { resetsAt: '2026-10-28T22:00:00.000Z', logins: LOGINS } }); assert.deepEqual(read, { logins: LOGINS, resetsAt: '2026-10-28T22:00:00.000Z' }); assert.equal(resetTime(read.resetsAt), '22:00 UTC'); }); it('answers null for a site that is not in demo mode', () => { assert.equal(readDemo({ site_mode: 'live' }), null); assert.equal(readDemo(null), null); assert.equal(readDemo({ demo: { logins: [] } }), null); }); it('drops malformed rows, extra fields and an unreadable time', () => { const read = readDemo({ demo: { resetsAt: 'soon', logins: [ { username: 'demo-admin', password: 'pw-a', role: 'admin', token: 'x' }, { username: 'x'.repeat(65), password: 'pw', role: 'admin' }, { username: 'demo-player', password: 42, role: 'player' }, 'demo-moderator', ], }, }); assert.deepEqual(read, { logins: [LOGINS[0]], resetsAt: null }); assert.equal(resetTime(read.resetsAt), null); }); it('shows at most eight', () => { const many = Array.from({ length: 12 }, (_, i) => ({ username: `u${i}`, password: 'p', role: 'player' })); assert.equal(readDemo({ demo: { logins: many } }).logins.length, 8); }); }); describe('demoLogins', () => { beforeEach(() => resetDemoLoginsCache()); it('reads the public settings route under the demo URL, without its trailing slash', async () => { let asked = null; const fetchImpl = async (url) => { asked = url; return answer({ demo: { logins: LOGINS } })(); }; const read = await demoLogins('https://demo.example.test/', { fetchImpl }); assert.equal(asked, 'https://demo.example.test/api/v1/public/settings'); assert.deepEqual(read.logins, LOGINS); }); it('caches an answer for a minute', async () => { let calls = 0; let clock = 1_000; const fetchImpl = async () => { calls++; return answer({ demo: { logins: LOGINS } })(); }; const options = { fetchImpl, now: () => clock }; await demoLogins('https://demo.example.test', options); clock += 59_000; await demoLogins('https://demo.example.test', options); assert.equal(calls, 1); clock += 2_000; await demoLogins('https://demo.example.test', options); assert.equal(calls, 2); }); it('answers null when the demo is down, and asks again sooner', async () => { let calls = 0; let clock = 1_000; const fetchImpl = async () => { calls++; throw new Error('connect ECONNREFUSED'); }; const options = { fetchImpl, now: () => clock }; assert.equal(await demoLogins('https://demo.example.test', options), null); clock += 10_000; assert.equal(await demoLogins('https://demo.example.test', options), null); assert.equal(calls, 1); clock += 6_000; await demoLogins('https://demo.example.test', options); assert.equal(calls, 2); }); it('answers null for an error status and for a non-web URL', async () => { assert.equal(await demoLogins('https://demo.example.test', { fetchImpl: answer({}, 502) }), null); resetDemoLoginsCache(); let called = false; const fetchImpl = async () => { called = true; return answer({ demo: { logins: LOGINS } })(); }; assert.equal(await demoLogins('file:///etc/passwd', { fetchImpl }), null); assert.equal(await demoLogins('not a url', { fetchImpl }), null); assert.equal(called, false); }); it('shares one request between renders that arrive together', async () => { let calls = 0; const fetchImpl = async () => { calls++; await new Promise((resolve) => setTimeout(resolve, 20)); return answer({ demo: { logins: LOGINS } })(); }; const [a, b] = await Promise.all([ demoLogins('https://demo.example.test', { fetchImpl }), demoLogins('https://demo.example.test', { fetchImpl }), ]); assert.equal(calls, 1); assert.deepEqual(a, b); }); }); describe('applyBrand: which demo links point anywhere (D83)', () => { const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-demo-brand-')); const dist = path.join(scratch, 'dist'); const mount = path.join(scratch, 'brand'); const page = path.join(dist, 'client', 'index.html'); // The four shapes a build writes: the homepage slot, a core deep link, and a link into // each game's pages. Same attribute order as the templates; checkBrand.mjs holds them to it. const STOCK = 'See it running\n' + 'w\n' + 'r\n' + 'u\n'; fs.mkdirSync(path.dirname(page), { recursive: true }); fs.mkdirSync(mount, { recursive: true }); fs.writeFileSync(page, STOCK); const apply = (brand) => { fs.writeFileSync(path.join(mount, 'brand.json'), JSON.stringify(brand)); execFileSync(process.execPath, [path.join(ROOT, 'scripts/applyBrand.mjs')], { env: { ...process.env, BRAND_DIST: dist, BRAND_DIR: mount, BRAND_DEFAULT_DIR: path.join(ROOT, 'brand-default'), }, stdio: 'pipe', }); return fs.readFileSync(page, 'utf8'); }; const hrefOf = (html, marker) => html.split('\n').find((line) => line.includes(marker)).match(/href="([^"]*)"/)[1]; after(() => fs.rmSync(scratch, { recursive: true, force: true })); it('fills the slot, core links and the listed game only', () => { const html = apply({ demoUrl: 'https://demo.example.test', demoGames: ['rust'] }); assert.equal(hrefOf(html, 'demo-cta'), 'https://demo.example.test'); assert.equal(hrefOf(html, '/wiki'), 'https://demo.example.test/wiki'); assert.equal(hrefOf(html, 'tab=map'), 'https://demo.example.test/rust/servers/demo?tab=map'); assert.equal(hrefOf(html, '/uo/market'), ''); assert.match(html, /data-demo-path="\/uo\/market" data-demo-game="uo"/); }); it('hides a game link again when only demoGames changes', () => { const html = apply({ demoUrl: 'https://demo.example.test', demoGames: [] }); assert.equal(hrefOf(html, 'tab=map'), ''); assert.equal(hrefOf(html, '/wiki'), 'https://demo.example.test/wiki'); }); it('treats a malformed demoGames as no games', () => { apply({ demoUrl: 'https://demo.example.test', demoGames: ['rust'] }); const html = apply({ demoUrl: 'https://demo.example.test', demoGames: 'rust' }); assert.equal(hrefOf(html, 'tab=map'), ''); }); it('returns to the stock markup when the demo is unmounted', () => { apply({ demoUrl: 'https://demo.example.test', demoGames: ['rust', 'uo'] }); const html = apply({}); assert.equal(html, STOCK); }); });