feat(demo): list the demo's sign-ins on /beta and link into it per game
Some checks failed
PR checks / checks (pull_request) Failing after 2m16s
Some checks failed
PR checks / checks (pull_request) Failing after 2m16s
The public demo (demo.runicgateway.com) is live, so the site's demo slot can be switched on. Two decisions of record, PLAN.md §15: - D82: /beta reads the demo's GET /api/v1/public/settings demo.logins on the server, caches it for a minute, and lists the published sign-ins with the next wipe. If the demo does not answer, /beta links to its sign-in page instead. Gate two reads as in place. - D83: a deep link into a game module's pages carries data-demo-game, and applyBrand.mjs fills it only for games in the new mounted demoGames list. The Rust features gain links into /rust and /rust/servers/demo. UO's stay hidden on a Rust demo. The public-demo not-built entry is removed, because its resolvedBy was met. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
208
test/demo.test.mjs
Normal file
208
test/demo.test.mjs
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 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 =
|
||||
'<a class="btn demo-cta" href="" data-demo-url="">See it running</a>\n' +
|
||||
'<a class="demo-link" href="" data-demo-url="" data-demo-path="/wiki" rel="noopener">w</a>\n' +
|
||||
'<a class="demo-link" href="" data-demo-url="" data-demo-path="/rust/servers/demo?tab=map" data-demo-game="rust" rel="noopener">r</a>\n' +
|
||||
'<a class="demo-link" href="" data-demo-url="" data-demo-path="/uo/market" data-demo-game="uo" rel="noopener">u</a>\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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user