308 lines
18 KiB
TypeScript
308 lines
18 KiB
TypeScript
import { STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { test } from 'node:test';
|
|
import type { PageModel, OverlayModel, PageId } from '../../YouleNexus/assets/framework/presentation/ui-contracts.ts';
|
|
import type { ResolveRuntimeConfigOptions, RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts';
|
|
|
|
function deferred() {
|
|
let resolve!: () => void;
|
|
let reject!: (error: unknown) => void;
|
|
const promise = new Promise<void>((yes, no) => { resolve = yes; reject = no; });
|
|
return { promise, resolve, reject };
|
|
}
|
|
async function flush() { for (let i = 0; i < 30; i++) await Promise.resolve(); }
|
|
function fixture(name = 'player-login-success.json') {
|
|
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'));
|
|
}
|
|
class Socket {
|
|
readyState = 0;
|
|
sent: string[] = [];
|
|
closes = 0;
|
|
closeError?: Error;
|
|
onopen: (() => void) | null = null;
|
|
onclose: (() => void) | null = null;
|
|
onmessage: ((event: { data: string }) => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
send(frame: string) { this.sent.push(frame); }
|
|
close() { this.closes++; if (this.closeError) throw this.closeError; this.readyState = 3; this.onclose?.(); }
|
|
open() { this.readyState = 1; this.onopen?.(); }
|
|
push(message: unknown) { this.onmessage?.({ data: JSON.stringify(message) }); }
|
|
}
|
|
async function setup(overrides: { config?: Partial<ResolveRuntimeConfigOptions>; render?: (model: PageModel) => void;
|
|
cancelFrame?: () => void; showOverlay?: (model: OverlayModel) => void;
|
|
record?: (event: { kind: string; data: unknown }) => void; onBusy?: (busy: boolean) => void;
|
|
onConfigResolved?: (config: RuntimeConfig) => void; onResourcesReady?: () => void;
|
|
onSocketReady?: (ready: boolean) => void; onStopped?: () => void } = {}) {
|
|
const api = await import('../../YouleNexus/assets/scripts/local-platform-login/local-login-host.ts')
|
|
.catch(error => assert.fail(`Local login host implementation must exist: ${String(error)}`));
|
|
const resources = deferred(), minimum = deferred();
|
|
const sockets: Socket[] = [], pages: PageModel[] = [], overlays: OverlayModel[] = [], activations: PageId[] = [];
|
|
const failures: unknown[] = [], busy: boolean[] = [], evidence: { kind: string; data: unknown }[] = [];
|
|
const values = new Map<string, string>(), frames = new Set<() => void>();
|
|
let fetches = 0;
|
|
const host = api.createLocalLoginHost({
|
|
onConfigResolved: overrides.onConfigResolved, onResourcesReady: overrides.onResourcesReady,
|
|
onSocketReady: overrides.onSocketReady, onStopped: overrides.onStopped,
|
|
configOptions: { hostKind: 'h5', win: {}, search: '?profile=local&version=731',
|
|
fetcher: { async fetch() { fetches++; return { player_server_tcp: 'remote.example:3088' }; } }, ...overrides.config },
|
|
storage: { getItem: key => values.get(key) ?? null, setItem: (key, value) => { values.set(key, value); } },
|
|
random: () => .5, now: () => 123456, sampleHost: () => ({ location: null }),
|
|
view: { render: model => { overrides.render?.(model); pages.push(model); },
|
|
activate: page => { activations.push(page); }, showOverlay: model => { overlays.push(model); overrides.showOverlay?.(model); } },
|
|
scheduler: { request: callback => { frames.add(callback); return () => { frames.delete(callback); overrides.cancelFrame?.(); }; } },
|
|
loadResources: () => resources.promise, waitForMinimumDisplay: () => minimum.promise,
|
|
createSocket: () => { const socket = new Socket(); sockets.push(socket); return socket as unknown as WebSocket; },
|
|
record: event => { evidence.push(event); overrides.record?.(event); },
|
|
onBusy: value => { busy.push(value); overrides.onBusy?.(value); }, onFailure: error => { failures.push(error); },
|
|
});
|
|
const draw = () => { for (const frame of [...frames]) { frames.delete(frame); frame(); } };
|
|
const ready = async () => { const starting = host.start(); resources.resolve(); minimum.resolve(); await flush(); sockets[0]!.open(); await starting; draw(); };
|
|
return { host, resources, minimum, sockets, pages, overlays, activations, failures, busy, evidence, values, frames, draw, ready, fetches: () => fetches };
|
|
}
|
|
|
|
test('host exposes resolved config and independent socket/resources readiness before display, and stops late events', async () => {
|
|
const configs: RuntimeConfig[] = [], socket: boolean[] = []; let resources = 0, stopped = 0;
|
|
const h = await setup({ onConfigResolved: config => configs.push(config), onResourcesReady: () => resources++,
|
|
onSocketReady: ready => socket.push(ready), onStopped: () => stopped++ });
|
|
const starting = h.host.start(); await flush();
|
|
assert.equal(configs.length, 1); assert.equal(configs[0].identity.version, 731);
|
|
assert.equal(configs[0].remoteConfig.getValue('player_server_tcp'), 'remote.example:3088');
|
|
h.sockets[0].open(); await flush(); assert.equal(socket[socket.length - 1], true);
|
|
assert.equal(h.host.getSnapshot().ready, false);
|
|
h.resources.resolve(); await flush(); assert.equal(resources, 1);
|
|
assert.equal(h.host.getSnapshot().ready, false);
|
|
h.minimum.resolve(); await starting; h.host.stop(); assert.equal(stopped, 1);
|
|
const events = socket.length; h.sockets[0].open(); await flush(); assert.equal(socket.length, events);
|
|
});
|
|
|
|
test('host stop cancels presentation and ignores a resource completion still pending', async () => {
|
|
let resources = 0, stopped = 0;
|
|
const h = await setup({ onResourcesReady: () => resources++, onStopped: () => stopped++ });
|
|
const starting = h.host.start(); const rejection = assert.rejects(starting, /stop|terminal/);
|
|
await flush(); h.host.stop(); h.resources.resolve(); h.minimum.resolve();
|
|
await rejection; assert.equal(resources, 0); assert.equal(stopped, 1);
|
|
});
|
|
|
|
for (const order of [
|
|
['resources', 'socket', 'minimum'], ['resources', 'minimum', 'socket'],
|
|
['socket', 'resources', 'minimum'], ['socket', 'minimum', 'resources'],
|
|
['minimum', 'resources', 'socket'], ['minimum', 'socket', 'resources'],
|
|
] as const) test(`all startup gates remain independent in ${order.join(' -> ')} order`, async t => {
|
|
const h = await setup(); t.after(() => h.host.stop());
|
|
const starting = h.host.start(); await flush();
|
|
for (let index = 0; index < order.length; index++) {
|
|
const gate = order[index];
|
|
if (gate === 'socket') h.sockets[0].open(); else h[gate].resolve();
|
|
await flush();
|
|
assert.equal(h.host.getSnapshot().ready, index === order.length - 1);
|
|
assert.equal(h.activations.includes('login'), index === order.length - 1);
|
|
}
|
|
await starting; h.draw(); assert.equal(h.activations.includes('login'), true);
|
|
});
|
|
|
|
test('pending config cannot be bypassed by previously completed resource/display tasks', async t => {
|
|
const config = deferred(); let resolved = 0;
|
|
const h = await setup({ config: { fetcher: { async fetch() { await config.promise; return {}; } } },
|
|
onConfigResolved: () => resolved++ });
|
|
t.after(() => h.host.stop()); const starting = h.host.start();
|
|
h.resources.resolve(); h.minimum.resolve(); await flush();
|
|
assert.equal(resolved, 0); assert.equal(h.sockets.length, 0); assert.equal(h.host.getSnapshot().ready, false);
|
|
config.resolve(); await flush(); assert.equal(resolved, 1); assert.equal(h.host.getSnapshot().ready, false);
|
|
h.sockets[0].open(); await starting; h.draw(); assert.equal(h.host.getSnapshot().ready, true);
|
|
});
|
|
|
|
for (const missing of ['resources', 'minimum', 'socket'] as const) test(`login waits for ${missing}, and start composes only once`, async t => {
|
|
const h = await setup(); t.after(() => h.host.stop());
|
|
const starting = h.host.start(); const repeated = h.host.start();
|
|
assert.strictEqual(starting, repeated);
|
|
await flush();
|
|
if (missing !== 'resources') h.resources.resolve();
|
|
if (missing !== 'minimum') h.minimum.resolve();
|
|
if (missing !== 'socket') h.sockets[0]!.open();
|
|
await flush();
|
|
assert.equal(h.host.getSnapshot().ready, false);
|
|
await assert.rejects(h.host.login(), /ready/i);
|
|
assert.equal(h.values.size, 0); assert.deepEqual(h.sockets[0]!.sent, []);
|
|
h.resources.resolve(); h.minimum.resolve(); if (missing === 'socket') h.sockets[0]!.open();
|
|
await starting;
|
|
assert.equal(h.host.getSnapshot().ready, true); assert.equal(h.fetches(), 1);
|
|
assert.equal(h.sockets.length, 1);
|
|
assert.equal(h.evidence.filter(event => event.kind === 'startup-config').length, 1);
|
|
await h.host.login();
|
|
assert.equal(JSON.parse(h.sockets[0]!.sent[0]!).data.version, 731);
|
|
});
|
|
|
|
test('duplicate clicks send once; response updates the sole Store and lobby projection and clears busy', async t => {
|
|
const h = await setup(); t.after(() => h.host.stop()); await h.ready();
|
|
const first = h.host.login(), second = h.host.login(); assert.strictEqual(first, second);
|
|
assert.equal(h.host.getSnapshot().busy, true);
|
|
await first; await h.host.login(); assert.equal(h.sockets[0]!.sent.length, 1);
|
|
assert.deepEqual(h.busy, [true]);
|
|
h.sockets[0]!.push(fixture()); h.draw();
|
|
assert.equal(h.host.getSnapshot().state?.app.phase, 'logged-in');
|
|
assert.equal(h.host.getSnapshot().state?.room.kind, 'outside');
|
|
assert.deepEqual(h.pages[h.pages.length - 1], { page: 'lobby', self: { playerid: 430511, nickname: '测试号', avatar: 'http://a', bean: 0 } });
|
|
assert.deepEqual(h.busy, [true, false]);
|
|
const before = h.pages.length; h.host.stop(); h.draw(); assert.equal(h.pages.length, before);
|
|
});
|
|
|
|
for (const rpc of ['player_login', 'kick_server']) test(`${rpc} retains original server reason through terminal cleanup`, async t => {
|
|
const h = await setup(); t.after(() => h.host.stop()); await h.ready(); await h.host.login();
|
|
const data = { state: 17, msg: '原始服务器原因' };
|
|
h.sockets[0]!.push({ route: 'agent', rpc, data });
|
|
assert.equal(h.failures.length, 1); assert.deepEqual(h.failures[0], data);
|
|
assert.equal(h.host.getSnapshot().ready, false); assert.equal(h.host.getSnapshot().busy, false);
|
|
assert.equal(h.sockets[0]!.closes, 1);
|
|
await assert.rejects(h.host.login(), /stop|terminal/i);
|
|
});
|
|
|
|
test('room recovery is refused before any player or room state enters Store', async t => {
|
|
const h = await setup(); t.after(() => h.host.stop()); await h.ready(); await h.host.login();
|
|
h.sockets[0]!.push(fixture('player-login-room.json'));
|
|
assert.match(String(h.failures[0]), /room recovery/);
|
|
assert.equal(h.host.getSnapshot().state?.players.selfPlayerId, null);
|
|
assert.equal(h.host.getSnapshot().state?.room.kind, 'outside');
|
|
assert.equal(h.pages.some(model => model.page === 'lobby'), false);
|
|
});
|
|
|
|
test('corrupt account cache prevents sending and preserves the cache', async t => {
|
|
const h = await setup(); t.after(() => h.host.stop()); await h.ready();
|
|
// Discover the authority-owned key from a prior independent local login.
|
|
const prior = await setup(); t.after(() => prior.host.stop()); await prior.ready(); await prior.host.login();
|
|
for (const [key, value] of prior.values) h.values.set(key, value);
|
|
const accountKey = [...h.values].find(([, value]) => value.includes('account'))![0];
|
|
h.values.set(accountKey, '{broken');
|
|
await assert.rejects(h.host.login(), /invalid JSON/);
|
|
assert.equal(h.sockets[0]!.sent.length, 0); assert.equal(h.values.get(accountKey), '{broken');
|
|
assert.equal(h.failures.length, 1);
|
|
});
|
|
|
|
for (const phase of ['config', 'resources', 'prepare'] as const) test(`stop during ${phase} suppresses late connection, send and render`, async () => {
|
|
const h = await setup();
|
|
let operation: Promise<void>;
|
|
if (phase === 'prepare') { await h.ready(); operation = h.host.login(); }
|
|
else { operation = h.host.start(); if (phase === 'resources') await flush(); }
|
|
const settled = operation.catch(() => {});
|
|
// Let config resolution / account preparation begin before terminal stop.
|
|
if (phase !== 'resources') await Promise.resolve();
|
|
h.host.stop(); const pageCount = h.pages.length;
|
|
h.resources.resolve(); h.minimum.resolve(); await settled; await flush(); h.draw();
|
|
assert.equal(h.pages.length, pageCount); assert.equal(h.frames.size, 0);
|
|
assert.equal(h.sockets.flatMap(socket => socket.sent).length, 0);
|
|
if (phase === 'config') assert.equal(h.sockets.length, 0);
|
|
assert.deepEqual(h.failures, []);
|
|
});
|
|
|
|
for (const config of [{ startupConfig: {...STARTUP_CONFIG,mode:'release' as const} }, { startupConfig: {...STARTUP_CONFIG,useLocalServer:false} }, { hostKind: 'native-settings' as const }]) test('invalid local input cannot create an account or connection', async () => {
|
|
const h = await setup({ config });
|
|
await assert.rejects(h.host.start(), /local startup/);
|
|
assert.equal(h.values.size, 0); assert.equal(h.sockets.length, 0); assert.equal(h.failures.length, 1);
|
|
});
|
|
|
|
test('resource failure is reported with original object after shutdown', async () => {
|
|
const h = await setup(); const error = new Error('resource dependency missing');
|
|
const starting = h.host.start(); await flush(); h.resources.reject(error); h.minimum.resolve();
|
|
await assert.rejects(starting);
|
|
assert.strictEqual(h.failures[0], error); assert.equal(h.sockets[0]!.closes, 1);
|
|
});
|
|
|
|
test('observer and socket cleanup failures both survive terminal reporting', async t => {
|
|
const observer = new Error('render observer failed'), cleanup = new Error('socket close failed');
|
|
const h = await setup({ render: model => { if (model.page === 'lobby') throw observer; } });
|
|
t.after(() => h.host.stop()); await h.ready(); await h.host.login(); h.sockets[0]!.closeError = cleanup;
|
|
h.sockets[0]!.push(fixture());
|
|
const contains = (value: unknown, target: unknown): boolean => value === target ||
|
|
(!!value && typeof value === 'object' && 'errors' in value && (value.errors as unknown[]).some(error => contains(error, target)));
|
|
assert.equal(contains(h.failures[0], observer), true); assert.equal(contains(h.failures[0], cleanup), true);
|
|
});
|
|
|
|
test('resource error remains primary when closing its socket also fails', async () => {
|
|
const h = await setup(); const primary = new Error('resource failed'), cleanup = new Error('close failed');
|
|
const starting = h.host.start(); await flush(); h.sockets[0]!.closeError = cleanup;
|
|
h.resources.reject(primary); h.minimum.resolve(); await assert.rejects(starting);
|
|
const errors = (h.failures[0] as { errors: unknown[] }).errors;
|
|
assert.strictEqual(errors[0], primary); assert.ok(errors.includes(cleanup));
|
|
});
|
|
|
|
test('kick data remains primary when closing its socket also fails', async () => {
|
|
const h = await setup(); await h.ready(); await h.host.login();
|
|
const cleanup = new Error('kick close failed'); h.sockets[0]!.closeError = cleanup;
|
|
const data = { msg: 'original kick reason' };
|
|
h.sockets[0]!.push({ route: 'agent', rpc: 'kick_server', data });
|
|
const errors = (h.failures[0] as { errors: unknown[] }).errors;
|
|
assert.deepEqual(errors[0], data); assert.ok(errors.includes(cleanup));
|
|
});
|
|
|
|
test('pending frame cancellation retains its primary error when socket cleanup also fails', async () => {
|
|
const primary = new Error('pending frame cancel failed'), cleanup = new Error('socket close failed');
|
|
let armed = false;
|
|
const h = await setup({ cancelFrame: () => { if (armed) throw primary; } });
|
|
await h.ready(); await h.host.login();
|
|
armed = true; h.sockets[0]!.closeError = cleanup;
|
|
// The real login response schedules a Store frame, then showLobby flush cancels it.
|
|
h.sockets[0]!.push(fixture());
|
|
assert.equal(h.failures.length, 1);
|
|
const fault = h.failures[0] as { errors?: unknown[] };
|
|
assert.ok(fault.errors, 'primary and cleanup failures must both be retained');
|
|
assert.strictEqual(fault.errors[0], primary);
|
|
assert.ok(fault.errors.includes(cleanup));
|
|
assert.equal(h.host.getSnapshot().ready, false); assert.equal(h.frames.size, 0);
|
|
assert.equal(h.sockets[0]!.closes, 1);
|
|
});
|
|
|
|
test('fatal overlay observer failure retains the original Runtime error object', async () => {
|
|
const observer = new Error('fatal overlay observer failed');
|
|
let primary: Error | undefined;
|
|
const h = await setup({ showOverlay: model => {
|
|
if (model.kind === 'fatal') { primary = model.error; throw observer; }
|
|
} });
|
|
await h.ready();
|
|
h.sockets[0]!.push({ route: 'agent', rpc: 'unsupported_test_rpc', data: {} });
|
|
assert.ok(primary); assert.match(primary.message, /Unsupported platform rpc/);
|
|
assert.equal(h.failures.length, 1);
|
|
const fault = h.failures[0] as { errors?: unknown[] };
|
|
assert.ok(fault.errors, 'Runtime error and overlay observer error must both be retained');
|
|
assert.strictEqual(fault.errors[0], primary); assert.strictEqual(fault.errors[1], observer);
|
|
assert.equal(h.host.getSnapshot().ready, false); assert.equal(h.sockets[0]!.closes, 1);
|
|
});
|
|
|
|
test('remote fetch and parse failures leave local sockets and account storage untouched', async () => {
|
|
for (const fetch of [async () => { throw new Error('network down'); }, async () => ({ agentlist: {} }), async () => null]) {
|
|
const h = await setup({ config: { fetcher: { fetch } } });
|
|
const result = h.host.start().catch(error => error);
|
|
await flush();
|
|
assert.equal(h.sockets.length, 0);
|
|
assert.equal(h.values.size, 0);
|
|
assert.ok(await result instanceof Error);
|
|
assert.equal(h.failures.length, 1);
|
|
}
|
|
});
|
|
|
|
test('stop while remote fetch is pending prevents late socket creation', async () => {
|
|
const pending = deferred();
|
|
let fetched = false;
|
|
const h = await setup({ config: { fetcher: { async fetch() {
|
|
fetched = true; await pending.promise; return { player_server_tcp: 'remote.example:3088' };
|
|
} } } });
|
|
const result = h.host.start().catch(error => error);
|
|
await flush(); assert.equal(fetched, true); assert.equal(h.sockets.length, 0);
|
|
h.host.stop(); pending.resolve(); await result;
|
|
assert.equal(h.sockets.length, 0); assert.equal(h.values.size, 0);
|
|
});
|
|
|
|
test('startup diagnostics exclude raw remote fields', async t => {
|
|
const h = await setup({ config: { fetcher: { async fetch() {
|
|
return { player_server_tcp: 'remote.example:3088', privateConfig: 'DO_NOT_RECORD_REMOTE_SECRET' };
|
|
} } } });
|
|
t.after(() => h.host.stop()); await h.ready();
|
|
assert.equal(JSON.stringify(h.evidence).includes('DO_NOT_RECORD_REMOTE_SECRET'), false);
|
|
});
|
|
|
|
test('default no-query host reaches login ready and ignores conflicting selectors', async t => {
|
|
for (const search of ['', '?mode=release&mode=other&profile=prod&profile=unknown']) {
|
|
const h=await setup({config:{search}}); t.after(()=>h.host.stop()); await h.ready();
|
|
assert.equal(h.fetches(),1); assert.equal(h.host.getSnapshot().ready,true); assert.ok(h.activations.includes('login')); assert.equal(h.failures.length,0);
|
|
}
|
|
});
|