Files
youle_cocos/cocoscreator_projects/framework-tests/native/webview-javascript-bridge.test.ts
T

262 lines
11 KiB
TypeScript

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<string, Set<() => 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);
});