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 } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; function deferred() { let resolve!: () => void; let reject!: (error: unknown) => void; const promise = new Promise((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; render?: (model: PageModel) => void; record?: (event: { kind: string; data: unknown }) => void; onBusy?: (busy: boolean) => 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(), frames = new Set<() => void>(); let fetches = 0; const host = api.createLocalLoginHost({ configOptions: { mode: 'debug', hostKind: 'h5', win: {}, search: '?profile=local&version=731', fetcher: { async fetch(): Promise { fetches++; throw new Error('unexpected fetch'); } }, ...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); } }, scheduler: { request: callback => { frames.add(callback); return () => { frames.delete(callback); }; } }, 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 }; } 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(), 0); 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; 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 [{ search: '?profile=prod' }, { 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)); });