From 5e5127564cfe8ee171ba80dcd34a240f3ae2eabd Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 14 Jan 2026 13:13:22 +0100 Subject: [PATCH 01/11] feat: add new ConnectivityController --- .github/CODEOWNERS | 1 + README.md | 4 + packages/connectivity-controller/CHANGELOG.md | 14 ++ packages/connectivity-controller/LICENSE | 20 +++ packages/connectivity-controller/README.md | 15 ++ .../connectivity-controller/jest.config.js | 26 +++ packages/connectivity-controller/package.json | 72 ++++++++ .../src/ConnectivityController.test.ts | 160 ++++++++++++++++++ .../src/ConnectivityController.ts | 158 +++++++++++++++++ packages/connectivity-controller/src/index.ts | 12 ++ .../tsconfig.build.json | 13 ++ .../connectivity-controller/tsconfig.json | 8 + packages/connectivity-controller/typedoc.json | 7 + teams.json | 1 + tsconfig.build.json | 1 + tsconfig.json | 1 + yarn.lock | 18 ++ 17 files changed, 531 insertions(+) create mode 100644 packages/connectivity-controller/CHANGELOG.md create mode 100644 packages/connectivity-controller/LICENSE create mode 100644 packages/connectivity-controller/README.md create mode 100644 packages/connectivity-controller/jest.config.js create mode 100644 packages/connectivity-controller/package.json create mode 100644 packages/connectivity-controller/src/ConnectivityController.test.ts create mode 100644 packages/connectivity-controller/src/ConnectivityController.ts create mode 100644 packages/connectivity-controller/src/index.ts create mode 100644 packages/connectivity-controller/tsconfig.build.json create mode 100644 packages/connectivity-controller/tsconfig.json create mode 100644 packages/connectivity-controller/typedoc.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index dd322799ccc..f97fef01a56 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -68,6 +68,7 @@ /packages/base-controller @MetaMask/core-platform /packages/build-utils @MetaMask/core-platform /packages/composable-controller @MetaMask/core-platform +/packages/connectivity-controller @MetaMask/core-platform /packages/controller-utils @MetaMask/core-platform /packages/error-reporting-service @MetaMask/core-platform /packages/eth-json-rpc-middleware @MetaMask/core-platform diff --git a/README.md b/README.md index 92d9c369396..48d06d72c30 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/chain-agnostic-permission`](packages/chain-agnostic-permission) - [`@metamask/claims-controller`](packages/claims-controller) - [`@metamask/composable-controller`](packages/composable-controller) +- [`@metamask/connectivity-controller`](packages/connectivity-controller) - [`@metamask/controller-utils`](packages/controller-utils) - [`@metamask/core-backend`](packages/core-backend) - [`@metamask/delegation-controller`](packages/delegation-controller) @@ -111,6 +112,7 @@ linkStyle default opacity:0.5 chain_agnostic_permission(["@metamask/chain-agnostic-permission"]); claims_controller(["@metamask/claims-controller"]); composable_controller(["@metamask/composable-controller"]); + connectivity_controller(["@metamask/connectivity-controller"]); controller_utils(["@metamask/controller-utils"]); core_backend(["@metamask/core-backend"]); delegation_controller(["@metamask/delegation-controller"]); @@ -231,6 +233,8 @@ linkStyle default opacity:0.5 composable_controller --> base_controller; composable_controller --> messenger; composable_controller --> json_rpc_engine; + connectivity_controller --> base_controller; + connectivity_controller --> messenger; core_backend --> accounts_controller; core_backend --> controller_utils; core_backend --> keyring_controller; diff --git a/packages/connectivity-controller/CHANGELOG.md b/packages/connectivity-controller/CHANGELOG.md new file mode 100644 index 00000000000..a7b62e188a8 --- /dev/null +++ b/packages/connectivity-controller/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release ([#7623](https://github.com/MetaMask/core/pull/7623)) + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/connectivity-controller/LICENSE b/packages/connectivity-controller/LICENSE new file mode 100644 index 00000000000..c8a0ff6be3a --- /dev/null +++ b/packages/connectivity-controller/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/connectivity-controller/README.md b/packages/connectivity-controller/README.md new file mode 100644 index 00000000000..fb6eabf17b6 --- /dev/null +++ b/packages/connectivity-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/connectivity-controller` + +ConnectivityController stores the device's internet connectivity status. + +## Installation + +`yarn add @metamask/connectivity-controller` + +or + +`npm install @metamask/connectivity-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/connectivity-controller/jest.config.js b/packages/connectivity-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/connectivity-controller/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/connectivity-controller/package.json b/packages/connectivity-controller/package.json new file mode 100644 index 00000000000..404f78b55e8 --- /dev/null +++ b/packages/connectivity-controller/package.json @@ -0,0 +1,72 @@ +{ + "name": "@metamask/connectivity-controller", + "version": "0.0.0", + "description": "ConnectivityController stores the device's internet connectivity status", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/connectivity-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/connectivity-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/connectivity-controller", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/base-controller": "^9.0.0", + "@metamask/messenger": "^0.3.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/connectivity-controller/src/ConnectivityController.test.ts b/packages/connectivity-controller/src/ConnectivityController.test.ts new file mode 100644 index 00000000000..f8d3a896276 --- /dev/null +++ b/packages/connectivity-controller/src/ConnectivityController.test.ts @@ -0,0 +1,160 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import type { ConnectivityControllerMessenger } from './ConnectivityController'; +import { ConnectivityController } from './ConnectivityController'; + +describe('ConnectivityController', () => { + describe('constructor', () => { + it('accepts initial state', async () => { + const givenState = {}; + + await withController( + { options: { state: givenState } }, + ({ controller }) => { + expect(controller.state).toStrictEqual(givenState); + }, + ); + }); + + it('fills in missing initial state with defaults', async () => { + await withController(({ controller }) => { + expect(controller.state).toMatchInlineSnapshot(`Object {}`); + }); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`Object {}`); + }); + }); + + it('includes expected state in state logs', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(`Object {}`); + }); + }); + + it('persists expected state', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(`Object {}`); + }); + }); + + it('exposes expected state to UI', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(`Object {}`); + }); + }); + }); +}); + +/** + * The type of the messenger populated with all external actions and events + * required by the controller under test. + */ +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * The callback that `withController` calls. + */ +type WithControllerCallback = (payload: { + controller: ConnectivityController; + rootMessenger: RootMessenger; + controllerMessenger: ConnectivityControllerMessenger; +}) => Promise | ReturnValue; + +/** + * The options that `withController` takes. + */ +type WithControllerOptions = { + options: Partial[0]>; +}; + +/** + * Constructs the messenger populated with all external actions and events + * required by the controller under test. + * + * @returns The root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for the controller under test. + * + * @param rootMessenger - The root messenger, with all external actions and + * events required by the controller's messenger. + * @returns The controller-specific messenger. + */ +function getMessenger( + rootMessenger: RootMessenger, +): ConnectivityControllerMessenger { + return new Messenger({ + namespace: 'ConnectivityController', + parent: rootMessenger, + }); +} + +/** + * Wrap tests for the controller under test by ensuring that the controller is + * created ahead of time and then safely destroyed afterward as needed. + * + * @param args - Either a function, or an options bag + a function. The options + * bag contains arguments for the controller constructor. All constructor + * arguments are optional and will be filled in with defaults in as needed + * (including `messenger`). The function is called with the instantiated + * controller, root messenger, and controller messenger. + * @returns The same return value as the given function. + */ +async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + const rootMessenger = getRootMessenger(); + const controllerMessenger = getMessenger(rootMessenger); + const controller = new ConnectivityController({ + messenger: controllerMessenger, + ...options, + }); + return await testFunction({ controller, rootMessenger, controllerMessenger }); +} diff --git a/packages/connectivity-controller/src/ConnectivityController.ts b/packages/connectivity-controller/src/ConnectivityController.ts new file mode 100644 index 00000000000..76ba2bc4a7d --- /dev/null +++ b/packages/connectivity-controller/src/ConnectivityController.ts @@ -0,0 +1,158 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; + +/** + * The name of the {@link ConnectivityController}, used to namespace the + * controller's actions and events and to namespace the controller's state data + * when composed with other controllers. + */ +export const controllerName = 'ConnectivityController'; + +/** + * Describes the shape of the state object for {@link ConnectivityController}. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type ConnectivityControllerState = { + // Empty state - to be implemented +}; + +/** + * The metadata for each property in {@link ConnectivityControllerState}. + */ +const connectivityControllerMetadata = + {} satisfies StateMetadata; + +/** + * Constructs the default {@link ConnectivityController} state. This allows + * consumers to provide a partial state object when initializing the controller + * and also helps in constructing complete state objects for this controller in + * tests. + * + * @returns The default {@link ConnectivityController} state. + */ +export function getDefaultConnectivityControllerState(): ConnectivityControllerState { + return {}; +} + +/** + * Retrieves the state of the {@link ConnectivityController}. + */ +export type ConnectivityControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + ConnectivityControllerState +>; + +/** + * Actions that {@link ConnectivityControllerMessenger} exposes to other consumers. + */ +export type ConnectivityControllerActions = + ConnectivityControllerGetStateAction; + +/** + * Actions from other messengers that {@link ConnectivityControllerMessenger} calls. + */ +type AllowedActions = never; + +/** + * Published when the state of {@link ConnectivityController} changes. + */ +export type ConnectivityControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + ConnectivityControllerState +>; + +/** + * Events that {@link ConnectivityControllerMessenger} exposes to other consumers. + */ +export type ConnectivityControllerEvents = + ConnectivityControllerStateChangeEvent; + +/** + * Events from other messengers that {@link ConnectivityControllerMessenger} subscribes + * to. + */ +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events accessed by + * {@link ConnectivityController}. + */ +export type ConnectivityControllerMessenger = Messenger< + typeof controllerName, + ConnectivityControllerActions | AllowedActions, + ConnectivityControllerEvents | AllowedEvents +>; + +/** + * `ConnectivityController` manages connectivity for the application. + * + * @example + * + * ``` ts + * import { Messenger } from '@metamask/messenger'; + * import type { + * ConnectivityControllerActions, + * ConnectivityControllerEvents, + * } from '@metamask/connectivity-controller'; + * import { ConnectivityController } from '@metamask/connectivity-controller'; + * + * const rootMessenger = new Messenger< + * 'Root', + * ConnectivityControllerActions, + * ConnectivityControllerEvents + * >({ namespace: 'Root' }); + * const connectivityControllerMessenger = new Messenger< + * 'ConnectivityController', + * ConnectivityControllerActions, + * ConnectivityControllerEvents, + * typeof rootMessenger, + * >({ + * namespace: 'ConnectivityController', + * parent: rootMessenger, + * }); + * // Instantiate the controller to register its actions on the messenger + * new ConnectivityController({ + * messenger: connectivityControllerMessenger, + * }); + * + * const connectivityControllerState = await rootMessenger.call( + * 'ConnectivityController:getState', + * ); + * ``` + */ +export class ConnectivityController extends BaseController< + typeof controllerName, + ConnectivityControllerState, + ConnectivityControllerMessenger +> { + /** + * Constructs a new {@link ConnectivityController}. + * + * @param args - The arguments to this controller. + * @param args.messenger - The messenger suited for this controller. + * @param args.state - The desired state with which to initialize this + * controller. Missing properties will be filled in with defaults. + */ + constructor({ + messenger, + state, + }: { + messenger: ConnectivityControllerMessenger; + state?: Partial; + }) { + super({ + messenger, + metadata: connectivityControllerMetadata, + name: controllerName, + state: { + ...getDefaultConnectivityControllerState(), + ...state, + }, + }); + } +} diff --git a/packages/connectivity-controller/src/index.ts b/packages/connectivity-controller/src/index.ts new file mode 100644 index 00000000000..8f04773025d --- /dev/null +++ b/packages/connectivity-controller/src/index.ts @@ -0,0 +1,12 @@ +export type { + ConnectivityControllerState, + ConnectivityControllerGetStateAction, + ConnectivityControllerActions, + ConnectivityControllerStateChangeEvent, + ConnectivityControllerEvents, + ConnectivityControllerMessenger, +} from './ConnectivityController'; +export { + ConnectivityController, + getDefaultConnectivityControllerState, +} from './ConnectivityController'; diff --git a/packages/connectivity-controller/tsconfig.build.json b/packages/connectivity-controller/tsconfig.build.json new file mode 100644 index 00000000000..931c4d6594b --- /dev/null +++ b/packages/connectivity-controller/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/connectivity-controller/tsconfig.json b/packages/connectivity-controller/tsconfig.json new file mode 100644 index 00000000000..68c3ddfc2cd --- /dev/null +++ b/packages/connectivity-controller/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [{ "path": "../base-controller" }, { "path": "../messenger" }], + "include": ["../../types", "./src"] +} diff --git a/packages/connectivity-controller/typedoc.json b/packages/connectivity-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/connectivity-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index 0d749eecf5d..dc7dc9d2f93 100644 --- a/teams.json +++ b/teams.json @@ -37,6 +37,7 @@ "metamask/base-controller": "team-core-platform", "metamask/build-utils": "team-core-platform", "metamask/composable-controller": "team-core-platform", + "metamask/connectivity-controller": "team-core-platform", "metamask/controller-utils": "team-core-platform", "metamask/error-reporting-service": "team-core-platform", "metamask/messenger": "team-core-platform", diff --git a/tsconfig.build.json b/tsconfig.build.json index a76d9f8d06b..5231360a42a 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -21,6 +21,7 @@ { "path": "./packages/bridge-controller/tsconfig.build.json" }, { "path": "./packages/bridge-status-controller/tsconfig.build.json" }, { "path": "./packages/build-utils/tsconfig.build.json" }, + { "path": "./packages/connectivity-controller/tsconfig.build.json" }, { "path": "./packages/chain-agnostic-permission/tsconfig.build.json" }, { "path": "./packages/claims-controller/tsconfig.build.json" }, { "path": "./packages/composable-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index abe30f5662f..c818b2a0693 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ { "path": "./packages/bridge-controller" }, { "path": "./packages/bridge-status-controller" }, { "path": "./packages/build-utils" }, + { "path": "./packages/connectivity-controller" }, { "path": "./packages/chain-agnostic-permission" }, { "path": "./packages/claims-controller" }, { "path": "./packages/composable-controller" }, diff --git a/yarn.lock b/yarn.lock index 5da153fd94f..82462656171 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2964,6 +2964,24 @@ __metadata: languageName: unknown linkType: soft +"@metamask/connectivity-controller@workspace:packages/connectivity-controller": + version: 0.0.0-use.local + resolution: "@metamask/connectivity-controller@workspace:packages/connectivity-controller" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^27.4.1" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/contract-metadata@npm:^2.4.0": version: 2.5.0 resolution: "@metamask/contract-metadata@npm:2.5.0" From 9e00b40b5353f90ce196b8c944c1a412997d1caf Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 14 Jan 2026 14:44:53 +0100 Subject: [PATCH 02/11] feat: add initial controller implementation --- .../src/ConnectivityController.test.ts | 281 +++++++++++++++++- .../src/ConnectivityController.ts | 117 +++++--- packages/connectivity-controller/src/index.ts | 2 + packages/connectivity-controller/src/types.ts | 43 +++ 4 files changed, 384 insertions(+), 59 deletions(-) create mode 100644 packages/connectivity-controller/src/types.ts diff --git a/packages/connectivity-controller/src/ConnectivityController.test.ts b/packages/connectivity-controller/src/ConnectivityController.test.ts index f8d3a896276..99d7ca36d2b 100644 --- a/packages/connectivity-controller/src/ConnectivityController.test.ts +++ b/packages/connectivity-controller/src/ConnectivityController.test.ts @@ -7,25 +7,111 @@ import type { } from '@metamask/messenger'; import type { ConnectivityControllerMessenger } from './ConnectivityController'; -import { ConnectivityController } from './ConnectivityController'; +import { + ConnectivityController, + controllerName, +} from './ConnectivityController'; +import { ConnectivityStatus } from './types'; +import type { ConnectivityService } from './types'; + +/** + * A test implementation of ConnectivityService. + * Allows manual control of connectivity status via setStatus. + */ +class TestConnectivityService implements ConnectivityService { + #status: ConnectivityStatus; + + #onConnectivityChangeCallbacks: ((status: ConnectivityStatus) => void)[] = []; + + constructor(initialStatus: ConnectivityStatus = ConnectivityStatus.Online) { + this.#status = initialStatus; + } + + getStatus(): ConnectivityStatus { + return this.#status; + } + + onConnectivityChange(callback: (status: ConnectivityStatus) => void): void { + this.#onConnectivityChangeCallbacks.push(callback); + } + + setStatus(status: ConnectivityStatus): void { + this.#status = status; + this.#onConnectivityChangeCallbacks.forEach((callback) => callback(status)); + } + + destroy(): void { + this.#onConnectivityChangeCallbacks = []; + } +} describe('ConnectivityController', () => { describe('constructor', () => { - it('accepts initial state', async () => { - const givenState = {}; + it('uses service initial state when online', async () => { + const mockService: ConnectivityService = { + getStatus: jest.fn().mockReturnValue(ConnectivityStatus.Online), + onConnectivityChange: jest.fn(), + destroy: jest.fn(), + }; await withController( - { options: { state: givenState } }, + { options: { connectivityService: mockService } }, ({ controller }) => { - expect(controller.state).toStrictEqual(givenState); + expect(controller.state.connectivityStatus).toBe( + ConnectivityStatus.Online, + ); + expect(mockService.getStatus).toHaveBeenCalled(); }, ); }); - it('fills in missing initial state with defaults', async () => { - await withController(({ controller }) => { - expect(controller.state).toMatchInlineSnapshot(`Object {}`); - }); + it('uses service initial state when offline', async () => { + const mockService: ConnectivityService = { + getStatus: jest.fn().mockReturnValue(ConnectivityStatus.Offline), + onConnectivityChange: jest.fn(), + destroy: jest.fn(), + }; + + await withController( + { options: { connectivityService: mockService } }, + ({ controller }) => { + expect(controller.state.connectivityStatus).toBe( + ConnectivityStatus.Offline, + ); + }, + ); + }); + + it('subscribes to service connectivity changes', async () => { + const mockService: ConnectivityService = { + getStatus: jest.fn().mockReturnValue(ConnectivityStatus.Online), + onConnectivityChange: jest.fn(), + destroy: jest.fn(), + }; + + await withController( + { options: { connectivityService: mockService } }, + () => { + expect(mockService.onConnectivityChange).toHaveBeenCalledWith( + expect.any(Function), + ); + }, + ); + }); + + it('has correct name property', async () => { + const mockService: ConnectivityService = { + getStatus: jest.fn().mockReturnValue(ConnectivityStatus.Online), + onConnectivityChange: jest.fn(), + destroy: jest.fn(), + }; + + await withController( + { options: { connectivityService: mockService } }, + ({ controller }) => { + expect(controller.name).toBe(controllerName); + }, + ); }); }); @@ -38,7 +124,11 @@ describe('ConnectivityController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(` + Object { + "connectivityStatus": "online", + } + `); }); }); @@ -50,7 +140,11 @@ describe('ConnectivityController', () => { controller.metadata, 'includeInStateLogs', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(` + Object { + "connectivityStatus": "online", + } + `); }); }); @@ -74,10 +168,167 @@ describe('ConnectivityController', () => { controller.metadata, 'usedInUi', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(` + Object { + "connectivityStatus": "online", + } + `); }); }); }); + + describe('service callbacks', () => { + it('updates state when service reports offline', async () => { + const mockOnConnectivityChange = jest.fn(); + const mockService: ConnectivityService = { + getStatus: jest.fn().mockReturnValue(ConnectivityStatus.Online), + onConnectivityChange: mockOnConnectivityChange, + destroy: jest.fn(), + }; + + await withController( + { options: { connectivityService: mockService } }, + ({ controller }) => { + expect(controller.state.connectivityStatus).toBe( + ConnectivityStatus.Online, + ); + + // Get the callback that was passed to onConnectivityChange + const capturedCallback = mockOnConnectivityChange.mock + .calls[0]?.[0] as + | ((status: ConnectivityStatus) => void) + | undefined; + expect(capturedCallback).toBeDefined(); + + // Simulate service reporting offline + capturedCallback?.(ConnectivityStatus.Offline); + + expect(controller.state.connectivityStatus).toBe( + ConnectivityStatus.Offline, + ); + }, + ); + }); + + it('updates state when service reports online', async () => { + const mockOnConnectivityChange = jest.fn(); + const mockService: ConnectivityService = { + getStatus: jest.fn().mockReturnValue(ConnectivityStatus.Offline), + onConnectivityChange: mockOnConnectivityChange, + destroy: jest.fn(), + }; + + await withController( + { options: { connectivityService: mockService } }, + ({ controller }) => { + expect(controller.state.connectivityStatus).toBe( + ConnectivityStatus.Offline, + ); + + // Get the callback that was passed to onConnectivityChange + const capturedCallback = mockOnConnectivityChange.mock + .calls[0]?.[0] as + | ((status: ConnectivityStatus) => void) + | undefined; + expect(capturedCallback).toBeDefined(); + + // Simulate service reporting online + capturedCallback?.(ConnectivityStatus.Online); + + expect(controller.state.connectivityStatus).toBe( + ConnectivityStatus.Online, + ); + }, + ); + }); + + it('emits stateChange event when status changes', async () => { + const mockOnConnectivityChange = jest.fn(); + const mockService: ConnectivityService = { + getStatus: jest.fn().mockReturnValue(ConnectivityStatus.Online), + onConnectivityChange: mockOnConnectivityChange, + destroy: jest.fn(), + }; + + await withController( + { options: { connectivityService: mockService } }, + ({ controllerMessenger }) => { + const eventHandler = jest.fn(); + controllerMessenger.subscribe( + `${controllerName}:stateChange`, + eventHandler, + ); + + // Get the callback that was passed to onConnectivityChange + const capturedCallback = mockOnConnectivityChange.mock + .calls[0]?.[0] as + | ((status: ConnectivityStatus) => void) + | undefined; + expect(capturedCallback).toBeDefined(); + + capturedCallback?.(ConnectivityStatus.Offline); + + expect(eventHandler).toHaveBeenCalledWith( + { connectivityStatus: ConnectivityStatus.Offline }, + expect.any(Array), + ); + }, + ); + }); + + it('does not emit event when status does not change', async () => { + const mockOnConnectivityChange = jest.fn(); + const mockService: ConnectivityService = { + getStatus: jest.fn().mockReturnValue(ConnectivityStatus.Online), + onConnectivityChange: mockOnConnectivityChange, + destroy: jest.fn(), + }; + + await withController( + { options: { connectivityService: mockService } }, + ({ controllerMessenger }) => { + const eventHandler = jest.fn(); + controllerMessenger.subscribe( + `${controllerName}:stateChange`, + eventHandler, + ); + + // Get the callback that was passed to onConnectivityChange + const capturedCallback = mockOnConnectivityChange.mock + .calls[0]?.[0] as + | ((status: ConnectivityStatus) => void) + | undefined; + expect(capturedCallback).toBeDefined(); + + // Report online when already online + capturedCallback?.(ConnectivityStatus.Online); + + expect(eventHandler).not.toHaveBeenCalled(); + }, + ); + }); + }); + + describe('with TestConnectivityService', () => { + it('updates state when service.setStatus is called', async () => { + const service = new TestConnectivityService(); + + await withController( + { options: { connectivityService: service } }, + ({ controller }) => { + expect(controller.state.connectivityStatus).toBe( + ConnectivityStatus.Online, + ); + + service.setStatus(ConnectivityStatus.Offline); + + expect(controller.state.connectivityStatus).toBe( + ConnectivityStatus.Offline, + ); + }, + ); + }); + }); }); /** @@ -139,8 +390,8 @@ function getMessenger( * @param args - Either a function, or an options bag + a function. The options * bag contains arguments for the controller constructor. All constructor * arguments are optional and will be filled in with defaults in as needed - * (including `messenger`). The function is called with the instantiated - * controller, root messenger, and controller messenger. + * (including `messenger` and `connectivityService`). The function is called + * with the instantiated controller, root messenger, and controller messenger. * @returns The same return value as the given function. */ async function withController( @@ -152,8 +403,10 @@ async function withController( args.length === 2 ? args : [{}, args[0]]; const rootMessenger = getRootMessenger(); const controllerMessenger = getMessenger(rootMessenger); + const defaultService = new TestConnectivityService(); const controller = new ConnectivityController({ messenger: controllerMessenger, + connectivityService: defaultService, ...options, }); return await testFunction({ controller, rootMessenger, controllerMessenger }); diff --git a/packages/connectivity-controller/src/ConnectivityController.ts b/packages/connectivity-controller/src/ConnectivityController.ts index 76ba2bc4a7d..1fd9be1fcca 100644 --- a/packages/connectivity-controller/src/ConnectivityController.ts +++ b/packages/connectivity-controller/src/ConnectivityController.ts @@ -6,6 +6,9 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; +import { ConnectivityStatus } from './types'; +import type { ConnectivityService } from './types'; + /** * The name of the {@link ConnectivityController}, used to namespace the * controller's actions and events and to namespace the controller's state data @@ -14,18 +17,27 @@ import type { Messenger } from '@metamask/messenger'; export const controllerName = 'ConnectivityController'; /** - * Describes the shape of the state object for {@link ConnectivityController}. + * State for the {@link ConnectivityController}. */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type export type ConnectivityControllerState = { - // Empty state - to be implemented + /** + * The current device connectivity status. + * Named with 'connectivity' prefix to avoid conflicts when state is flattened in Redux. + */ + connectivityStatus: ConnectivityStatus; }; /** * The metadata for each property in {@link ConnectivityControllerState}. */ -const connectivityControllerMetadata = - {} satisfies StateMetadata; +const connectivityControllerMetadata = { + connectivityStatus: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, +} satisfies StateMetadata; /** * Constructs the default {@link ConnectivityController} state. This allows @@ -36,7 +48,9 @@ const connectivityControllerMetadata = * @returns The default {@link ConnectivityController} state. */ export function getDefaultConnectivityControllerState(): ConnectivityControllerState { - return {}; + return { + connectivityStatus: ConnectivityStatus.Online, + }; } /** @@ -89,41 +103,50 @@ export type ConnectivityControllerMessenger = Messenger< >; /** - * `ConnectivityController` manages connectivity for the application. + * Options for constructing the {@link ConnectivityController}. + */ +export type ConnectivityControllerOptions = { + /** + * The messenger for inter-controller communication. + */ + messenger: ConnectivityControllerMessenger; + + /** + * Connectivity service for platform-specific detection. + * + * The controller subscribes to the service's `onConnectivityChange` + * callback to receive connectivity updates. + * + * Platform implementations: + * - Mobile: Use `NetInfoConnectivityService` with `@react-native-community/netinfo` + * - Extension (same context): Use `BrowserConnectivityService` + * - Extension (cross-context): Use `PassiveConnectivityService` and call + * `setStatus()` from the UI context + */ + connectivityService: ConnectivityService; +}; + +/** + * ConnectivityController stores the device's internet connectivity status. * - * @example + * This controller is platform-agnostic and designed to be used across different + * MetaMask clients (extension, mobile). It requires a `ConnectivityService` to + * be injected, which provides platform-specific connectivity detection. * - * ``` ts - * import { Messenger } from '@metamask/messenger'; - * import type { - * ConnectivityControllerActions, - * ConnectivityControllerEvents, - * } from '@metamask/connectivity-controller'; - * import { ConnectivityController } from '@metamask/connectivity-controller'; + * The controller subscribes to the service's `onConnectivityChange` callback + * and updates its state accordingly. All connectivity updates flow through + * the service, ensuring a single source of truth. * - * const rootMessenger = new Messenger< - * 'Root', - * ConnectivityControllerActions, - * ConnectivityControllerEvents - * >({ namespace: 'Root' }); - * const connectivityControllerMessenger = new Messenger< - * 'ConnectivityController', - * ConnectivityControllerActions, - * ConnectivityControllerEvents, - * typeof rootMessenger, - * >({ - * namespace: 'ConnectivityController', - * parent: rootMessenger, - * }); - * // Instantiate the controller to register its actions on the messenger - * new ConnectivityController({ - * messenger: connectivityControllerMessenger, - * }); + * **Platform implementations:** * - * const connectivityControllerState = await rootMessenger.call( - * 'ConnectivityController:getState', - * ); - * ``` + * - **Mobile:** Inject `NetInfoConnectivityService` using `@react-native-community/netinfo` + * - **Extension:** Inject `PassiveConnectivityService` in the background. + * Status is updated via the `setDeviceConnectivityStatus` API, which is called from: + * - MV3: Offscreen document (where browser events work reliably) + * - MV2: Background page (where browser events work directly) + * + * This controller provides a centralized state for connectivity status, + * enabling the UI and other controllers to adapt when the user goes offline. */ export class ConnectivityController extends BaseController< typeof controllerName, @@ -135,24 +158,28 @@ export class ConnectivityController extends BaseController< * * @param args - The arguments to this controller. * @param args.messenger - The messenger suited for this controller. - * @param args.state - The desired state with which to initialize this - * controller. Missing properties will be filled in with defaults. + * @param args.connectivityService - The connectivity service to use. */ constructor({ messenger, - state, - }: { - messenger: ConnectivityControllerMessenger; - state?: Partial; - }) { + connectivityService, + }: ConnectivityControllerOptions) { + const initialStatus = connectivityService.getStatus(); + super({ messenger, metadata: connectivityControllerMetadata, name: controllerName, state: { ...getDefaultConnectivityControllerState(), - ...state, + connectivityStatus: initialStatus, }, }); + + connectivityService.onConnectivityChange((status) => { + this.update((draftState) => { + draftState.connectivityStatus = status; + }); + }); } } diff --git a/packages/connectivity-controller/src/index.ts b/packages/connectivity-controller/src/index.ts index 8f04773025d..fee79e50fd9 100644 --- a/packages/connectivity-controller/src/index.ts +++ b/packages/connectivity-controller/src/index.ts @@ -6,6 +6,8 @@ export type { ConnectivityControllerEvents, ConnectivityControllerMessenger, } from './ConnectivityController'; +export type { ConnectivityService } from './types'; +export { ConnectivityStatus } from './types'; export { ConnectivityController, getDefaultConnectivityControllerState, diff --git a/packages/connectivity-controller/src/types.ts b/packages/connectivity-controller/src/types.ts new file mode 100644 index 00000000000..48f0771a32e --- /dev/null +++ b/packages/connectivity-controller/src/types.ts @@ -0,0 +1,43 @@ +/** + * Connectivity status constants. + * Used to represent whether the device has internet connectivity. + */ +export const ConnectivityStatus = { + Online: 'online', + Offline: 'offline', +} as const; + +export type ConnectivityStatus = + (typeof ConnectivityStatus)[keyof typeof ConnectivityStatus]; + +/** + * Service interface for platform-specific connectivity detection. + * + * Each platform (extension, mobile) implements this interface using + * platform-specific APIs: + * - Extension: `navigator.onLine` and `online`/`offline` events + * - Mobile: `@react-native-community/netinfo` + * + * The service is injected into the ConnectivityController, which + * subscribes to connectivity changes and updates its state accordingly. + */ +export type ConnectivityService = { + /** + * Returns the current connectivity status. + * + * @returns 'online' if the device is online, 'offline' otherwise. + */ + getStatus(): ConnectivityStatus; + + /** + * Registers a callback to be called when connectivity status changes. + * + * @param callback - Function called with 'online' when online, 'offline' when offline. + */ + onConnectivityChange(callback: (status: ConnectivityStatus) => void): void; + + /** + * Cleans up any resources (event listeners, subscriptions). + */ + destroy(): void; +}; From 9d171f5a60034c219129f29fcbf0b5f928b81efa Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 14 Jan 2026 22:14:30 +0100 Subject: [PATCH 03/11] refactor(connectivity-controller): rename ConnectivityStatus to CONNECTIVITY_STATUSES - Rename ConnectivityStatus constant to CONNECTIVITY_STATUSES - Export ConnectivityStatus as a type instead of enum - Update all references to use CONNECTIVITY_STATUSES - Export CONNECTIVITY_STATUSES from index --- .../src/ConnectivityController.test.ts | 52 ++++++++++--------- .../src/ConnectivityController.ts | 6 +-- packages/connectivity-controller/src/index.ts | 4 +- packages/connectivity-controller/src/types.ts | 4 +- 4 files changed, 34 insertions(+), 32 deletions(-) diff --git a/packages/connectivity-controller/src/ConnectivityController.test.ts b/packages/connectivity-controller/src/ConnectivityController.test.ts index 99d7ca36d2b..cab93b3c321 100644 --- a/packages/connectivity-controller/src/ConnectivityController.test.ts +++ b/packages/connectivity-controller/src/ConnectivityController.test.ts @@ -11,8 +11,8 @@ import { ConnectivityController, controllerName, } from './ConnectivityController'; -import { ConnectivityStatus } from './types'; -import type { ConnectivityService } from './types'; +import { CONNECTIVITY_STATUSES } from './types'; +import type { ConnectivityService, ConnectivityStatus } from './types'; /** * A test implementation of ConnectivityService. @@ -23,7 +23,9 @@ class TestConnectivityService implements ConnectivityService { #onConnectivityChangeCallbacks: ((status: ConnectivityStatus) => void)[] = []; - constructor(initialStatus: ConnectivityStatus = ConnectivityStatus.Online) { + constructor( + initialStatus: ConnectivityStatus = CONNECTIVITY_STATUSES.Online, + ) { this.#status = initialStatus; } @@ -49,7 +51,7 @@ describe('ConnectivityController', () => { describe('constructor', () => { it('uses service initial state when online', async () => { const mockService: ConnectivityService = { - getStatus: jest.fn().mockReturnValue(ConnectivityStatus.Online), + getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), onConnectivityChange: jest.fn(), destroy: jest.fn(), }; @@ -58,7 +60,7 @@ describe('ConnectivityController', () => { { options: { connectivityService: mockService } }, ({ controller }) => { expect(controller.state.connectivityStatus).toBe( - ConnectivityStatus.Online, + CONNECTIVITY_STATUSES.Online, ); expect(mockService.getStatus).toHaveBeenCalled(); }, @@ -67,7 +69,7 @@ describe('ConnectivityController', () => { it('uses service initial state when offline', async () => { const mockService: ConnectivityService = { - getStatus: jest.fn().mockReturnValue(ConnectivityStatus.Offline), + getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Offline), onConnectivityChange: jest.fn(), destroy: jest.fn(), }; @@ -76,7 +78,7 @@ describe('ConnectivityController', () => { { options: { connectivityService: mockService } }, ({ controller }) => { expect(controller.state.connectivityStatus).toBe( - ConnectivityStatus.Offline, + CONNECTIVITY_STATUSES.Offline, ); }, ); @@ -84,7 +86,7 @@ describe('ConnectivityController', () => { it('subscribes to service connectivity changes', async () => { const mockService: ConnectivityService = { - getStatus: jest.fn().mockReturnValue(ConnectivityStatus.Online), + getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), onConnectivityChange: jest.fn(), destroy: jest.fn(), }; @@ -101,7 +103,7 @@ describe('ConnectivityController', () => { it('has correct name property', async () => { const mockService: ConnectivityService = { - getStatus: jest.fn().mockReturnValue(ConnectivityStatus.Online), + getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), onConnectivityChange: jest.fn(), destroy: jest.fn(), }; @@ -181,7 +183,7 @@ describe('ConnectivityController', () => { it('updates state when service reports offline', async () => { const mockOnConnectivityChange = jest.fn(); const mockService: ConnectivityService = { - getStatus: jest.fn().mockReturnValue(ConnectivityStatus.Online), + getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), onConnectivityChange: mockOnConnectivityChange, destroy: jest.fn(), }; @@ -190,7 +192,7 @@ describe('ConnectivityController', () => { { options: { connectivityService: mockService } }, ({ controller }) => { expect(controller.state.connectivityStatus).toBe( - ConnectivityStatus.Online, + CONNECTIVITY_STATUSES.Online, ); // Get the callback that was passed to onConnectivityChange @@ -201,10 +203,10 @@ describe('ConnectivityController', () => { expect(capturedCallback).toBeDefined(); // Simulate service reporting offline - capturedCallback?.(ConnectivityStatus.Offline); + capturedCallback?.(CONNECTIVITY_STATUSES.Offline); expect(controller.state.connectivityStatus).toBe( - ConnectivityStatus.Offline, + CONNECTIVITY_STATUSES.Offline, ); }, ); @@ -213,7 +215,7 @@ describe('ConnectivityController', () => { it('updates state when service reports online', async () => { const mockOnConnectivityChange = jest.fn(); const mockService: ConnectivityService = { - getStatus: jest.fn().mockReturnValue(ConnectivityStatus.Offline), + getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Offline), onConnectivityChange: mockOnConnectivityChange, destroy: jest.fn(), }; @@ -222,7 +224,7 @@ describe('ConnectivityController', () => { { options: { connectivityService: mockService } }, ({ controller }) => { expect(controller.state.connectivityStatus).toBe( - ConnectivityStatus.Offline, + CONNECTIVITY_STATUSES.Offline, ); // Get the callback that was passed to onConnectivityChange @@ -233,10 +235,10 @@ describe('ConnectivityController', () => { expect(capturedCallback).toBeDefined(); // Simulate service reporting online - capturedCallback?.(ConnectivityStatus.Online); + capturedCallback?.(CONNECTIVITY_STATUSES.Online); expect(controller.state.connectivityStatus).toBe( - ConnectivityStatus.Online, + CONNECTIVITY_STATUSES.Online, ); }, ); @@ -245,7 +247,7 @@ describe('ConnectivityController', () => { it('emits stateChange event when status changes', async () => { const mockOnConnectivityChange = jest.fn(); const mockService: ConnectivityService = { - getStatus: jest.fn().mockReturnValue(ConnectivityStatus.Online), + getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), onConnectivityChange: mockOnConnectivityChange, destroy: jest.fn(), }; @@ -266,10 +268,10 @@ describe('ConnectivityController', () => { | undefined; expect(capturedCallback).toBeDefined(); - capturedCallback?.(ConnectivityStatus.Offline); + capturedCallback?.(CONNECTIVITY_STATUSES.Offline); expect(eventHandler).toHaveBeenCalledWith( - { connectivityStatus: ConnectivityStatus.Offline }, + { connectivityStatus: CONNECTIVITY_STATUSES.Offline }, expect.any(Array), ); }, @@ -279,7 +281,7 @@ describe('ConnectivityController', () => { it('does not emit event when status does not change', async () => { const mockOnConnectivityChange = jest.fn(); const mockService: ConnectivityService = { - getStatus: jest.fn().mockReturnValue(ConnectivityStatus.Online), + getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), onConnectivityChange: mockOnConnectivityChange, destroy: jest.fn(), }; @@ -301,7 +303,7 @@ describe('ConnectivityController', () => { expect(capturedCallback).toBeDefined(); // Report online when already online - capturedCallback?.(ConnectivityStatus.Online); + capturedCallback?.(CONNECTIVITY_STATUSES.Online); expect(eventHandler).not.toHaveBeenCalled(); }, @@ -317,13 +319,13 @@ describe('ConnectivityController', () => { { options: { connectivityService: service } }, ({ controller }) => { expect(controller.state.connectivityStatus).toBe( - ConnectivityStatus.Online, + CONNECTIVITY_STATUSES.Online, ); - service.setStatus(ConnectivityStatus.Offline); + service.setStatus(CONNECTIVITY_STATUSES.Offline); expect(controller.state.connectivityStatus).toBe( - ConnectivityStatus.Offline, + CONNECTIVITY_STATUSES.Offline, ); }, ); diff --git a/packages/connectivity-controller/src/ConnectivityController.ts b/packages/connectivity-controller/src/ConnectivityController.ts index 1fd9be1fcca..8928f31ccf1 100644 --- a/packages/connectivity-controller/src/ConnectivityController.ts +++ b/packages/connectivity-controller/src/ConnectivityController.ts @@ -6,8 +6,8 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; -import { ConnectivityStatus } from './types'; -import type { ConnectivityService } from './types'; +import { CONNECTIVITY_STATUSES } from './types'; +import type { ConnectivityService, ConnectivityStatus } from './types'; /** * The name of the {@link ConnectivityController}, used to namespace the @@ -49,7 +49,7 @@ const connectivityControllerMetadata = { */ export function getDefaultConnectivityControllerState(): ConnectivityControllerState { return { - connectivityStatus: ConnectivityStatus.Online, + connectivityStatus: CONNECTIVITY_STATUSES.Online, }; } diff --git a/packages/connectivity-controller/src/index.ts b/packages/connectivity-controller/src/index.ts index fee79e50fd9..d171cb0bf1b 100644 --- a/packages/connectivity-controller/src/index.ts +++ b/packages/connectivity-controller/src/index.ts @@ -6,8 +6,8 @@ export type { ConnectivityControllerEvents, ConnectivityControllerMessenger, } from './ConnectivityController'; -export type { ConnectivityService } from './types'; -export { ConnectivityStatus } from './types'; +export type { ConnectivityService, ConnectivityStatus } from './types'; +export { CONNECTIVITY_STATUSES } from './types'; export { ConnectivityController, getDefaultConnectivityControllerState, diff --git a/packages/connectivity-controller/src/types.ts b/packages/connectivity-controller/src/types.ts index 48f0771a32e..c64d94b3024 100644 --- a/packages/connectivity-controller/src/types.ts +++ b/packages/connectivity-controller/src/types.ts @@ -2,13 +2,13 @@ * Connectivity status constants. * Used to represent whether the device has internet connectivity. */ -export const ConnectivityStatus = { +export const CONNECTIVITY_STATUSES = { Online: 'online', Offline: 'offline', } as const; export type ConnectivityStatus = - (typeof ConnectivityStatus)[keyof typeof ConnectivityStatus]; + (typeof CONNECTIVITY_STATUSES)[keyof typeof CONNECTIVITY_STATUSES]; /** * Service interface for platform-specific connectivity detection. From eda16e41722eec8742eb145f6139160134dd0b96 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 14 Jan 2026 22:20:10 +0100 Subject: [PATCH 04/11] refactor(connectivity-controller): twik jsdoc --- .../src/ConnectivityController.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/connectivity-controller/src/ConnectivityController.ts b/packages/connectivity-controller/src/ConnectivityController.ts index 8928f31ccf1..eeb63d71675 100644 --- a/packages/connectivity-controller/src/ConnectivityController.ts +++ b/packages/connectivity-controller/src/ConnectivityController.ts @@ -113,15 +113,6 @@ export type ConnectivityControllerOptions = { /** * Connectivity service for platform-specific detection. - * - * The controller subscribes to the service's `onConnectivityChange` - * callback to receive connectivity updates. - * - * Platform implementations: - * - Mobile: Use `NetInfoConnectivityService` with `@react-native-community/netinfo` - * - Extension (same context): Use `BrowserConnectivityService` - * - Extension (cross-context): Use `PassiveConnectivityService` and call - * `setStatus()` from the UI context */ connectivityService: ConnectivityService; }; @@ -137,14 +128,6 @@ export type ConnectivityControllerOptions = { * and updates its state accordingly. All connectivity updates flow through * the service, ensuring a single source of truth. * - * **Platform implementations:** - * - * - **Mobile:** Inject `NetInfoConnectivityService` using `@react-native-community/netinfo` - * - **Extension:** Inject `PassiveConnectivityService` in the background. - * Status is updated via the `setDeviceConnectivityStatus` API, which is called from: - * - MV3: Offscreen document (where browser events work reliably) - * - MV2: Background page (where browser events work directly) - * * This controller provides a centralized state for connectivity status, * enabling the UI and other controllers to adapt when the user goes offline. */ From 85ecacbae6d8790d7be4727fee5923f74b5e4d99 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 14 Jan 2026 22:49:52 +0100 Subject: [PATCH 05/11] refactor(connectivity-controller): rename ConnectivityService to ConnectivityAdapter --- .../src/ConnectivityController.test.ts | 58 +++++++++---------- .../src/ConnectivityController.ts | 20 +++---- packages/connectivity-controller/src/index.ts | 2 +- packages/connectivity-controller/src/types.ts | 12 +--- 4 files changed, 43 insertions(+), 49 deletions(-) diff --git a/packages/connectivity-controller/src/ConnectivityController.test.ts b/packages/connectivity-controller/src/ConnectivityController.test.ts index cab93b3c321..caeb87f728f 100644 --- a/packages/connectivity-controller/src/ConnectivityController.test.ts +++ b/packages/connectivity-controller/src/ConnectivityController.test.ts @@ -12,13 +12,13 @@ import { controllerName, } from './ConnectivityController'; import { CONNECTIVITY_STATUSES } from './types'; -import type { ConnectivityService, ConnectivityStatus } from './types'; +import type { ConnectivityAdapter, ConnectivityStatus } from './types'; /** - * A test implementation of ConnectivityService. + * A test implementation of ConnectivityAdapter. * Allows manual control of connectivity status via setStatus. */ -class TestConnectivityService implements ConnectivityService { +class TestConnectivityAdapter implements ConnectivityAdapter { #status: ConnectivityStatus; #onConnectivityChangeCallbacks: ((status: ConnectivityStatus) => void)[] = []; @@ -50,32 +50,32 @@ class TestConnectivityService implements ConnectivityService { describe('ConnectivityController', () => { describe('constructor', () => { it('uses service initial state when online', async () => { - const mockService: ConnectivityService = { + const mockAdapter: ConnectivityAdapter = { getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), onConnectivityChange: jest.fn(), destroy: jest.fn(), }; await withController( - { options: { connectivityService: mockService } }, + { options: { connectivityAdapter: mockAdapter } }, ({ controller }) => { expect(controller.state.connectivityStatus).toBe( CONNECTIVITY_STATUSES.Online, ); - expect(mockService.getStatus).toHaveBeenCalled(); + expect(mockAdapter.getStatus).toHaveBeenCalled(); }, ); }); it('uses service initial state when offline', async () => { - const mockService: ConnectivityService = { + const mockAdapter: ConnectivityAdapter = { getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Offline), onConnectivityChange: jest.fn(), destroy: jest.fn(), }; await withController( - { options: { connectivityService: mockService } }, + { options: { connectivityAdapter: mockAdapter } }, ({ controller }) => { expect(controller.state.connectivityStatus).toBe( CONNECTIVITY_STATUSES.Offline, @@ -85,16 +85,16 @@ describe('ConnectivityController', () => { }); it('subscribes to service connectivity changes', async () => { - const mockService: ConnectivityService = { + const mockAdapter: ConnectivityAdapter = { getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), onConnectivityChange: jest.fn(), destroy: jest.fn(), }; await withController( - { options: { connectivityService: mockService } }, + { options: { connectivityAdapter: mockAdapter } }, () => { - expect(mockService.onConnectivityChange).toHaveBeenCalledWith( + expect(mockAdapter.onConnectivityChange).toHaveBeenCalledWith( expect.any(Function), ); }, @@ -102,14 +102,14 @@ describe('ConnectivityController', () => { }); it('has correct name property', async () => { - const mockService: ConnectivityService = { + const mockAdapter: ConnectivityAdapter = { getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), onConnectivityChange: jest.fn(), destroy: jest.fn(), }; await withController( - { options: { connectivityService: mockService } }, + { options: { connectivityAdapter: mockAdapter } }, ({ controller }) => { expect(controller.name).toBe(controllerName); }, @@ -182,14 +182,14 @@ describe('ConnectivityController', () => { describe('service callbacks', () => { it('updates state when service reports offline', async () => { const mockOnConnectivityChange = jest.fn(); - const mockService: ConnectivityService = { + const mockAdapter: ConnectivityAdapter = { getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), onConnectivityChange: mockOnConnectivityChange, destroy: jest.fn(), }; await withController( - { options: { connectivityService: mockService } }, + { options: { connectivityAdapter: mockAdapter } }, ({ controller }) => { expect(controller.state.connectivityStatus).toBe( CONNECTIVITY_STATUSES.Online, @@ -214,14 +214,14 @@ describe('ConnectivityController', () => { it('updates state when service reports online', async () => { const mockOnConnectivityChange = jest.fn(); - const mockService: ConnectivityService = { + const mockAdapter: ConnectivityAdapter = { getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Offline), onConnectivityChange: mockOnConnectivityChange, destroy: jest.fn(), }; await withController( - { options: { connectivityService: mockService } }, + { options: { connectivityAdapter: mockAdapter } }, ({ controller }) => { expect(controller.state.connectivityStatus).toBe( CONNECTIVITY_STATUSES.Offline, @@ -246,14 +246,14 @@ describe('ConnectivityController', () => { it('emits stateChange event when status changes', async () => { const mockOnConnectivityChange = jest.fn(); - const mockService: ConnectivityService = { + const mockAdapter: ConnectivityAdapter = { getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), onConnectivityChange: mockOnConnectivityChange, destroy: jest.fn(), }; await withController( - { options: { connectivityService: mockService } }, + { options: { connectivityAdapter: mockAdapter } }, ({ controllerMessenger }) => { const eventHandler = jest.fn(); controllerMessenger.subscribe( @@ -280,14 +280,14 @@ describe('ConnectivityController', () => { it('does not emit event when status does not change', async () => { const mockOnConnectivityChange = jest.fn(); - const mockService: ConnectivityService = { + const mockAdapter: ConnectivityAdapter = { getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), onConnectivityChange: mockOnConnectivityChange, destroy: jest.fn(), }; await withController( - { options: { connectivityService: mockService } }, + { options: { connectivityAdapter: mockAdapter } }, ({ controllerMessenger }) => { const eventHandler = jest.fn(); controllerMessenger.subscribe( @@ -311,18 +311,18 @@ describe('ConnectivityController', () => { }); }); - describe('with TestConnectivityService', () => { - it('updates state when service.setStatus is called', async () => { - const service = new TestConnectivityService(); + describe('with TestConnectivityAdapter', () => { + it('updates state when adapter.setStatus is called', async () => { + const adapter = new TestConnectivityAdapter(); await withController( - { options: { connectivityService: service } }, + { options: { connectivityAdapter: adapter } }, ({ controller }) => { expect(controller.state.connectivityStatus).toBe( CONNECTIVITY_STATUSES.Online, ); - service.setStatus(CONNECTIVITY_STATUSES.Offline); + adapter.setStatus(CONNECTIVITY_STATUSES.Offline); expect(controller.state.connectivityStatus).toBe( CONNECTIVITY_STATUSES.Offline, @@ -392,7 +392,7 @@ function getMessenger( * @param args - Either a function, or an options bag + a function. The options * bag contains arguments for the controller constructor. All constructor * arguments are optional and will be filled in with defaults in as needed - * (including `messenger` and `connectivityService`). The function is called + * (including `messenger` and `connectivityAdapter`). The function is called * with the instantiated controller, root messenger, and controller messenger. * @returns The same return value as the given function. */ @@ -405,10 +405,10 @@ async function withController( args.length === 2 ? args : [{}, args[0]]; const rootMessenger = getRootMessenger(); const controllerMessenger = getMessenger(rootMessenger); - const defaultService = new TestConnectivityService(); + const defaultAdapter = new TestConnectivityAdapter(); const controller = new ConnectivityController({ messenger: controllerMessenger, - connectivityService: defaultService, + connectivityAdapter: defaultAdapter, ...options, }); return await testFunction({ controller, rootMessenger, controllerMessenger }); diff --git a/packages/connectivity-controller/src/ConnectivityController.ts b/packages/connectivity-controller/src/ConnectivityController.ts index eeb63d71675..63bd8e304aa 100644 --- a/packages/connectivity-controller/src/ConnectivityController.ts +++ b/packages/connectivity-controller/src/ConnectivityController.ts @@ -7,7 +7,7 @@ import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import { CONNECTIVITY_STATUSES } from './types'; -import type { ConnectivityService, ConnectivityStatus } from './types'; +import type { ConnectivityAdapter, ConnectivityStatus } from './types'; /** * The name of the {@link ConnectivityController}, used to namespace the @@ -112,21 +112,21 @@ export type ConnectivityControllerOptions = { messenger: ConnectivityControllerMessenger; /** - * Connectivity service for platform-specific detection. + * Connectivity adapter for platform-specific detection. */ - connectivityService: ConnectivityService; + connectivityAdapter: ConnectivityAdapter; }; /** * ConnectivityController stores the device's internet connectivity status. * * This controller is platform-agnostic and designed to be used across different - * MetaMask clients (extension, mobile). It requires a `ConnectivityService` to + * MetaMask clients (extension, mobile). It requires a `ConnectivityAdapter` to * be injected, which provides platform-specific connectivity detection. * - * The controller subscribes to the service's `onConnectivityChange` callback + * The controller subscribes to the adapter's `onConnectivityChange` callback * and updates its state accordingly. All connectivity updates flow through - * the service, ensuring a single source of truth. + * the adapter, ensuring a single source of truth. * * This controller provides a centralized state for connectivity status, * enabling the UI and other controllers to adapt when the user goes offline. @@ -141,13 +141,13 @@ export class ConnectivityController extends BaseController< * * @param args - The arguments to this controller. * @param args.messenger - The messenger suited for this controller. - * @param args.connectivityService - The connectivity service to use. + * @param args.connectivityAdapter - The connectivity adapter to use. */ constructor({ messenger, - connectivityService, + connectivityAdapter, }: ConnectivityControllerOptions) { - const initialStatus = connectivityService.getStatus(); + const initialStatus = connectivityAdapter.getStatus(); super({ messenger, @@ -159,7 +159,7 @@ export class ConnectivityController extends BaseController< }, }); - connectivityService.onConnectivityChange((status) => { + connectivityAdapter.onConnectivityChange((status) => { this.update((draftState) => { draftState.connectivityStatus = status; }); diff --git a/packages/connectivity-controller/src/index.ts b/packages/connectivity-controller/src/index.ts index d171cb0bf1b..d3306e84e19 100644 --- a/packages/connectivity-controller/src/index.ts +++ b/packages/connectivity-controller/src/index.ts @@ -6,7 +6,7 @@ export type { ConnectivityControllerEvents, ConnectivityControllerMessenger, } from './ConnectivityController'; -export type { ConnectivityService, ConnectivityStatus } from './types'; +export type { ConnectivityAdapter, ConnectivityStatus } from './types'; export { CONNECTIVITY_STATUSES } from './types'; export { ConnectivityController, diff --git a/packages/connectivity-controller/src/types.ts b/packages/connectivity-controller/src/types.ts index c64d94b3024..13b336064fd 100644 --- a/packages/connectivity-controller/src/types.ts +++ b/packages/connectivity-controller/src/types.ts @@ -11,17 +11,11 @@ export type ConnectivityStatus = (typeof CONNECTIVITY_STATUSES)[keyof typeof CONNECTIVITY_STATUSES]; /** - * Service interface for platform-specific connectivity detection. - * + * Adapter interface for platform-specific connectivity detection. * Each platform (extension, mobile) implements this interface using - * platform-specific APIs: - * - Extension: `navigator.onLine` and `online`/`offline` events - * - Mobile: `@react-native-community/netinfo` - * - * The service is injected into the ConnectivityController, which - * subscribes to connectivity changes and updates its state accordingly. + * platform-specific APIs to detect internet connectivity. */ -export type ConnectivityService = { +export type ConnectivityAdapter = { /** * Returns the current connectivity status. * From 16c51b55a5cc3e4435f70715cbecedfe7e04fdde Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 15 Jan 2026 00:13:24 +0100 Subject: [PATCH 06/11] refactor: simplify and remove useless tests --- .../src/ConnectivityController.test.ts | 209 +++--------------- 1 file changed, 25 insertions(+), 184 deletions(-) diff --git a/packages/connectivity-controller/src/ConnectivityController.test.ts b/packages/connectivity-controller/src/ConnectivityController.test.ts index caeb87f728f..c7826c5118a 100644 --- a/packages/connectivity-controller/src/ConnectivityController.test.ts +++ b/packages/connectivity-controller/src/ConnectivityController.test.ts @@ -7,46 +7,10 @@ import type { } from '@metamask/messenger'; import type { ConnectivityControllerMessenger } from './ConnectivityController'; -import { - ConnectivityController, - controllerName, -} from './ConnectivityController'; +import { ConnectivityController } from './ConnectivityController'; import { CONNECTIVITY_STATUSES } from './types'; import type { ConnectivityAdapter, ConnectivityStatus } from './types'; -/** - * A test implementation of ConnectivityAdapter. - * Allows manual control of connectivity status via setStatus. - */ -class TestConnectivityAdapter implements ConnectivityAdapter { - #status: ConnectivityStatus; - - #onConnectivityChangeCallbacks: ((status: ConnectivityStatus) => void)[] = []; - - constructor( - initialStatus: ConnectivityStatus = CONNECTIVITY_STATUSES.Online, - ) { - this.#status = initialStatus; - } - - getStatus(): ConnectivityStatus { - return this.#status; - } - - onConnectivityChange(callback: (status: ConnectivityStatus) => void): void { - this.#onConnectivityChangeCallbacks.push(callback); - } - - setStatus(status: ConnectivityStatus): void { - this.#status = status; - this.#onConnectivityChangeCallbacks.forEach((callback) => callback(status)); - } - - destroy(): void { - this.#onConnectivityChangeCallbacks = []; - } -} - describe('ConnectivityController', () => { describe('constructor', () => { it('uses service initial state when online', async () => { @@ -83,38 +47,6 @@ describe('ConnectivityController', () => { }, ); }); - - it('subscribes to service connectivity changes', async () => { - const mockAdapter: ConnectivityAdapter = { - getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), - onConnectivityChange: jest.fn(), - destroy: jest.fn(), - }; - - await withController( - { options: { connectivityAdapter: mockAdapter } }, - () => { - expect(mockAdapter.onConnectivityChange).toHaveBeenCalledWith( - expect.any(Function), - ); - }, - ); - }); - - it('has correct name property', async () => { - const mockAdapter: ConnectivityAdapter = { - getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), - onConnectivityChange: jest.fn(), - destroy: jest.fn(), - }; - - await withController( - { options: { connectivityAdapter: mockAdapter } }, - ({ controller }) => { - expect(controller.name).toBe(controllerName); - }, - ); - }); }); describe('metadata', () => { @@ -179,32 +111,28 @@ describe('ConnectivityController', () => { }); }); - describe('service callbacks', () => { + describe('when connectivity changes via the adapter', () => { it('updates state when service reports offline', async () => { - const mockOnConnectivityChange = jest.fn(); + let onConnectivityChangeCallback: ( + connectivityStatus: ConnectivityStatus, + ) => void; const mockAdapter: ConnectivityAdapter = { getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), - onConnectivityChange: mockOnConnectivityChange, + onConnectivityChange( + callback: (connectivityStatus: ConnectivityStatus) => void, + ) { + onConnectivityChangeCallback = callback; + }, destroy: jest.fn(), }; - await withController( { options: { connectivityAdapter: mockAdapter } }, ({ controller }) => { expect(controller.state.connectivityStatus).toBe( CONNECTIVITY_STATUSES.Online, ); - - // Get the callback that was passed to onConnectivityChange - const capturedCallback = mockOnConnectivityChange.mock - .calls[0]?.[0] as - | ((status: ConnectivityStatus) => void) - | undefined; - expect(capturedCallback).toBeDefined(); - // Simulate service reporting offline - capturedCallback?.(CONNECTIVITY_STATUSES.Offline); - + onConnectivityChangeCallback(CONNECTIVITY_STATUSES.Offline); expect(controller.state.connectivityStatus).toBe( CONNECTIVITY_STATUSES.Offline, ); @@ -213,123 +141,32 @@ describe('ConnectivityController', () => { }); it('updates state when service reports online', async () => { - const mockOnConnectivityChange = jest.fn(); + let onConnectivityChangeCallback: ( + connectivityStatus: ConnectivityStatus, + ) => void; const mockAdapter: ConnectivityAdapter = { getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Offline), - onConnectivityChange: mockOnConnectivityChange, + onConnectivityChange( + callback: (connectivityStatus: ConnectivityStatus) => void, + ) { + onConnectivityChangeCallback = callback; + }, destroy: jest.fn(), }; - await withController( { options: { connectivityAdapter: mockAdapter } }, ({ controller }) => { expect(controller.state.connectivityStatus).toBe( CONNECTIVITY_STATUSES.Offline, ); - - // Get the callback that was passed to onConnectivityChange - const capturedCallback = mockOnConnectivityChange.mock - .calls[0]?.[0] as - | ((status: ConnectivityStatus) => void) - | undefined; - expect(capturedCallback).toBeDefined(); - // Simulate service reporting online - capturedCallback?.(CONNECTIVITY_STATUSES.Online); - + onConnectivityChangeCallback(CONNECTIVITY_STATUSES.Online); expect(controller.state.connectivityStatus).toBe( CONNECTIVITY_STATUSES.Online, ); }, ); }); - - it('emits stateChange event when status changes', async () => { - const mockOnConnectivityChange = jest.fn(); - const mockAdapter: ConnectivityAdapter = { - getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), - onConnectivityChange: mockOnConnectivityChange, - destroy: jest.fn(), - }; - - await withController( - { options: { connectivityAdapter: mockAdapter } }, - ({ controllerMessenger }) => { - const eventHandler = jest.fn(); - controllerMessenger.subscribe( - `${controllerName}:stateChange`, - eventHandler, - ); - - // Get the callback that was passed to onConnectivityChange - const capturedCallback = mockOnConnectivityChange.mock - .calls[0]?.[0] as - | ((status: ConnectivityStatus) => void) - | undefined; - expect(capturedCallback).toBeDefined(); - - capturedCallback?.(CONNECTIVITY_STATUSES.Offline); - - expect(eventHandler).toHaveBeenCalledWith( - { connectivityStatus: CONNECTIVITY_STATUSES.Offline }, - expect.any(Array), - ); - }, - ); - }); - - it('does not emit event when status does not change', async () => { - const mockOnConnectivityChange = jest.fn(); - const mockAdapter: ConnectivityAdapter = { - getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), - onConnectivityChange: mockOnConnectivityChange, - destroy: jest.fn(), - }; - - await withController( - { options: { connectivityAdapter: mockAdapter } }, - ({ controllerMessenger }) => { - const eventHandler = jest.fn(); - controllerMessenger.subscribe( - `${controllerName}:stateChange`, - eventHandler, - ); - - // Get the callback that was passed to onConnectivityChange - const capturedCallback = mockOnConnectivityChange.mock - .calls[0]?.[0] as - | ((status: ConnectivityStatus) => void) - | undefined; - expect(capturedCallback).toBeDefined(); - - // Report online when already online - capturedCallback?.(CONNECTIVITY_STATUSES.Online); - - expect(eventHandler).not.toHaveBeenCalled(); - }, - ); - }); - }); - - describe('with TestConnectivityAdapter', () => { - it('updates state when adapter.setStatus is called', async () => { - const adapter = new TestConnectivityAdapter(); - - await withController( - { options: { connectivityAdapter: adapter } }, - ({ controller }) => { - expect(controller.state.connectivityStatus).toBe( - CONNECTIVITY_STATUSES.Online, - ); - - adapter.setStatus(CONNECTIVITY_STATUSES.Offline); - - expect(controller.state.connectivityStatus).toBe( - CONNECTIVITY_STATUSES.Offline, - ); - }, - ); - }); }); }); @@ -405,7 +242,11 @@ async function withController( args.length === 2 ? args : [{}, args[0]]; const rootMessenger = getRootMessenger(); const controllerMessenger = getMessenger(rootMessenger); - const defaultAdapter = new TestConnectivityAdapter(); + const defaultAdapter: ConnectivityAdapter = { + getStatus: jest.fn().mockReturnValue(CONNECTIVITY_STATUSES.Online), + onConnectivityChange: jest.fn(), + destroy: jest.fn(), + }; const controller = new ConnectivityController({ messenger: controllerMessenger, connectivityAdapter: defaultAdapter, From faf57288076c8b47246a40c536724fe188373da4 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 14 Jan 2026 16:44:28 +0100 Subject: [PATCH 07/11] feat(network-controller): connect NetworkController to ConnectivityController - Add ConnectivityController:getState action handler requirement - Suppress rpcEndpointUnavailable and rpcEndpointDegraded events when offline - Update NetworkController to check connectivity status before publishing events - Add tests for offline event suppression --- README.md | 1 + packages/network-controller/CHANGELOG.md | 5 + packages/network-controller/package.json | 1 + .../src/NetworkController.ts | 3 +- .../rpc-endpoint-events.test.ts | 185 ++++++++++++++++++ .../src/create-network-client.ts | 20 ++ packages/network-controller/tests/helpers.ts | 24 ++- .../network-controller/tsconfig.build.json | 1 + packages/network-controller/tsconfig.json | 1 + .../TransactionControllerIntegration.test.ts | 16 +- yarn.lock | 3 +- 11 files changed, 256 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 48d06d72c30..f835529429a 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,7 @@ linkStyle default opacity:0.5 name_controller --> controller_utils; name_controller --> messenger; network_controller --> base_controller; + network_controller --> connectivity_controller; network_controller --> controller_utils; network_controller --> eth_block_tracker; network_controller --> eth_json_rpc_middleware; diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 5817ea03e03..3ff1b14e42b 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** NetworkController now requires `ConnectivityController:getState` action handler to be registered on the messenger ([#7627](https://github.com/MetaMask/core/pull/7627)) + - The `NetworkController` now depends on the `ConnectivityController` to suppress `NetworkController:rpcEndpointUnavailable` and `NetworkController:rpcEndpointDegraded` events when the user is offline. + ## [28.0.0] ### Changed diff --git a/packages/network-controller/package.json b/packages/network-controller/package.json index 1fc7176dee7..1834430ebda 100644 --- a/packages/network-controller/package.json +++ b/packages/network-controller/package.json @@ -49,6 +49,7 @@ }, "dependencies": { "@metamask/base-controller": "^9.0.0", + "@metamask/connectivity-controller": "^0.0.0", "@metamask/controller-utils": "^11.18.0", "@metamask/eth-block-tracker": "^15.0.0", "@metamask/eth-json-rpc-infura": "^10.3.0", diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index 1652fa011a1..87e0603368c 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -3,6 +3,7 @@ import type { ControllerStateChangeEvent, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; +import type { ConnectivityControllerGetStateAction } from '@metamask/connectivity-controller'; import type { Partialize } from '@metamask/controller-utils'; import { InfuraNetworkType, @@ -729,7 +730,7 @@ export type NetworkControllerActions = /** * All actions that {@link NetworkController} calls internally. */ -type AllowedActions = never; +type AllowedActions = ConnectivityControllerGetStateAction; export type NetworkControllerMessenger = Messenger< typeof controllerName, diff --git a/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts b/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts index b3352b777c5..194ffe3c0f4 100644 --- a/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts +++ b/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts @@ -1,3 +1,4 @@ +import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; import { ConstantBackoff, DEFAULT_DEGRADED_THRESHOLD, @@ -263,6 +264,109 @@ describe('createNetworkClient - RPC endpoint events', () => { ); }); + it('suppresses the NetworkController:rpcEndpointUnavailable event when user is offline', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async (failoverComms) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + failoverComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + + const rootMessenger = buildRootMessenger({ + connectivityStatus: CONNECTIVITY_STATUSES.Offline, + }); + + const rpcEndpointUnavailableEventHandler = jest.fn(); + rootMessenger.subscribe( + 'NetworkController:rpcEndpointUnavailable', + rpcEndpointUnavailableEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + isRpcFailoverEnabled: true, + failoverRpcUrls: [failoverEndpointUrl], + messenger: rootMessenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall, clock }) => { + rootMessenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + clock.tick(backoffDuration); + }, + ); + + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries, + // breaking the circuit + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + // Event should be suppressed when offline, even though + // the circuit break was triggered + expect( + rpcEndpointUnavailableEventHandler, + ).not.toHaveBeenCalled(); + }, + ); + }, + ); + }, + ); + }); + it('does not publish the NetworkController:rpcEndpointChainDegraded event again if the max number of retries is reached in making requests to a failover endpoint', async () => { const failoverEndpointUrl = 'https://failover.endpoint/'; const request = { @@ -1117,6 +1221,87 @@ describe('createNetworkClient - RPC endpoint events', () => { ); }); + it('suppresses the NetworkController:rpcEndpointDegraded event when user is offline', async () => { + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (comms) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + comms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + + const rootMessenger = buildRootMessenger({ + connectivityStatus: CONNECTIVITY_STATUSES.Offline, + }); + + const rpcEndpointDegradedEventHandler = jest.fn(); + rootMessenger.subscribe( + 'NetworkController:rpcEndpointDegraded', + rpcEndpointDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + messenger: rootMessenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall, clock }) => { + rootMessenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + clock.tick(backoffDuration); + }, + ); + + // Hit the endpoint and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the endpoint and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the endpoint and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + // Event should be suppressed when offline, even though + // the degraded condition was triggered + expect( + rpcEndpointDegradedEventHandler, + ).not.toHaveBeenCalled(); + }, + ); + }, + ); + }); + it('publishes the NetworkController:rpcEndpointDegraded event when the time to complete a request to a primary endpoint is continually too long', async () => { const request = { method: 'eth_gasPrice', diff --git a/packages/network-controller/src/create-network-client.ts b/packages/network-controller/src/create-network-client.ts index e06d853b084..9c8e4c4e533 100644 --- a/packages/network-controller/src/create-network-client.ts +++ b/packages/network-controller/src/create-network-client.ts @@ -1,3 +1,4 @@ +import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; import type { CockatielFailureReason, InfuraNetworkType, @@ -279,6 +280,15 @@ function createRpcServiceChain({ throw new Error('Could not make request to endpoint.'); } + const connectivityState = messenger.call( + 'ConnectivityController:getState', + ); + if ( + connectivityState.connectivityStatus === CONNECTIVITY_STATUSES.Offline + ) { + return; + } + messenger.publish('NetworkController:rpcEndpointUnavailable', { chainId: configuration.chainId, networkClientId: id, @@ -305,6 +315,16 @@ function createRpcServiceChain({ ...rest }) => { const error = getError(rest); + + const connectivityState = messenger.call( + 'ConnectivityController:getState', + ); + if ( + connectivityState.connectivityStatus === CONNECTIVITY_STATUSES.Offline + ) { + return; + } + messenger.publish('NetworkController:rpcEndpointDegraded', { chainId: configuration.chainId, networkClientId: id, diff --git a/packages/network-controller/tests/helpers.ts b/packages/network-controller/tests/helpers.ts index 039bc41c759..c525540fb24 100644 --- a/packages/network-controller/tests/helpers.ts +++ b/packages/network-controller/tests/helpers.ts @@ -1,3 +1,5 @@ +import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; +import type { ConnectivityStatus } from '@metamask/connectivity-controller'; import { ChainId, InfuraNetworkType, @@ -84,15 +86,30 @@ export const TESTNET = { * Build a root messenger that includes all events used by the network * controller. * + * @param options - Optional configuration. + * @param options.connectivityStatus - The connectivity status to return by default. + * If not provided, defaults to Online. * @returns The messenger. */ -export function buildRootMessenger(): RootMessenger { +export function buildRootMessenger(options?: { + connectivityStatus?: ConnectivityStatus; +}): RootMessenger { const rootMessenger = new Messenger< MockAnyNamespace, MessengerActions, MessengerEvents >({ namespace: MOCK_ANY_NAMESPACE, captureException: jest.fn() }); + const connectivityStatus = + options?.connectivityStatus ?? CONNECTIVITY_STATUSES.Online; + + rootMessenger.registerActionHandler( + 'ConnectivityController:getState', + () => ({ + connectivityStatus, + }), + ); + return rootMessenger; } @@ -115,6 +132,11 @@ export function buildNetworkControllerMessenger( parent: rootMessenger, }); + rootMessenger.delegate({ + messenger: networkControllerMessenger, + actions: ['ConnectivityController:getState'], + }); + return networkControllerMessenger; } diff --git a/packages/network-controller/tsconfig.build.json b/packages/network-controller/tsconfig.build.json index 3aa1aa62e0f..a81948ea66a 100644 --- a/packages/network-controller/tsconfig.build.json +++ b/packages/network-controller/tsconfig.build.json @@ -8,6 +8,7 @@ "references": [ { "path": "../base-controller/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../connectivity-controller/tsconfig.build.json" }, { "path": "../eth-block-tracker/tsconfig.build.json" }, { "path": "../eth-json-rpc-middleware/tsconfig.build.json" }, { "path": "../eth-json-rpc-provider/tsconfig.build.json" }, diff --git a/packages/network-controller/tsconfig.json b/packages/network-controller/tsconfig.json index 9f1911d6466..e5a54a5785b 100644 --- a/packages/network-controller/tsconfig.json +++ b/packages/network-controller/tsconfig.json @@ -7,6 +7,7 @@ "references": [ { "path": "../base-controller" }, { "path": "../controller-utils" }, + { "path": "../connectivity-controller" }, { "path": "../eth-block-tracker" }, { "path": "../eth-json-rpc-middleware" }, { "path": "../eth-json-rpc-provider" }, diff --git a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts index 9cb52e729d1..f8f0a9eaeaf 100644 --- a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts +++ b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts @@ -5,6 +5,8 @@ import type { ApprovalControllerEvents, } from '@metamask/approval-controller'; import { ApprovalController } from '@metamask/approval-controller'; +import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; +import type { ConnectivityControllerGetStateAction } from '@metamask/connectivity-controller'; import { ApprovalType, BUILT_IN_NETWORKS, @@ -80,6 +82,7 @@ type AllTransactionControllerEvents = type AllActions = | AllTransactionControllerActions | NetworkControllerActions + | ConnectivityControllerGetStateAction | ApprovalControllerActions | AccountsControllerActions | RemoteFeatureFlagControllerGetStateAction; @@ -183,15 +186,26 @@ const setupController = async ( namespace: MOCK_ANY_NAMESPACE, }); + rootMessenger.registerActionHandler( + 'ConnectivityController:getState', + () => ({ + connectivityStatus: CONNECTIVITY_STATUSES.Online, + }), + ); + const networkControllerMessenger = new Messenger< 'NetworkController', - NetworkControllerActions, + NetworkControllerActions | ConnectivityControllerGetStateAction, NetworkControllerEvents, typeof rootMessenger >({ namespace: 'NetworkController', parent: rootMessenger, }); + rootMessenger.delegate({ + messenger: networkControllerMessenger, + actions: ['ConnectivityController:getState'], + }); const networkController = new NetworkController({ messenger: networkControllerMessenger, infuraProjectId, diff --git a/yarn.lock b/yarn.lock index 88b4ce0614c..a23971ddd31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2964,7 +2964,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/connectivity-controller@workspace:packages/connectivity-controller": +"@metamask/connectivity-controller@npm:^0.0.0, @metamask/connectivity-controller@workspace:packages/connectivity-controller": version: 0.0.0-use.local resolution: "@metamask/connectivity-controller@workspace:packages/connectivity-controller" dependencies: @@ -4207,6 +4207,7 @@ __metadata: "@json-rpc-specification/meta-schema": "npm:^1.0.6" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" + "@metamask/connectivity-controller": "npm:^0.0.0" "@metamask/controller-utils": "npm:^11.18.0" "@metamask/eth-block-tracker": "npm:^15.0.0" "@metamask/eth-json-rpc-infura": "npm:^10.3.0" From d59bf85993915a085fc1e047ef99ac6e649ce5d7 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 14 Jan 2026 23:45:32 +0100 Subject: [PATCH 08/11] feat(network-controller): prevent retries and suppress events when offline - Add isOffline check to RpcService retry filter policy to prevent retries when offline - Suppress rpcEndpointUnavailable and rpcEndpointDegraded events when offline - Add tests to verify retries don't happen when offline - Add tests to verify events are suppressed when offline - Update CHANGELOG with breaking change documentation --- packages/network-controller/CHANGELOG.md | 6 +- .../rpc-endpoint-events.test.ts | 172 ++++++++++++++-- .../src/create-network-client.ts | 27 +-- .../src/rpc-service/rpc-service.test.ts | 183 ++++++++++++++++++ .../src/rpc-service/rpc-service.ts | 13 ++ 5 files changed, 371 insertions(+), 30 deletions(-) diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 3ff1b14e42b..1f6762b8f00 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -10,7 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **BREAKING:** NetworkController now requires `ConnectivityController:getState` action handler to be registered on the messenger ([#7627](https://github.com/MetaMask/core/pull/7627)) - - The `NetworkController` now depends on the `ConnectivityController` to suppress `NetworkController:rpcEndpointUnavailable` and `NetworkController:rpcEndpointDegraded` events when the user is offline. + - The `NetworkController` now depends on the `ConnectivityController` to prevent retries and suppress events when the user is offline. + - When offline, RPC requests are not retried at the `RpcService` level, preventing unnecessary network calls and circuit breaker failures. + - When offline, `NetworkController:rpcEndpointUnavailable` and `NetworkController:rpcEndpointDegraded` events are suppressed since retries don't occur and circuit breakers don't trigger. + - You must register a `ConnectivityController:getState` action handler on your root messenger that returns an object with a `connectivityStatus` property (`'online'` or `'offline'`). + - You must delegate the `ConnectivityController:getState` action from your root messenger to the `NetworkControllerMessenger` using `rootMessenger.delegate({ messenger: networkControllerMessenger, actions: ['ConnectivityController:getState'] })`. ## [28.0.0] diff --git a/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts b/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts index 194ffe3c0f4..69795edbcfe 100644 --- a/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts +++ b/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts @@ -264,6 +264,89 @@ describe('createNetworkClient - RPC endpoint events', () => { ); }); + it('does not retry requests when user is offline', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async () => { + // Mock only one failure - if retries were happening, we'd need more + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: 1, + response: { + httpStatus: 503, + }, + }); + primaryComms.mockRpcCall({ + request: { + method: 'eth_gasPrice', + params: [], + }, + times: 1, + response: { + httpStatus: 503, + }, + }); + + const rootMessenger = buildRootMessenger({ + connectivityStatus: CONNECTIVITY_STATUSES.Offline, + }); + + const rpcEndpointRetriedEventHandler = jest.fn(); + rootMessenger.subscribe( + 'NetworkController:rpcEndpointRetried', + rpcEndpointRetriedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + isRpcFailoverEnabled: true, + failoverRpcUrls: [failoverEndpointUrl], + messenger: rootMessenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall }) => { + // When offline, errors are not retried, so the request + // should fail immediately without retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + // Verify that retry event was not published + expect( + rpcEndpointRetriedEventHandler, + ).not.toHaveBeenCalled(); + }, + ); + }, + ); + }, + ); + }); + it('suppresses the NetworkController:rpcEndpointUnavailable event when user is offline', async () => { const failoverEndpointUrl = 'https://failover.endpoint/'; const request = { @@ -340,22 +423,20 @@ describe('createNetworkClient - RPC endpoint events', () => { }, ); - // Hit the primary and exceed the max number of retries + // When offline, errors are not retried, so the circuit + // won't break and onServiceBreak won't be called await expect(makeRpcCall(request)).rejects.toThrow( expectedError, ); - // Hit the primary and exceed the max number of retries await expect(makeRpcCall(request)).rejects.toThrow( expectedError, ); - // Hit the primary and exceed the max number of retries, - // breaking the circuit await expect(makeRpcCall(request)).rejects.toThrow( expectedError, ); - // Event should be suppressed when offline, even though - // the circuit break was triggered + // Event should be suppressed when offline because retries + // are prevented, so onServiceBreak is never called expect( rpcEndpointUnavailableEventHandler, ).not.toHaveBeenCalled(); @@ -1221,6 +1302,76 @@ describe('createNetworkClient - RPC endpoint events', () => { ); }); + it('does not retry requests when user is offline (degraded scenario)', async () => { + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (comms) => { + // Mock only one failure - if retries were happening, we'd need more + comms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: 1, + response: { + httpStatus: 503, + }, + }); + comms.mockRpcCall({ + request: { + method: 'eth_gasPrice', + params: [], + }, + times: 1, + response: { + httpStatus: 503, + }, + }); + + const rootMessenger = buildRootMessenger({ + connectivityStatus: CONNECTIVITY_STATUSES.Offline, + }); + + const rpcEndpointRetriedEventHandler = jest.fn(); + rootMessenger.subscribe( + 'NetworkController:rpcEndpointRetried', + rpcEndpointRetriedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + messenger: rootMessenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall }) => { + // When offline, errors are not retried, so the request + // should fail immediately without retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + // Verify that retry event was not published + expect(rpcEndpointRetriedEventHandler).not.toHaveBeenCalled(); + }, + ); + }, + ); + }); + it('suppresses the NetworkController:rpcEndpointDegraded event when user is offline', async () => { const request = { method: 'eth_gasPrice', @@ -1278,21 +1429,20 @@ describe('createNetworkClient - RPC endpoint events', () => { }, ); - // Hit the endpoint and exceed the max number of retries + // When offline, errors are not retried, so the circuit + // won't accumulate failures and onServiceDegraded won't be called await expect(makeRpcCall(request)).rejects.toThrow( expectedError, ); - // Hit the endpoint and exceed the max number of retries await expect(makeRpcCall(request)).rejects.toThrow( expectedError, ); - // Hit the endpoint and exceed the max number of retries await expect(makeRpcCall(request)).rejects.toThrow( expectedError, ); - // Event should be suppressed when offline, even though - // the degraded condition was triggered + // Event should be suppressed when offline because retries + // are prevented, so onServiceDegraded is never called expect( rpcEndpointDegradedEventHandler, ).not.toHaveBeenCalled(); diff --git a/packages/network-controller/src/create-network-client.ts b/packages/network-controller/src/create-network-client.ts index 9c8e4c4e533..6a0a78c9148 100644 --- a/packages/network-controller/src/create-network-client.ts +++ b/packages/network-controller/src/create-network-client.ts @@ -211,10 +211,19 @@ function createRpcServiceChain({ const availableEndpointUrls: [string, ...string[]] = isRpcFailoverEnabled ? [primaryEndpointUrl, ...(configuration.failoverRpcUrls ?? [])] : [primaryEndpointUrl]; + + const isOffline = (): boolean => { + const connectivityState = messenger.call('ConnectivityController:getState'); + return ( + connectivityState.connectivityStatus === CONNECTIVITY_STATUSES.Offline + ); + }; + const rpcServiceConfigurations = availableEndpointUrls.map((endpointUrl) => ({ ...getRpcServiceOptions(endpointUrl), endpointUrl, logger, + isOffline, })); /** @@ -280,15 +289,6 @@ function createRpcServiceChain({ throw new Error('Could not make request to endpoint.'); } - const connectivityState = messenger.call( - 'ConnectivityController:getState', - ); - if ( - connectivityState.connectivityStatus === CONNECTIVITY_STATUSES.Offline - ) { - return; - } - messenger.publish('NetworkController:rpcEndpointUnavailable', { chainId: configuration.chainId, networkClientId: id, @@ -316,15 +316,6 @@ function createRpcServiceChain({ }) => { const error = getError(rest); - const connectivityState = messenger.call( - 'ConnectivityController:getState', - ); - if ( - connectivityState.connectivityStatus === CONNECTIVITY_STATUSES.Offline - ) { - return; - } - messenger.publish('NetworkController:rpcEndpointDegraded', { chainId: configuration.chainId, networkClientId: id, diff --git a/packages/network-controller/src/rpc-service/rpc-service.test.ts b/packages/network-controller/src/rpc-service/rpc-service.test.ts index 463c80f93e9..dd3aa5235fb 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.test.ts @@ -1424,6 +1424,107 @@ function testsForRetriableFetchErrors({ expect(onAvailableListener).toHaveBeenCalledTimes(1); }); + it('does not retry when offline, only makes one fetch call', async () => { + const clock = getClock(); + const mockFetch = jest.fn(() => { + throw producedError; + }); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + isOffline: (): boolean => true, + }); + service.onRetry(() => { + clock.next(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // When offline, no retries should happen, so only 1 fetch call + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('does not call onDegraded when offline', async () => { + const clock = getClock(); + const mockFetch = jest.fn(() => { + throw producedError; + }); + const endpointUrl = 'https://rpc.example.chain'; + const onDegradedListener = jest.fn(); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl, + isOffline: (): boolean => true, + }); + service.onRetry(() => { + clock.next(); + }); + service.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + // When offline, retries don't happen, so onDegraded should not be called + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('does not call onBreak when offline', async () => { + const clock = getClock(); + const mockFetch = jest.fn(() => { + throw producedError; + }); + const endpointUrl = 'https://rpc.example.chain'; + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl, + isOffline: (): boolean => true, + }); + service.onRetry(() => { + clock.next(); + }); + service.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Make multiple requests - even though we'd normally break the circuit, + // when offline, no retries happen so circuit won't break + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + // When offline, retries don't happen, so circuit won't break and onBreak + // should not be called + expect(onBreakListener).not.toHaveBeenCalled(); + }); + /* eslint-enable jest/require-top-level-describe */ } @@ -1654,5 +1755,87 @@ function testsForRetriableResponses({ ); }); + it('does not retry when offline, only makes one request', async () => { + const clock = getClock(); + const scope = nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(1) + .reply(httpStatus, responseBody); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + isOffline: (): boolean => true, + }); + service.onRetry(() => { + clock.next(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // When offline, no retries should happen, so only 1 request + expect(scope.isDone()).toBe(true); + }); + + it('does not call onBreak when offline', async () => { + const clock = getClock(); + const scope = nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(3) + .reply(httpStatus, responseBody); + const endpointUrl = 'https://rpc.example.chain'; + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => true, + }); + service.onRetry(() => { + clock.next(); + }); + service.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Make multiple requests - even though we'd normally break the circuit, + // when offline, no retries happen so circuit won't break + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + // When offline, retries don't happen, so circuit won't break and onBreak + // should not be called + expect(onBreakListener).not.toHaveBeenCalled(); + expect(scope.isDone()).toBe(true); + }); + /* eslint-enable jest/require-top-level-describe,jest/no-identical-title */ } diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts index df09eda0e0a..9b3deae8eba 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -57,6 +57,12 @@ export type RpcServiceOptions = { * not accepted, as it is overwritten. See {@link createServicePolicy}. */ policyOptions?: Omit; + /** + * A function that checks if the user is currently offline. If provided and + * returns true, connection errors will not be retried, preventing degraded + * and break callbacks from being triggered. + */ + isOffline?: () => boolean; }; const log = createModuleLogger(projectLogger, 'RpcService'); @@ -277,6 +283,7 @@ export class RpcService implements AbstractRpcService { logger, fetchOptions = {}, policyOptions = {}, + isOffline, } = options; this.#fetch = givenFetch; @@ -294,6 +301,12 @@ export class RpcService implements AbstractRpcService { maxConsecutiveFailures: DEFAULT_MAX_CONSECUTIVE_FAILURES, ...policyOptions, retryFilterPolicy: handleWhen((error) => { + // If user is offline, don't retry any errors + // This prevents degraded/break callbacks from being triggered + if (isOffline?.()) { + return false; + } + return ( // Ignore errors where the request failed to establish isConnectionError(error) || From c0b38406758fb45d2568bbeb560087b4c09faf07 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 15 Jan 2026 15:29:14 +0100 Subject: [PATCH 09/11] fix: make isOffline mandatory --- .../src/AssetsContractController.test.ts | 7 ++- .../src/GasFeeController.test.ts | 7 ++- packages/network-controller/CHANGELOG.md | 1 - ...create-auto-managed-network-client.test.ts | 13 +++++ .../rpc-endpoint-events.test.ts | 26 ++++++---- .../src/rpc-service/rpc-service.test.ts | 48 +++++++++++++++---- .../src/rpc-service/rpc-service.ts | 10 ++-- packages/network-controller/tests/helpers.ts | 10 ++-- .../tests/network-client/helpers.ts | 2 +- .../tests/network-client/rpc-failover.ts | 8 +++- .../TransactionControllerIntegration.test.ts | 1 + 11 files changed, 98 insertions(+), 35 deletions(-) diff --git a/packages/assets-controllers/src/AssetsContractController.test.ts b/packages/assets-controllers/src/AssetsContractController.test.ts index 96af8484b2f..b918a543461 100644 --- a/packages/assets-controllers/src/AssetsContractController.test.ts +++ b/packages/assets-controllers/src/AssetsContractController.test.ts @@ -34,6 +34,7 @@ import { } from './AssetsContractController'; import { SupportedTokenDetectionNetworks } from './assetsUtil'; import { mockNetwork } from '../../../tests/mock-network'; +import { RpcServiceOptions } from '../../network-controller/src/rpc-service/rpc-service'; import { buildInfuraNetworkClientConfiguration } from '../../network-controller/tests/helpers'; type AllAssetsContractControllerActions = @@ -110,9 +111,13 @@ async function setupAssetContractControllers({ namespace: 'NetworkController', parent: messenger, }), - getRpcServiceOptions: () => ({ + getRpcServiceOptions: (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ fetch, btoa, + isOffline: (): boolean => false, }), }); if (useNetworkControllerProvider) { diff --git a/packages/gas-fee-controller/src/GasFeeController.test.ts b/packages/gas-fee-controller/src/GasFeeController.test.ts index 1d4f5e2e2ea..2619b2aa7ea 100644 --- a/packages/gas-fee-controller/src/GasFeeController.test.ts +++ b/packages/gas-fee-controller/src/GasFeeController.test.ts @@ -35,6 +35,7 @@ import type { GasFeeStateFeeMarket, GasFeeStateLegacy, } from './GasFeeController'; +import { RpcServiceOptions } from '../../network-controller/src/rpc-service/rpc-service'; import { buildCustomNetworkConfiguration, buildCustomRpcEndpoint, @@ -99,9 +100,13 @@ const setupNetworkController = async ({ messenger: networkControllerMessenger, state, infuraProjectId, - getRpcServiceOptions: () => ({ + getRpcServiceOptions: (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ fetch, btoa, + isOffline: (): boolean => false, }), }); diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 1f6762b8f00..660479382f8 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -11,7 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** NetworkController now requires `ConnectivityController:getState` action handler to be registered on the messenger ([#7627](https://github.com/MetaMask/core/pull/7627)) - The `NetworkController` now depends on the `ConnectivityController` to prevent retries and suppress events when the user is offline. - - When offline, RPC requests are not retried at the `RpcService` level, preventing unnecessary network calls and circuit breaker failures. - When offline, `NetworkController:rpcEndpointUnavailable` and `NetworkController:rpcEndpointDegraded` events are suppressed since retries don't occur and circuit breakers don't trigger. - You must register a `ConnectivityController:getState` action handler on your root messenger that returns an object with a `connectivityStatus` property (`'online'` or `'offline'`). - You must delegate the `ConnectivityController:getState` action from your root messenger to the `NetworkControllerMessenger` using `rootMessenger.delegate({ messenger: networkControllerMessenger, actions: ['ConnectivityController:getState'] })`. diff --git a/packages/network-controller/src/create-auto-managed-network-client.test.ts b/packages/network-controller/src/create-auto-managed-network-client.test.ts index 9ef882d9d9d..07927cbbcee 100644 --- a/packages/network-controller/src/create-auto-managed-network-client.test.ts +++ b/packages/network-controller/src/create-auto-managed-network-client.test.ts @@ -42,6 +42,7 @@ describe('createAutoManagedNetworkClient', () => { getRpcServiceOptions: () => ({ fetch, btoa, + isOffline: (): boolean => false, }), messenger: buildNetworkControllerMessenger(), isRpcFailoverEnabled: false, @@ -59,6 +60,7 @@ describe('createAutoManagedNetworkClient', () => { getRpcServiceOptions: () => ({ fetch, btoa, + isOffline: (): boolean => false, }), messenger: buildNetworkControllerMessenger(), isRpcFailoverEnabled: false, @@ -73,6 +75,7 @@ describe('createAutoManagedNetworkClient', () => { getRpcServiceOptions: () => ({ fetch, btoa, + isOffline: (): boolean => false, }), messenger: buildNetworkControllerMessenger(), isRpcFailoverEnabled: false, @@ -107,6 +110,7 @@ describe('createAutoManagedNetworkClient', () => { getRpcServiceOptions: () => ({ fetch, btoa, + isOffline: (): boolean => false, }), messenger: buildNetworkControllerMessenger(), isRpcFailoverEnabled: false, @@ -147,6 +151,7 @@ describe('createAutoManagedNetworkClient', () => { > => ({ btoa, fetch, + isOffline: (): boolean => false, }); const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ pollingInterval: 5000, @@ -211,6 +216,7 @@ describe('createAutoManagedNetworkClient', () => { > => ({ btoa, fetch, + isOffline: (): boolean => false, }); const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ pollingInterval: 5000, @@ -285,6 +291,7 @@ describe('createAutoManagedNetworkClient', () => { > => ({ btoa, fetch, + isOffline: (): boolean => false, }); const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ pollingInterval: 5000, @@ -341,6 +348,7 @@ describe('createAutoManagedNetworkClient', () => { getRpcServiceOptions: () => ({ fetch, btoa, + isOffline: (): boolean => false, }), messenger: buildNetworkControllerMessenger(), isRpcFailoverEnabled: false, @@ -401,6 +409,7 @@ describe('createAutoManagedNetworkClient', () => { getRpcServiceOptions: () => ({ fetch, btoa, + isOffline: (): boolean => false, }), messenger: buildNetworkControllerMessenger(), isRpcFailoverEnabled: false, @@ -464,6 +473,7 @@ describe('createAutoManagedNetworkClient', () => { > => ({ btoa, fetch, + isOffline: (): boolean => false, }); const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ pollingInterval: 5000, @@ -524,6 +534,7 @@ describe('createAutoManagedNetworkClient', () => { > => ({ btoa, fetch, + isOffline: (): boolean => false, }); const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ pollingInterval: 5000, @@ -592,6 +603,7 @@ describe('createAutoManagedNetworkClient', () => { > => ({ btoa, fetch, + isOffline: (): boolean => false, }); const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ pollingInterval: 5000, @@ -657,6 +669,7 @@ describe('createAutoManagedNetworkClient', () => { getRpcServiceOptions: () => ({ fetch, btoa, + isOffline: (): boolean => false, }), messenger: buildNetworkControllerMessenger(), isRpcFailoverEnabled: false, diff --git a/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts b/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts index 69795edbcfe..a4f9cfdbb65 100644 --- a/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts +++ b/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts @@ -83,6 +83,7 @@ describe('createNetworkClient - RPC endpoint events', () => { policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, + isOffline: (): boolean => false, }), }, async ({ makeRpcCall, clock, chainId }) => { @@ -199,6 +200,7 @@ describe('createNetworkClient - RPC endpoint events', () => { policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, + isOffline: (): boolean => false, }), }, async ({ makeRpcCall, clock, chainId, rpcUrl }) => { @@ -292,16 +294,6 @@ describe('createNetworkClient - RPC endpoint events', () => { httpStatus: 503, }, }); - primaryComms.mockRpcCall({ - request: { - method: 'eth_gasPrice', - params: [], - }, - times: 1, - response: { - httpStatus: 503, - }, - }); const rootMessenger = buildRootMessenger({ connectivityStatus: CONNECTIVITY_STATUSES.Offline, @@ -326,6 +318,7 @@ describe('createNetworkClient - RPC endpoint events', () => { policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, + isOffline: (): boolean => false, }), }, async ({ makeRpcCall }) => { @@ -411,6 +404,7 @@ describe('createNetworkClient - RPC endpoint events', () => { policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, + isOffline: (): boolean => false, }), }, async ({ makeRpcCall, clock }) => { @@ -486,6 +480,7 @@ describe('createNetworkClient - RPC endpoint events', () => { policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, + isOffline: (): boolean => false, }), }, async ({ makeRpcCall, clock, chainId }) => { @@ -593,6 +588,7 @@ describe('createNetworkClient - RPC endpoint events', () => { policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, + isOffline: (): boolean => false, }), }, async ({ makeRpcCall, clock, chainId }) => { @@ -708,6 +704,7 @@ describe('createNetworkClient - RPC endpoint events', () => { policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, + isOffline: (): boolean => false, }), }, async ({ makeRpcCall, clock, chainId, rpcUrl }) => { @@ -835,6 +832,7 @@ describe('createNetworkClient - RPC endpoint events', () => { policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, + isOffline: (): boolean => false, }), }, async ({ makeRpcCall, clock, chainId, rpcUrl }) => { @@ -1007,6 +1005,7 @@ describe('createNetworkClient - RPC endpoint events', () => { policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, + isOffline: (): boolean => false, }), }, async ({ makeRpcCall, clock, chainId }) => { @@ -1093,6 +1092,7 @@ describe('createNetworkClient - RPC endpoint events', () => { policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, + isOffline: (): boolean => false, }), }, async ({ makeRpcCall, clock, chainId }) => { @@ -1162,6 +1162,7 @@ describe('createNetworkClient - RPC endpoint events', () => { policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, + isOffline: (): boolean => false, }), }, async ({ makeRpcCall, clock, chainId }) => { @@ -1253,6 +1254,7 @@ describe('createNetworkClient - RPC endpoint events', () => { policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, + isOffline: (): boolean => false, }), }, async ({ makeRpcCall, clock, chainId, rpcUrl }) => { @@ -1355,6 +1357,7 @@ describe('createNetworkClient - RPC endpoint events', () => { policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, + isOffline: (): boolean => false, }), }, async ({ makeRpcCall }) => { @@ -1417,6 +1420,7 @@ describe('createNetworkClient - RPC endpoint events', () => { policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, + isOffline: (): boolean => false, }), }, async ({ makeRpcCall, clock }) => { @@ -1482,6 +1486,7 @@ describe('createNetworkClient - RPC endpoint events', () => { policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, + isOffline: (): boolean => false, }), }, async ({ makeRpcCall, clock, chainId, rpcUrl }) => { @@ -1575,6 +1580,7 @@ describe('createNetworkClient - RPC endpoint events', () => { policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, + isOffline: (): boolean => false, }), }, async ({ makeRpcCall, chainId }) => { diff --git a/packages/network-controller/src/rpc-service/rpc-service.test.ts b/packages/network-controller/src/rpc-service/rpc-service.test.ts index dd3aa5235fb..01ebe80dca6 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.test.ts @@ -56,6 +56,7 @@ describe('RpcService', () => { fetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); service.onRetry(() => { clock.next(); @@ -106,6 +107,7 @@ describe('RpcService', () => { fetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); service.onRetry(() => { clock.next(); @@ -172,6 +174,7 @@ describe('RpcService', () => { fetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); service.onRetry(() => { clock.next(); @@ -225,6 +228,7 @@ describe('RpcService', () => { fetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); service.onRetry(() => { clock.next(); @@ -273,6 +277,7 @@ describe('RpcService', () => { fetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); service.onRetry(() => { clock.next(); @@ -320,6 +325,7 @@ describe('RpcService', () => { fetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); service.onRetry(() => { clock.next(); @@ -453,6 +459,7 @@ describe('RpcService', () => { }, btoa, endpointUrl, + isOffline: (): boolean => false, }); }, expectedError: new Error('oops'), @@ -612,6 +619,7 @@ describe('RpcService', () => { fetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); // @ts-expect-error Intentionally passing bad input. @@ -657,6 +665,7 @@ describe('RpcService', () => { fetch, btoa, endpointUrl: 'https://username:password@rpc.example.chain', + isOffline: (): boolean => false, }); const response = await service.request({ @@ -696,6 +705,7 @@ describe('RpcService', () => { fetch, btoa, endpointUrl: 'https://username:password@rpc.example.chain', + isOffline: (): boolean => false, }); await service.request({ @@ -731,6 +741,7 @@ describe('RpcService', () => { fetch, btoa, endpointUrl: 'https://username:password@rpc.example.chain', + isOffline: (): boolean => false, }); await service.request( @@ -780,6 +791,7 @@ describe('RpcService', () => { fetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); const response = await service.request({ @@ -826,6 +838,7 @@ describe('RpcService', () => { fetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); const response = await service.request( @@ -865,6 +878,7 @@ describe('RpcService', () => { fetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); const response = await service.request({ @@ -906,6 +920,7 @@ describe('RpcService', () => { fetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); service.onDegraded(onDegradedListener); @@ -943,6 +958,7 @@ describe('RpcService', () => { fetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); service.onAvailable(onAvailableListener); @@ -994,6 +1010,7 @@ describe('RpcService', () => { fetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); service.onAvailable(onAvailableListener); @@ -1052,7 +1069,12 @@ function testsForNonRetriableErrors({ // do nothing }, createService = (args): RpcService => { - return new RpcService({ fetch, btoa, endpointUrl: args.endpointUrl }); + return new RpcService({ + fetch, + btoa, + endpointUrl: args.endpointUrl, + isOffline: (): boolean => false, + }); }, endpointUrl = 'https://rpc.example.chain', rpcMethod = `eth_chainId`, @@ -1190,6 +1212,7 @@ function testsForRetriableFetchErrors({ fetch: mockFetch, btoa, endpointUrl: 'https://rpc.example.chain', + isOffline: (): boolean => false, }); service.onRetry(() => { clock.next(); @@ -1218,6 +1241,7 @@ function testsForRetriableFetchErrors({ fetch: mockFetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); service.onRetry(() => { clock.next(); @@ -1252,6 +1276,7 @@ function testsForRetriableFetchErrors({ fetch: mockFetch, btoa, endpointUrl: 'https://rpc.example.chain', + isOffline: (): boolean => false, }); service.onRetry(() => { clock.next(); @@ -1282,6 +1307,7 @@ function testsForRetriableFetchErrors({ fetch: mockFetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); service.onRetry(() => { clock.next(); @@ -1318,6 +1344,7 @@ function testsForRetriableFetchErrors({ btoa, endpointUrl, logger, + isOffline: (): boolean => false, }); service.onRetry(() => { clock.next(); @@ -1358,6 +1385,7 @@ function testsForRetriableFetchErrors({ btoa, endpointUrl, logger, + isOffline: (): boolean => false, }); service.onRetry(() => { clock.next(); @@ -1405,6 +1433,7 @@ function testsForRetriableFetchErrors({ fetch: mockFetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); service.onAvailable(onAvailableListener); service.onRetry(() => { @@ -1426,9 +1455,7 @@ function testsForRetriableFetchErrors({ it('does not retry when offline, only makes one fetch call', async () => { const clock = getClock(); - const mockFetch = jest.fn(() => { - throw producedError; - }); + const mockFetch = jest.fn(); const service = new RpcService({ fetch: mockFetch, btoa, @@ -1454,9 +1481,7 @@ function testsForRetriableFetchErrors({ it('does not call onDegraded when offline', async () => { const clock = getClock(); - const mockFetch = jest.fn(() => { - throw producedError; - }); + const mockFetch = jest.fn(); const endpointUrl = 'https://rpc.example.chain'; const onDegradedListener = jest.fn(); const service = new RpcService({ @@ -1486,9 +1511,7 @@ function testsForRetriableFetchErrors({ it('does not call onBreak when offline', async () => { const clock = getClock(); - const mockFetch = jest.fn(() => { - throw producedError; - }); + const mockFetch = jest.fn(); const endpointUrl = 'https://rpc.example.chain'; const onBreakListener = jest.fn(); const service = new RpcService({ @@ -1574,6 +1597,7 @@ function testsForRetriableResponses({ fetch, btoa, endpointUrl: 'https://rpc.example.chain', + isOffline: (): boolean => false, }); service.onRetry(() => { clock.next(); @@ -1606,6 +1630,7 @@ function testsForRetriableResponses({ fetch, btoa, endpointUrl: 'https://rpc.example.chain', + isOffline: (): boolean => false, }); service.onRetry(() => { clock.next(); @@ -1642,6 +1667,7 @@ function testsForRetriableResponses({ fetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); service.onRetry(() => { clock.next(); @@ -1683,6 +1709,7 @@ function testsForRetriableResponses({ fetch, btoa, endpointUrl, + isOffline: (): boolean => false, }); service.onRetry(() => { clock.next(); @@ -1731,6 +1758,7 @@ function testsForRetriableResponses({ btoa, endpointUrl, logger, + isOffline: (): boolean => false, }); service.onRetry(() => { clock.next(); diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts index 9b3deae8eba..32e24494526 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -58,11 +58,11 @@ export type RpcServiceOptions = { */ policyOptions?: Omit; /** - * A function that checks if the user is currently offline. If provided and - * returns true, connection errors will not be retried, preventing degraded - * and break callbacks from being triggered. + * A function that checks if the user is currently offline. If it returns true, + * connection errors will not be retried, preventing degraded and break + * callbacks from being triggered. */ - isOffline?: () => boolean; + isOffline: () => boolean; }; const log = createModuleLogger(projectLogger, 'RpcService'); @@ -303,7 +303,7 @@ export class RpcService implements AbstractRpcService { retryFilterPolicy: handleWhen((error) => { // If user is offline, don't retry any errors // This prevents degraded/break callbacks from being triggered - if (isOffline?.()) { + if (isOffline()) { return false; } diff --git a/packages/network-controller/tests/helpers.ts b/packages/network-controller/tests/helpers.ts index c525540fb24..860eb2a5347 100644 --- a/packages/network-controller/tests/helpers.ts +++ b/packages/network-controller/tests/helpers.ts @@ -91,18 +91,17 @@ export const TESTNET = { * If not provided, defaults to Online. * @returns The messenger. */ -export function buildRootMessenger(options?: { +export function buildRootMessenger({ + connectivityStatus = CONNECTIVITY_STATUSES.Online, +}: { connectivityStatus?: ConnectivityStatus; -}): RootMessenger { +} = {}): RootMessenger { const rootMessenger = new Messenger< MockAnyNamespace, MessengerActions, MessengerEvents >({ namespace: MOCK_ANY_NAMESPACE, captureException: jest.fn() }); - const connectivityStatus = - options?.connectivityStatus ?? CONNECTIVITY_STATUSES.Online; - rootMessenger.registerActionHandler( 'ConnectivityController:getState', () => ({ @@ -644,6 +643,7 @@ export async function withController( > => ({ fetch, btoa, + isOffline: (): boolean => false, }), ...rest, }); diff --git a/packages/network-controller/tests/network-client/helpers.ts b/packages/network-controller/tests/network-client/helpers.ts index eb251e1536f..deaf9266e91 100644 --- a/packages/network-controller/tests/network-client/helpers.ts +++ b/packages/network-controller/tests/network-client/helpers.ts @@ -502,7 +502,7 @@ export async function withNetworkClient( getRpcServiceOptions = (): Omit< RpcServiceOptions, 'failoverService' | 'endpointUrl' - > => ({ fetch, btoa }), + > => ({ fetch, btoa, isOffline: (): boolean => false }), getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({}), messenger = buildRootMessenger(), networkClientId = 'some-network-client-id', diff --git a/packages/network-controller/tests/network-client/rpc-failover.ts b/packages/network-controller/tests/network-client/rpc-failover.ts index 0a2029abd43..2decfa2dc72 100644 --- a/packages/network-controller/tests/network-client/rpc-failover.ts +++ b/packages/network-controller/tests/network-client/rpc-failover.ts @@ -88,6 +88,7 @@ export function testsForRpcFailoverBehavior({ getRpcServiceOptions: () => ({ fetch, btoa, + isOffline: (): boolean => false, policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, @@ -177,7 +178,11 @@ export function testsForRpcFailoverBehavior({ failoverRpcUrls: ['https://failover.endpoint'], messenger, getRpcServiceOptions: (rpcEndpointUrl) => { - const commonOptions = { fetch, btoa }; + const commonOptions = { + fetch, + btoa, + isOffline: (): boolean => false, + }; if (rpcEndpointUrl === 'https://failover.endpoint') { const headers: HeadersInit = { 'X-Baz': 'Qux', @@ -263,6 +268,7 @@ export function testsForRpcFailoverBehavior({ getRpcServiceOptions: () => ({ fetch, btoa, + isOffline: (): boolean => false, policyOptions: { backoff: new ConstantBackoff(backoffDuration), }, diff --git a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts index f8f0a9eaeaf..a66d5c6dc7f 100644 --- a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts +++ b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts @@ -214,6 +214,7 @@ const setupController = async ( > => ({ fetch, btoa, + isOffline: (): boolean => false, }), }); await networkController.initializeProvider(); From 75b0aaa0f77ac9cb0a0a0745d06408d725dbb0fc Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 15 Jan 2026 16:38:51 +0100 Subject: [PATCH 10/11] fix: unit tests --- .../src/AssetsContractController.test.ts | 6 +-- .../src/GasFeeController.test.ts | 6 +-- .../src/rpc-service/rpc-service-chain.test.ts | 50 +++++++++++++++++++ .../src/rpc-service/rpc-service.test.ts | 12 +++-- .../tests/NetworkController.test.ts | 18 +++++++ 5 files changed, 79 insertions(+), 13 deletions(-) diff --git a/packages/assets-controllers/src/AssetsContractController.test.ts b/packages/assets-controllers/src/AssetsContractController.test.ts index b918a543461..89e14a370bc 100644 --- a/packages/assets-controllers/src/AssetsContractController.test.ts +++ b/packages/assets-controllers/src/AssetsContractController.test.ts @@ -34,7 +34,6 @@ import { } from './AssetsContractController'; import { SupportedTokenDetectionNetworks } from './assetsUtil'; import { mockNetwork } from '../../../tests/mock-network'; -import { RpcServiceOptions } from '../../network-controller/src/rpc-service/rpc-service'; import { buildInfuraNetworkClientConfiguration } from '../../network-controller/tests/helpers'; type AllAssetsContractControllerActions = @@ -111,10 +110,7 @@ async function setupAssetContractControllers({ namespace: 'NetworkController', parent: messenger, }), - getRpcServiceOptions: (): Omit< - RpcServiceOptions, - 'failoverService' | 'endpointUrl' - > => ({ + getRpcServiceOptions: () => ({ fetch, btoa, isOffline: (): boolean => false, diff --git a/packages/gas-fee-controller/src/GasFeeController.test.ts b/packages/gas-fee-controller/src/GasFeeController.test.ts index 2619b2aa7ea..75685ef8329 100644 --- a/packages/gas-fee-controller/src/GasFeeController.test.ts +++ b/packages/gas-fee-controller/src/GasFeeController.test.ts @@ -35,7 +35,6 @@ import type { GasFeeStateFeeMarket, GasFeeStateLegacy, } from './GasFeeController'; -import { RpcServiceOptions } from '../../network-controller/src/rpc-service/rpc-service'; import { buildCustomNetworkConfiguration, buildCustomRpcEndpoint, @@ -100,10 +99,7 @@ const setupNetworkController = async ({ messenger: networkControllerMessenger, state, infuraProjectId, - getRpcServiceOptions: (): Omit< - RpcServiceOptions, - 'failoverService' | 'endpointUrl' - > => ({ + getRpcServiceOptions: () => ({ fetch, btoa, isOffline: (): boolean => false, diff --git a/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts index c6a6f106efa..6e1508a6e0a 100644 --- a/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts @@ -56,6 +56,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: 'https://rpc.example.chain', }, ]); @@ -73,6 +74,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: 'https://rpc.example.chain', }, ]); @@ -90,6 +92,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: 'https://rpc.example.chain', }, ]); @@ -107,6 +110,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: 'https://rpc.example.chain', }, ]); @@ -124,6 +128,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: 'https://rpc.example.chain', }, ]); @@ -143,6 +148,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: 'https://rpc.example.chain', }, ]); @@ -173,11 +179,13 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: 'https://first.endpoint', }, { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: 'https://second.endpoint', fetchOptions: { headers: { @@ -188,6 +196,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: 'https://third.chain', }, ]); @@ -242,16 +251,19 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: 'https://first.endpoint', }, { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: 'https://second.endpoint', }, { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: 'https://third.chain', }, ]); @@ -345,11 +357,13 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: 'https://first.endpoint', }, { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: 'https://second.endpoint', fetchOptions: { headers: { @@ -360,6 +374,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: 'https://third.chain', fetchOptions: { referrer: 'https://some.referrer', @@ -427,6 +442,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl, }, ]); @@ -501,11 +517,13 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: primaryEndpointUrl, }, { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: secondaryEndpointUrl, fetchOptions: { headers: { @@ -516,6 +534,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: tertiaryEndpointUrl, }, ]); @@ -593,11 +612,13 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: primaryEndpointUrl, }, { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: secondaryEndpointUrl, }, ]); @@ -657,11 +678,13 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: primaryEndpointUrl, }, { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: secondaryEndpointUrl, }, ]); @@ -756,11 +779,13 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: primaryEndpointUrl, }, { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: secondaryEndpointUrl, }, ]); @@ -856,11 +881,13 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: primaryEndpointUrl, }, { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: secondaryEndpointUrl, }, ]); @@ -940,16 +967,19 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: primaryEndpointUrl, }, { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: secondaryEndpointUrl, }, { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: tertiaryEndpointUrl, }, ]); @@ -1034,6 +1064,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl, }, ]); @@ -1091,6 +1122,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl, }, ]); @@ -1154,6 +1186,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl, }, ]); @@ -1219,11 +1252,13 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: primaryEndpointUrl, }, { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: secondaryEndpointUrl, }, ]); @@ -1292,6 +1327,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl, }, ]); @@ -1374,11 +1410,13 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: primaryEndpointUrl, }, { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: secondaryEndpointUrl, }, ]); @@ -1445,6 +1483,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl, }, ]); @@ -1509,6 +1548,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl, }, ]); @@ -1579,6 +1619,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl, }, ]); @@ -1655,11 +1696,13 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: primaryEndpointUrl, }, { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: secondaryEndpointUrl, }, ]); @@ -1739,6 +1782,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl, }, ]); @@ -1831,11 +1875,13 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: primaryEndpointUrl, }, { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: secondaryEndpointUrl, }, ]); @@ -1924,6 +1970,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl, }, ]); @@ -1976,11 +2023,13 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: primaryEndpointUrl, }, { fetch, btoa, + isOffline: (): boolean => false, endpointUrl: secondaryEndpointUrl, }, ]); @@ -2046,6 +2095,7 @@ describe('RpcServiceChain', () => { { fetch, btoa, + isOffline: (): boolean => false, endpointUrl, }, ]); diff --git a/packages/network-controller/src/rpc-service/rpc-service.test.ts b/packages/network-controller/src/rpc-service/rpc-service.test.ts index 01ebe80dca6..ef1c6dff1d8 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.test.ts @@ -1455,7 +1455,9 @@ function testsForRetriableFetchErrors({ it('does not retry when offline, only makes one fetch call', async () => { const clock = getClock(); - const mockFetch = jest.fn(); + const mockFetch = jest.fn(() => { + throw producedError; + }); const service = new RpcService({ fetch: mockFetch, btoa, @@ -1481,7 +1483,9 @@ function testsForRetriableFetchErrors({ it('does not call onDegraded when offline', async () => { const clock = getClock(); - const mockFetch = jest.fn(); + const mockFetch = jest.fn(() => { + throw producedError; + }); const endpointUrl = 'https://rpc.example.chain'; const onDegradedListener = jest.fn(); const service = new RpcService({ @@ -1511,7 +1515,9 @@ function testsForRetriableFetchErrors({ it('does not call onBreak when offline', async () => { const clock = getClock(); - const mockFetch = jest.fn(); + const mockFetch = jest.fn(() => { + throw producedError; + }); const endpointUrl = 'https://rpc.example.chain'; const onBreakListener = jest.fn(); const service = new RpcService({ diff --git a/packages/network-controller/tests/NetworkController.test.ts b/packages/network-controller/tests/NetworkController.test.ts index 1937cfd9b8b..311333834ee 100644 --- a/packages/network-controller/tests/NetworkController.test.ts +++ b/packages/network-controller/tests/NetworkController.test.ts @@ -179,6 +179,7 @@ describe('NetworkController', () => { > => ({ fetch, btoa, + isOffline: (): boolean => false, }), }), ).toThrow( @@ -208,6 +209,7 @@ describe('NetworkController', () => { > => ({ fetch, btoa, + isOffline: (): boolean => false, }), }), ).toThrow( @@ -244,6 +246,7 @@ describe('NetworkController', () => { > => ({ fetch, btoa, + isOffline: (): boolean => false, }), }), ).toThrow( @@ -279,6 +282,7 @@ describe('NetworkController', () => { > => ({ fetch, btoa, + isOffline: (): boolean => false, }), }), ).toThrow( @@ -314,6 +318,7 @@ describe('NetworkController', () => { > => ({ fetch, btoa, + isOffline: (): boolean => false, }), }), ).toThrow( @@ -359,6 +364,7 @@ describe('NetworkController', () => { > => ({ fetch, btoa, + isOffline: (): boolean => false, }), }), ).toThrow( @@ -399,6 +405,7 @@ describe('NetworkController', () => { > => ({ fetch, btoa, + isOffline: (): boolean => false, }), }); @@ -452,6 +459,7 @@ describe('NetworkController', () => { > => ({ fetch, btoa, + isOffline: (): boolean => false, }), }); @@ -4635,6 +4643,7 @@ describe('NetworkController', () => { > => ({ btoa, fetch, + isOffline: (): boolean => false, fetchOptions: { headers: { 'X-Foo': 'Bar', @@ -6065,6 +6074,7 @@ describe('NetworkController', () => { > => ({ btoa, fetch, + isOffline: (): boolean => false, fetchOptions: { headers: { 'X-Foo': 'Bar', @@ -6297,6 +6307,7 @@ describe('NetworkController', () => { > => ({ btoa, fetch, + isOffline: (): boolean => false, fetchOptions: { headers: { 'X-Foo': 'Bar', @@ -7296,6 +7307,7 @@ describe('NetworkController', () => { > => ({ btoa, fetch, + isOffline: (): boolean => false, fetchOptions: { headers: { 'X-Foo': 'Bar', @@ -8165,6 +8177,7 @@ describe('NetworkController', () => { > => ({ btoa, fetch, + isOffline: (): boolean => false, fetchOptions: { headers: { 'X-Foo': 'Bar', @@ -9163,6 +9176,7 @@ describe('NetworkController', () => { > => ({ btoa, fetch, + isOffline: (): boolean => false, fetchOptions: { headers: { 'X-Foo': 'Bar', @@ -10328,6 +10342,7 @@ describe('NetworkController', () => { > => ({ btoa, fetch, + isOffline: (): boolean => false, fetchOptions: { headers: { 'X-Foo': 'Bar', @@ -11044,6 +11059,7 @@ describe('NetworkController', () => { > => ({ btoa, fetch, + isOffline: (): boolean => false, fetchOptions: { headers: { 'X-Foo': 'Bar', @@ -11778,6 +11794,7 @@ describe('NetworkController', () => { > => ({ btoa, fetch, + isOffline: (): boolean => false, fetchOptions: { headers: { 'X-Foo': 'Bar', @@ -12487,6 +12504,7 @@ describe('NetworkController', () => { > => ({ btoa, fetch, + isOffline: (): boolean => false, fetchOptions: { headers: { 'X-Foo': 'Bar', From 2a3b2a3a781e25aafb94f2fe6d0c62b6fcdae489 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 15 Jan 2026 17:25:57 +0100 Subject: [PATCH 11/11] refactor: rpc-service.test.ts offline tests --- .../src/rpc-service/rpc-service.test.ts | 204 +++++++++--------- 1 file changed, 103 insertions(+), 101 deletions(-) diff --git a/packages/network-controller/src/rpc-service/rpc-service.test.ts b/packages/network-controller/src/rpc-service/rpc-service.test.ts index ef1c6dff1d8..18edeb6c867 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.test.ts @@ -601,6 +601,109 @@ describe('RpcService', () => { }, ); + describe('when offline', () => { + it('does not retry when offline, only makes one fetch call', async () => { + const expectedError = new TypeError('Failed to fetch'); + const mockFetch = jest.fn(() => { + throw expectedError; + }); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + isOffline: (): boolean => true, + }); + service.onRetry(() => { + clock.next(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // When offline, no retries should happen, so only 1 fetch call + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('does not call onDegraded when offline', async () => { + const expectedError = new TypeError('Failed to fetch'); + const mockFetch = jest.fn(() => { + throw expectedError; + }); + const endpointUrl = 'https://rpc.example.chain'; + const onDegradedListener = jest.fn(); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl, + isOffline: (): boolean => true, + }); + service.onRetry(() => { + clock.next(); + }); + service.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + // When offline, retries don't happen, so onDegraded should not be called + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('does not call onBreak when offline', async () => { + const expectedError = new TypeError('Failed to fetch'); + const mockFetch = jest.fn(() => { + throw expectedError; + }); + const endpointUrl = 'https://rpc.example.chain'; + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl, + isOffline: (): boolean => true, + }); + service.onRetry(() => { + clock.next(); + }); + service.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Make multiple requests - even though we'd normally break the circuit, + // when offline, no retries happen so circuit won't break + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + // When offline, retries don't happen, so circuit won't break and onBreak + // should not be called + expect(onBreakListener).not.toHaveBeenCalled(); + }); + }); + it('removes non-JSON-RPC-compliant properties from the request body before sending it to the endpoint', async () => { const endpointUrl = 'https://rpc.example.chain'; nock(endpointUrl) @@ -1453,107 +1556,6 @@ function testsForRetriableFetchErrors({ expect(onAvailableListener).toHaveBeenCalledTimes(1); }); - it('does not retry when offline, only makes one fetch call', async () => { - const clock = getClock(); - const mockFetch = jest.fn(() => { - throw producedError; - }); - const service = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl: 'https://rpc.example.chain', - isOffline: (): boolean => true, - }); - service.onRetry(() => { - clock.next(); - }); - - const jsonRpcRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', - params: [], - }; - await expect(service.request(jsonRpcRequest)).rejects.toThrow( - expectedError, - ); - // When offline, no retries should happen, so only 1 fetch call - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it('does not call onDegraded when offline', async () => { - const clock = getClock(); - const mockFetch = jest.fn(() => { - throw producedError; - }); - const endpointUrl = 'https://rpc.example.chain'; - const onDegradedListener = jest.fn(); - const service = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl, - isOffline: (): boolean => true, - }); - service.onRetry(() => { - clock.next(); - }); - service.onDegraded(onDegradedListener); - - const jsonRpcRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', - params: [], - }; - await expect(service.request(jsonRpcRequest)).rejects.toThrow( - expectedError, - ); - - // When offline, retries don't happen, so onDegraded should not be called - expect(onDegradedListener).not.toHaveBeenCalled(); - }); - - it('does not call onBreak when offline', async () => { - const clock = getClock(); - const mockFetch = jest.fn(() => { - throw producedError; - }); - const endpointUrl = 'https://rpc.example.chain'; - const onBreakListener = jest.fn(); - const service = new RpcService({ - fetch: mockFetch, - btoa, - endpointUrl, - isOffline: (): boolean => true, - }); - service.onRetry(() => { - clock.next(); - }); - service.onBreak(onBreakListener); - - const jsonRpcRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'eth_chainId', - params: [], - }; - // Make multiple requests - even though we'd normally break the circuit, - // when offline, no retries happen so circuit won't break - await expect(service.request(jsonRpcRequest)).rejects.toThrow( - expectedError, - ); - await expect(service.request(jsonRpcRequest)).rejects.toThrow( - expectedError, - ); - await expect(service.request(jsonRpcRequest)).rejects.toThrow( - expectedError, - ); - - // When offline, retries don't happen, so circuit won't break and onBreak - // should not be called - expect(onBreakListener).not.toHaveBeenCalled(); - }); - /* eslint-enable jest/require-top-level-describe */ }