import assert from 'node:assert/strict'; import { test } from 'node:test'; import { NATIVE_CALL_NAMES, REGISTERED_NATIVE_HANDLER_NAMES, type NativeBridgeChannel, type NativeHandler, type NativeResponseCallback, } from '../../YouleNexus/assets/framework/adapters/native/native-contracts.ts'; import { WebViewJavascriptBridgeAdapter, setupWebViewJavascriptBridge, type BridgeLoaderElement, type WebViewJavascriptBridgeEnvironment, } from '../../YouleNexus/assets/framework/adapters/native/webview-javascript-bridge.ts'; const EXPECTED_REGISTERED_NAMES = [ 'getVideoinfo', 'sharelogin', 'gameui_play_voice', 'gameui_stop_voice', 'sharesuccess', 'getphoneinfo', 'getAddressBook', 'phonestate', 'appservice', 'getaudiourl', 'getBattery', 'getwifiLevel', 'getnetwork', 'shakeEnd', 'yPaytype', 'getlocationinfo', 'getWebdata', 'PayuserPaytypePaystate', 'setPostUrl', 'getphoto', ] as const; const EXPECTED_CALL_NAMES = [ 'getphoto', 'h5Webpay', 'getVideoinfo', 'getphoneInfo', 'getAddressBook', 'createRoom', 'exitRoom', 'accreditlogin', 'cancellogin', 'OpenurlTitleData', 'getOther', 'voicePlaying', 'gameCopytext', 'opencamera', 'gamepastetext', 'getGameinstall', 'getGameplay', 'getphonestate', 'ypayType', 'SwitchOverGameData', 'backgameData', 'startlocation', 'getlocationinfo', 'srcIsloop', 'opensaoma', 'startshake', 'stopshake', 'SwitchShake', 'sharephotourl', 'browser', 'mediaTypeAudio', 'prepareaudio', 'vibrator', 'repeatvibrator', 'canclevibrator', 'orientation', 'notification', 'friendsSharetypeUrlToptitleDescript', ] as const; interface RecordingEnvironment extends WebViewJavascriptBridgeEnvironment { created: BridgeLoaderElement[]; domOperations: string[]; events: Array<{ name: string; capture: boolean }>; removedEvents: Array<{ name: string; capture: boolean }>; runZeroDelayTimers(): void; fireReady(): void; } function makeBridgeEnvironment( initial: { bridge?: NativeBridgeChannel; callbacks?: Array<(bridge: NativeBridgeChannel) => void> } = {}, ): RecordingEnvironment { const created: BridgeLoaderElement[] = []; const domOperations: string[] = []; const events: Array<{ name: string; capture: boolean }> = []; const removedEvents: Array<{ name: string; capture: boolean }> = []; const listeners = new Map void>>(); const timers: Array<() => void> = []; const environment: RecordingEnvironment = { WebViewJavascriptBridge: initial.bridge, WVJBCallbacks: initial.callbacks, document: { addEventListener(name, listener, capture) { events.push({ name, capture }); const owned = listeners.get(name) ?? new Set<() => void>(); owned.add(listener); listeners.set(name, owned); }, removeEventListener(name, listener, capture) { removedEvents.push({ name, capture }); listeners.get(name)?.delete(listener); }, createElement(tagName) { const element: BridgeLoaderElement = { tagName, style: { display: '' }, src: '' }; created.push(element); return element; }, documentElement: { appendChild(element) { domOperations.push(`append:${element.tagName}`); }, removeChild(element) { domOperations.push(`remove:${element.tagName}`); }, }, }, setTimeout(callback, delay) { assert.equal(delay, 0); timers.push(callback); return timers.length; }, created, domOperations, events, removedEvents, runZeroDelayTimers() { for (const timer of timers.splice(0)) timer(); }, fireReady() { for (const listener of [...(listeners.get('WebViewJavascriptBridgeReady') ?? [])]) listener(); }, }; return environment; } function makeRecordingChannel() { const registered: Array<{ name: string; handler: NativeHandler }> = []; const called: Array<{ name: string; data: unknown; callback?: NativeResponseCallback }> = []; const channel: NativeBridgeChannel = { registerHandler(name, handler) { registered.push({ name, handler }); }, callHandler(name, data, callback) { called.push({ name, data, callback }); }, }; return { channel, registered, called }; } test('existing WebViewJavascriptBridge calls back immediately', () => { const { channel: bridge } = makeRecordingChannel(); const env = makeBridgeEnvironment({ bridge }); const seen: unknown[] = []; setupWebViewJavascriptBridge(env, value => seen.push(value)); assert.deepEqual(seen, [bridge]); }); test('immediate bridge branch still continues through the callback queue and iframe block', () => { const { channel: bridge } = makeRecordingChannel(); const env = makeBridgeEnvironment({ bridge }); const callback = () => undefined; setupWebViewJavascriptBridge(env, callback); assert.deepEqual(env.WVJBCallbacks, [callback]); assert.deepEqual(env.domOperations, ['append:iframe']); }); test('without bridge registers the exact ready listener', () => { const env = makeBridgeEnvironment(); setupWebViewJavascriptBridge(env, () => undefined); assert.deepEqual(env.events, [{ name: 'WebViewJavascriptBridgeReady', capture: false }]); }); test('ready listener reads the bridge from the environment at event time', () => { const env = makeBridgeEnvironment(); const { channel: bridge } = makeRecordingChannel(); const seen: unknown[] = []; setupWebViewJavascriptBridge(env, value => seen.push(value)); env.WebViewJavascriptBridge = bridge; env.fireReady(); assert.deepEqual(seen, [bridge]); }); test('first WVJB callback creates and removes the loader iframe', () => { const env = makeBridgeEnvironment(); setupWebViewJavascriptBridge(env, () => undefined); assert.equal(env.created[0].tagName, 'iframe'); assert.equal(env.created[0].style.display, 'none'); assert.equal(env.created[0].src, 'wvjbscheme://__BRIDGE_LOADED__'); assert.deepEqual(env.domOperations, ['append:iframe']); env.runZeroDelayTimers(); assert.deepEqual(env.domOperations, ['append:iframe', 'remove:iframe']); }); test('existing WVJB callback queue is appended without creating another iframe', () => { const first = () => undefined; const env = makeBridgeEnvironment({ callbacks: [first] }); const second = () => undefined; setupWebViewJavascriptBridge(env, second); assert.deepEqual(env.WVJBCallbacks, [first, second]); assert.deepEqual(env.created, []); }); test('registered handler names preserve the complete active original tuple', () => { assert.deepEqual(REGISTERED_NATIVE_HANDLER_NAMES, EXPECTED_REGISTERED_NAMES); }); test('called native method names preserve the complete active original tuple', () => { assert.deepEqual(NATIVE_CALL_NAMES, EXPECTED_CALL_NAMES); }); test('register preserves handler identity and every callback argument in order', () => { const recording = makeRecordingChannel(); const env = makeBridgeEnvironment({ bridge: recording.channel }); const adapter = new WebViewJavascriptBridgeAdapter(env); const seen: unknown[][] = []; const handler: NativeHandler = (...args) => { seen.push(args); }; adapter.register('PayuserPaytypePaystate', handler); assert.equal(recording.registered[0].name, 'PayuserPaytypePaystate'); assert.equal(recording.registered[0].handler, handler); const responseCallback = () => undefined; const args = [{ user: 1 }, 'type', 3, 'typeplay', { raw: true }, responseCallback] as const; recording.registered[0].handler(...args); assert.deepEqual(seen, [args]); }); test('call preserves payload and callback identity without changing callback arguments', () => { const recording = makeRecordingChannel(); const env = makeBridgeEnvironment({ bridge: recording.channel }); const adapter = new WebViewJavascriptBridgeAdapter(env); const payload = { exact: true }; const responses: unknown[][] = []; const callback: NativeResponseCallback = (...args) => { responses.push(args); }; adapter.call('notification', payload, callback); assert.deepEqual(recording.called.map(({ name }) => name), ['notification']); assert.equal(recording.called[0].data, payload); assert.equal(recording.called[0].callback, callback); recording.called[0].callback?.('first', { second: true }); assert.deepEqual(responses, [['first', { second: true }]]); }); test('unknown register and call names fail explicitly', () => { const recording = makeRecordingChannel(); const adapter = new WebViewJavascriptBridgeAdapter(makeBridgeEnvironment({ bridge: recording.channel })); assert.throws(() => adapter.register('unknown' as never, () => undefined), /unknown/i); assert.throws(() => adapter.call('unknown' as never, {}), /unknown/i); }); test('registration queued before readiness runs once even if both original callbacks fire', () => { const recording = makeRecordingChannel(); const env = makeBridgeEnvironment(); const adapter = new WebViewJavascriptBridgeAdapter(env); adapter.register('getVideoinfo', () => undefined); env.WebViewJavascriptBridge = recording.channel; env.fireReady(); for (const callback of [...(env.WVJBCallbacks ?? [])]) callback(recording.channel); assert.equal(adapter.isReady(), true); assert.deepEqual(recording.registered.map(({ name }) => name), ['getVideoinfo']); }); test('two adapter instances own independent channels without global active channel', () => { const first = makeRecordingChannel(); const second = makeRecordingChannel(); const globalRecord = globalThis as typeof globalThis & { __activeChannel?: unknown }; const previous = globalRecord.__activeChannel; delete globalRecord.__activeChannel; try { const firstAdapter = new WebViewJavascriptBridgeAdapter(makeBridgeEnvironment({ bridge: first.channel })); const secondAdapter = new WebViewJavascriptBridgeAdapter(makeBridgeEnvironment({ bridge: second.channel })); firstAdapter.call('browser', { owner: 'first' }); secondAdapter.call('browser', { owner: 'second' }); assert.deepEqual(first.called.map(({ data }) => data), [{ owner: 'first' }]); assert.deepEqual(second.called.map(({ data }) => data), [{ owner: 'second' }]); assert.equal('__activeChannel' in globalRecord, false); } finally { if (previous !== undefined) globalRecord.__activeChannel = previous; else delete globalRecord.__activeChannel; } }); test('dispose removes only its ready listener and rejects later use', () => { const env = makeBridgeEnvironment(); const first = new WebViewJavascriptBridgeAdapter(env); const second = new WebViewJavascriptBridgeAdapter(env); first.dispose(); assert.deepEqual(env.removedEvents, [{ name: 'WebViewJavascriptBridgeReady', capture: false }]); const recording = makeRecordingChannel(); env.WebViewJavascriptBridge = recording.channel; env.fireReady(); assert.throws(() => first.isReady(), /disposed/i); assert.equal(second.isReady(), true); assert.throws(() => first.register('getVideoinfo', () => undefined), /disposed/i); assert.throws(() => first.call('browser', ''), /disposed/i); }); test('call before readiness fails explicitly', () => { const adapter = new WebViewJavascriptBridgeAdapter(makeBridgeEnvironment()); assert.throws(() => adapter.call('browser', ''), /not ready/i); });