From a173d410e05dc96ec61069a5f34b7a3f9f154f83 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 01:47:57 +0800 Subject: [PATCH] refactor(native): isolate exact WVJB compatibility adapter --- .../adapters/native/native-contracts.ts | 91 ++++++ .../native/native-settings-adapter.ts | 24 ++ .../native/webview-javascript-bridge.ts | 133 +++++++++ .../framework/platform/native-bridge.ts | 152 ---------- .../native/native-settings-adapter.test.ts | 33 +++ .../native/webview-javascript-bridge.test.ts | 261 ++++++++++++++++++ .../platform/native-bridge.test.ts | 150 ---------- 7 files changed, 542 insertions(+), 302 deletions(-) create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/adapters/native/native-contracts.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/adapters/native/native-settings-adapter.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/adapters/native/webview-javascript-bridge.ts delete mode 100644 cocoscreator_projects/YouleNexus/assets/framework/platform/native-bridge.ts create mode 100644 cocoscreator_projects/framework-tests/native/native-settings-adapter.test.ts create mode 100644 cocoscreator_projects/framework-tests/native/webview-javascript-bridge.test.ts delete mode 100644 cocoscreator_projects/framework-tests/platform/native-bridge.test.ts diff --git a/cocoscreator_projects/YouleNexus/assets/framework/adapters/native/native-contracts.ts b/cocoscreator_projects/YouleNexus/assets/framework/adapters/native/native-contracts.ts new file mode 100644 index 0000000..81a93b8 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/adapters/native/native-contracts.ts @@ -0,0 +1,91 @@ +/** External native handler names registered by the legacy H5 client. */ +export const REGISTERED_NATIVE_HANDLER_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; + +/** External native methods called by the legacy H5 client. */ +export const NATIVE_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; + +export type RegisteredNativeHandlerName = (typeof REGISTERED_NATIVE_HANDLER_NAMES)[number]; +export type NativeCallName = (typeof NATIVE_CALL_NAMES)[number]; +export type NativeSettingName = 'agent' | 'gameconfig' | 'gameserver' | 'servertype'; + +/** Handler arguments remain opaque until a consuming feature defines its contract. */ +export type NativeHandler = (...args: unknown[]) => unknown; +export type NativeResponseCallback = (...args: unknown[]) => void; + +export interface NativeBridgeChannel { + registerHandler(name: string, handler: NativeHandler): void; + callHandler(name: string, data: unknown, callback?: NativeResponseCallback): void; +} + +export interface NativeSettingsPort { + getOtherName(name: NativeSettingName): string | number; + getChannelName(): string | number; + getMarketName(): string | number; +} + +export interface NativeBridgePort { + isReady(): boolean; + register(name: RegisteredNativeHandlerName, handler: NativeHandler): void; + call(name: NativeCallName, data: unknown, callback?: NativeResponseCallback): void; + dispose(): void; +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/adapters/native/native-settings-adapter.ts b/cocoscreator_projects/YouleNexus/assets/framework/adapters/native/native-settings-adapter.ts new file mode 100644 index 0000000..a9e3e3d --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/adapters/native/native-settings-adapter.ts @@ -0,0 +1,24 @@ +import type { NativeSettingName, NativeSettingsPort } from './native-contracts.ts'; + +export interface NativeSettingsChannel { + getothername(name: NativeSettingName): string | number; + getchannelName(): string | number; + getmarketname(): string | number; +} + +/** Exact-name adapter over the object injected at window.settings. */ +export class NativeSettingsAdapter implements NativeSettingsPort { + constructor(private readonly settings: NativeSettingsChannel) {} + + getOtherName(name: NativeSettingName): string | number { + return this.settings.getothername(name); + } + + getChannelName(): string | number { + return this.settings.getchannelName(); + } + + getMarketName(): string | number { + return this.settings.getmarketname(); + } +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/adapters/native/webview-javascript-bridge.ts b/cocoscreator_projects/YouleNexus/assets/framework/adapters/native/webview-javascript-bridge.ts new file mode 100644 index 0000000..b8d2904 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/adapters/native/webview-javascript-bridge.ts @@ -0,0 +1,133 @@ +import { + NATIVE_CALL_NAMES, + REGISTERED_NATIVE_HANDLER_NAMES, + type NativeBridgeChannel, + type NativeBridgePort, + type NativeCallName, + type NativeHandler, + type NativeResponseCallback, + type RegisteredNativeHandlerName, +} from './native-contracts.ts'; + +export interface BridgeLoaderElement { + readonly tagName: string; + readonly style: { display: string }; + src: string; +} + +export interface WebViewJavascriptBridgeEnvironment { + WebViewJavascriptBridge?: NativeBridgeChannel; + WVJBCallbacks?: Array<(bridge: NativeBridgeChannel) => void>; + readonly document: { + addEventListener(name: string, listener: () => void, capture: boolean): void; + removeEventListener(name: string, listener: () => void, capture: boolean): void; + createElement(tagName: 'iframe'): BridgeLoaderElement; + readonly documentElement: { + appendChild(element: BridgeLoaderElement): void; + removeChild(element: BridgeLoaderElement): void; + }; + }; + setTimeout(callback: () => void, delay: 0): unknown; +} + +export type DisposeBridgeSetup = () => void; + +/** + * Reproduces the legacy WVJB bootstrap ordering, including continuing after + * an immediate bridge callback into the callback-queue/iframe block. + */ +export function setupWebViewJavascriptBridge( + environment: WebViewJavascriptBridgeEnvironment, + callback: (bridge: NativeBridgeChannel) => void, +): DisposeBridgeSetup { + let readyListener: (() => void) | undefined; + + if (environment.WebViewJavascriptBridge) { + callback(environment.WebViewJavascriptBridge); + } else { + readyListener = () => callback(environment.WebViewJavascriptBridge as NativeBridgeChannel); + environment.document.addEventListener( + 'WebViewJavascriptBridgeReady', + readyListener, + false, + ); + } + + if (environment.WVJBCallbacks) { + environment.WVJBCallbacks.push(callback); + } else { + environment.WVJBCallbacks = [callback]; + const loader = environment.document.createElement('iframe'); + loader.style.display = 'none'; + loader.src = 'wvjbscheme://__BRIDGE_LOADED__'; + environment.document.documentElement.appendChild(loader); + environment.setTimeout(() => environment.document.documentElement.removeChild(loader), 0); + } + + return () => { + if (readyListener) { + environment.document.removeEventListener( + 'WebViewJavascriptBridgeReady', + readyListener, + false, + ); + } + const callbackIndex = environment.WVJBCallbacks?.indexOf(callback) ?? -1; + if (callbackIndex >= 0) environment.WVJBCallbacks?.splice(callbackIndex, 1); + }; +} + +function includesName(names: readonly Name[], name: string): name is Name { + return (names as readonly string[]).includes(name); +} + +/** Per-instance facade over one WVJB channel. */ +export class WebViewJavascriptBridgeAdapter implements NativeBridgePort { + private channel: NativeBridgeChannel | undefined; + private readonly handlers = new Map(); + private disposed = false; + private readonly disposeSetup: DisposeBridgeSetup; + + constructor(environment: WebViewJavascriptBridgeEnvironment) { + this.disposeSetup = setupWebViewJavascriptBridge(environment, channel => { + if (this.disposed || this.channel) return; + this.channel = channel; + for (const [name, handler] of this.handlers) channel.registerHandler(name, handler); + }); + } + + isReady(): boolean { + this.assertUsable(); + return this.channel !== undefined; + } + + register(name: RegisteredNativeHandlerName, handler: NativeHandler): void { + this.assertUsable(); + if (!includesName(REGISTERED_NATIVE_HANDLER_NAMES, name)) { + throw new Error(`Unknown registered native handler: ${String(name)}`); + } + this.handlers.set(name, handler); + this.channel?.registerHandler(name, handler); + } + + call(name: NativeCallName, data: unknown, callback?: NativeResponseCallback): void { + this.assertUsable(); + if (!includesName(NATIVE_CALL_NAMES, name)) { + throw new Error(`Unknown native call: ${String(name)}`); + } + if (!this.channel) throw new Error('Native bridge is not ready'); + this.channel.callHandler(name, data, callback); + } + + dispose(): void { + if (this.disposed) return; + this.disposeSetup(); + this.handlers.clear(); + this.channel = undefined; + this.disposed = true; + } + + private assertUsable(): void { + if (this.disposed) throw new Error('Native bridge adapter is disposed'); + } +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/native-bridge.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/native-bridge.ts deleted file mode 100644 index f55a93f..0000000 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/native-bridge.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * 原生↔H5 异步桥(WebViewJavascriptBridge,WVJB)+ window.settings 同步取值。 - * - * 严格对齐原 gameabc 项目(05_Func.js:2627 起)的外部契约: - * - 14 个 handler 名白名单(与原生 app 逐字对齐) - * - `window.WVJBCallbacks` / `WebViewJavascriptBridgeReady` 事件名 - * - `window.settings.getothername(name)` 同步取值 API - * - 回调签名 `(data, responseCallback)` 与数据结构 - * - * 内部命名(接口/函数/常量)走现代专业风格,与上述外部契约清晰隔离。 - * - * 架构边界:本模块只暴露受限 facade,不允许 framework 业务代码直接读 - * window/bridge 内部。 - */ - -/** window 上的原生注入对象(05_Func.js:2467-2471 同步取值)。 */ -declare global { - interface Window { - settings?: { - /** 同步取一个原生配置项,无则返回空串 */ - getothername(name: string): string; - }; - /** WVJB 启动前的全局回调队列(外部契约,不能改名) */ - WVJBCallbacks?: Array<(bridge: NativeBridgeChannel) => void>; - /** WebView 注入 WVJB 的事件名(外部契约) */ - WebViewJavascriptBridgeReady?: Array<() => void>; - } -} - -/** - * 原生桥通道契约 —— WVJB bridge 实例的最小子集。 - * 内部命名为 "Channel" 以避免缩写、明确"双向通信通道"语义。 - */ -export interface NativeBridgeChannel { - /** H5 注册供原生侧调用的 handler */ - registerHandler(name: string, handler: NativeHandler): void; - /** H5 主动调用原生侧 */ - callHandler(name: string, data: unknown, responseCallback?: (resp: unknown) => void): void; -} - -/** H5 注册供原生调用的 handler 函数签名(外部 WVJB 协议约定) */ -export type NativeHandler = (data: unknown, responseCallback: NativeResponseCallback) => unknown; - -/** 响应回调:原生侧通过此把结果回传给 H5 */ -export type NativeResponseCallback = (responseData: unknown) => void; - -/** - * 14 个已注册 handler 名白名单(05_Func.js:2651+ 原项目实装)。 - * 与原生 app 端逐字对齐 —— app 按这些名字调,改名 app 也要改。 - * - * 字面量联合类型:registerHandler/callHandler 入参必须在此集合内, - * 拼写错误编译期就发现,集合外名字运行时显式抛错(第二准则:不静默兜底)。 - */ -export const NATIVE_HANDLER_NAMES = [ - 'getVideoinfo', - 'sharelogin', - 'sharesuccess', - 'gameui_play_voice', - 'gameui_stop_voice', - 'getphoneinfo', - 'getAddressBook', - 'phonestate', - 'appservice', - 'getaudiourl', - 'getBattery', - 'getwifiLevel', - 'getnetwork', - 'shakeEnd', -] as const; - -export type NativeHandlerName = (typeof NATIVE_HANDLER_NAMES)[number]; - -function isNativeHandlerName(name: string): name is NativeHandlerName { - return (NATIVE_HANDLER_NAMES as readonly string[]).includes(name); -} - -/** createBridge 初始化选项 */ -export interface BridgeOptions { - /** 通道就绪回调(原生侧 WebView bridgeReady 时触发一次) */ - onReady(channel: NativeBridgeChannel): void; - /** - * 初始化超时的兜底(可省略)。 - * 原生侧可能永不触发 WebViewJavascriptBridgeReady,业务可在此降级。 - */ - onTimeout?(reason: string): void; -} - -/** - * 受限 Bridge facade —— 框架业务访问原生能力的唯一入口。 - * - * 创建后立即尝试初始化: - * 1. 把 setup 回调 push 到 window.WVJBCallbacks(原生侧约定的入口) - * 2. 原生侧 WebView bridgeReady 时遍历 WVJBCallbacks 调用每个 cb → onReady 被触发 - * - * 一旦通道就绪,onReady 被调用一次。 - */ -export interface Bridge { - /** 当前通道是否已就绪 */ - isReady(): boolean; - /** H5 注册供原生侧调用(白名单校验) */ - registerHandler(name: NativeHandlerName, handler: NativeHandler): void; - /** H5 调用原生侧(白名单校验) */ - callHandler(name: NativeHandlerName, data: unknown, cb?: NativeResponseCallback): void; -} - -export function createBridge(opts: BridgeOptions): Bridge { - const g = globalThis as any; - // 外部契约:把 setup cb push 到 window.WVJBCallbacks - if (!g.WVJBCallbacks) g.WVJBCallbacks = []; - let ready = false; - g.WVJBCallbacks.push((channel: NativeBridgeChannel) => { - ready = true; - // 缓存通道供 callHandler 委托(避免 onReady 闭包外传) - g.__activeChannel = channel; - opts.onReady(channel); - }); - - return { - isReady() { return ready; }, - registerHandler(name, handler) { - if (!isNativeHandlerName(name)) { - throw new Error(`registerHandler: handler "${name}" 不在白名单(第二准则:不静默兜底)`); - } - if (!ready) throw new Error('NativeBridge not ready: 通道未初始化,请先等 onReady 回调'); - g.__activeChannel.registerHandler(name, handler); - }, - callHandler(name, data, cb) { - if (!isNativeHandlerName(name)) { - throw new Error(`callHandler: handler "${name}" 不在白名单(第二准则:不静默兜底)`); - } - if (!ready) throw new Error('NativeBridge not ready: 通道未初始化,请先等 onReady 回调'); - g.__activeChannel.callHandler(name, data, cb); - }, - }; -} - -/** - * 从 window 取原生注入的配置项(05_Func.js:2467-2471 的 getothername)。 - * - * @param name 配置项名(渠道/包信息/gameserver 覆盖等) - * @returns 原生侧返回值;缺失时返回空串(对齐原项目:无则空字符串而非 undefined) - * @throws 原生侧未注入 window.settings 时显式抛错(不静默返回空串——避免下游误用) - */ -export function getNativeSetting(name: string): string { - const settings = (globalThis as any).window?.settings; - if (!settings || typeof settings.getothername !== 'function') { - throw new Error( - `getNativeSetting("${name}"): window.settings 未注入(原生侧未加载或非 WebView 环境)`, - ); - } - return settings.getothername(name); -} \ No newline at end of file diff --git a/cocoscreator_projects/framework-tests/native/native-settings-adapter.test.ts b/cocoscreator_projects/framework-tests/native/native-settings-adapter.test.ts new file mode 100644 index 0000000..9408977 --- /dev/null +++ b/cocoscreator_projects/framework-tests/native/native-settings-adapter.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { NativeSettingsAdapter } from '../../YouleNexus/assets/framework/adapters/native/native-settings-adapter.ts'; + +test('native settings calls the three original method spellings and preserves returns', () => { + const calls: unknown[][] = []; + const adapter = new NativeSettingsAdapter({ + getothername(name) { + calls.push(['getothername', name]); + return name === 'agent' ? 0 : `value:${name}`; + }, + getchannelName() { + calls.push(['getchannelName']); + return 'channel-a'; + }, + getmarketname() { + calls.push(['getmarketname']); + return 17; + }, + }); + + assert.equal(adapter.getOtherName('agent'), 0); + assert.equal(adapter.getOtherName('gameserver'), 'value:gameserver'); + assert.equal(adapter.getChannelName(), 'channel-a'); + assert.equal(adapter.getMarketName(), 17); + assert.deepEqual(calls, [ + ['getothername', 'agent'], + ['getothername', 'gameserver'], + ['getchannelName'], + ['getmarketname'], + ]); +}); diff --git a/cocoscreator_projects/framework-tests/native/webview-javascript-bridge.test.ts b/cocoscreator_projects/framework-tests/native/webview-javascript-bridge.test.ts new file mode 100644 index 0000000..420d1b6 --- /dev/null +++ b/cocoscreator_projects/framework-tests/native/webview-javascript-bridge.test.ts @@ -0,0 +1,261 @@ +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); +}); diff --git a/cocoscreator_projects/framework-tests/platform/native-bridge.test.ts b/cocoscreator_projects/framework-tests/platform/native-bridge.test.ts deleted file mode 100644 index dbb3d51..0000000 --- a/cocoscreator_projects/framework-tests/platform/native-bridge.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { - createBridge, - getNativeSetting, - type NativeHandler, - type NativeResponseCallback, - type NativeBridgeChannel, -} from '../../YouleNexus/assets/framework/platform/native-bridge.ts'; - -/** - * Mock 浏览器侧 globals。Node 单测无 window,需 stub。 - */ -function makeEnv() { - const calls: { handler: string; data: unknown }[] = []; - const responses: Map void> = new Map(); - const handlers: Map = new Map(); - - const channel: NativeBridgeChannel = { - registerHandler(name, h) { handlers.set(name, h); }, - callHandler(name, data, cb) { - calls.push({ handler: name, data }); - if (cb) responses.set(name, cb); - }, - }; - - const g = globalThis as any; - const oldWVJB = g.WVJBCallbacks; - g.WVJBCallbacks = []; - // mock 原生侧 WebView bridge 就绪:遍历 WVJBCallbacks 全调用 - const triggerReady = () => { - for (const cb of [...g.WVJBCallbacks]) cb(channel); - }; - - return { - channel, - restore: () => { g.WVJBCallbacks = oldWVJB; }, - calls, - handlers, - responses, - triggerReady, - }; -} - -test('createBridge: 通道就绪时 onReady 触发', () => { - const env = makeEnv(); - let got: any = null; - const bridge = createBridge({ - onReady(c) { got = c; }, - }); - env.triggerReady(); - assert.equal(got, env.channel, 'onReady 应收到 mock channel'); - env.restore(); -}); - -test('registerHandler: H5 注册供原生调用', () => { - const env = makeEnv(); - let received: any = null; - const bridge = createBridge({ - onReady(c) { - (globalThis as any).__activeChannel = c; - bridge.registerHandler('getVideoinfo', (data) => { - received = data; - return { ok: true }; - }); - }, - }); - env.triggerReady(); - // 原生侧调用 - const handler = env.handlers.get('getVideoinfo'); - assert.ok(handler, 'handler 应已注册'); - handler!({ vid: 'abc' }, (() => {}) as NativeResponseCallback); - assert.deepEqual(received, { vid: 'abc' }); - env.restore(); -}); - -test('callHandler: H5 调用原生,响应通过 cb 接收', () => { - const env = makeEnv(); - const bridge = createBridge({ - onReady(c) { (globalThis as any).__activeChannel = c; }, - }); - env.triggerReady(); - let resp: unknown = null; - bridge.callHandler('getBattery', {}, (r) => { resp = r; }); - // 模拟原生侧响应 - const cb = env.responses.get('getBattery'); - assert.ok(cb, 'callHandler 应记录 cb'); - cb!({ level: 80, charging: false }); - assert.deepEqual(resp, { level: 80, charging: false }); - env.restore(); -}); - -test('callHandler: handler 名不在白名单 → 显式抛错(第二准则)', () => { - const env = makeEnv(); - const bridge = createBridge({ onReady: () => {} }); - env.triggerReady(); - assert.throws( - () => bridge.callHandler('unknown_handler' as any, {}, () => {}), - /unknown_handler/, - ); - env.restore(); -}); - -test('registerHandler: handler 名不在白名单 → 显式抛错', () => { - const env = makeEnv(); - const bridge = createBridge({ onReady: () => {} }); - env.triggerReady(); - assert.throws( - () => bridge.registerHandler('unknown_handler' as any, () => undefined), - /unknown_handler/, - ); - env.restore(); -}); - -test('bridge 未就绪时 callHandler/registerHandler 显式抛错', () => { - const g = globalThis as any; - const old = g.WVJBCallbacks; - g.WVJBCallbacks = []; // 空队列,triggerReady 不被调用 - const bridge = createBridge({ onReady: () => {} }); - assert.throws(() => bridge.callHandler('getBattery', {}, () => {}), /not ready/i); - assert.throws(() => bridge.registerHandler('getVideoinfo', (() => undefined) as any), /not ready/i); - g.WVJBCallbacks = old; -}); - -test('重复 createBridge: 多个 instance 各自注册独立回调', () => { - const g = globalThis as any; - const old = g.WVJBCallbacks; - g.WVJBCallbacks = []; - createBridge({ onReady: () => {} }); - createBridge({ onReady: () => {} }); - assert.equal(g.WVJBCallbacks.length, 2, '每个 instance 独立注册一个 cb'); - g.WVJBCallbacks = old; -}); - -test('getNativeSetting: 同步取 window.settings.getothername', () => { - const g = globalThis as any; - const oldWindow = g.window; - g.window = { settings: { getothername: (n: string) => n === 'agentid' ? 'A' : '' } }; - assert.equal(getNativeSetting('agentid'), 'A'); - assert.equal(getNativeSetting('missing'), ''); - g.window = oldWindow; -}); - -test('getNativeSetting: window.settings 未注入 → 显式抛错(不静默兜底)', () => { - const g = globalThis as any; - const oldWindow = g.window; - g.window = {}; // 无 settings - assert.throws(() => getNativeSetting('agentid'), /window\.settings 未注入/); - g.window = oldWindow; -}); \ No newline at end of file