1434 lines
68 KiB
JavaScript
1434 lines
68 KiB
JavaScript
// test/client-static.test.js — static and unit checks for the browser shell (public/).
|
|
//
|
|
// Static:
|
|
// * shared/constants.js + shared/protocol.js import cleanly in Node (pure ESM, no Node APIs needed).
|
|
// * every public/js/**/*.js file (and every inline <script type="module"> in public HTML) parses as an
|
|
// ES module (`node --input-type=module --check`, stdin).
|
|
// * public/index.html and public/dev/uikit.html only reference files that exist (src/href/import map);
|
|
// /fonts/ and /assets/ are produced by the assets pipeline and are reported, not failed, when absent.
|
|
// * the static import graph resolves: relative/absolute specifiers point at existing files (with browser
|
|
// URL semantics: '../../shared/x.js' from /js/ is /shared/x.js), bare specifiers are in the import map,
|
|
// and every named import is actually exported by its target.
|
|
// Unit (pure logic of the client modules, imported directly in Node — 'preact'/'htm' resolve from
|
|
// node_modules): store, net (fake WebSocket + fake timers), identity (fake BroadcastChannel hub), data,
|
|
// screen helpers, toasts.
|
|
|
|
import { test, describe } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const PUBLIC = path.join(ROOT, 'public');
|
|
const HTML_PAGES = ['index.html', 'dev/uikit.html'];
|
|
const OPTIONAL_PREFIXES = ['/fonts/', '/assets/']; // generated by tools/fetch-assets.mjs
|
|
|
|
// ---- helpers -------------------------------------------------------------------------------------------
|
|
|
|
function walk(dir, ext, out = []) {
|
|
for (const name of readdirSync(dir)) {
|
|
const p = path.join(dir, name);
|
|
if (statSync(p).isDirectory()) walk(p, ext, out);
|
|
else if (p.endsWith(ext)) out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** URL path served by the server → file on disk (mirrors the server/http/static.js mounts). */
|
|
function urlPathToFile(urlPath) {
|
|
const clean = decodeURIComponent(urlPath.split(/[?#]/)[0]);
|
|
if (clean.startsWith('/shared/')) return path.join(ROOT, clean);
|
|
if (clean.startsWith('/data/')) return path.join(ROOT, clean);
|
|
return path.join(PUBLIC, clean);
|
|
}
|
|
|
|
/** File under public/ → its URL path. */
|
|
const fileToUrlPath = (file) => '/' + path.relative(PUBLIC, file).split(path.sep).join('/');
|
|
|
|
function checkModuleSyntax(source) {
|
|
const r = spawnSync(process.execPath, ['--input-type=module', '--check'], { input: source, encoding: 'utf8' });
|
|
return r.status === 0 ? null : (r.stderr || r.stdout || 'syntax error').split('\n').slice(0, 6).join('\n');
|
|
}
|
|
|
|
function parseImportMap(htmlSrc) {
|
|
const m = htmlSrc.match(/<script\s+type="importmap"\s*>([\s\S]*?)<\/script>/);
|
|
return m ? JSON.parse(m[1]).imports || {} : {};
|
|
}
|
|
|
|
function inlineModules(htmlSrc) {
|
|
const out = [];
|
|
const re = /<script\s+type="module"\s*>([\s\S]*?)<\/script>/g;
|
|
let m;
|
|
while ((m = re.exec(htmlSrc))) out.push(m[1]);
|
|
return out;
|
|
}
|
|
|
|
function htmlRefs(htmlSrc) {
|
|
const refs = [];
|
|
const re = /\s(?:src|href)="([^"]+)"/g;
|
|
let m;
|
|
while ((m = re.exec(htmlSrc))) {
|
|
const v = m[1];
|
|
if (/^(https?:|data:|mailto:|#|javascript:)/.test(v)) continue;
|
|
refs.push(v);
|
|
}
|
|
return refs;
|
|
}
|
|
|
|
/** Static import/export-from statements: [{ spec, clause }]. Statements start at the beginning of a line. */
|
|
function staticImports(src) {
|
|
const out = [];
|
|
const re = /^[ \t]*(?:import|export)\b([^;'"]*?)\bfrom\s*['"]([^'"]+)['"]|^[ \t]*import\s*['"]([^'"]+)['"]/gm;
|
|
let m;
|
|
while ((m = re.exec(src))) {
|
|
if (m[3]) out.push({ spec: m[3], clause: '' });
|
|
else out.push({ spec: m[2], clause: m[1].trim() });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Names a module exports ('*' when it re-exports everything and names can't be listed). */
|
|
function exportedNames(src) {
|
|
const names = new Set();
|
|
let m;
|
|
const decl = /export\s+(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+([\w$]+)/g;
|
|
while ((m = decl.exec(src))) names.add(m[1]);
|
|
const list = /export\s*\{([^}]*)\}/g;
|
|
while ((m = list.exec(src))) {
|
|
for (const part of m[1].split(',')) {
|
|
const p = part.trim();
|
|
if (!p) continue;
|
|
const as = p.split(/\s+as\s+/);
|
|
names.add((as[1] || as[0]).trim());
|
|
}
|
|
}
|
|
if (/export\s+default\b/.test(src)) names.add('default');
|
|
if (/export\s*\*\s*from/.test(src)) names.add('*');
|
|
return names;
|
|
}
|
|
|
|
/** Imported binding names from an import clause ('default' for a default import). */
|
|
function importedNames(clause) {
|
|
if (!clause || /^\*\s*as\s/.test(clause)) return [];
|
|
const names = [];
|
|
const brace = clause.match(/\{([^}]*)\}/);
|
|
if (brace) {
|
|
for (const part of brace[1].split(',')) {
|
|
const p = part.trim();
|
|
if (p) names.push(p.split(/\s+as\s+/)[0].trim());
|
|
}
|
|
}
|
|
const head = clause.replace(/\{[^}]*\}/, '').replace(/,/g, ' ').trim();
|
|
if (head && !head.startsWith('*') && /^[\w$]+$/.test(head)) names.push('default');
|
|
return names;
|
|
}
|
|
|
|
// ---- static checks --------------------------------------------------------------------------------------
|
|
|
|
describe('shared modules', () => {
|
|
test('constants.js and protocol.js import in Node', async () => {
|
|
const c = await import(pathToFileURL(path.join(ROOT, 'shared/constants.js')).href);
|
|
const p = await import(pathToFileURL(path.join(ROOT, 'shared/protocol.js')).href);
|
|
assert.equal(typeof c.PROTOCOL_VERSION, 'number');
|
|
for (const d of c.DIFFICULTIES) {
|
|
assert.ok(c.DIFFICULTY_NAMES[d], `name for ${d}`);
|
|
assert.match(c.DIFFICULTY_COLORS[d], /^#[0-9a-f]{6}$/i);
|
|
}
|
|
for (const code of Object.keys(c.ERR)) assert.ok(c.ERR_TEXT[code], `ERR_TEXT.${code}`);
|
|
for (const ph of Object.keys(c.PHASE)) assert.ok(c.PHASE_NAMES[ph], `PHASE_NAMES.${ph}`);
|
|
assert.equal(typeof p.validateC2S, 'function');
|
|
assert.equal(p.validateC2S({ t: 'room.create', mode: 'coop', difficulty: 'HARD' }), null);
|
|
assert.ok(Array.isArray(p.S2C) && p.S2C.includes('room.state'));
|
|
});
|
|
});
|
|
|
|
const JS_FILES = walk(path.join(PUBLIC, 'js'), '.js');
|
|
|
|
describe('client modules parse as ES modules', () => {
|
|
test('found client modules', () => {
|
|
for (const f of ['main.js', 'net.js', 'store.js', 'data.js', 'ui/components.js', 'ui/toasts.js',
|
|
'screens/title.js', 'screens/lobby.js', 'screens/room.js', 'screens/game.js']) {
|
|
assert.ok(existsSync(path.join(PUBLIC, 'js', f)), `public/js/${f} exists`);
|
|
}
|
|
});
|
|
for (const file of JS_FILES) {
|
|
test(path.relative(ROOT, file), () => {
|
|
const err = checkModuleSyntax(readFileSync(file, 'utf8'));
|
|
assert.equal(err, null, err || '');
|
|
});
|
|
}
|
|
for (const page of HTML_PAGES) {
|
|
test(`inline modules of public/${page}`, () => {
|
|
const src = readFileSync(path.join(PUBLIC, page), 'utf8');
|
|
for (const mod of inlineModules(src)) {
|
|
const err = checkModuleSyntax(mod);
|
|
assert.equal(err, null, err || '');
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('HTML pages reference existing files', () => {
|
|
for (const page of HTML_PAGES) {
|
|
test(`public/${page}`, (t) => {
|
|
const src = readFileSync(path.join(PUBLIC, page), 'utf8');
|
|
const pageUrl = new URL(`http://host/${page}`);
|
|
const imports = parseImportMap(src);
|
|
assert.ok(imports.preact && imports['preact/hooks'] && imports.htm, 'import map has preact, preact/hooks, htm');
|
|
const refs = [...htmlRefs(src), ...Object.values(imports)];
|
|
for (const ref of refs) {
|
|
const urlPath = new URL(ref, pageUrl).pathname;
|
|
const file = urlPathToFile(urlPath);
|
|
if (!existsSync(file)) {
|
|
if (OPTIONAL_PREFIXES.some((p) => urlPath.startsWith(p))) t.diagnostic(`optional asset missing: ${urlPath}`);
|
|
else assert.fail(`${page} references missing ${urlPath}`);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
test('index.html boots main.js as a module and has the rotate hint', () => {
|
|
const src = readFileSync(path.join(PUBLIC, 'index.html'), 'utf8');
|
|
assert.match(src, /<script type="module" src="\/js\/main\.js"[^>]*><\/script>/);
|
|
assert.match(src, /class="rotate-hint"/);
|
|
assert.match(src, /fonts\.googleapis\.com\/css2\?family=Noto\+Sans\+SC/);
|
|
assert.match(src, /href="\/fonts\/fonts\.css"/);
|
|
});
|
|
});
|
|
|
|
describe('static import graph resolves', () => {
|
|
const importMap = parseImportMap(readFileSync(path.join(PUBLIC, 'index.html'), 'utf8'));
|
|
const exportsCache = new Map();
|
|
const exportsOf = (file) => {
|
|
if (!exportsCache.has(file)) exportsCache.set(file, exportedNames(readFileSync(file, 'utf8')));
|
|
return exportsCache.get(file);
|
|
};
|
|
|
|
function checkSource(label, src, baseUrl) {
|
|
for (const { spec, clause } of staticImports(src)) {
|
|
let urlPath;
|
|
if (/^(\.{1,2}\/|\/)/.test(spec)) urlPath = new URL(spec, baseUrl).pathname;
|
|
else {
|
|
assert.ok(importMap[spec], `${label}: bare specifier '${spec}' is not in the import map`);
|
|
urlPath = importMap[spec];
|
|
}
|
|
const file = urlPathToFile(urlPath);
|
|
assert.ok(existsSync(file), `${label}: '${spec}' → ${urlPath} does not exist`);
|
|
const names = exportsOf(file);
|
|
if (names.has('*')) continue;
|
|
for (const n of importedNames(clause)) {
|
|
assert.ok(names.has(n), `${label}: '${n}' is not exported by ${urlPath}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const file of JS_FILES) {
|
|
test(path.relative(ROOT, file), () => {
|
|
checkSource(path.relative(ROOT, file), readFileSync(file, 'utf8'), new URL(`http://host${fileToUrlPath(file)}`));
|
|
});
|
|
}
|
|
test('dev/uikit.html inline module', () => {
|
|
const src = readFileSync(path.join(PUBLIC, 'dev/uikit.html'), 'utf8');
|
|
for (const mod of inlineModules(src)) checkSource('uikit.html', mod, new URL('http://host/dev/uikit.html'));
|
|
});
|
|
test('client modules never import Node built-ins', () => {
|
|
for (const file of JS_FILES) {
|
|
const src = readFileSync(file, 'utf8');
|
|
assert.doesNotMatch(src, /from\s+['"]node:/, `${path.relative(ROOT, file)} imports a node: module`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---- unit: store ------------------------------------------------------------------------------------------
|
|
|
|
const mod = (rel) => import(pathToFileURL(path.join(PUBLIC, 'js', rel)).href);
|
|
|
|
describe('store.js', () => {
|
|
test('createStore get/set/patch/subscribe', async () => {
|
|
const { createStore } = await mod('store.js');
|
|
const s = createStore({ a: 1, nested: { x: 1, y: 2 } });
|
|
const seen = [];
|
|
const off = s.subscribe((st, prev) => seen.push([st.a, prev.a]));
|
|
s.set({ a: 2 });
|
|
s.set((st) => ({ a: st.a + 1 }));
|
|
s.patch('nested', { y: 5 });
|
|
assert.deepEqual(s.get().nested, { x: 1, y: 5 });
|
|
s.set(null); // ignored
|
|
off();
|
|
s.set({ a: 99 });
|
|
assert.deepEqual(seen, [[2, 1], [3, 2], [3, 3]]);
|
|
s.reset();
|
|
assert.equal(s.get().a, 1);
|
|
});
|
|
|
|
test('a throwing listener does not break others', async () => {
|
|
const { createStore } = await mod('store.js');
|
|
const s = createStore({ v: 0 });
|
|
let ok = 0;
|
|
const orig = console.error;
|
|
console.error = () => {};
|
|
try {
|
|
s.subscribe(() => { throw new Error('boom'); });
|
|
s.subscribe(() => { ok++; });
|
|
s.set({ v: 1 });
|
|
} finally { console.error = orig; }
|
|
assert.equal(ok, 1);
|
|
});
|
|
|
|
test('shallowEqual', async () => {
|
|
const { shallowEqual } = await mod('store.js');
|
|
assert.ok(shallowEqual({ a: 1, b: 'x' }, { a: 1, b: 'x' }));
|
|
assert.ok(!shallowEqual({ a: 1 }, { a: 1, b: 2 }));
|
|
assert.ok(!shallowEqual({ a: {} }, { a: {} }));
|
|
assert.ok(shallowEqual([1, 2], [1, 2]));
|
|
assert.ok(!shallowEqual([1], { 0: 1 }));
|
|
assert.ok(shallowEqual(null, null));
|
|
assert.ok(!shallowEqual(null, {}));
|
|
});
|
|
|
|
test('selectRoute', async () => {
|
|
const { selectRoute } = await mod('store.js');
|
|
const base = { session: { entered: true }, room: null, match: { public: null } };
|
|
assert.equal(selectRoute({ ...base, session: { entered: false } }), 'title');
|
|
assert.equal(selectRoute(base), 'lobby');
|
|
assert.equal(selectRoute({ ...base, room: { code: 'ABCD' } }), 'room');
|
|
assert.equal(selectRoute({ ...base, room: { code: 'ABCD', inMatch: true } }), 'game');
|
|
assert.equal(selectRoute({ ...base, room: { code: 'ABCD' }, match: { public: { phase: 'PREP' } } }), 'game');
|
|
assert.equal(selectRoute({ ...base, room: { code: 'ABCD' }, match: { public: { phase: 'LOBBY' } } }), 'room');
|
|
assert.equal(selectRoute(undefined), 'title');
|
|
});
|
|
|
|
test('serverNow applies the clock offset', async () => {
|
|
const { store, serverNow } = await mod('store.js');
|
|
store.set({ clock: { offset: 5000, rtt: 10, synced: true } });
|
|
const d = serverNow() - Date.now();
|
|
assert.ok(d >= 4990 && d <= 5010, `offset applied (${d})`);
|
|
store.set({ clock: { offset: NaN } });
|
|
assert.ok(Math.abs(serverNow() - Date.now()) < 50, 'NaN offset ignored');
|
|
store.set({ clock: { offset: 0, rtt: null, synced: false } });
|
|
});
|
|
});
|
|
|
|
// ---- unit: net --------------------------------------------------------------------------------------------
|
|
|
|
function fakeTimers(start = 1_000_000) {
|
|
let now = start;
|
|
let seq = 0;
|
|
const q = new Map();
|
|
const add = (fn, ms, every) => { const id = ++seq; q.set(id, { at: now + Math.max(0, ms | 0), fn, every }); return id; };
|
|
return {
|
|
now: () => now,
|
|
setTimeout: (fn, ms) => add(fn, ms, 0),
|
|
clearTimeout: (id) => q.delete(id),
|
|
setInterval: (fn, ms) => add(fn, ms, Math.max(1, ms | 0)),
|
|
clearInterval: (id) => q.delete(id),
|
|
advance(ms) {
|
|
const end = now + ms;
|
|
for (;;) {
|
|
let next = null;
|
|
for (const [id, t] of q) if (t.at <= end && (!next || t.at < next[1].at)) next = [id, t];
|
|
if (!next) break;
|
|
const [id, t] = next;
|
|
now = t.at;
|
|
if (t.every) t.at += t.every; else q.delete(id);
|
|
t.fn();
|
|
}
|
|
now = end;
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeFakeWS() {
|
|
const sockets = [];
|
|
class FakeWS {
|
|
constructor(url) {
|
|
this.url = url;
|
|
this.readyState = 0;
|
|
this.sent = [];
|
|
this.closedWith = null;
|
|
sockets.push(this);
|
|
}
|
|
send(data) {
|
|
if (this.readyState !== 1) throw new Error('not open');
|
|
this.sent.push(JSON.parse(data));
|
|
}
|
|
close(code) { this.readyState = 3; this.closedWith = code ?? 1000; }
|
|
// test helpers
|
|
open() { this.readyState = 1; this.onopen?.(); }
|
|
recv(obj) { this.onmessage?.({ data: typeof obj === 'string' ? obj : JSON.stringify(obj) }); }
|
|
drop(code = 1006) { this.readyState = 3; this.onclose?.({ code }); }
|
|
last(t) { return [...this.sent].reverse().find((m) => m.t === t); }
|
|
}
|
|
return { FakeWS, sockets };
|
|
}
|
|
|
|
async function makeNet(extra = {}) {
|
|
const { Net } = await mod('net.js');
|
|
const timers = fakeTimers();
|
|
const { FakeWS, sockets } = makeFakeWS();
|
|
const net = new Net({ url: 'ws://test/ws', WebSocket: FakeWS, timers, now: timers.now, random: () => 0.5, getToken: () => 'tok-1', ...extra });
|
|
const statuses = [];
|
|
net.on('status', (s) => statuses.push(s.status));
|
|
return { net, timers, sockets, statuses, ws: () => sockets[sockets.length - 1] };
|
|
}
|
|
|
|
/** Connect, open, set a name and complete the handshake. */
|
|
async function onlineNet(extra) {
|
|
const ctx = await makeNet(extra);
|
|
ctx.net.setName('凯尔希');
|
|
ctx.ws().open();
|
|
const hello = ctx.ws().last('hello');
|
|
ctx.ws().recv({ t: 'welcome', rid: hello.rid, playerId: 'p_1', token: 'tok-2', name: '凯尔希', serverNow: ctx.timers.now() });
|
|
return ctx;
|
|
}
|
|
|
|
const quiet = async (fn) => {
|
|
const w = console.warn;
|
|
console.warn = () => {};
|
|
try { return await fn(); } finally { console.warn = w; }
|
|
};
|
|
|
|
/** Answer the latest ping on a fake socket. */
|
|
function ws0Pong(sock) {
|
|
const p = sock.last('ping');
|
|
if (p) sock.recv({ t: 'pong', c: p.c, s: p.c });
|
|
}
|
|
|
|
describe('net.js', () => {
|
|
test('backoffDelay grows, is capped and jittered', async () => {
|
|
const { backoffDelay, BACKOFF } = await mod('net.js');
|
|
const mid = () => 0.5;
|
|
assert.equal(backoffDelay(0, mid), BACKOFF.base);
|
|
assert.equal(backoffDelay(1, mid), BACKOFF.base * 2);
|
|
assert.equal(backoffDelay(50, mid), BACKOFF.max);
|
|
assert.ok(backoffDelay(3, () => 0) < backoffDelay(3, () => 0.999));
|
|
assert.ok(backoffDelay(-5, mid) >= 100);
|
|
assert.ok(backoffDelay(NaN, mid) >= 100);
|
|
});
|
|
|
|
test('errorText / NetError', async () => {
|
|
const { errorText, NetError } = await mod('net.js');
|
|
assert.equal(errorText('ROOM_FULL'), '房间已满');
|
|
assert.equal(errorText('TIMEOUT'), '请求超时,请重试');
|
|
assert.equal(errorText('WHATEVER', 'server says'), 'server says');
|
|
const e = new NetError('NO_FUNDS');
|
|
assert.ok(e instanceof Error);
|
|
assert.equal(e.code, 'NO_FUNDS');
|
|
assert.equal(e.message, '资金不足');
|
|
assert.match(new NetError('BAD_MSG', 'x', 'version mismatch: server 2').message, /版本/);
|
|
});
|
|
|
|
test('defaultWsUrl', async () => {
|
|
const { defaultWsUrl } = await mod('net.js');
|
|
assert.equal(defaultWsUrl({ protocol: 'http:', host: 'a:3000' }), 'ws://a:3000/ws');
|
|
assert.equal(defaultWsUrl({ protocol: 'https:', host: 'x.io' }), 'wss://x.io/ws');
|
|
});
|
|
|
|
test('connect without a name → connected + ping; setName → hello with token & version', async () => {
|
|
const { PROTOCOL_VERSION } = await import(pathToFileURL(path.join(ROOT, 'shared/constants.js')).href);
|
|
const { net, ws, statuses } = await makeNet();
|
|
net.connect();
|
|
assert.equal(net.status, 'connecting');
|
|
ws().open();
|
|
assert.equal(net.status, 'connected');
|
|
assert.ok(ws().last('ping'), 'latency probe before hello');
|
|
net.setName(' 凯尔希 ');
|
|
const hello = ws().last('hello');
|
|
assert.deepEqual({ ...hello, rid: 0 }, { t: 'hello', rid: 0, name: '凯尔希', version: PROTOCOL_VERSION, token: 'tok-1' });
|
|
assert.equal(net.status, 'handshaking');
|
|
ws().recv({ t: 'welcome', rid: hello.rid, playerId: 'p_1', token: 't', name: '凯尔希', serverNow: Date.now() });
|
|
assert.equal(net.status, 'online');
|
|
assert.equal(net.playerId, 'p_1');
|
|
assert.deepEqual(statuses.slice(0, 2), ['connecting', 'connected']);
|
|
net.setName('凯尔希');
|
|
assert.equal(ws().sent.filter((m) => m.t === 'hello').length, 1, 'same name: no second hello');
|
|
net.setName('阿米娅');
|
|
assert.equal(ws().last('hello').name, '阿米娅', 'rename re-sends hello on the live socket');
|
|
});
|
|
|
|
test('token longer than the protocol limit is not sent', async () => {
|
|
const { net, ws } = await makeNet({ getToken: () => 'x'.repeat(65) });
|
|
net.setName('A');
|
|
ws().open();
|
|
assert.equal(ws().last('hello').token, undefined);
|
|
});
|
|
|
|
test('request resolves on ok, rejects on error with ERR text', async () => {
|
|
const { net, ws } = await onlineNet();
|
|
const p1 = net.request('room.create', { mode: 'coop', difficulty: 'HARD' });
|
|
const m1 = ws().last('room.create');
|
|
assert.equal(m1.mode, 'coop');
|
|
ws().recv({ t: 'ok', rid: m1.rid });
|
|
assert.equal((await p1).t, 'ok');
|
|
|
|
const p2 = net.request('room.join', { code: 'ABCD' });
|
|
const m2 = ws().last('room.join');
|
|
ws().recv({ t: 'error', rid: m2.rid, code: 'ROOM_NOT_FOUND', msg: '…' });
|
|
await assert.rejects(p2, (e) => e.code === 'ROOM_NOT_FOUND' && e.message === '未找到该同盟密钥对应的房间');
|
|
assert.equal(net.pendingCount, 0);
|
|
});
|
|
|
|
test('invalid request fails locally with BAD_MSG; hello via request is refused', async () => {
|
|
const { net, ws } = await onlineNet();
|
|
const before = ws().sent.length;
|
|
await quiet(() => assert.rejects(net.request('room.create', { mode: 'pvp', difficulty: 'HARD' }), (e) => e.code === 'BAD_MSG'));
|
|
await assert.rejects(net.request('hello', { name: 'x' }), (e) => e.code === 'BAD_MSG');
|
|
await quiet(() => assert.rejects(net.request('nope', {}), (e) => e.code === 'BAD_MSG'));
|
|
assert.equal(ws().sent.length, before, 'nothing sent');
|
|
});
|
|
|
|
test('request times out after 8 s', async () => {
|
|
const { net, timers, ws } = await onlineNet();
|
|
const p = net.request('room.leave', {});
|
|
timers.advance(7999);
|
|
assert.equal(net.pendingCount, 1);
|
|
timers.advance(2);
|
|
await assert.rejects(p, (e) => e.code === 'TIMEOUT');
|
|
assert.equal(net.pendingCount, 0);
|
|
const unhandled = [];
|
|
net.on('unhandledError', (e) => unhandled.push(e.code));
|
|
ws().recv({ t: 'error', rid: ws().last('room.leave').rid, code: 'NOT_IN_ROOM', msg: 'x' });
|
|
assert.deepEqual(unhandled, [], 'late reply to a timed-out request is not reported twice');
|
|
});
|
|
|
|
test('requests before a session: OFFLINE when idle, queued while handshaking then flushed', async () => {
|
|
const { net, ws } = await makeNet();
|
|
await assert.rejects(net.request('room.leave', {}), (e) => e.code === 'OFFLINE');
|
|
net.setName('A');
|
|
const p = net.request('room.ready', { ready: true });
|
|
ws().open();
|
|
assert.equal(ws().last('room.ready'), undefined, 'not sent before welcome');
|
|
ws().recv({ t: 'welcome', rid: ws().last('hello').rid, playerId: 'p', token: 't', name: 'A', serverNow: 1 });
|
|
const sent = ws().last('room.ready');
|
|
assert.ok(sent, 'flushed after welcome');
|
|
ws().recv({ t: 'ok', rid: sent.rid });
|
|
await p;
|
|
});
|
|
|
|
test('drop → pending rejects, reconnect with backoff, hello again', async () => {
|
|
const { net, ws, sockets, timers } = await onlineNet();
|
|
const p = net.request('room.leave', {});
|
|
ws().drop();
|
|
await assert.rejects(p, (e) => e.code === 'DISCONNECTED');
|
|
assert.equal(net.status, 'reconnecting');
|
|
assert.equal(net.attempt, 1);
|
|
assert.equal(sockets.length, 1);
|
|
timers.advance(499);
|
|
assert.equal(sockets.length, 1);
|
|
timers.advance(2);
|
|
assert.equal(sockets.length, 2, 'reconnected after backoff');
|
|
ws().open();
|
|
assert.equal(ws().last('hello').name, '凯尔希');
|
|
ws().drop();
|
|
assert.equal(net.attempt, 2, 'attempt keeps growing until welcome');
|
|
timers.advance(1001);
|
|
ws().open();
|
|
ws().recv({ t: 'welcome', rid: ws().last('hello').rid, playerId: 'p_1', token: 't', name: '凯尔希', serverNow: 1 });
|
|
assert.equal(net.attempt, 0);
|
|
assert.equal(net.status, 'online');
|
|
});
|
|
|
|
test('pong gives latency and clock offset', async () => {
|
|
const { net, ws, timers } = await onlineNet();
|
|
const clocks = [];
|
|
net.on('clock', (c) => clocks.push(c));
|
|
net.probe();
|
|
const ping = ws().last('ping');
|
|
timers.advance(40);
|
|
ws().recv({ t: 'pong', c: ping.c, s: ping.c + 20 + 5000 });
|
|
assert.equal(net.ping, 40);
|
|
assert.equal(net.clockOffset, 5000);
|
|
assert.ok(clocks.at(-1).synced);
|
|
assert.ok(Math.abs(net.serverNow() - (timers.now() + 5000)) <= 1);
|
|
ws().recv({ t: 'pong', c: 'garbage', s: 1 });
|
|
ws().recv({ t: 'pong', c: timers.now() + 99999, s: 1 }); // negative rtt ignored
|
|
assert.equal(net.ping, 40);
|
|
});
|
|
|
|
test('silent socket is detected and replaced', async () => {
|
|
const { net, sockets, timers } = await quiet(() => onlineNet());
|
|
await quiet(async () => { timers.advance(20000); });
|
|
assert.equal(sockets[0].closedWith, 4000);
|
|
assert.equal(net.status, 'reconnecting');
|
|
});
|
|
|
|
test('throttled background timers (heartbeat ~1/min) do not look like a dead socket', async () => {
|
|
const { timers, sockets, net, ws } = await (async () => {
|
|
const t = fakeTimers();
|
|
const throttled = { ...t, setInterval: (fn) => t.setInterval(fn, 60_000) }; // intensive throttling
|
|
return onlineNet({ timers: throttled, now: t.now }).then((ctx) => ({ ...ctx, timers: t }));
|
|
})();
|
|
for (let i = 0; i < 5; i++) {
|
|
const ping = ws().last('ping');
|
|
timers.advance(30);
|
|
ws().recv({ t: 'pong', c: ping.c, s: ping.c }); // server answers promptly
|
|
timers.advance(60_000 - 30); // next (late) heartbeat
|
|
}
|
|
assert.equal(sockets.length, 1, 'no reconnect churn');
|
|
assert.equal(net.status, 'online');
|
|
// …but a socket that really stopped answering is still replaced (at the next late heartbeat).
|
|
await quiet(async () => { timers.advance(130_000); });
|
|
assert.equal(net.status, 'reconnecting');
|
|
});
|
|
|
|
test('server close 4001 (session replaced) stops auto-reconnect', async () => {
|
|
const { net, ws, sockets, timers } = await onlineNet();
|
|
const events = [];
|
|
net.on('replaced', (e) => events.push(e.code));
|
|
ws().drop(4001);
|
|
assert.equal(net.status, 'closed');
|
|
assert.deepEqual(events, ['REPLACED']);
|
|
timers.advance(60000);
|
|
assert.equal(sockets.length, 1, 'no reconnect');
|
|
await assert.rejects(net.request('room.leave', {}), (e) => e.code === 'CLOSED');
|
|
net.connect();
|
|
assert.equal(sockets.length, 2, 'manual connect resumes');
|
|
});
|
|
|
|
test('server drops an idle pre-hello socket (4002): swapped quietly, no status flicker', async () => {
|
|
const { net, sockets, timers, statuses } = await makeNet();
|
|
net.connect();
|
|
sockets[0].open();
|
|
assert.equal(net.status, 'connected');
|
|
for (let i = 0; i < 10; i++) { ws0Pong(sockets[0]); timers.advance(4000); } // idle on the title, server answers pings
|
|
ws0Pong(sockets[0]);
|
|
const before = statuses.length;
|
|
sockets[0].drop(4002);
|
|
assert.equal(sockets.length, 2, 'fresh socket opened at once');
|
|
assert.equal(net.status, 'connected');
|
|
sockets[1].open();
|
|
assert.equal(net.status, 'connected');
|
|
assert.deepEqual(statuses.slice(before), ['connected'], 'only the (unchanged) connected status is re-emitted');
|
|
net.setName('A');
|
|
assert.ok(sockets[1].last('hello'), 'hello goes out on the new socket');
|
|
// a socket closed with 4002 right after opening is not swapped quietly (no tight loop)
|
|
sockets[1].drop(4002);
|
|
assert.equal(net.status, 'reconnecting');
|
|
});
|
|
|
|
test('hello error → connected, helloError, queued requests fail', async () => {
|
|
const { net, ws } = await makeNet();
|
|
const errs = [];
|
|
net.on('helloError', (e) => errs.push(e));
|
|
net.on('unhandledError', () => assert.fail('hello error must not be reported as unhandled'));
|
|
net.setName('A');
|
|
const p = net.request('room.leave', {});
|
|
ws().open();
|
|
ws().recv({ t: 'error', rid: ws().last('hello').rid, code: 'BAD_MSG', msg: 'x', detail: 'version mismatch: server 2' });
|
|
assert.equal(net.status, 'connected');
|
|
assert.equal(errs.length, 1);
|
|
assert.match(errs[0].message, /版本/);
|
|
await assert.rejects(p, (e) => e.code === 'OFFLINE');
|
|
});
|
|
|
|
test('pushes are emitted by type and "*"; unsolicited errors as unhandledError; junk ignored', async () => {
|
|
const { net, ws } = await onlineNet();
|
|
const got = [];
|
|
net.on('room.state', (m) => got.push(m.code));
|
|
const all = [];
|
|
const off = net.on('*', (m) => all.push(m.t));
|
|
const unhandled = [];
|
|
net.on('unhandledError', (e) => unhandled.push(e.code));
|
|
ws().recv({ t: 'room.state', code: 'WXYZ', seats: [] });
|
|
ws().recv({ t: 'error', code: 'RATE', msg: 'r' });
|
|
await quiet(async () => { ws().recv('not json'); });
|
|
ws().recv('[1,2]');
|
|
ws().recv({ nope: 1 });
|
|
off();
|
|
ws().recv({ t: 'm.toast', text: 'x' });
|
|
assert.deepEqual(got, ['WXYZ']);
|
|
assert.deepEqual(all, ['room.state', 'error']);
|
|
assert.deepEqual(unhandled, ['RATE']);
|
|
});
|
|
|
|
test('a throwing listener does not break dispatch', async () => {
|
|
const { net, ws } = await onlineNet();
|
|
let ok = 0;
|
|
const orig = console.error;
|
|
console.error = () => {};
|
|
try {
|
|
net.on('m.public', () => { throw new Error('boom'); });
|
|
net.on('m.public', () => { ok++; });
|
|
ws().recv({ t: 'm.public', phase: 'PREP', serverNow: 1 });
|
|
} finally { console.error = orig; }
|
|
assert.equal(ok, 1);
|
|
});
|
|
|
|
test('send() is fire-and-forget and validated', async () => {
|
|
const { net, ws } = await onlineNet();
|
|
assert.equal(net.send('g.emote', { id: 'autochess_battle_happy' }), true);
|
|
assert.deepEqual(ws().last('g.emote'), { t: 'g.emote', id: 'autochess_battle_happy' });
|
|
await quiet(async () => assert.equal(net.send('g.emote', { id: 'nope' }), false));
|
|
net.close();
|
|
assert.equal(net.send('g.emote', { id: 'autochess_battle_happy' }), false);
|
|
assert.equal(net.status, 'closed');
|
|
});
|
|
|
|
test('close() rejects queued requests and stops reconnecting', async () => {
|
|
const { net, sockets, timers } = await makeNet();
|
|
net.setName('A');
|
|
const p = net.request('room.leave', {});
|
|
net.close();
|
|
await assert.rejects(p, (e) => e.code === 'CLOSED');
|
|
timers.advance(60000);
|
|
assert.equal(sockets.length, 1);
|
|
});
|
|
|
|
test('constructor failure schedules a retry instead of throwing', async () => {
|
|
const { Net } = await mod('net.js');
|
|
const timers = fakeTimers();
|
|
let n = 0;
|
|
class Boom { constructor() { n++; throw new Error('SecurityError'); } }
|
|
const net = new Net({ url: 'ws://x/ws', WebSocket: Boom, timers, now: timers.now, random: () => 0.5 });
|
|
await quiet(async () => { net.connect(); timers.advance(600); });
|
|
assert.equal(n, 2);
|
|
assert.equal(net.status, 'reconnecting');
|
|
net.close();
|
|
});
|
|
});
|
|
|
|
// ---- unit: identity ----------------------------------------------------------------------------------------
|
|
|
|
function memStorage() {
|
|
const m = new Map();
|
|
return {
|
|
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
|
setItem: (k, v) => { m.set(k, String(v)); },
|
|
removeItem: (k) => { m.delete(k); },
|
|
/** sessionStorage is copied when a tab is duplicated */
|
|
clone() { const c = memStorage(); for (const [k, v] of m) c.setItem(k, v); return c; },
|
|
};
|
|
}
|
|
|
|
/** In-memory BroadcastChannel hub: messages reach every other open member asynchronously. */
|
|
function channelHub() {
|
|
const members = new Set();
|
|
return {
|
|
create() {
|
|
const ch = {
|
|
listeners: new Set(),
|
|
postMessage(data) {
|
|
if (!members.has(ch)) throw new Error('InvalidStateError: channel closed');
|
|
const copy = structuredClone(data);
|
|
for (const m of members) {
|
|
if (m === ch) continue;
|
|
queueMicrotask(() => { if (members.has(m)) for (const fn of m.listeners) fn({ data: copy }); });
|
|
}
|
|
},
|
|
addEventListener(type, fn) { if (type === 'message') ch.listeners.add(fn); },
|
|
close() { members.delete(ch); },
|
|
};
|
|
members.add(ch);
|
|
return ch;
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('identity (reconnect-token selection across tabs)', () => {
|
|
const recent = (local) => JSON.parse(local.getItem('sp.tokens') || '[]');
|
|
|
|
test('new tab, second tab, duplicated tab, reload, reopen', async () => {
|
|
const { createIdentity } = await mod('net.js');
|
|
const hub = channelHub();
|
|
const local = memStorage();
|
|
const tab = (id, session = memStorage()) => {
|
|
const channel = hub.create();
|
|
return { id: createIdentity({ local, session, tabId: id, channel, queryMs: 5 }), session, channel };
|
|
};
|
|
|
|
const A = tab('A');
|
|
assert.equal(await A.id.init(), null, 'fresh browser: no token → new session');
|
|
assert.equal(A.id.getToken(), null);
|
|
A.id.saveToken('tokA');
|
|
assert.equal(A.id.getToken(), 'tokA');
|
|
assert.deepEqual(recent(local), ['tokA']);
|
|
|
|
const B = tab('B');
|
|
assert.equal(await B.id.init(), null, 'A is live: a second tab must not take tokA');
|
|
B.id.saveToken('tokB');
|
|
assert.deepEqual(recent(local), ['tokB', 'tokA']);
|
|
|
|
const dup = tab('Adup', A.session.clone());
|
|
assert.equal(dup.session.getItem('sp.token'), 'tokA', 'duplicated tab starts with a copy of A\'s token');
|
|
assert.equal(await dup.id.init(), null, 'duplicated tab becomes a new player');
|
|
assert.equal(dup.session.getItem('sp.token'), null, 'copied token dropped from the duplicate');
|
|
assert.equal(A.id.getToken(), 'tokA', 'original tab keeps its session');
|
|
dup.channel.close();
|
|
|
|
A.channel.close(); // A reloads: the old document is gone before the new one asks
|
|
const A2 = tab('A2', A.session);
|
|
assert.equal(await A2.id.init(), 'tokA', 'reload resumes the own token');
|
|
A2.channel.close(); // tab closed
|
|
|
|
const C = tab('C');
|
|
assert.equal(await C.id.init(), 'tokA', 'reopened tab resumes the most recent token no live tab holds (tokB is B\'s)');
|
|
assert.equal(C.session.getItem('sp.token'), 'tokA');
|
|
});
|
|
|
|
test('a busy background tab still answers (no timer-based liveness)', async () => {
|
|
const { createIdentity } = await mod('net.js');
|
|
const hub = channelHub();
|
|
const local = memStorage();
|
|
const bg = createIdentity({ local, session: memStorage(), tabId: 'bg', channel: hub.create(), queryMs: 5 });
|
|
await bg.init();
|
|
bg.saveToken('tokBg');
|
|
await new Promise((r) => setTimeout(r, 30)); // arbitrary idle time: nothing to refresh
|
|
const fresh = createIdentity({ local, session: memStorage(), tabId: 'fresh', channel: hub.create(), queryMs: 5 });
|
|
assert.equal(await fresh.init(), null);
|
|
});
|
|
|
|
test('simultaneous resolution: the smaller tab id wins a shared token', async () => {
|
|
const { createIdentity } = await mod('net.js');
|
|
const hub = channelHub();
|
|
const local = memStorage();
|
|
local.setItem('sp.tokens', JSON.stringify(['tokShared']));
|
|
const x = createIdentity({ local, session: memStorage(), tabId: 'a-tab', channel: hub.create(), queryMs: 5 });
|
|
const y = createIdentity({ local, session: memStorage(), tabId: 'b-tab', channel: hub.create(), queryMs: 5 });
|
|
const [tx, ty] = await Promise.all([x.init(), y.init()]);
|
|
assert.equal(tx, 'tokShared');
|
|
assert.equal(ty, null);
|
|
});
|
|
|
|
for (const [firstId, secondId, expected] of [
|
|
['a-tab', 'b-tab', ['tokShared', null]],
|
|
['b-tab', 'a-tab', [null, 'tokShared']],
|
|
]) {
|
|
test(`staggered channels: ${firstId} starts before ${secondId}`, async () => {
|
|
const { createIdentity, CLAIM_QUERY_MS } = await mod('net.js');
|
|
const hub = channelHub(), timers = fakeTimers(), local = memStorage();
|
|
local.setItem('sp.tokens', JSON.stringify(['tokShared']));
|
|
const firstSession = memStorage(), secondSession = memStorage();
|
|
const first = createIdentity({ local, session: firstSession, tabId: firstId,
|
|
channel: hub.create(), setTimeout: timers.setTimeout });
|
|
const firstPick = first.init();
|
|
timers.advance(30);
|
|
// This channel did not exist when the first tab broadcast its query.
|
|
const second = createIdentity({ local, session: secondSession, tabId: secondId,
|
|
channel: hub.create(), setTimeout: timers.setTimeout });
|
|
const secondPick = second.init();
|
|
await new Promise((r) => setImmediate(r));
|
|
timers.advance(CLAIM_QUERY_MS - 30);
|
|
assert.equal(await firstPick, expected[0]);
|
|
timers.advance(30);
|
|
assert.equal(await secondPick, expected[1]);
|
|
assert.deepEqual([first.getToken(), second.getToken()], expected);
|
|
assert.deepEqual([firstSession.getItem('sp.token'), secondSession.getItem('sp.token')], expected);
|
|
});
|
|
}
|
|
|
|
test('staggered elections reserve fallback candidates while a live owner keeps its token', async () => {
|
|
const { createIdentity, CLAIM_QUERY_MS } = await mod('net.js');
|
|
const hub = channelHub(), timers = fakeTimers(), local = memStorage();
|
|
const owner = createIdentity({ local, session: memStorage(), tabId: 'z-owner', channel: hub.create() });
|
|
await owner.init();
|
|
owner.saveToken('tokOwned');
|
|
local.setItem('sp.tokens', JSON.stringify(['tokOwned', 'tokFree']));
|
|
const first = createIdentity({ local, session: memStorage(), tabId: 'a-tab',
|
|
channel: hub.create(), setTimeout: timers.setTimeout });
|
|
const firstPick = first.init();
|
|
timers.advance(30);
|
|
const duplicateSession = memStorage();
|
|
duplicateSession.setItem('sp.token', 'tokOwned');
|
|
const second = createIdentity({ local, session: duplicateSession, tabId: 'b-tab',
|
|
channel: hub.create(), setTimeout: timers.setTimeout });
|
|
const secondPick = second.init();
|
|
await new Promise((r) => setImmediate(r));
|
|
timers.advance(CLAIM_QUERY_MS - 30);
|
|
assert.equal(await firstPick, 'tokFree');
|
|
timers.advance(30);
|
|
assert.equal(await secondPick, null);
|
|
assert.equal(duplicateSession.getItem('sp.token'), null, 'the copied live token is discarded');
|
|
assert.equal(owner.getToken(), 'tokOwned', 'a live owner outranks even a smaller tab id');
|
|
});
|
|
|
|
test('without BroadcastChannel only the own token is used; init is idempotent; welcome during init wins', async () => {
|
|
const { createIdentity } = await mod('net.js');
|
|
const local = memStorage();
|
|
local.setItem('sp.tokens', JSON.stringify(['someoneElse']));
|
|
const lone = createIdentity({ local, session: memStorage(), tabId: 'L', channel: null });
|
|
assert.equal(await lone.init(), null, 'never adopts a shared token it cannot verify');
|
|
const session = memStorage();
|
|
session.setItem('sp.token', 'mine');
|
|
const own = createIdentity({ local, session, tabId: 'O', channel: null });
|
|
assert.equal(own.getToken(), 'mine', 'before init: own token');
|
|
const p1 = own.init();
|
|
assert.equal(own.init(), p1, 'same promise');
|
|
assert.equal(await p1, 'mine');
|
|
|
|
const hub = channelHub();
|
|
const racing = createIdentity({ local: memStorage(), session: memStorage(), tabId: 'R', channel: hub.create(), queryMs: 5 });
|
|
racing.saveToken('fromWelcome'); // a welcome arrived before init finished
|
|
assert.equal(await racing.init(), 'fromWelcome');
|
|
});
|
|
|
|
test('entered flag, name, clearToken, bad tokens and missing storage', async () => {
|
|
const { createIdentity, tokenHash } = await mod('net.js');
|
|
const local = memStorage();
|
|
const id = createIdentity({ local, session: memStorage(), tabId: 'X', channel: null });
|
|
await id.init();
|
|
assert.equal(id.wasEntered(), false);
|
|
id.setEntered(true);
|
|
assert.equal(id.wasEntered(), true);
|
|
id.setEntered(false);
|
|
assert.equal(id.wasEntered(), false);
|
|
id.saveName('凯尔希');
|
|
assert.equal(id.loadName(), '凯尔希');
|
|
id.saveToken('t');
|
|
assert.deepEqual(recent(local), ['t']);
|
|
id.clearToken();
|
|
assert.equal(id.getToken(), null);
|
|
assert.deepEqual(recent(local), [], 'forgotten token leaves the recent list');
|
|
id.saveToken('');
|
|
id.saveToken('x'.repeat(65));
|
|
assert.equal(id.getToken(), null, 'invalid tokens ignored');
|
|
for (let i = 0; i < 9; i++) id.saveToken(`t${i}`);
|
|
assert.equal(recent(local).length, 4, 'recent list capped');
|
|
local.setItem('sp.tokens', '{broken');
|
|
id.saveToken('ok');
|
|
assert.deepEqual(recent(local), ['ok'], 'corrupt list replaced');
|
|
assert.match(tokenHash('abc'), /^[0-9a-f]{8}$/);
|
|
assert.notEqual(tokenHash('abc'), tokenHash('abd'));
|
|
|
|
const none = createIdentity({ local: null, session: null, channel: null });
|
|
assert.equal(await none.init(), null);
|
|
none.saveToken('x');
|
|
assert.equal(none.getToken(), 'x', 'kept in memory for this page');
|
|
assert.equal(none.loadName(), '');
|
|
assert.equal(none.wasEntered(), false);
|
|
});
|
|
});
|
|
|
|
// ---- unit: data --------------------------------------------------------------------------------------------
|
|
|
|
function fakeFetch(files) {
|
|
const calls = [];
|
|
const fn = async (url) => {
|
|
calls.push(url);
|
|
const name = url.split('/').pop();
|
|
if (!(name in files)) return { ok: false, status: 404, json: async () => ({}) };
|
|
const v = files[name];
|
|
return { ok: true, status: 200, json: async () => (typeof v === 'string' ? JSON.parse(v) : v) };
|
|
};
|
|
fn.calls = calls;
|
|
return fn;
|
|
}
|
|
|
|
describe('data.js', () => {
|
|
test('indexes map, array and wrapped shapes; tolerates missing/bad files', async () => {
|
|
const { createDataStore } = await mod('data.js');
|
|
const fetch = fakeFetch({
|
|
'chess.json': { chess_a: { name: 'A' }, chess_b: { name: 'B' } },
|
|
'bonds.json': [{ bondId: 'yanShip', name: '炎' }, { bondId: 'sargonShip', name: '萨尔贡' }, { junk: 1 }],
|
|
'items.json': { version: 2, items: [{ id: 'it1', name: 'I' }] },
|
|
'bands.json': '{"broken": ',
|
|
});
|
|
const d = createDataStore({ fetch, base: '/data/' });
|
|
const w = console.warn;
|
|
const warns = [];
|
|
console.warn = (m) => warns.push(m);
|
|
try {
|
|
const [chess] = await Promise.all([d.load('chess'), d.load('chess')]);
|
|
assert.equal(fetch.calls.filter((u) => u === '/data/chess.json').length, 1, 'single fetch for concurrent loads');
|
|
assert.equal(chess.chess_a.name, 'A');
|
|
assert.equal(d.lookup('chess', 'chess_b').name, 'B');
|
|
await d.loadAll('bonds', 'items', 'bands', 'enemies');
|
|
assert.equal(d.lookup('bonds', 'yanShip').name, '炎');
|
|
assert.equal(d.list('bonds').length, 2);
|
|
assert.equal(d.lookup('items', 'it1').name, 'I');
|
|
assert.equal(d.get('bands'), null);
|
|
assert.equal(d.status('bands'), 'missing');
|
|
assert.equal(d.status('enemies'), 'missing');
|
|
assert.equal(d.lookup('enemies', 'x'), null);
|
|
assert.equal(d.status('never'), 'idle');
|
|
assert.equal(await d.load('../etc/passwd'), null, 'unsafe names rejected');
|
|
await d.load('enemies');
|
|
assert.equal(warns.filter((m) => String(m).includes('enemies')).length, 1, 'missing file warned once');
|
|
} finally { console.warn = w; }
|
|
});
|
|
|
|
test('subscribe + invalidate refetches', async () => {
|
|
const { createDataStore } = await mod('data.js');
|
|
const fetch = fakeFetch({ 'config.json': { modes: { mode_multi_hard: { name: '绝境模拟' } } } });
|
|
const d = createDataStore({ fetch });
|
|
const seen = [];
|
|
d.subscribe((n) => seen.push(n));
|
|
await d.load('config');
|
|
d.invalidate('config');
|
|
await d.load('config');
|
|
assert.equal(fetch.calls.length, 2);
|
|
assert.deepEqual(seen, ['config', 'config', 'config']);
|
|
});
|
|
|
|
test('invalidate during an in-flight load: the stale load stays silent, the refetch wins', async () => {
|
|
const { createDataStore } = await mod('data.js');
|
|
let n = 0;
|
|
const releases = [];
|
|
const fetch = (url) => new Promise((resolve) => {
|
|
const v = ++n;
|
|
releases.push(() => resolve({ ok: true, status: 200, json: async () => ({ rec: { v } }) }));
|
|
});
|
|
const d = createDataStore({ fetch });
|
|
const seen = [];
|
|
d.subscribe((name) => seen.push(`${name}:${d.status(name)}`));
|
|
const first = d.load('bonds');
|
|
const second = d.invalidate('bonds');
|
|
releases[1]();
|
|
await second;
|
|
releases[0]();
|
|
await first;
|
|
assert.equal(d.lookup('bonds', 'rec').v, 2, 'refetched value kept');
|
|
assert.equal(d.status('bonds'), 'ready');
|
|
assert.deepEqual(seen, ['bonds:loading', 'bonds:ready'], 'no notification from the superseded load');
|
|
assert.equal(await d.invalidate('never-loaded'), null);
|
|
});
|
|
|
|
test('buildIndex edge cases', async () => {
|
|
const { buildIndex } = await mod('data.js');
|
|
assert.equal(buildIndex('x', null).size, 0);
|
|
assert.equal(buildIndex('x', 42).size, 0);
|
|
assert.equal(buildIndex('x', [{ id: 1 }, { id: 1 }, null]).size, 1);
|
|
assert.equal(buildIndex('x', { a: 1, b: { v: 1 } }).size, 1, 'non-object values skipped');
|
|
});
|
|
});
|
|
|
|
// ---- unit: screen helpers & components ----------------------------------------------------------------------
|
|
|
|
describe('screen helpers', () => {
|
|
test('title: sanitizeName / isValidName / findUiAsset', async () => {
|
|
const { sanitizeName, isValidName, findUiAsset } = await mod('screens/title.js');
|
|
assert.equal(sanitizeName(' 凯尔希 '), '凯尔希');
|
|
assert.equal(sanitizeName('a\u0000b\u200bc\u202ed'), 'abcd');
|
|
assert.equal(sanitizeName('a b'), 'a b');
|
|
assert.equal(sanitizeName('x'.repeat(30)).length, 12);
|
|
const emoji = '😀'.repeat(7); // 14 UTF-16 units
|
|
const cut = sanitizeName(emoji);
|
|
assert.ok(cut.length <= 12 && !/[\ud800-\udbff]$/.test(cut), 'no dangling surrogate');
|
|
const server = await import(pathToFileURL(path.join(ROOT, 'server/net.js')).href);
|
|
for (const raw of ['e\u0301', '\ud800x', '\udc00', 'a \u200b b', '\u00a0\u3000A\u3000B', 'a\u2028b', '😀'.repeat(7), ' x '.repeat(9)]) {
|
|
const c = sanitizeName(raw);
|
|
assert.ok(c.length <= 12, 'protocol limit (UTF-16 units)');
|
|
assert.equal(server.sanitizeName(c) ?? '', c, `server keeps the client-sanitized ${JSON.stringify(raw)} unchanged`);
|
|
}
|
|
assert.equal(sanitizeName(null), '');
|
|
assert.equal(isValidName(' '), false);
|
|
assert.equal(isValidName('Doctor'), true);
|
|
assert.equal(findUiAsset({ ui: { 'entry/bkg_01': '/assets/ui/entry/bkg_01.png' } }, ['titleBackdrop', 'entry/bkg_01']), '/assets/ui/entry/bkg_01.png');
|
|
assert.equal(findUiAsset({ ui: { titleBackdrop: { url: '/a.png' } } }, ['titleBackdrop']), '/a.png');
|
|
assert.equal(findUiAsset({ files: ['/assets/ui/entry_bkg_01.webp'] }, ['entry_bkg_01']), '/assets/ui/entry_bkg_01.webp');
|
|
assert.equal(findUiAsset(null, ['x']), null);
|
|
assert.equal(findUiAsset({ ui: {} }, ['x']), null);
|
|
});
|
|
|
|
test('title exposes the shared settings modal', () => {
|
|
const source = readFileSync(path.join(PUBLIC, 'js/screens/title.js'), 'utf8');
|
|
assert.match(source, /import \{ SettingsModal \} from '\.\.\/ui\/settings\.js'/);
|
|
assert.match(source, /class="title-settings fsbtn tapx"/, 'title screen includes the settings control');
|
|
assert.match(source, /<\$\{SettingsModal\} open=\$\{settingsOpen\}/, 'settings control opens the shared modal');
|
|
});
|
|
|
|
test('lobby: normalizeCode / parseRoomParam / difficultyInfo', async () => {
|
|
const { normalizeCode, parseRoomParam, difficultyInfo, MODE_TEXT } = await mod('screens/lobby.js');
|
|
assert.equal(normalizeCode('ab-c d9'), 'ABCD');
|
|
assert.equal(normalizeCode('http://192.168.1.2:3000/?room=wxyz'), 'WXYZ');
|
|
assert.equal(normalizeCode(undefined), '');
|
|
assert.equal(parseRoomParam('?room=abcd'), 'ABCD');
|
|
assert.equal(parseRoomParam('?x=1&room=ABCDEF'), 'ABCDEF');
|
|
assert.equal(parseRoomParam('?room=ABCDEFG'), null);
|
|
assert.equal(parseRoomParam('?room=AB'), null);
|
|
assert.equal(parseRoomParam('?room=AB%3Ccd'), null);
|
|
assert.equal(parseRoomParam(''), null);
|
|
const solo = difficultyInfo('solo', 'FUNNY');
|
|
assert.equal(solo.rounds, 9);
|
|
assert.equal(solo.hidden, false);
|
|
assert.equal(solo.desc, MODE_TEXT.single.FUNNY.desc);
|
|
const hard = difficultyInfo('coop', 'HARD');
|
|
assert.equal(hard.rounds, 14);
|
|
assert.equal(hard.hidden, true);
|
|
assert.equal(hard.code, 'AC-3');
|
|
assert.ok(hard.effects.length >= 2);
|
|
assert.doesNotThrow(() => difficultyInfo('coop', undefined), 'unknown difficulty degrades gracefully');
|
|
assert.equal(difficultyInfo('coop', 'BOGUS').rounds, 14);
|
|
});
|
|
|
|
// Regression: `onClick=${handler}` hands Preact's click EVENT as the first argument, and a default parameter only
|
|
// applies to `undefined` — so `normalizeCode(eventTarget)` produced "[object HTMLElement]" → "OBJE", and every
|
|
// 观战 click sent the same nonsense key no matter what was typed in the field. codeArg() is the guard for that.
|
|
test('lobby: a click event is never mistaken for an alliance key (codeArg)', async () => {
|
|
const { codeArg, normalizeCode } = await mod('screens/lobby.js');
|
|
// a DOM element stringifies to "[object HTMLElement]" — the shape that caused the bug
|
|
const element = { toString: () => '[object HTMLElement]' };
|
|
assert.equal(normalizeCode(element), 'OBJE', 'the old bug: the element normalises into a fake 4-letter code');
|
|
assert.equal(codeArg(element, 'NJBU'), 'NJBU', 'a non-string argument falls back to the input field');
|
|
// plain event-like objects and other non-strings behave the same
|
|
for (const weird of [{}, [], 42, true, null, undefined, Symbol('x'), () => {}]) {
|
|
assert.equal(codeArg(weird, 'NJBU'), 'NJBU', `falls back for ${String(weird)}`);
|
|
}
|
|
// an explicit string always wins (the full-room prompt passes the code it asked for)
|
|
assert.equal(codeArg('ab cd', 'NJBU'), 'ABCD');
|
|
// no usable code at all
|
|
assert.equal(codeArg(element, ''), null);
|
|
assert.equal(codeArg(element, 'AB'), null);
|
|
assert.equal(codeArg(undefined, undefined), null);
|
|
assert.equal(codeArg('ZZZ QQQ', ''), 'ZZZQ', 'a real string is still truncated to ROOM_CODE_LEN');
|
|
});
|
|
|
|
test('lobby: battlefield note per difficulty (标准 fixed 战场#01, 险境 8 / 绝境·终极 7 random) matches config.json modes[].stages', async () => {
|
|
const { difficultyInfo, stageNote, stageLabel, STAGE_POOL } = await mod('screens/lobby.js');
|
|
const cfg = JSON.parse(readFileSync(path.join(ROOT, 'data/config.json'), 'utf8'));
|
|
for (const [modeId, m] of Object.entries(cfg.modes)) {
|
|
if (!m.inScope) continue;
|
|
const pool = STAGE_POOL[m.difficulty];
|
|
assert.equal(Array.isArray(pool) ? pool.length : pool, m.stages.length, `${modeId} fallback pool size`);
|
|
if (Array.isArray(pool)) assert.deepEqual(pool, m.stages, `${modeId} fixed stage`);
|
|
}
|
|
// config.json not loaded here: the embedded fallback
|
|
for (const room of ['solo', 'coop']) {
|
|
assert.equal(difficultyInfo(room, 'FUNNY').stageNote, '战场固定为 战场#01');
|
|
assert.equal(difficultyInfo(room, 'NORMAL').stageNote, '战场随机(共8张)');
|
|
assert.equal(difficultyInfo(room, 'HARD').stageNote, '战场随机(共7张)');
|
|
assert.equal(difficultyInfo(room, 'ABYSS').stageNote, '战场随机(共7张)');
|
|
}
|
|
assert.equal(difficultyInfo('coop', 'BOGUS').stageNote, '');
|
|
assert.equal(stageNote(cfg.modes.mode_multi_normal.stages), '战场随机(共8张)');
|
|
assert.equal(stageNote(['act1autochess_m01']), '战场固定为 战场#01');
|
|
assert.equal(stageNote([]), '');
|
|
assert.equal(stageNote(null), '');
|
|
assert.equal(stageLabel('act2autochess_m02'), '战场#06', 'act2 continues the numbering');
|
|
assert.equal(stageLabel('nope'), '');
|
|
});
|
|
|
|
test('room: normalizeSeats / roomFacts / inviteLink', async () => {
|
|
const { normalizeSeats, roomFacts, inviteLink } = await mod('screens/room.js');
|
|
const room = {
|
|
code: 'ABCD', hostId: 'h', mode: 'coop', difficulty: 'HARD',
|
|
seats: [
|
|
{ seat: 0, playerId: 'h', name: 'Host', isBot: false, ready: false, connected: true },
|
|
{ seat: 1, playerId: 'g', name: 'Guest', isBot: false, ready: false, connected: true },
|
|
null,
|
|
{ seat: 3, playerId: 'ai_1', name: 'AI·华法琳', isBot: true, ready: true, connected: true },
|
|
],
|
|
};
|
|
assert.equal(normalizeSeats(room).length, 4);
|
|
assert.equal(normalizeSeats({ mode: 'solo', seats: [room.seats[0], null, null, null] }).length, 1);
|
|
assert.deepEqual(normalizeSeats({ mode: 'coop', seats: 'bad' }), [null, null, null, null]);
|
|
let f = roomFacts(room, 'h');
|
|
assert.equal(f.isHost, true);
|
|
assert.equal(f.canStart, false, 'guest not ready');
|
|
assert.equal(f.humans.length, 2);
|
|
assert.equal(f.emptySeats, 1);
|
|
assert.equal(f.readyHumans, 1, 'host counts as ready (start = host ready)');
|
|
room.seats[1].ready = true;
|
|
f = roomFacts(room, 'h');
|
|
assert.equal(f.canStart, true, 'host start does not need the host to be ready');
|
|
room.seats[1].connected = false;
|
|
assert.equal(roomFacts(room, 'h').canStart, false, 'disconnected guest blocks start');
|
|
assert.equal(roomFacts(room, 'g').canStart, false, 'guests cannot start');
|
|
assert.equal(roomFacts(null, 'x').mine, null);
|
|
assert.match(inviteLink('ABCD'), /\?room=ABCD$/);
|
|
});
|
|
|
|
test('room: spectator seats (community report #26) — isSpectating; roomFacts never counts a spectator as a player', async () => {
|
|
const { roomFacts } = await mod('screens/room.js');
|
|
const { isSpectating } = await mod('store.js');
|
|
const room = {
|
|
code: 'ABCD', hostId: 'h', mode: 'coop', difficulty: 'HARD',
|
|
seats: [{ seat: 0, playerId: 'h', name: 'Host', isBot: false, ready: false, connected: true }, null, null, null],
|
|
spectators: [{ playerId: 's', name: 'Spec', connected: true }],
|
|
};
|
|
assert.equal(isSpectating(room, 's'), true);
|
|
assert.equal(isSpectating(room, 'h'), false);
|
|
assert.equal(isSpectating({ ...room, spectators: undefined }, 's'), false, 'a room.state without the list');
|
|
assert.equal(isSpectating(room, null), false);
|
|
assert.equal(isSpectating(null, 's'), false);
|
|
const f = roomFacts(room, 's');
|
|
assert.equal(f.spectating, true);
|
|
assert.equal(f.mine, null);
|
|
assert.equal(f.humans.length, 1, 'never a player');
|
|
assert.equal(f.emptySeats, 3, 'a free player seat stays free (入座)');
|
|
assert.deepEqual(f.spectators.map((x) => x.playerId), ['s']);
|
|
const hf = roomFacts(room, 'h');
|
|
assert.equal(hf.canStart, true, 'a spectator never blocks the start');
|
|
assert.equal(hf.spectating, false);
|
|
assert.deepEqual(roomFacts({ ...room, spectators: 'bad' }, 'h').spectators, []);
|
|
});
|
|
|
|
test('components: roman / doctorNo / secondsLeft', async () => {
|
|
const { roman, doctorNo, secondsLeft } = await mod('ui/components.js');
|
|
assert.deepEqual([1, 2, 3, 4, 5, 6].map(roman), ['I', 'II', 'III', 'IV', 'V', 'VI']);
|
|
assert.equal(roman(0), '');
|
|
assert.equal(roman(99), '');
|
|
assert.match(doctorNo('p_abc123'), /^\d{4}$/);
|
|
assert.equal(doctorNo('p_abc123'), doctorNo('p_abc123'));
|
|
assert.notEqual(doctorNo('p_abc123'), doctorNo('p_abc124'));
|
|
assert.match(doctorNo(undefined), /^\d{4}$/);
|
|
assert.equal(secondsLeft(0), null, 'deadline 0 = untimed phase');
|
|
assert.equal(secondsLeft(null), null);
|
|
assert.equal(secondsLeft(-5), null);
|
|
assert.equal(secondsLeft(undefined), null);
|
|
assert.equal(secondsLeft(Date.now() - 5000), 0);
|
|
const s = secondsLeft(Date.now() + 9500);
|
|
assert.ok(s === 10 || s === 9, `ceil seconds (${s})`);
|
|
});
|
|
|
|
test('toasts: merge identical, describeError', async () => {
|
|
const { toast, dismissToast, describeError } = await mod('ui/toasts.js');
|
|
const a = toast('资金不足', 'error');
|
|
const b = toast('资金不足', 'error');
|
|
assert.equal(a, b, 'identical toasts merge');
|
|
const c = toast('资金不足', 'warn');
|
|
assert.notEqual(a, c);
|
|
assert.equal(toast(' '), -1);
|
|
dismissToast(a);
|
|
dismissToast(c);
|
|
dismissToast(12345); // unknown id: no-op
|
|
assert.equal(describeError({ code: 'NO_FUNDS' }), '资金不足');
|
|
assert.equal(describeError('ROOM_FULL'), '房间已满');
|
|
assert.equal(describeError(new Error('boom')), 'boom');
|
|
assert.equal(describeError(null), '发生未知错误');
|
|
assert.equal(describeError({}), '发生未知错误');
|
|
});
|
|
});
|
|
|
|
|
|
// ---- multi-device / browser compatibility (static) -----------------------------------------------------------------
|
|
// Supported: desktop Chrome / Edge / Firefox (incl. ESR 115) / Safari 15+, iPadOS / iOS 15+ Safari, Android Chrome.
|
|
// Guards against syntax and CSS features that silently break those (a lookbehind regex is a SyntaxError in Safari
|
|
// < 16.4 and stops the whole client; :has() / container queries / CSS nesting are ignored by Firefox ESR 115 or Safari
|
|
// 15; color-mix() by Safari < 16.2; backdrop-filter needs its -webkit- twin before Safari 18).
|
|
|
|
const CSS_FILES = walk(path.join(PUBLIC, 'css'), '.css');
|
|
const BROWSER_JS = [...JS_FILES, ...walk(path.join(ROOT, 'shared'), '.js'), ...walk(path.join(ROOT, 'server/sim'), '.js').filter((f) => !/nodeData\.js$/i.test(f))];
|
|
/** Rules of a stylesheet as { selector, body } (flat; @media blocks are walked into). */
|
|
function cssRules(src) {
|
|
const out = [];
|
|
const clean = src.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
const re = /([^{}]+)\{([^{}]*)\}/g;
|
|
let m;
|
|
while ((m = re.exec(clean))) out.push({ selector: m[1].trim(), body: m[2] });
|
|
return out;
|
|
}
|
|
|
|
describe('multi-device & browser compatibility (static)', () => {
|
|
const index = readFileSync(path.join(PUBLIC, 'index.html'), 'utf8');
|
|
|
|
test('index.html: landscape viewport without page zoom, notch-aware, devices.css last, boot diagnostics', () => {
|
|
const vp = index.match(/<meta name="viewport" content="([^"]+)"/)?.[1] || '';
|
|
for (const k of ['width=device-width', 'initial-scale=1', 'maximum-scale=1', 'user-scalable=no', 'viewport-fit=cover']) assert.ok(vp.includes(k), `viewport has ${k}`);
|
|
const sheets = [...index.matchAll(/<link rel="stylesheet" href="(\/css\/[^"]+)"/g)].map((x) => x[1]);
|
|
assert.equal(sheets[sheets.length - 1], '/css/devices.css', 'devices.css is the last stylesheet');
|
|
assert.match(index, /<script nomodule>/, 'a browser without ES modules gets a message');
|
|
assert.match(index, /onerror="window\.__spBootFail/, 'a module graph that cannot load gets a message');
|
|
assert.match(index, /apple-mobile-web-app-capable/);
|
|
assert.match(index, /class="rotate-hint"/);
|
|
});
|
|
|
|
test('main.js imports the polyfills before anything else and installs the device layer', () => {
|
|
const src = readFileSync(path.join(PUBLIC, 'js/main.js'), 'utf8');
|
|
const first = staticImports(src)[0];
|
|
assert.equal(first?.spec, './ui/compat.js');
|
|
assert.match(src, /installDeviceSupport\(\)/);
|
|
});
|
|
|
|
test('client modules use no bare specifiers (import maps need Safari 16.4+) — ui/fieldHost.js included', () => {
|
|
// (the vendored hooks.module.js is rewritten by tools/vendor.mjs to import "./preact.module.js" — see the vendor
|
|
// test below; the import map stays for pages that still name "preact" / "htm")
|
|
for (const file of JS_FILES) {
|
|
const rel = path.relative(path.join(PUBLIC, 'js'), file).split(path.sep).join('/');
|
|
for (const { spec } of staticImports(readFileSync(file, 'utf8'))) {
|
|
assert.match(spec, /^(\.{1,2}\/|\/)/, `${rel}: bare specifier '${spec}'`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('vendored ES modules import no bare specifiers (hooks → ./preact.module.js, the URL the import map names)', async () => {
|
|
const { rewriteBare, VENDOR_REWRITES } = await import('../tools/vendor.mjs');
|
|
assert.equal(rewriteBare('import{options as n}from"preact";', { preact: './preact.module.js' }), 'import{options as n}from"./preact.module.js";');
|
|
assert.equal(rewriteBare("import { h } from 'preact'; import('preact')", { preact: './p.js' }), "import { h } from './p.js'; import('./p.js')");
|
|
assert.equal(rewriteBare('from"preact/hooks";from"preactx"', { preact: './p.js' }), 'from"preact/hooks";from"preactx"', 'only the exact specifier');
|
|
assert.deepEqual(VENDOR_REWRITES['hooks.module.js'], { preact: './preact.module.js' });
|
|
for (const f of ['hooks.module.js', 'preact.module.js', 'htm.module.js']) {
|
|
const file = path.join(PUBLIC, 'vendor', f);
|
|
if (!existsSync(file)) continue;
|
|
for (const { spec } of staticImports(readFileSync(file, 'utf8'))) assert.match(spec, /^(\.{1,2}\/|\/)/, `vendor/${f}: bare specifier '${spec}' (run npm run vendor)`);
|
|
}
|
|
});
|
|
|
|
test('no regex lookbehind in code that runs in the browser (SyntaxError in Safari < 16.4)', () => {
|
|
for (const file of BROWSER_JS) {
|
|
const src = readFileSync(file, 'utf8').replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, '');
|
|
assert.doesNotMatch(src, /\(\?<[=!]/, `${path.relative(ROOT, file)} uses a lookbehind`);
|
|
}
|
|
});
|
|
|
|
test('APIs newer than Safari 15.0 / Firefox ESR are polyfilled by ui/compat.js', () => {
|
|
const compat = readFileSync(path.join(PUBLIC, 'js/ui/compat.js'), 'utf8');
|
|
const needs = { 'Object.hasOwn': 'hasOwn', '.at(': "'at'", structuredClone: 'structuredClone', '.findLast(': 'findLast' };
|
|
for (const file of BROWSER_JS) {
|
|
const src = readFileSync(file, 'utf8');
|
|
for (const [api, mark] of Object.entries(needs)) {
|
|
if (src.includes(api)) assert.ok(compat.includes(mark), `${path.relative(ROOT, file)} uses ${api}: compat.js must polyfill it`);
|
|
}
|
|
// never polyfillable: these would break Safari 15 / Firefox ESR outright
|
|
for (const bad of ['Object.groupBy', 'Promise.withResolvers', 'Array.fromAsync', 'Iterator.from', '.toSorted(', '.toReversed(', '.toSpliced(', 'Set.prototype.union', '.union(']) {
|
|
assert.ok(!src.includes(bad), `${path.relative(ROOT, file)} uses ${bad}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('CSS: no :has(), container queries, @layer or nesting; prefixed backdrop-filter; color-mix() has a fallback', () => {
|
|
for (const file of CSS_FILES) {
|
|
const rel = path.relative(ROOT, file);
|
|
const src = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
assert.doesNotMatch(src, /:has\(/, `${rel}: :has() (Firefox ESR 115, Safari < 15.4)`);
|
|
assert.doesNotMatch(src, /@container|container-type|@layer/, `${rel}: container queries / cascade layers`);
|
|
for (const r of cssRules(src)) {
|
|
assert.ok(!/(^|[\s,])&/.test(r.selector), `${rel}: CSS nesting in '${r.selector}'`);
|
|
const decls = r.body.split(';').map((d) => d.trim()).filter(Boolean);
|
|
decls.forEach((d, i) => {
|
|
const prop = d.split(':')[0].trim();
|
|
if (prop === 'backdrop-filter') assert.ok(decls.some((x) => x.startsWith('-webkit-backdrop-filter')), `${rel} '${r.selector}': backdrop-filter without -webkit- twin`);
|
|
if (/color-mix\(/.test(d)) {
|
|
const fallback = decls.slice(0, i).some((x) => x.split(':')[0].trim() === prop && !/color-mix\(/.test(x));
|
|
assert.ok(fallback, `${rel} '${r.selector}': ${prop} uses color-mix() without a fallback before it`);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
test('devices.css: safe-area insets for the HUD and screens, touch-action, hit areas, overscroll', () => {
|
|
const src = readFileSync(path.join(PUBLIC, 'css/devices.css'), 'utf8');
|
|
for (const k of ['safe-area-inset-left', 'safe-area-inset-right', 'safe-area-inset-top', 'safe-area-inset-bottom']) assert.ok(src.includes(k), k);
|
|
assert.match(src, /\.gm__hud \{[^}]*var\(--sa-l\)/);
|
|
assert.match(src, /\.screen:not\(\.gm\) \{[^}]*var\(--sa-t\)/);
|
|
assert.match(src, /overscroll-behavior: none/);
|
|
assert.match(src, /touch-action: none/);
|
|
assert.match(src, /width: max\(100%, var\(--tap-min\)\)/);
|
|
assert.match(src, /--tap-min: 44px/);
|
|
});
|
|
});
|
|
|
|
describe('explicit local match recovery (#431)', () => {
|
|
async function recoveryPair() {
|
|
const { createIdentity } = await mod('net.js');
|
|
const local = memStorage(), hub = channelHub(), ownerSession = memStorage(), guestSession = memStorage();
|
|
const ownerChannel = hub.create();
|
|
const owner = createIdentity({ local, session: ownerSession, tabId: 'z-owner', channel: ownerChannel, queryMs: 5, now: () => 100 });
|
|
const guest = createIdentity({ local, session: guestSession, tabId: 'a-guest', channel: hub.create(), queryMs: 5, now: () => 200 });
|
|
await owner.init(); owner.saveToken('seat-owner'); owner.rememberMatch({ name: 'Owner', code: 'ROOM1' });
|
|
await guest.init(); guest.saveToken('seat-guest');
|
|
return { createIdentity, local, hub, ownerSession, guestSession, ownerChannel, owner, guest, id: guest.recoverable()[0].id };
|
|
}
|
|
test('live: a smaller tab id cannot reclaim an active seat', async () => {
|
|
const { guest, owner, id } = await recoveryPair();
|
|
assert.equal(await guest.resume(id), false);
|
|
assert.equal(guest.getToken(), 'seat-guest');
|
|
assert.equal(owner.getToken(), 'seat-owner');
|
|
});
|
|
test('reload: the original session wins over a recovery attempt during navigation', async () => {
|
|
const p = await recoveryPair();
|
|
p.ownerChannel.close();
|
|
assert.equal(await p.guest.resume(p.id), true, 'old document is gone');
|
|
const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), queryMs: 5 });
|
|
assert.equal(await reloaded.init(), 'seat-owner');
|
|
assert.equal(p.ownerSession.getItem('sp.token'), 'seat-owner');
|
|
assert.equal(p.guest.getToken(), 'seat-guest', 'unconfirmed recovery yields before hello');
|
|
});
|
|
test('midInit: the session token answers claims before init finishes', async () => {
|
|
const p = await recoveryPair();
|
|
p.ownerChannel.close();
|
|
let finish;
|
|
const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), setTimeout: (fn) => { finish = fn; } });
|
|
const pending = reloaded.init();
|
|
assert.equal(await p.guest.resume(p.id), false);
|
|
finish();
|
|
assert.equal(await pending, 'seat-owner');
|
|
assert.equal(p.ownerSession.getItem('sp.token'), 'seat-owner');
|
|
assert.equal(p.guest.getToken(), 'seat-guest');
|
|
});
|
|
test('an occupied session token never falls back to a different recent identity', async () => {
|
|
const p = await recoveryPair();
|
|
p.local.setItem('sp.tokens', JSON.stringify(['seat-free', 'seat-owner']));
|
|
const duplicate = p.createIdentity({ local: p.local, session: p.ownerSession.clone(), tabId: '0-copy', channel: p.hub.create(), queryMs: 5 });
|
|
assert.equal(await duplicate.init(), null);
|
|
assert.equal(duplicate.getToken(), null);
|
|
});
|
|
test('resume persists neither its token nor a welcome stamp until the server welcomes it', async () => {
|
|
const p = await recoveryPair();
|
|
p.ownerChannel.close();
|
|
const stamp = p.guestSession.getItem('sp.tokenWelcome');
|
|
assert.equal(await p.guest.resume(p.id), true);
|
|
assert.equal(p.guestSession.getItem('sp.token'), 'seat-guest');
|
|
assert.equal(p.guestSession.getItem('sp.tokenWelcome'), stamp);
|
|
p.guest.saveToken('seat-owner');
|
|
assert.equal(p.guestSession.getItem('sp.token'), 'seat-owner');
|
|
assert.ok(p.guestSession.getItem('sp.tokenWelcome'));
|
|
});
|
|
test('an earlier welcome stamp survives reload and beats a later live welcome', async () => {
|
|
const p = await recoveryPair();
|
|
p.ownerChannel.close();
|
|
assert.equal(await p.guest.resume(p.id), true);
|
|
p.guest.saveToken('seat-owner'); // recovery's welcome is later than the original owner's
|
|
const originalStamp = p.ownerSession.getItem('sp.tokenWelcome');
|
|
const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), queryMs: 5, now: () => 300 });
|
|
assert.equal(await reloaded.init(), 'seat-owner');
|
|
reloaded.saveToken('seat-owner');
|
|
assert.equal(p.ownerSession.getItem('sp.tokenWelcome'), originalStamp, 'reconnect does not reset seniority');
|
|
});
|
|
test('a welcome received during init cannot be overwritten or erased by its pending result', async () => {
|
|
const { createIdentity } = await mod('net.js');
|
|
const local = memStorage(), session = memStorage(), hub = channelHub();
|
|
session.setItem('sp.token', 'old');
|
|
let finish;
|
|
const id = createIdentity({ local, session, channel: hub.create(), setTimeout: (fn) => { finish = fn; } });
|
|
const pending = id.init();
|
|
id.saveToken('new'); finish();
|
|
assert.equal(await pending, 'new');
|
|
assert.equal(session.getItem('sp.token'), 'new');
|
|
});
|
|
test('an idle tab can reclaim a closed match tab without taking a live one', async () => {
|
|
const { createIdentity } = await mod('net.js');
|
|
const local = memStorage(), hub = channelHub(), ca = hub.create(), cb = hub.create();
|
|
const a = createIdentity({ local, session: memStorage(), tabId: 'a', channel: ca, queryMs: 5 });
|
|
const b = createIdentity({ local, session: memStorage(), tabId: 'b', channel: cb, queryMs: 5 });
|
|
await a.init(); a.saveToken('idle-token');
|
|
await b.init(); b.saveToken('match-token'); b.rememberMatch({ name: 'Doctor B', code: 'ABC123' });
|
|
const [item] = a.recoverable();
|
|
assert.equal(item.name, 'Doctor B');
|
|
assert.equal(JSON.stringify(item).includes('match-token'), false);
|
|
assert.equal(await a.resume(item.id), false, 'live original keeps its seat');
|
|
assert.equal(a.getToken(), 'idle-token');
|
|
cb.close();
|
|
assert.equal(await a.resume(item.id), true);
|
|
assert.equal(a.getToken(), 'match-token');
|
|
a.rememberMatch(null);
|
|
assert.deepEqual(JSON.parse(local.getItem('sp.matches')), {});
|
|
ca.close();
|
|
});
|
|
test('simultaneous recoveries pick one winner; absent channels and unknown ids cannot recover', async () => {
|
|
const { createIdentity } = await mod('net.js');
|
|
const local = memStorage(), hub = channelHub();
|
|
local.setItem('sp.tokens', JSON.stringify(['idle-b', 'idle-a']));
|
|
const sa = memStorage(), sb = memStorage(); sa.setItem('sp.token', 'idle-a'); sb.setItem('sp.token', 'idle-b');
|
|
const a = createIdentity({ local, session: sa, tabId: 'a', channel: hub.create(), queryMs: 5 });
|
|
const b = createIdentity({ local, session: sb, tabId: 'b', channel: hub.create(), queryMs: 5 });
|
|
await Promise.all([a.init(), b.init()]);
|
|
const closed = createIdentity({ local, session: memStorage(), channel: null });
|
|
closed.saveToken('closed-token'); closed.rememberMatch({ name: 'C', code: 'XYZ' });
|
|
const id = a.recoverable()[0].id;
|
|
assert.deepEqual(await Promise.all([a.resume(id), b.resume(id)]), [true, false]);
|
|
assert.equal(await b.resume('unknown'), false);
|
|
assert.equal(await closed.resume(id), false);
|
|
});
|
|
test('server replacement clears expired metadata and malformed storage remains safe', async () => {
|
|
const { createIdentity } = await mod('net.js');
|
|
const local = memStorage(); const a = createIdentity({ local, session: memStorage(), channel: null });
|
|
a.saveToken('old'); a.rememberMatch({ name: 'D', code: 'AAA' }); a.saveToken('new');
|
|
assert.deepEqual(a.recoverable(), []);
|
|
for (const bad of ['null', '[]', '{', '"hello"']) {
|
|
local.setItem('sp.matches', bad); assert.deepEqual(a.recoverable(), []);
|
|
}
|
|
});
|
|
});
|