diff --git a/.agents/skills/mpx2rn/references/rn-api-reference.md b/.agents/skills/mpx2rn/references/rn-api-reference.md index a94a2b62cd..558a9b7553 100644 --- a/.agents/skills/mpx2rn/references/rn-api-reference.md +++ b/.agents/skills/mpx2rn/references/rn-api-reference.md @@ -1106,9 +1106,10 @@ mpx.use(apiProxy, { | 字段名 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `url` | `string` | 是 | `wss://` 或 `ws://` 地址。 | +| `header` | `Object` | 否 | 请求头,会传递给 RN 原生 **WebSocket**。 | | `protocols` | `string[]` | 否 | 子协议列表。 | -> 微信同名 API 的 `header`、`timeout`、`tcpNoDelay`、`perMessageDeflate` 等扩展参数**当前 RN 中暂不支持**。 +> 微信同名 API 的 `timeout`、`tcpNoDelay`、`perMessageDeflate` 等扩展参数**当前 RN 中暂不支持**。 #### 返回值 @@ -1576,13 +1577,13 @@ mpx.use(apiProxy, { #### 说明 -取消键盘高度监听:仅移除与注册时**同一引用**的 **`callback`**;**不传引用不会移除其它监听**。当监听列表为空时会移除键盘显示/隐藏相关订阅。 +取消键盘高度监听:传入与注册时**同一引用**的 **`callback`** 时移除对应监听;不传回调时移除全部监听。当监听列表为空时会移除键盘显示/隐藏相关订阅。 #### 入参 | 字段名 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | -| `callback` | `function` | 是 | 与 **`onKeyboardHeightChange`** 注册时为同一函数引用。 | +| `callback` | `function` | 否 | 与 **`onKeyboardHeightChange`** 注册时为同一函数引用;不传时移除全部监听。 | #### 返回值 @@ -1730,7 +1731,7 @@ mpx.config.rnConfig.bluetoothPermission = () => { | `stopWifi` | 结束 Wi‑Fi 模块并清空列表监听;RN iOS 目标直接走 `fail`。 | RN Android 成功时为 `{ errMsg: 'stopWifi:ok' }`。 | | `getWifiList` | `startWifi` 就绪后扫描热点,通过 `onGetWifiList` 交付列表;RN iOS 目标直接走 `fail`。 | `success`:`{ errMsg: 'getWifiList:ok', errno: 0, errCode: 0 }`;热点列表不在此载荷中。 | | `onGetWifiList` | 注册接收热点列表的回调。 | `{ wifiList }`;每项含 `SSID`、`BSSID`、`frequency`、`signalStrength`。 | -| `offGetWifiList` | 移除热点列表回调。 | 传入与注册时同一 `callback`;当前实现不支持省略参数清空全部。 | +| `offGetWifiList` | 移除热点列表回调。 | 传入与注册时同一 `callback`;省略参数清空全部。 | | `getConnectedWifi` | 读取当前已连接 Wi‑Fi;支持 `partialInfo`,且需先 `startWifi` 成功就绪。 | `{ errMsg: 'getConnectedWifi:ok', wifi }`;`wifi` 含 `SSID`、`BSSID`、`signalStrength`、`frequency`。 | Wi-Fi API 的成功 `errMsg` 与微信文档保持一致,均以 `:ok` 结尾。 diff --git a/docs-vitepress/api-proxy/media/audio/createInnerAudioContext.md b/docs-vitepress/api-proxy/media/audio/createInnerAudioContext.md index 194e26fc9c..3cba19b2a1 100644 --- a/docs-vitepress/api-proxy/media/audio/createInnerAudioContext.md +++ b/docs-vitepress/api-proxy/media/audio/createInnerAudioContext.md @@ -18,3 +18,5 @@ ### 注意事项 {#notes} InnerAudioContext 音频资源不会自动释放,因此如果不再需要使用音频,请及时调用 InnerAudioContext.destroy() 释放资源,避免内存泄漏。 + +Web 端的 `on*` 事件允许注册多个监听函数。调用对应的 `off*` 方法并传入注册时的同一函数引用,只移除该监听;不传参数时移除该事件的全部监听。 diff --git a/docs-vitepress/api-proxy/network/websocket/connectSocket.md b/docs-vitepress/api-proxy/network/websocket/connectSocket.md index 6ad83be228..ae41435ff8 100644 --- a/docs-vitepress/api-proxy/network/websocket/connectSocket.md +++ b/docs-vitepress/api-proxy/network/websocket/connectSocket.md @@ -2,7 +2,7 @@ 创建一个 WebSocket 连接。 -支持情况: 微信、支付宝 +支持情况: 微信、支付宝、RN、web [参考文档](https://developers.weixin.qq.com/miniprogram/dev/api/network/upload/wx.uploadFile.html) @@ -13,7 +13,7 @@ | 属性 | 类型 | 默认值 | 必填 | 说明 | 最低版本 | 支付宝 | RN | web | | ------------------- | -------------- | ------ | ---- | -------------------------------------------- | -------- | ------ | ---------- | --- | | url | string | | 是 | 开发者服务器 wss 接口地址 | | | | | -| header | Object | | 否 | HTTP Header,Header 中不能设置 Referer | | | | | +| header | Object | | 否 | HTTP Header,Header 中不能设置 Referer | | | | | | protocols | Array.<string> | | 否 | 子协议数组 | 1.4.0 | | | | | tcpNoDelay | boolean | false | 否 | 建立 TCP 连接的时候的 TCP_NODELAY 设置 | 2.4.0 | | | | | perMessageDeflate | boolean | false | 否 | 是否开启压缩扩展 | 2.8.0 | | | | diff --git a/docs-vitepress/api-proxy/storage/clearStorageSync.md b/docs-vitepress/api-proxy/storage/clearStorageSync.md index 89a850db13..e7942e61ff 100644 --- a/docs-vitepress/api-proxy/storage/clearStorageSync.md +++ b/docs-vitepress/api-proxy/storage/clearStorageSync.md @@ -4,6 +4,8 @@ 支持情况: 微信、支付宝、web +> RN 的存储实现基于异步的 AsyncStorage,不支持此同步 API,请使用 `mpx.clearStorage`。 + [参考文档](https://developers.weixin.qq.com/miniprogram/dev/api/storage/wx.clearStorageSync.html) diff --git a/docs-vitepress/api-proxy/storage/removeStorageSync.md b/docs-vitepress/api-proxy/storage/removeStorageSync.md index 3649d552c0..737605a11d 100644 --- a/docs-vitepress/api-proxy/storage/removeStorageSync.md +++ b/docs-vitepress/api-proxy/storage/removeStorageSync.md @@ -4,6 +4,8 @@ 支持情况: 微信、支付宝、web +> RN 的存储实现基于异步的 AsyncStorage,不支持此同步 API,请使用 `mpx.removeStorage`。 + [参考文档](https://developers.weixin.qq.com/miniprogram/dev/api/storage/wx.removeStorageSync.html) diff --git a/packages/api-proxy/__tests__/ali/set-navigation-bar.spec.js b/packages/api-proxy/__tests__/ali/set-navigation-bar.spec.js new file mode 100644 index 0000000000..b488ba108a --- /dev/null +++ b/packages/api-proxy/__tests__/ali/set-navigation-bar.spec.js @@ -0,0 +1,30 @@ +import { ENV_OBJ } from '../../src/common/js' +import { setNavigationBarTitle } from '../../src/platform/api/set-navigation-bar/index.ali' + +jest.mock('../../src/common/js/index', () => { + const actual = jest.requireActual('../../src/common/js/index') + return Object.assign({}, actual, { + ENV_OBJ: { + canIUse: jest.fn(() => true), + setNavigationBarTitle: jest.fn() + } + }) +}) + +describe('Ali setNavigationBarTitle', () => { + test('should normalize the success errMsg', () => { + const success = jest.fn() + ENV_OBJ.setNavigationBarTitle.mockImplementation(({ success }) => { + success({}) + }) + + setNavigationBarTitle({ + title: 'title', + success + }) + + expect(success).toHaveBeenCalledWith({ + errMsg: 'setNavigationBarTitle:ok' + }) + }) +}) diff --git a/packages/api-proxy/__tests__/rn/ble-connection.spec.js b/packages/api-proxy/__tests__/rn/ble-connection.spec.js index ee8eaa998f..b3fa9e319e 100644 --- a/packages/api-proxy/__tests__/rn/ble-connection.spec.js +++ b/packages/api-proxy/__tests__/rn/ble-connection.spec.js @@ -109,4 +109,18 @@ describe('RN BLE connection APIs', () => { })) expect(BleManager.requestMTU).not.toHaveBeenCalled() }) + + test.each([false, 0, ''])('generic parameter check should reject invalid value %p', (value) => { + const fail = jest.fn() + const complete = jest.fn() + + getBLEDeviceRSSI({ deviceId: value, fail, complete }) + + expect(fail).toHaveBeenCalledWith({ + errMsg: 'getBLEDeviceRSSI:fail parameter error', + errno: 1509000 + }) + expect(complete).toHaveBeenCalled() + expect(BleManager.readRSSI).not.toHaveBeenCalled() + }) }) diff --git a/packages/api-proxy/__tests__/rn/camera.spec.js b/packages/api-proxy/__tests__/rn/camera.spec.js new file mode 100644 index 0000000000..0874ade9d4 --- /dev/null +++ b/packages/api-proxy/__tests__/rn/camera.spec.js @@ -0,0 +1,32 @@ +import CameraContext from '../../src/platform/api/camera/rnCamera' + +describe('RN CameraContext', () => { + test('setZoom should invoke fail only when the native method throws', () => { + const cameraContext = new CameraContext() + const success = jest.fn() + const fail = jest.fn() + const complete = jest.fn() + cameraContext.camera = { + setZoom: jest.fn(() => { + throw new Error('set zoom failed') + }) + } + + cameraContext.setZoom({ + zoom: 2, + success, + fail, + complete + }) + + expect(cameraContext.camera.setZoom).toHaveBeenCalledWith(2) + expect(success).not.toHaveBeenCalled() + expect(fail).toHaveBeenCalledWith({ + errMsg: 'setZoom:fail set zoom failed' + }) + expect(complete).toHaveBeenCalledTimes(1) + expect(complete).toHaveBeenCalledWith({ + errMsg: 'setZoom:fail set zoom failed' + }) + }) +}) diff --git a/packages/api-proxy/__tests__/rn/keyboard.spec.js b/packages/api-proxy/__tests__/rn/keyboard.spec.js new file mode 100644 index 0000000000..f0a951e209 --- /dev/null +++ b/packages/api-proxy/__tests__/rn/keyboard.spec.js @@ -0,0 +1,92 @@ +const mockSubscriptions = [] +const mockAddListener = jest.fn(eventName => { + // RN 新版 addListener 返回订阅对象,业务代码只能移除自己持有的订阅。 + const subscription = { + eventName, + remove: jest.fn() + } + mockSubscriptions.push(subscription) + return subscription +}) +const mockRemoveAllListeners = jest.fn() +const mockDismiss = jest.fn() + +jest.mock('react-native', () => ({ + Keyboard: { + addListener: mockAddListener, + removeAllListeners: mockRemoveAllListeners, + dismiss: mockDismiss + } +}), { virtual: true }) + +const { + onKeyboardHeightChange, + offKeyboardHeightChange, + hideKeyboard +} = require('../../src/platform/api/keyboard/index.ios') + +describe('RN keyboard', () => { + beforeEach(() => { + offKeyboardHeightChange() + mockSubscriptions.length = 0 + mockAddListener.mockClear() + mockRemoveAllListeners.mockClear() + mockDismiss.mockReset() + }) + + it('should only remove owned subscriptions when called without callback', () => { + onKeyboardHeightChange(jest.fn()) + + offKeyboardHeightChange() + + // 不传回调会清空业务回调,但不能调用 removeAllListeners 误删外部监听。 + expect(mockSubscriptions).toHaveLength(2) + mockSubscriptions.forEach(subscription => { + expect(subscription.remove).toHaveBeenCalledTimes(1) + }) + expect(mockRemoveAllListeners).not.toHaveBeenCalled() + }) + + it('should only remove owned subscriptions when the last callback is removed', () => { + const callback = jest.fn() + onKeyboardHeightChange(callback) + + offKeyboardHeightChange(callback) + + // 最后一个业务回调移除后,键盘显示和隐藏两个底层订阅都应释放。 + mockSubscriptions.forEach(subscription => { + expect(subscription.remove).toHaveBeenCalledTimes(1) + }) + expect(mockRemoveAllListeners).not.toHaveBeenCalled() + }) + + it('should subscribe again after all callbacks are removed', () => { + const callback = jest.fn() + onKeyboardHeightChange(callback) + offKeyboardHeightChange(callback) + + onKeyboardHeightChange(jest.fn()) + + // 首轮订阅释放后再次注册,需要重新创建两个底层订阅。 + expect(mockAddListener).toHaveBeenCalledTimes(4) + }) + + it('should include the API name in the hideKeyboard fail errMsg', () => { + const fail = jest.fn() + const complete = jest.fn() + mockDismiss.mockImplementation(() => { + throw new Error('dismiss failed') + }) + + hideKeyboard({ + fail, + complete + }) + + const result = { + errMsg: 'hideKeyboard:fail dismiss failed' + } + expect(fail).toHaveBeenCalledWith(result) + expect(complete).toHaveBeenCalledWith(result) + }) +}) diff --git a/packages/api-proxy/__tests__/rn/network.spec.js b/packages/api-proxy/__tests__/rn/network.spec.js new file mode 100644 index 0000000000..bb24a5577c --- /dev/null +++ b/packages/api-proxy/__tests__/rn/network.spec.js @@ -0,0 +1,85 @@ +const mockUnsubscribe = jest.fn() +const mockAddEventListener = jest.fn(() => mockUnsubscribe) + +jest.mock('@react-native-community/netinfo', () => ({ + __esModule: true, + default: { + addEventListener: mockAddEventListener, + fetch: jest.fn() + }, + NetInfoStateType: { + cellular: 'cellular', + none: 'none', + wifi: 'wifi' + } +}), { virtual: true }) + +jest.mock('../../src/common/js', () => ({ + successHandle: jest.fn(), + failHandle: jest.fn(), + defineUnsupportedProps: jest.fn() +})) + +const { + getNetworkType, + offNetworkStatusChange, + onNetworkStatusChange +} = require('../../src/platform/api/device/network/rnNetwork') +const NetInfo = require('@react-native-community/netinfo').default +const { failHandle } = require('../../src/common/js') + +describe('RN network events', () => { + beforeEach(() => { + offNetworkStatusChange() + mockAddEventListener.mockClear() + mockUnsubscribe.mockClear() + NetInfo.fetch.mockReset() + failHandle.mockClear() + }) + + test('should clear callbacks and native subscription when callback is null', () => { + onNetworkStatusChange(jest.fn()) + + // null 与 undefined 含义一致,都应清空回调并释放 NetInfo 底层订阅。 + offNetworkStatusChange(null) + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1) + }) + + test('should release native subscription after the last callback is removed', () => { + const callbackA = jest.fn() + const callbackB = jest.fn() + onNetworkStatusChange(callbackA) + onNetworkStatusChange(callbackB) + + offNetworkStatusChange(callbackA) + expect(mockUnsubscribe).not.toHaveBeenCalled() + + offNetworkStatusChange(callbackB) + expect(mockUnsubscribe).toHaveBeenCalledTimes(1) + }) + + test('should subscribe again after all callbacks are removed', () => { + onNetworkStatusChange(jest.fn()) + offNetworkStatusChange() + + // 底层订阅释放后再次调用 on,需要重新向 NetInfo 注册。 + onNetworkStatusChange(jest.fn()) + + expect(mockAddEventListener).toHaveBeenCalledTimes(2) + }) + + test('getNetworkType should include API name when fetching fails', async () => { + const fail = jest.fn() + const complete = jest.fn() + NetInfo.fetch.mockRejectedValue(new Error('fetch failed')) + + getNetworkType({ fail, complete }) + await Promise.resolve() + await Promise.resolve() + + expect(failHandle).toHaveBeenCalledWith({ + errMsg: 'getNetworkType:fail fetch failed' + }, fail, complete) + }) +}) diff --git a/packages/api-proxy/__tests__/rn/socket.spec.js b/packages/api-proxy/__tests__/rn/socket.spec.js new file mode 100644 index 0000000000..3a15119e20 --- /dev/null +++ b/packages/api-proxy/__tests__/rn/socket.spec.js @@ -0,0 +1,53 @@ +import { connectSocket } from '../../src/platform/api/socket/index.ios' +import { connectSocket as connectSocketWeb } from '../../src/platform/api/socket/index.web' + +class MockWebSocket { + constructor (url, protocols, options) { + this.url = url + this.protocols = protocols + this.options = options + this.readyState = 0 + this.CONNECTING = 0 + this.OPEN = 1 + this.CLOSING = 2 + this.CLOSED = 3 + this.close = jest.fn() + } +} + +describe('RN connectSocket', () => { + beforeEach(() => { + global.WebSocket = jest.fn((...args) => new MockWebSocket(...args)) + }) + + it('should forward header and protocols to WebSocket', () => { + const header = { + Authorization: 'token' + } + + connectSocket({ + url: 'wss://example.com', + protocols: ['chat'], + header + }) + + expect(global.WebSocket).toHaveBeenCalledWith( + 'wss://example.com', + ['chat'], + { + headers: header + } + ) + }) + + it('should not forward header options on web', () => { + connectSocketWeb({ + url: 'wss://example.com', + header: { + Authorization: 'token' + } + }) + + expect(global.WebSocket).toHaveBeenCalledWith('wss://example.com') + }) +}) diff --git a/packages/api-proxy/__tests__/rn/storage.spec.js b/packages/api-proxy/__tests__/rn/storage.spec.js index e4eb958fdb..5580bdfb2d 100644 --- a/packages/api-proxy/__tests__/rn/storage.spec.js +++ b/packages/api-proxy/__tests__/rn/storage.spec.js @@ -1,7 +1,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage' -import { removeStorageSync, clearStorageSync } from '../../src/platform/api/storage/rnStorage' +import { getStorageInfo, removeStorageSync, clearStorageSync } from '../../src/platform/api/storage/rnStorage' jest.mock('@react-native-async-storage/async-storage', () => ({ + getAllKeys: jest.fn(), removeItem: jest.fn(), clear: jest.fn() }), { virtual: true }) @@ -28,4 +29,22 @@ describe('RN storage APIs', () => { expect(AsyncStorage.removeItem).not.toHaveBeenCalled() expect(AsyncStorage.clear).not.toHaveBeenCalled() }) + + test('getStorageInfo should use the correct API name when it fails', () => { + const error = new Error('get keys failed') + const fail = jest.fn() + const complete = jest.fn() + AsyncStorage.getAllKeys.mockImplementation(callback => callback(error)) + + getStorageInfo({ + fail, + complete + }) + + const result = { + errMsg: 'getStorageInfo:fail Error: get keys failed' + } + expect(fail).toHaveBeenCalledWith(result) + expect(complete).toHaveBeenCalledWith(result) + }) }) diff --git a/packages/api-proxy/__tests__/rn/toast.spec.js b/packages/api-proxy/__tests__/rn/toast.spec.js index 981a098366..b199805552 100644 --- a/packages/api-proxy/__tests__/rn/toast.spec.js +++ b/packages/api-proxy/__tests__/rn/toast.spec.js @@ -1,6 +1,12 @@ import Portal from '@mpxjs/webpack-plugin/lib/runtime/components/react/dist/mpx-portal/index' import { showToast, showLoading } from '../../src/platform/api/toast/rnToast' +jest.mock('react/jsx-runtime', () => ({ + jsx: (type, props) => ({ type, props }), + jsxs: (type, props) => ({ type, props }), + Fragment: 'Fragment' +}), { virtual: true }) + jest.mock('react-native', () => ({ View: 'View', Text: 'Text', @@ -17,7 +23,7 @@ jest.mock('react-native', () => ({ jest.mock('@mpxjs/webpack-plugin/lib/runtime/components/react/dist/mpx-portal/index', () => ({ add: jest.fn(), remove: jest.fn() -})) +}), { virtual: true }) describe('RN toast APIs', () => { test.each([ diff --git a/packages/api-proxy/__tests__/rn/wifi.spec.js b/packages/api-proxy/__tests__/rn/wifi.spec.js index 30f777fb7d..f393b1d004 100644 --- a/packages/api-proxy/__tests__/rn/wifi.spec.js +++ b/packages/api-proxy/__tests__/rn/wifi.spec.js @@ -3,7 +3,9 @@ import { PermissionsAndroid } from 'react-native' import { startWifi, stopWifi, - getWifiList + getWifiList, + onGetWifiList, + offGetWifiList } from '../../src/platform/api/device/wifi/index.ios' jest.mock('react-native', () => ({ @@ -25,6 +27,11 @@ jest.mock('@mpxjs/core', () => ({ } })) +jest.mock('@mpxjs/utils', () => ({ + noop: jest.fn(), + type: value => Object.prototype.toString.call(value).slice(8, -1) +})) + jest.mock('react-native-wifi-reborn', () => ({ __esModule: true, default: { @@ -37,6 +44,7 @@ describe('RN Wi-Fi APIs', () => { beforeEach(() => { global.__mpx_mode__ = 'android' stopWifi() + offGetWifiList() jest.clearAllMocks() PermissionsAndroid.request.mockResolvedValue(PermissionsAndroid.RESULTS.GRANTED) WifiManager.isEnabled.mockResolvedValue(true) @@ -63,6 +71,85 @@ describe('RN Wi-Fi APIs', () => { expect(complete).toHaveBeenCalledWith(result) }) + test('stopWifi should preserve Wi-Fi list listeners', async () => { + const listener = jest.fn() + + await new Promise((resolve, reject) => { + startWifi({ success: resolve, fail: reject }) + }) + onGetWifiList(listener) + stopWifi() + await new Promise((resolve, reject) => { + startWifi({ success: resolve, fail: reject }) + }) + WifiManager.reScanAndLoadWifiList.mockResolvedValue([]) + await new Promise((resolve, reject) => { + getWifiList({ success: resolve, fail: reject }) + }) + + expect(listener).toHaveBeenCalledWith({ wifiList: [] }) + offGetWifiList(listener) + }) + + test('offGetWifiList should remove the specified listener after stopWifi', async () => { + const removedListener = jest.fn() + const retainedListener = jest.fn() + + await new Promise((resolve, reject) => { + startWifi({ success: resolve, fail: reject }) + }) + onGetWifiList(removedListener) + onGetWifiList(retainedListener) + stopWifi() + offGetWifiList(removedListener) + await new Promise((resolve, reject) => { + startWifi({ success: resolve, fail: reject }) + }) + WifiManager.reScanAndLoadWifiList.mockResolvedValue([]) + await new Promise((resolve, reject) => { + getWifiList({ success: resolve, fail: reject }) + }) + + expect(removedListener).not.toHaveBeenCalled() + expect(retainedListener).toHaveBeenCalledWith({ wifiList: [] }) + }) + + test('offGetWifiList should remove all listeners without callback', async () => { + const firstListener = jest.fn() + const secondListener = jest.fn() + + await new Promise((resolve, reject) => { + startWifi({ success: resolve, fail: reject }) + }) + onGetWifiList(firstListener) + onGetWifiList(secondListener) + offGetWifiList() + WifiManager.reScanAndLoadWifiList.mockResolvedValue([]) + await new Promise((resolve, reject) => { + getWifiList({ success: resolve, fail: reject }) + }) + + expect(firstListener).not.toHaveBeenCalled() + expect(secondListener).not.toHaveBeenCalled() + }) + + test('onGetWifiList should not register the same callback repeatedly', async () => { + const listener = jest.fn() + + await new Promise((resolve, reject) => { + startWifi({ success: resolve, fail: reject }) + }) + onGetWifiList(listener) + onGetWifiList(listener) + WifiManager.reScanAndLoadWifiList.mockResolvedValue([]) + await new Promise((resolve, reject) => { + getWifiList({ success: resolve, fail: reject }) + }) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith({ wifiList: [] }) + }) + test('getWifiList should return the WeChat-compatible success errMsg', async () => { await new Promise((resolve, reject) => { startWifi({ success: resolve, fail: reject }) @@ -80,4 +167,29 @@ describe('RN Wi-Fi APIs', () => { }) expect(complete).toHaveBeenCalledWith(result) }) + + test('getWifiList should normalize native scan failures', async () => { + await new Promise((resolve, reject) => { + startWifi({ success: resolve, fail: reject }) + }) + WifiManager.reScanAndLoadWifiList.mockRejectedValue(new Error('native scan error')) + const fail = jest.fn() + const complete = jest.fn() + + await new Promise(resolve => { + getWifiList({ + fail (result) { + fail(result) + resolve() + }, + complete + }) + }) + + const result = { + errMsg: 'getWifiList:fail' + } + expect(fail).toHaveBeenCalledWith(result) + expect(complete).toHaveBeenCalledWith(result) + }) }) diff --git a/packages/api-proxy/__tests__/rn/window.spec.js b/packages/api-proxy/__tests__/rn/window.spec.js new file mode 100644 index 0000000000..0b9dcd28c6 --- /dev/null +++ b/packages/api-proxy/__tests__/rn/window.spec.js @@ -0,0 +1,60 @@ +const mockSubscriptions = [] +const mockAddEventListener = jest.fn(() => { + const subscription = { remove: jest.fn() } + mockSubscriptions.push(subscription) + return subscription +}) + +jest.mock('react-native', () => ({ + Dimensions: { + addEventListener: mockAddEventListener + } +}), { virtual: true }) + +const { + offWindowResize, + onWindowResize +} = require('../../src/platform/api/window/rnWindow') + +describe('RN window resize', () => { + beforeEach(() => { + offWindowResize() + mockSubscriptions.length = 0 + mockAddEventListener.mockClear() + }) + + test('should remove native subscription without callback', () => { + onWindowResize(jest.fn()) + const subscription = mockSubscriptions[0] + + // 不传回调时,清空业务回调并释放 Dimensions 底层订阅。 + offWindowResize() + + expect(subscription.remove).toHaveBeenCalledTimes(1) + }) + + test('should not remove the same native subscription repeatedly', () => { + onWindowResize(jest.fn()) + const subscription = mockSubscriptions[0] + + offWindowResize() + offWindowResize() + + expect(subscription.remove).toHaveBeenCalledTimes(1) + }) + + test('should keep subscription until the last callback is removed', () => { + const firstCallback = jest.fn() + const secondCallback = jest.fn() + onWindowResize(firstCallback) + onWindowResize(secondCallback) + const subscription = mockSubscriptions[0] + + offWindowResize(firstCallback) + expect(subscription.remove).not.toHaveBeenCalled() + + // 最后一个业务回调移除后才释放共用的底层订阅。 + offWindowResize(secondCallback) + expect(subscription.remove).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/api-proxy/__tests__/web/app-event.spec.js b/packages/api-proxy/__tests__/web/app-event.spec.js new file mode 100644 index 0000000000..d813f154ae --- /dev/null +++ b/packages/api-proxy/__tests__/web/app-event.spec.js @@ -0,0 +1,46 @@ +import { + offAppHide, + offAppShow, + offError, + offUnhandledRejection, + onAppHide, + onAppShow, + onError, + onUnhandledRejection +} from '../../src/platform/api/app/index.web' + +describe('Web app events', () => { + const eventApis = [ + ['show', onAppShow, offAppShow], + ['hide', onAppHide, offAppHide], + ['error', onError, offError], + ['rejection', onUnhandledRejection, offUnhandledRejection] + ] + + afterEach(() => { + // 每个用例后清空模块级回调,避免测试之间互相影响。 + eventApis.forEach(([, , offEvent]) => offEvent()) + }) + + test.each(eventApis)('%s event should only remove the specified callback', (eventName, onEvent, offEvent) => { + const firstCallback = jest.fn() + const secondCallback = jest.fn() + onEvent(firstCallback) + onEvent(secondCallback) + + // 传入具体回调时,只注销目标回调,保留其他监听。 + offEvent(firstCallback) + + expect(global.__mpxAppCbs[eventName]).toEqual([secondCallback]) + }) + + test.each(eventApis)('%s event should remove all callbacks without callback', (eventName, onEvent, offEvent) => { + onEvent(jest.fn()) + onEvent(jest.fn()) + + // 不传回调时,清空当前事件的全部监听。 + offEvent() + + expect(global.__mpxAppCbs[eventName]).toHaveLength(0) + }) +}) diff --git a/packages/api-proxy/__tests__/web/audio.spec.js b/packages/api-proxy/__tests__/web/audio.spec.js new file mode 100644 index 0000000000..f8f353d06d --- /dev/null +++ b/packages/api-proxy/__tests__/web/audio.spec.js @@ -0,0 +1,60 @@ +import { createInnerAudioContext } from '../../src/platform/api/audio/index.web' + +describe('Web inner audio events', () => { + let nativeAudio + + beforeEach(() => { + global.Audio = jest.fn(function () { + nativeAudio = this + this.addEventListener = jest.fn() + this.removeEventListener = jest.fn() + this.pause = jest.fn() + }) + }) + + test('should remove the wrapper corresponding to the specified pause callback', () => { + const audio = createInnerAudioContext() + const callback = jest.fn() + + audio.onPause(callback) + const wrapper = nativeAudio.addEventListener.mock.calls[0][1] + audio.offPause(callback) + + // pause 使用包装函数屏蔽 stop 触发的事件,注销时必须移除包装函数而非原回调。 + expect(wrapper).not.toBe(callback) + expect(nativeAudio.removeEventListener).toHaveBeenCalledWith('pause', wrapper) + }) + + test('should remove all callbacks for an event without callback', () => { + const audio = createInnerAudioContext() + const firstCallback = jest.fn() + const secondCallback = jest.fn() + audio.onPlay(firstCallback) + audio.onPlay(secondCallback) + + // 不传回调时,应移除当前事件下注册的所有原生监听。 + audio.offPlay() + + expect(nativeAudio.removeEventListener).toHaveBeenCalledTimes(2) + expect(nativeAudio.removeEventListener).toHaveBeenCalledWith('play', firstCallback) + expect(nativeAudio.removeEventListener).toHaveBeenCalledWith('play', secondCallback) + }) + + test('should remove stop callbacks separately', () => { + jest.useFakeTimers() + const audio = createInnerAudioContext() + const removedCallback = jest.fn() + const retainedCallback = jest.fn() + audio.onStop(removedCallback) + audio.onStop(retainedCallback) + + // offStop 只管理模拟的 stop 回调,不应影响其他仍保留的 stop 监听。 + audio.offStop(removedCallback) + audio.stop() + jest.runAllTimers() + + expect(removedCallback).not.toHaveBeenCalled() + expect(retainedCallback).toHaveBeenCalledTimes(1) + jest.useRealTimers() + }) +}) diff --git a/packages/api-proxy/__tests__/web/device-network.spec.js b/packages/api-proxy/__tests__/web/device-network.spec.js index 2ab36ec2df..95b54317c0 100644 --- a/packages/api-proxy/__tests__/web/device-network.spec.js +++ b/packages/api-proxy/__tests__/web/device-network.spec.js @@ -1,4 +1,8 @@ -import { getNetworkType } from '../../src/platform/api/device/network/index.web' +import { + getNetworkType, + offNetworkStatusChange, + onNetworkStatusChange +} from '../../src/platform/api/device/network/index.web' describe('test getNetworkType', () => { test('should be enums value', () => { @@ -7,4 +11,71 @@ describe('test getNetworkType', () => { }) }) }) -// todo complete unit tests + +describe('test onNetworkStatusChange', () => { + test('should return unknown when the browser comes online without Network Information API', () => { + Object.defineProperty(navigator, 'connection', { + configurable: true, + value: undefined + }) + const callback = jest.fn() + + onNetworkStatusChange(callback) + window.dispatchEvent(new Event('online')) + + expect(callback).toHaveBeenCalledWith({ + isConnected: true, + networkType: 'unknown' + }) + offNetworkStatusChange(callback) + }) + + test('should remove all proxy callbacks when the same callback is registered repeatedly', () => { + // 模拟浏览器 Network Information API,并记录监听的注册和移除情况。 + const addEventListener = jest.fn() + const removeEventListener = jest.fn() + Object.defineProperty(navigator, 'connection', { + configurable: true, + value: { + addEventListener, + removeEventListener + } + }) + const callback = jest.fn() + + onNetworkStatusChange(callback) + onNetworkStatusChange(callback) + offNetworkStatusChange() + + // 同一个业务回调重复注册时会生成不同的代理回调,清空监听时二者都应被移除。 + const firstProxyCallback = addEventListener.mock.calls[0][1] + const secondProxyCallback = addEventListener.mock.calls[1][1] + expect(firstProxyCallback).not.toBe(secondProxyCallback) + expect(removeEventListener).toHaveBeenCalledTimes(2) + expect(removeEventListener).toHaveBeenCalledWith('change', firstProxyCallback) + expect(removeEventListener).toHaveBeenCalledWith('change', secondProxyCallback) + }) + + test('should remove all proxy callbacks for the specified callback', () => { + const addEventListener = jest.fn() + const removeEventListener = jest.fn() + Object.defineProperty(navigator, 'connection', { + configurable: true, + value: { + addEventListener, + removeEventListener + } + }) + const callback = jest.fn() + + // 指定业务回调注销时,也要移除该回调重复注册产生的全部代理回调。 + onNetworkStatusChange(callback) + onNetworkStatusChange(callback) + offNetworkStatusChange(callback) + + expect(removeEventListener).toHaveBeenCalledTimes(2) + addEventListener.mock.calls.forEach(([, proxyCallback]) => { + expect(removeEventListener).toHaveBeenCalledWith('change', proxyCallback) + }) + }) +}) diff --git a/packages/api-proxy/__tests__/web/route.spec.js b/packages/api-proxy/__tests__/web/route.spec.js new file mode 100644 index 0000000000..1bfe550a32 --- /dev/null +++ b/packages/api-proxy/__tests__/web/route.spec.js @@ -0,0 +1,102 @@ +import { + navigateTo, + redirectTo, + reLaunch, + switchTab +} from '../../src/platform/api/route/index.web' + +function createRouter () { + return { + currentRoute: { + path: '/pages/current/index', + query: {} + }, + history: { + current: {} + }, + stack: [{}], + match: jest.fn(url => ({ path: url })), + push: jest.fn((location, onComplete) => onComplete()), + replace: jest.fn((location, onComplete) => onComplete()), + go: jest.fn() + } +} + +function createCallbacks () { + return { + success: jest.fn(), + fail: jest.fn(), + complete: jest.fn() + } +} + +describe('Web route APIs', () => { + beforeEach(() => { + global.__tabBarPagesMap = { + 'pages/tab/index': true + } + global.__mpxRouter = createRouter() + }) + + afterEach(() => { + delete global.__tabBarPagesMap + delete global.__mpxRouter + }) + + describe('invalid route target', () => { + test.each([ + ['navigateTo', navigateTo, 'push'], + ['redirectTo', redirectTo, 'replace'] + ])('%s should stop after rejecting a tabBar page', (name, api, routerMethod) => { + const callbacks = createCallbacks() + + api(Object.assign({ + url: '/pages/tab/index' + }, callbacks)) + + expect(callbacks.fail).toHaveBeenCalledTimes(1) + expect(callbacks.success).not.toHaveBeenCalled() + expect(callbacks.complete).toHaveBeenCalledTimes(1) + expect(global.__mpxRouter[routerMethod]).not.toHaveBeenCalled() + }) + + test('switchTab should stop after rejecting a non-tabBar page', () => { + const callbacks = createCallbacks() + + switchTab(Object.assign({ + url: '/pages/detail/index' + }, callbacks)) + + expect(callbacks.fail).toHaveBeenCalledTimes(1) + expect(callbacks.success).not.toHaveBeenCalled() + expect(callbacks.complete).toHaveBeenCalledTimes(1) + expect(global.__mpxRouter.replace).not.toHaveBeenCalled() + }) + }) + + describe('single callback settlement', () => { + test('reLaunch should invoke success and complete only once', () => { + const callbacks = createCallbacks() + + reLaunch(Object.assign({ + url: '/pages/detail/index' + }, callbacks)) + + expect(callbacks.success).toHaveBeenCalledTimes(1) + expect(callbacks.fail).not.toHaveBeenCalled() + expect(callbacks.complete).toHaveBeenCalledTimes(1) + }) + + test('switchTab should invoke success and complete only once', () => { + const callbacks = createCallbacks() + + switchTab(Object.assign({ + url: '/pages/tab/index' + }, callbacks)) + + expect(callbacks.success).toHaveBeenCalledTimes(1) + expect(callbacks.fail).not.toHaveBeenCalled() + expect(callbacks.complete).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/api-proxy/__tests__/web/socket-task.spec.js b/packages/api-proxy/__tests__/web/socket-task.spec.js new file mode 100644 index 0000000000..87404b5632 --- /dev/null +++ b/packages/api-proxy/__tests__/web/socket-task.spec.js @@ -0,0 +1,27 @@ +import SocketTask from '../../src/platform/api/socket/SocketTask' + +describe('SocketTask', () => { + const NativeWebSocket = global.WebSocket + + afterEach(() => { + global.WebSocket = NativeWebSocket + }) + + test('reports the actual CloseEvent after close is requested', () => { + const close = jest.fn() + global.WebSocket = jest.fn(function () { + this.close = close + this.readyState = 1 + }) + const socketTask = new SocketTask('ws://localhost') + const onClose = jest.fn() + + socketTask.onClose(onClose) + socketTask.close({ code: 1000, reason: 'normal closure' }) + // close 入参只是关闭请求,回调应以服务端最终返回的 CloseEvent 为准。 + socketTask._socket.onclose({ code: 1006, reason: '' }) + + expect(close).toHaveBeenCalledWith(1000, 'normal closure') + expect(onClose).toHaveBeenCalledWith({ code: 1006, reason: '' }) + }) +}) diff --git a/packages/api-proxy/__tests__/web/tab-bar.spec.js b/packages/api-proxy/__tests__/web/tab-bar.spec.js new file mode 100644 index 0000000000..d2591b91eb --- /dev/null +++ b/packages/api-proxy/__tests__/web/tab-bar.spec.js @@ -0,0 +1,40 @@ +import { + setTabBarItem, + setTabBarStyle, + showTabBar, + hideTabBar +} from '../../src/platform/api/tab-bar/index.web' + +describe('Web tabBar APIs', () => { + afterEach(() => { + delete global.__tabBar + }) + + test.each([ + ['setTabBarStyle', setTabBarStyle, { color: '#000000' }], + ['setTabBarItem', setTabBarItem, { index: 0, text: 'home' }], + ['showTabBar', showTabBar, {}], + ['hideTabBar', hideTabBar, {}] + ])('%s should not invoke fail after success', (name, api, options) => { + global.__tabBar = { + custom: false, + isShow: true, + list: [{}] + } + const success = jest.fn() + const fail = jest.fn() + const complete = jest.fn() + + api(Object.assign({}, options, { + success, + fail, + complete + })) + + expect(success).toHaveBeenCalledWith({ + errMsg: `${name}:ok` + }) + expect(fail).not.toHaveBeenCalled() + expect(complete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/api-proxy/__tests__/web/window.spec.js b/packages/api-proxy/__tests__/web/window.spec.js new file mode 100644 index 0000000000..1bf60d4975 --- /dev/null +++ b/packages/api-proxy/__tests__/web/window.spec.js @@ -0,0 +1,38 @@ +import { + offWindowResize, + onWindowResize +} from '../../src/platform/api/window/index.web' + +describe('Web window resize', () => { + afterEach(() => { + offWindowResize() + }) + + test('should remove the specified callback', () => { + const removedCallback = jest.fn() + const retainedCallback = jest.fn() + onWindowResize(removedCallback) + onWindowResize(retainedCallback) + + // 传入具体回调时,只移除对应监听。 + offWindowResize(removedCallback) + window.dispatchEvent(new Event('resize')) + + expect(removedCallback).not.toHaveBeenCalled() + expect(retainedCallback).toHaveBeenCalledTimes(1) + }) + + test('should remove all callbacks without callback', () => { + const firstCallback = jest.fn() + const secondCallback = jest.fn() + onWindowResize(firstCallback) + onWindowResize(secondCallback) + + // 不传回调时,后续 resize 不应再通知任何已注册回调。 + offWindowResize() + window.dispatchEvent(new Event('resize')) + + expect(firstCallback).not.toHaveBeenCalled() + expect(secondCallback).not.toHaveBeenCalled() + }) +}) diff --git a/packages/api-proxy/src/platform/api/app/index.web.js b/packages/api-proxy/src/platform/api/app/index.web.js index 7feb4c345c..3e3ee675f5 100644 --- a/packages/api-proxy/src/platform/api/app/index.web.js +++ b/packages/api-proxy/src/platform/api/app/index.web.js @@ -24,6 +24,10 @@ function onUnhandledRejection (callback) { } function offUnhandledRejection (callback) { + if (callback == null) { + global.__mpxAppCbs.rejection.length = 0 + return + } off(global.__mpxAppCbs.rejection, callback) } @@ -34,6 +38,10 @@ function onError (callback) { } function offError (callback) { + if (callback == null) { + global.__mpxAppCbs.error.length = 0 + return + } off(global.__mpxAppCbs.error, callback) } @@ -44,6 +52,10 @@ function onAppShow (callback) { } function offAppShow (callback) { + if (callback == null) { + global.__mpxAppCbs.show.length = 0 + return + } off(global.__mpxAppCbs.show, callback) } @@ -54,6 +66,10 @@ function onAppHide (callback) { } function offAppHide (callback) { + if (callback == null) { + global.__mpxAppCbs.hide.length = 0 + return + } off(global.__mpxAppCbs.hide, callback) } @@ -65,6 +81,10 @@ function onLazyLoadError (callback) { function offLazyLoadError (callback) { if (isReact) { + if (callback == null) { + global.__mpxAppCbs.lazyLoad.length = 0 + return + } off(global.__mpxAppCbs.lazyLoad, callback) } } diff --git a/packages/api-proxy/src/platform/api/audio/index.web.js b/packages/api-proxy/src/platform/api/audio/index.web.js index d8c1a853b0..c8ca3b1f8b 100644 --- a/packages/api-proxy/src/platform/api/audio/index.web.js +++ b/packages/api-proxy/src/platform/api/audio/index.web.js @@ -12,9 +12,16 @@ export const createInnerAudioContext = () => { __audio.pause = () => audio.pause() + const _stopCbs = [] + __audio.stop = () => { - __audio.pause() - __audio.seek(0) + _stopping = true // 打开屏蔽开关,后续 pause 事件的 wrapper 会看到这个标志 + audio.pause() + audio.currentTime = 0 + setTimeout(() => { + _stopping = false // pause 事件已经派发完,关掉开关 + _stopCbs.forEach(cb => cb()) + }, 0) } __audio.seek = value => { @@ -49,27 +56,63 @@ export const createInnerAudioContext = () => { 'Seeking', 'TimeUpdate', 'Waiting', - 'Stop', 'Error' ] - const eventListeners = [ - ['on', audio.addEventListener], - ['off', audio.removeEventListener] - ] + + let _stopping = false + + const eventCallbacks = {} eventNames.forEach(eventName => { - eventListeners.forEach(([eventNameItem, listenerFn]) => { - Object.defineProperty(__audio, `${eventNameItem}${eventName}`, { - get () { - return (callback = audio.cb) => { - if (eventNameItem !== 'off') { - audio.cb = callback + const nativeName = eventName.toLowerCase() + eventCallbacks[nativeName] = [] + + Object.defineProperty(__audio, `on${eventName}`, { + get () { + return (cb) => { + const wrapper = nativeName === 'pause' + ? (e) => { if (!_stopping) cb(e) } + : cb + eventCallbacks[nativeName].push({ cb, wrapper }) + audio.addEventListener(nativeName, wrapper) + } + } + }) + + Object.defineProperty(__audio, `off${eventName}`, { + get () { + return (cb) => { + if (cb == null) { + eventCallbacks[nativeName].forEach(({ wrapper }) => audio.removeEventListener(nativeName, wrapper)) + eventCallbacks[nativeName] = [] + } else { + const idx = eventCallbacks[nativeName].findIndex(item => item.cb === cb) + if (idx > -1) { + audio.removeEventListener(nativeName, eventCallbacks[nativeName][idx].wrapper) + eventCallbacks[nativeName].splice(idx, 1) } - // debugger - return listenerFn.call(audio, eventName.toLowerCase(), audio.cb) } } - }) + } }) }) + + Object.defineProperty(__audio, 'onStop', { + get () { + return (cb) => { _stopCbs.push(cb) } + } + }) + Object.defineProperty(__audio, 'offStop', { + get () { + return (cb) => { + if (cb == null) { + _stopCbs.length = 0 + } else { + const idx = _stopCbs.indexOf(cb) + if (idx > -1) _stopCbs.splice(idx, 1) + } + } + } + }) + return __audio } diff --git a/packages/api-proxy/src/platform/api/ble-connection/index.ios.js b/packages/api-proxy/src/platform/api/ble-connection/index.ios.js index a463949451..39dc54cc44 100644 --- a/packages/api-proxy/src/platform/api/ble-connection/index.ios.js +++ b/packages/api-proxy/src/platform/api/ble-connection/index.ios.js @@ -179,7 +179,7 @@ function closeBluetoothAdapter (options = {}) { complete(result) } catch (error) { const result = { - errMsg: 'closeBluetoothAdapter:fail ' + error.message + errMsg: 'closeBluetoothAdapter:fail ' + (error?.message != null ? error.message : '') } fail(result) complete(result) @@ -197,7 +197,7 @@ function startBluetoothDevicesDiscovery (options = {}) { } = options if (!bleManagerInitialized) { - commonFailHandler('startBluetoothDevicesDiscovery:fail', fail, complete, 'ble adapter hans\'t been opened or ble is unavailable.') + commonFailHandler('startBluetoothDevicesDiscovery:fail', fail, complete, 'ble adapter hasn\'t been opened or ble is unavailable.') return } DiscoverPeripheralSubscription = BleManager.onDiscoverPeripheral((device) => { @@ -257,7 +257,7 @@ function stopBluetoothDevicesDiscovery (options = {}) { const { success = noop, fail = noop, complete = noop } = options if (!bleManagerInitialized) { - commonFailHandler('stopBluetoothDevicesDiscovery:fail', fail, complete, 'ble adapter hans\'t been opened or ble is unavailable.') + commonFailHandler('stopBluetoothDevicesDiscovery:fail', fail, complete, 'ble adapter hasn\'t been opened or ble is unavailable.') return } removeBluetoothDevicesDiscovery() @@ -397,7 +397,7 @@ function getBluetoothDevices (options = {}) { // 该能力只是获取应用级 const { success = noop, fail = noop, complete = noop } = options if (!bleManagerInitialized) { const result = { - errMsg: 'getBluetoothDevices:fail ble adapter hans\'t been opened or ble is unavailable.' + errMsg: 'getBluetoothDevices:fail ble adapter hasn\'t been opened or ble is unavailable.' } fail(result) complete(result) @@ -576,15 +576,15 @@ function offBLECharacteristicValueChange () { function setBLEMTU (options = {}) { const BleManager = require('react-native-ble-manager').default const { deviceId, mtu, success = noop, fail = noop, complete = noop } = options - if (!deviceId && !mtu) { + if (deviceId == null && mtu == null) { commonFailHandler('setBLEMTU:fail', fail, complete, 'parameter error: parameter.deviceId should be String instead of Undefined;parameter.mtu should be Number instead of Undefined;') return } - if (!mtu) { + if (mtu == null) { commonFailHandler('setBLEMTU:fail', fail, complete, 'parameter error: parameter.mtu should be Number instead of Undefined;') return } - if (!deviceId) { + if (deviceId == null) { commonFailHandler('setBLEMTU:fail', fail, complete, 'parameter error: parameter.deviceId should be String instead of Undefined;') return } diff --git a/packages/api-proxy/src/platform/api/camera/rnCamera.js b/packages/api-proxy/src/platform/api/camera/rnCamera.js index 1f524f7049..5f44413ee4 100644 --- a/packages/api-proxy/src/platform/api/camera/rnCamera.js +++ b/packages/api-proxy/src/platform/api/camera/rnCamera.js @@ -10,10 +10,10 @@ export default class CameraContext { const { zoom, success = noop, fail = noop, complete = noop } = options try { if (this.camera.setZoom) { + this.camera.setZoom(zoom) const result = { errMsg: 'setZoom:ok' } success(result) complete(result) - this.camera.setZoom(zoom) } else { const result = { errMsg: 'setZoom:fail camera instance not found' diff --git a/packages/api-proxy/src/platform/api/create-intersection-observer/IntersectionObserver.js b/packages/api-proxy/src/platform/api/create-intersection-observer/IntersectionObserver.js index b7c880ab07..56f8ffaaa9 100644 --- a/packages/api-proxy/src/platform/api/create-intersection-observer/IntersectionObserver.js +++ b/packages/api-proxy/src/platform/api/create-intersection-observer/IntersectionObserver.js @@ -1,8 +1,6 @@ import { nextTick } from '../next-tick' import { parseDataset, warn } from '@mpxjs/utils' -let isInit = true - class WebIntersectionObserver { constructor (_component, options) { this._component = _component @@ -14,6 +12,7 @@ class WebIntersectionObserver { this._rootMargin = '' this._disconnected = false this._minThreshold = this.getMinThreshold() + this._isFirst = true } initObserver () { @@ -25,7 +24,7 @@ class WebIntersectionObserver { return new IntersectionObserver((entries, observer) => { const initialRatio = this._options.initialRatio || 0 entries.forEach(entry => { - if (!isInit || (isInit && (entry.intersectionRatio !== initialRatio && (this._minThreshold <= entry.intersectionRatio)))) { + if (!this._isFirst || (this._isFirst && (entry.intersectionRatio !== initialRatio && (this._minThreshold <= entry.intersectionRatio)))) { Object.defineProperties(entry, { id: { get () { @@ -57,7 +56,7 @@ class WebIntersectionObserver { this._callback && this._callback(entry) } }) - isInit = false + this._isFirst = false }, { root: this._root || null, rootMargin: this._rootMargin, diff --git a/packages/api-proxy/src/platform/api/device/network/onNetworkStatusChange.js b/packages/api-proxy/src/platform/api/device/network/onNetworkStatusChange.js index 36623d9eb9..a31197943b 100644 --- a/packages/api-proxy/src/platform/api/device/network/onNetworkStatusChange.js +++ b/packages/api-proxy/src/platform/api/device/network/onNetworkStatusChange.js @@ -8,7 +8,7 @@ if (isBrowser) { oldObserveList.forEach(fn => fn({ isConnected: false, networkType: 'none' })) }) window.addEventListener('online', () => { - oldObserveList.forEach(fn => fn({ isConnected: true, networkType: 'unknow' })) + oldObserveList.forEach(fn => fn({ isConnected: true, networkType: 'unknown' })) }) } @@ -25,7 +25,9 @@ export function onNetworkStatusChange (callbackFn) { networkType: isConnected ? evt.currentTarget.effectiveType : 'none' }) } - fnMap.set(callbackFn, proxyCallback) + const proxyCallbacks = fnMap.get(callbackFn) || new Set() + proxyCallbacks.add(proxyCallback) + fnMap.set(callbackFn, proxyCallbacks) navigator.connection.addEventListener('change', proxyCallback) } else { typeof callbackFn === 'function' && oldObserveList.add(callbackFn) @@ -37,8 +39,25 @@ export function offNetworkStatusChange (callbackFn) { throwSSRWarning('offNetworkStatusChange API is running in non browser environments') return } + if (callbackFn == null) { + // 不传 callback 时清除所有监听 + fnMap.forEach((proxyCallbacks) => { + if (navigator.connection) { + proxyCallbacks.forEach(proxyCallback => { + navigator.connection.removeEventListener('change', proxyCallback) + }) + } + }) + fnMap.clear() + oldObserveList.clear() + return + } if (navigator.connection) { - navigator.connection.removeEventListener('change', fnMap.get(callbackFn)) + const proxyCallbacks = fnMap.get(callbackFn) + proxyCallbacks && proxyCallbacks.forEach(proxyCallback => { + navigator.connection.removeEventListener('change', proxyCallback) + }) + fnMap.delete(callbackFn) } else { oldObserveList.has(callbackFn) && oldObserveList.delete(callbackFn) } diff --git a/packages/api-proxy/src/platform/api/device/network/rnNetwork.js b/packages/api-proxy/src/platform/api/device/network/rnNetwork.js index 96617bd69a..9daaa284ff 100644 --- a/packages/api-proxy/src/platform/api/device/network/rnNetwork.js +++ b/packages/api-proxy/src/platform/api/device/network/rnNetwork.js @@ -24,7 +24,7 @@ const getNetworkType = function (options = {}) { successHandle(result, success, complete) }).catch((err) => { const result = { - errMsg: err.message + errMsg: `getNetworkType:fail ${err.message}` } failHandle(result, fail, complete) }) @@ -45,7 +45,11 @@ const onNetworkStatusChange = function (callback) { const offNetworkStatusChange = function (callback) { if (callback && typeof callback === 'function') { _callbacks.delete(callback) - } else if (callback === undefined) { + if (!_callbacks.size) { + _unsubscribe && _unsubscribe() + _unsubscribe = null + } + } else if (callback == null) { _callbacks.clear() _unsubscribe && _unsubscribe() _unsubscribe = null diff --git a/packages/api-proxy/src/platform/api/device/wifi/index.ios.js b/packages/api-proxy/src/platform/api/device/wifi/index.ios.js index 6c2b1c13a4..9a342d9a52 100644 --- a/packages/api-proxy/src/platform/api/device/wifi/index.ios.js +++ b/packages/api-proxy/src/platform/api/device/wifi/index.ios.js @@ -78,7 +78,6 @@ function stopWifi (options = {}) { return } startWifiReady = false - wifiListListeners.length = 0 const result = { errMsg: 'stopWifi:ok' } @@ -137,14 +136,15 @@ function getWifiList (options = {}) { } function onGetWifiList (callback) { - if (!startWifiReady && wifiListListeners.indexOf(callback) > -1) { + if (wifiListListeners.indexOf(callback) > -1) { return } wifiListListeners.push(callback) } function offGetWifiList (callback) { - if (!startWifiReady) { + if (callback == null) { + wifiListListeners.length = 0 return } const index = wifiListListeners.indexOf(callback) diff --git a/packages/api-proxy/src/platform/api/keyboard/index.ios.js b/packages/api-proxy/src/platform/api/keyboard/index.ios.js index 611eb03292..f3b169d826 100644 --- a/packages/api-proxy/src/platform/api/keyboard/index.ios.js +++ b/packages/api-proxy/src/platform/api/keyboard/index.ios.js @@ -1,6 +1,8 @@ import { Keyboard } from 'react-native' import { successHandle, failHandle } from '../../../common/js' let hasListener = false +let keyboardShowSubscription +let keyboardHideSubscription const callbacks = [] function keyboardShowListener (e) { @@ -11,22 +13,15 @@ function keyboardShowListener (e) { })) } function keyboardHideListener (e) { - const endCoordinates = e.endCoordinates || {} - let height - if (__mpx_mode__ === 'ios') { - height = 0 - } else { - height = endCoordinates.height - } // eslint-disable-next-line node/no-callback-literal callbacks.forEach(cb => cb({ - height + height: 0 })) } const onKeyboardHeightChange = function (callback) { if (!hasListener) { - Keyboard.addListener('keyboardDidShow', keyboardShowListener) - Keyboard.addListener('keyboardDidHide', keyboardHideListener) + keyboardShowSubscription = Keyboard.addListener('keyboardDidShow', keyboardShowListener) + keyboardHideSubscription = Keyboard.addListener('keyboardDidHide', keyboardHideListener) hasListener = true } callbacks.push(callback) @@ -36,10 +31,13 @@ const offKeyboardHeightChange = function (callback) { if (index > -1) { callbacks.splice(index, 1) } - if (callbacks.length === 0) { - Keyboard.removeAllListeners('keyboardDidShow') - Keyboard.removeAllListeners('keyboardDidHide') - hasListener = false + if (callbacks.length === 0 || callback == null) { + callbacks.length = 0 + if (hasListener) { + keyboardShowSubscription.remove() + keyboardHideSubscription.remove() + hasListener = false + } } } @@ -50,7 +48,7 @@ const hideKeyboard = function (options = {}) { const result = { errMsg: 'hideKeyboard:ok' } successHandle(result, success, complete) } catch (err) { - const result = { errMsg: err.message } + const result = { errMsg: `hideKeyboard:fail ${err.message}` } failHandle(result, fail, complete) } } diff --git a/packages/api-proxy/src/platform/api/location/index.web.js b/packages/api-proxy/src/platform/api/location/index.web.js index 23fe942abc..306117a812 100644 --- a/packages/api-proxy/src/platform/api/location/index.web.js +++ b/packages/api-proxy/src/platform/api/location/index.web.js @@ -10,7 +10,7 @@ const getLocation = function (options = {}) { errMsg: 'getLocation:ok', latitude: coords.latitude, longitude: coords.longitude, - speed: coords.accuracy + speed: coords.speed } defineUnsupportedProps(result, ['horizontalAccuracy', 'verticalAccuracy']) successHandle(result, success, complete) diff --git a/packages/api-proxy/src/platform/api/route/index.web.js b/packages/api-proxy/src/platform/api/route/index.web.js index e41a946048..e521f70b20 100644 --- a/packages/api-proxy/src/platform/api/route/index.web.js +++ b/packages/api-proxy/src/platform/api/route/index.web.js @@ -13,6 +13,7 @@ function redirectTo (options = {}) { if (isTabBarPage(options.url, router)) { const res = { errMsg: 'redirectTo:fail can not redirectTo a tabBar page' } failHandle(res, options.fail, options.complete) + return } router.__mpxAction = { type: 'redirect' } if (routeCount === 0 && router.currentRoute.query.routeCount) routeCount = router.currentRoute.query.routeCount @@ -45,6 +46,7 @@ function navigateTo (options = {}) { if (isTabBarPage(options.url, router)) { const res = { errMsg: 'navigateTo:fail can not navigateTo a tabBar page' } failHandle(res, options.fail, options.complete) + return } const finalPath = resolvePath(options.url, router.currentRoute.path).slice(1) const eventChannel = new EventChannel() @@ -138,8 +140,10 @@ function reLaunch (options = {}) { } ) } - const res = { errMsg: 'reLaunch:ok' } - successHandle(res, options.success, options.complete) + if (delta > 0) { + const res = { errMsg: 'reLaunch:ok' } + successHandle(res, options.success, options.complete) + } } } @@ -156,6 +160,7 @@ function switchTab (options = {}) { if (!isTabBarPage(options.url, router)) { const res = { errMsg: 'switchTab:fail can not switch to no-tabBar page!' } failHandle(res, options.fail, options.complete) + return } router.__mpxAction = { type: 'switch', @@ -180,6 +185,7 @@ function switchTab (options = {}) { failHandle(res, options.fail, options.complete) } ) + return } } const res = { errMsg: 'switchTab:ok' } diff --git a/packages/api-proxy/src/platform/api/set-navigation-bar/index.ali.js b/packages/api-proxy/src/platform/api/set-navigation-bar/index.ali.js index a71a31e5bf..43cac50862 100644 --- a/packages/api-proxy/src/platform/api/set-navigation-bar/index.ali.js +++ b/packages/api-proxy/src/platform/api/set-navigation-bar/index.ali.js @@ -2,7 +2,7 @@ import { ENV_OBJ, changeOpts, handleSuccess } from '../../../common/js' function setNavigationBarTitle (options = {}) { handleSuccess(options, res => { - return changeOpts(res, {}, { errMsg: 'setScreenBrightness:ok' }) + return changeOpts(res, {}, { errMsg: 'setNavigationBarTitle:ok' }) }) if (ENV_OBJ.canIUse('setNavigationBarTitle')) { return ENV_OBJ.setNavigationBarTitle(options) diff --git a/packages/api-proxy/src/platform/api/set-navigation-bar/index.web.js b/packages/api-proxy/src/platform/api/set-navigation-bar/index.web.js index 198ac4b945..43d7a1b7c3 100644 --- a/packages/api-proxy/src/platform/api/set-navigation-bar/index.web.js +++ b/packages/api-proxy/src/platform/api/set-navigation-bar/index.web.js @@ -19,10 +19,13 @@ function setNavigationBarColor (options = {}) { return } const { backgroundColor, success, complete } = options - const meta = document.createElement('meta') - meta.setAttribute('name', 'theme-color') + let meta = document.querySelector('meta[name="theme-color"]') + if (!meta) { + meta = document.createElement('meta') + meta.setAttribute('name', 'theme-color') + document.head.appendChild(meta) + } meta.setAttribute('content', backgroundColor) - document.head.appendChild(meta) successHandle({ errMsg: 'setNavigationBarColor:ok' }, success, complete) } diff --git a/packages/api-proxy/src/platform/api/socket/SocketTask.js b/packages/api-proxy/src/platform/api/socket/SocketTask.js index fa6cb517ae..f4fb28513f 100644 --- a/packages/api-proxy/src/platform/api/socket/SocketTask.js +++ b/packages/api-proxy/src/platform/api/socket/SocketTask.js @@ -4,17 +4,20 @@ import { type } from '@mpxjs/utils' const socketTasks = new Set() class SocketTask { - constructor (url, protocols) { + constructor (url, protocols, header) { this._openCb = null this._closeCb = null this._messageCb = null this._errorCb = null - this._closeData = null - if (protocols && protocols.length > 0) { - this._socket = new window.WebSocket(url, protocols) + if (header) { + this._socket = new WebSocket(url, protocols, { + headers: header + }) + } else if (protocols && protocols.length > 0) { + this._socket = new WebSocket(url, protocols) } else { - this._socket = new window.WebSocket(url) + this._socket = new WebSocket(url) } this.addListener(this._socket) socketTasks.add(this._socket) @@ -57,12 +60,8 @@ class SocketTask { close (options = {}) { const { code = 1000, reason = '', success, fail, complete } = options - this._closeData = { - code, - reason - } try { - this._socket.close() + this._socket.close(code, reason) const res = { errMsg: 'closeSocket:ok' } successHandle(res, success, complete) } catch (err) { @@ -89,11 +88,7 @@ class SocketTask { if (typeof this._closeCb !== 'function') { return } - if (this._closeData) { - this._closeCb(event) - } else { - this._closeCb({ code: event.code, reason: event.reason }) - } + this._closeCb({ code: event.code, reason: event.reason }) } } diff --git a/packages/api-proxy/src/platform/api/socket/index.ios.js b/packages/api-proxy/src/platform/api/socket/index.ios.js index cddf8e7080..6e63401a36 100644 --- a/packages/api-proxy/src/platform/api/socket/index.ios.js +++ b/packages/api-proxy/src/platform/api/socket/index.ios.js @@ -1 +1,27 @@ -export * from './index.web' +import { successHandle, failHandle } from '../../../common/js' +import SocketTask from './SocketTask' + +function connectSocket (options = { url: '' }) { + const { url, protocols, header, success, fail, complete } = options + + try { + const socketTask = new SocketTask(url, protocols, header) + successHandle({ errMsg: 'connectSocket:ok' }, success, complete) + return socketTask + } catch (e) { + failHandle({ errMsg: `connectSocket:fail ${e}` }, fail, complete) + } +} + +export { + sendSocketMessage, + closeSocket, + onSocketOpen, + onSocketError, + onSocketMessage, + onSocketClose +} from './index.web' + +export { + connectSocket +} diff --git a/packages/api-proxy/src/platform/api/socket/index.web.js b/packages/api-proxy/src/platform/api/socket/index.web.js index 3b5137affe..e16f5a7f14 100644 --- a/packages/api-proxy/src/platform/api/socket/index.web.js +++ b/packages/api-proxy/src/platform/api/socket/index.web.js @@ -1,11 +1,7 @@ -import { warn, successHandle, failHandle, isBrowser, throwSSRWarning } from '../../../common/js' +import { warn, successHandle, failHandle } from '../../../common/js' import SocketTask from './SocketTask' function connectSocket (options = { url: '' }) { - if (!isBrowser) { - throwSSRWarning('connectSocket API is running in non browser environments') - return - } const { url, protocols, success, fail, complete } = options try { diff --git a/packages/api-proxy/src/platform/api/storage/index.web.js b/packages/api-proxy/src/platform/api/storage/index.web.js index d0c15a579a..3fd121b549 100644 --- a/packages/api-proxy/src/platform/api/storage/index.web.js +++ b/packages/api-proxy/src/platform/api/storage/index.web.js @@ -126,7 +126,7 @@ function removeStorage (options = { key: '' }) { function removeStorageSync (key) { if (!isBrowser) { - throwSSRWarning('getStorageInfoSync API is running in non browser environments') + throwSSRWarning('removeStorageSync API is running in non browser environments') return } window.localStorage.removeItem(key) diff --git a/packages/api-proxy/src/platform/api/storage/rnStorage.js b/packages/api-proxy/src/platform/api/storage/rnStorage.js index 213b7afaab..9f1e5ea385 100644 --- a/packages/api-proxy/src/platform/api/storage/rnStorage.js +++ b/packages/api-proxy/src/platform/api/storage/rnStorage.js @@ -67,7 +67,7 @@ function getStorageInfo (options = {}) { AsyncStorage.getAllKeys((err, keys) => { if (err) { const result = { - errMsg: `getStorage:fail ${err}` + errMsg: `getStorageInfo:fail ${err}` } failHandle(result, fail, complete) return diff --git a/packages/api-proxy/src/platform/api/tab-bar/index.web.js b/packages/api-proxy/src/platform/api/tab-bar/index.web.js index add213c535..0a2267bcc3 100644 --- a/packages/api-proxy/src/platform/api/tab-bar/index.web.js +++ b/packages/api-proxy/src/platform/api/tab-bar/index.web.js @@ -21,7 +21,7 @@ function setTabBarStyle (options = {}) { } if (resolved) { - successHandle(resolved, options.success, options.complete) + return successHandle(resolved, options.success, options.complete) } failHandle(rejected, options.fail, options.complete) } @@ -50,7 +50,7 @@ function setTabBarItem (options = {}) { } if (resolved) { - successHandle(resolved, options.success, options.complete) + return successHandle(resolved, options.success, options.complete) } failHandle(rejected, options.fail, options.complete) } @@ -66,7 +66,7 @@ function showTabBar (options = {}) { } if (resolved) { - successHandle(resolved, options.success, options.complete) + return successHandle(resolved, options.success, options.complete) } failHandle(rejected, options.fail, options.complete) } @@ -82,7 +82,7 @@ function hideTabBar (options = {}) { } if (resolved) { - successHandle(resolved, options.success, options.complete) + return successHandle(resolved, options.success, options.complete) } failHandle(rejected, options.fail, options.complete) } diff --git a/packages/api-proxy/src/platform/api/window/index.web.js b/packages/api-proxy/src/platform/api/window/index.web.js index b85f0c13c8..5cae440309 100644 --- a/packages/api-proxy/src/platform/api/window/index.web.js +++ b/packages/api-proxy/src/platform/api/window/index.web.js @@ -19,6 +19,11 @@ function onWindowResize (callback) { } function offWindowResize (callback) { + if (callback == null) { + // 不传 callback 时清除所有监听 + callbacks.length = 0 + return + } const index = callbacks.indexOf(callback) if (index > -1) { callbacks.splice(index, 1) diff --git a/packages/api-proxy/src/platform/api/window/rnWindow.js b/packages/api-proxy/src/platform/api/window/rnWindow.js index 026d127777..a86d715488 100644 --- a/packages/api-proxy/src/platform/api/window/rnWindow.js +++ b/packages/api-proxy/src/platform/api/window/rnWindow.js @@ -19,6 +19,7 @@ const addListener = function () { const removeListener = function () { subscription && subscription.remove() + subscription = null } function onWindowResize (callback) { @@ -29,6 +30,12 @@ function onWindowResize (callback) { } function offWindowResize (callback) { + if (callback == null) { + // 不传 callback 时清除所有监听 + callbacks.length = 0 + removeListener() + return + } const index = callbacks.indexOf(callback) if (index > -1) { callbacks.splice(index, 1)