import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import { resolveLocalStartup } from '../../YouleNexus/assets/framework/config/local-startup.ts'; import { RECONNECT_INTERVAL_MS } from '../../YouleNexus/assets/framework/core/constants.ts'; import type { WireEvent } from '../../YouleNexus/assets/framework/net/wire-client.ts'; import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts'; import { FakeTransport } from '../helpers/fake-transport.ts'; const login: OutboundEnvelope = { app: 'youle', route: 'agent', rpc: 'player_login', data: { gameid: 101 } }; function fixture(name: string) { return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')); } async function setup(recordHook?: (event: { kind: string; data: unknown }) => void) { const api = await import('../../YouleNexus/assets/scripts/local-platform-login/local-login-wire.ts') .catch(error => assert.fail(`Local login Wire implementation must exist: ${String(error)}`)); const context = await resolveLocalStartup({ mode: 'debug', hostKind: 'h5', win: {}, search: '?profile=local', fetcher: { async fetch(): Promise { throw new Error('unexpected fetch'); } } }); const transports: FakeTransport[] = []; const events: WireEvent[] = []; const evidence: { kind: string; data: unknown }[] = []; const faults: unknown[] = []; const order: string[] = []; const wire = api.createLocalLoginWire({ context, transportFactory: () => { const transport = new FakeTransport(); transports.push(transport); return transport; }, record: event => { evidence.push(event); recordHook?.(event); }, onFault: error => { order.push('fault'); faults.push(error); assert.equal(transports[0]?.closeCalls, 1); }, }); wire.subscribe(event => { events.push(event); order.push(event.type === 'message' ? event.message.rpc : event.type); }); const push = async (message: unknown) => { transports[0]!.serverPush(JSON.stringify(message)); await transports[0]!.flush(); }; return { wire, context, transports, events, evidence, faults, order, push }; } test('start is not open; only original lobby and ordinary agent/platform events are forwarded', async t => { const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); assert.equal(h.events.length, 0); await h.transports[0]!.flush(); let unsubscribed = 0; const unsubscribe = h.wire.subscribe(() => unsubscribed++); unsubscribe(); unsubscribe(); await h.push(fixture('player-login-success.json')); await h.push({ route: 'agent', rpc: 'show_message', data: { msg: 'hello' } }); await h.push({ route: 'platform', rpc: 'notice', data: {} }); assert.equal(unsubscribed, 0); assert.equal(h.events.filter(event => event.type === 'message').length, 3); for (const event of h.events) assert.ok(h.evidence.some(item => item.kind === 'wire-event' && item.data === event)); assert.deepEqual(h.faults, []); }); for (const [name, message, error] of [ ['room recovery', fixture('player-login-room.json'), /login room recovery/], ['malformed login', { route: 'agent', rpc: 'player_login', data: { state: 0 } }, /\$\.data\.playerid/], ['room route', { route: 'room', rpc: 'notice', data: {} }, /room/], ['game route', { route: 'mahjong', rpc: 'notice', data: {} }, /mahjong/], ['agent switch', { route: 'agent', rpc: 'connect_agentserver', data: {} }, /connect_agentserver/], ['platform switch', { route: 'platform', rpc: 'connect_roomserver', data: {} }, /connect_roomserver/], ] as const) test(`${name} stops before downstream delivery and remains terminal`, async t => { t.mock.timers.enable({ apis: ['setTimeout'] }); const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush(); await h.push(message); assert.equal(h.faults.length, 1); assert.match(String(h.faults[0]), error); assert.equal(h.events.length, 1); await h.push(fixture('player-login-success.json')); assert.throws(() => h.wire.reconnectCurrent(), /stopped|terminal/); assert.throws(() => h.wire.start(), /stopped|terminal/); h.wire.stop(); h.wire.stop(); t.mock.timers.tick(60000); assert.equal(h.transports.length, 1); assert.equal(h.events.length, 1); assert.equal(h.faults.length, 1); }); for (const message of [ { route: 'agent', rpc: 'player_login', data: { state: 1 } }, { route: 'agent', rpc: 'kick_server', data: { msg: 'bye' } }, ]) test(`${message.rpc} failure reaches existing handlers before host notification`, async t => { const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush(); h.wire.subscribe(event => { if (event.type === 'message') h.wire.stop(); }); await h.push(message); assert.deepEqual(h.order, ['open', message.rpc, 'fault']); assert.equal(h.faults.length, 1); }); test('outbound scope and explicit switch fail without sending or reconnecting', async t => { for (const operation of ['send', 'switch'] as const) { const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush(); assert.throws(() => operation === 'send' ? h.wire.send({ ...login, rpc: 'create_room' }) : h.wire.switchServer(h.context.config.servers[0]!), /scope|switchServer/); assert.deepEqual(h.transports[0]!.sent, []); assert.equal(h.faults.length, 1); } }); test('normal network close reconnects with the same single login envelope', async t => { t.mock.timers.enable({ apis: ['setTimeout'] }); const h = await setup(); t.after(() => h.wire.stop()); h.wire.subscribe(event => { if (event.type === 'open') h.wire.send(login); }); h.wire.start(); await h.transports[0]!.flush(); h.transports[0]!.serverError(); await h.transports[0]!.flush(); t.mock.timers.tick(RECONNECT_INTERVAL_MS); assert.equal(h.transports.length, 2); await h.transports[1]!.flush(); assert.equal(h.transports[0]!.sent[0], JSON.stringify(login)); assert.deepEqual(h.transports[1]!.sent, h.transports[0]!.sent); assert.deepEqual(h.faults, []); }); test('scope fault retains its primary error and independent cleanup error', async t => { const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush(); const cleanup = new Error('cleanup failed'); h.transports[0]!.close = () => { h.transports[0]!.closeCalls++; throw cleanup; }; await h.push({ route: 'room', rpc: 'notice', data: {} }); assert.equal(h.faults.length, 1); const errors = (h.faults[0] as { errors: unknown[] }).errors; assert.match(String(errors[0]), /room/); assert.equal(errors[1], cleanup); h.wire.stop(); assert.equal(h.transports[0]!.closeCalls, 1); }); test('evidence failure preserves the exact thrown value plus cleanup error and prevents delivery', async t => { const primary = { reason: 'evidence unavailable' }; const h = await setup(event => { if (event.kind === 'wire-event' && (event.data as WireEvent).type === 'message') throw primary; }); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush(); const cleanup = new Error('close failed'); h.transports[0]!.close = () => { h.transports[0]!.closeCalls++; throw cleanup; }; await h.push(fixture('player-login-success.json')); assert.deepEqual((h.faults[0] as { errors: unknown[] }).errors, [primary, cleanup]); assert.equal(h.events.length, 1); assert.equal(h.faults.length, 1); }); test('listener error is reported unchanged, stops remaining delivery, and stops only once', async t => { const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush(); const primary = new Error('downstream failed'); h.wire.subscribe(() => { throw primary; }); let laterDeliveries = 0; h.wire.subscribe(() => laterDeliveries++); await h.push(fixture('player-login-success.json')); assert.equal(h.faults[0], primary); assert.equal(laterDeliveries, 0); h.wire.stop(); assert.equal(h.transports[0]!.closeCalls, 1); }); test('terminal callback cannot send or reconnect before runtime stop; host notification is single', async t => { const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush(); h.wire.subscribe(event => { if (event.type !== 'message') return; assert.throws(() => h.wire.send(login), /terminal/); assert.throws(() => h.wire.reconnectCurrent(), /terminal/); }); await h.push({ route: 'agent', rpc: 'kick_server', data: { msg: 'bye' } }); assert.equal(h.faults.length, 1); assert.deepEqual(h.transports[0]!.sent, []); });