diff --git a/.github/workflows/build-test-lint.yml b/.github/workflows/build-test-lint.yml index c88f2608..7832c3d1 100644 --- a/.github/workflows/build-test-lint.yml +++ b/.github/workflows/build-test-lint.yml @@ -29,6 +29,13 @@ jobs: - name: Validate changelog if: ${{ !startsWith(github.ref, 'release/') }} run: yarn auto-changelog validate + - name: Require clean working directory + shell: bash + run: | + if ! git diff --exit-code; then + echo "Working tree dirty at end of job" + exit 1 + fi all-jobs-pass: name: All jobs pass runs-on: ubuntu-20.04 diff --git a/README.md b/README.md index 34bb9db0..1ab3de38 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ The Ethereum provider object injected by MetaMask into various environments. Contains a lot of implementation details specific to MetaMask, and is probably not suitable for out-of-the-box use with other wallets. -The `BaseProvider` implements the Ethereum JavaScript provider specification, [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193). `MetamaskInpageProvider` implements [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193) and legacy interfaces. +The `BaseProvider` implements the Ethereum JavaScript provider specification ([EIP-1193]), but must be modified by a sub-class in order to function. +`StreamProvider` is such a sub-class, which synchronizes its state and marshals JSON-RPC messages via a duplex stream. +`MetamaskInpageProvider` further extends `StreamProvider` to support legacy provider interfaces in addition to [EIP-1193], and is used to instantiate the object injected by MetaMask into web pages as `window.ethereum`. ## Usage @@ -65,3 +67,5 @@ The project follows the same release process as the other libraries in the MetaM 6. Once approved, the PR is squashed & merged 7. The commit on the base branch is tagged 8. The tag can be published as needed + +[eip-1193]: https://eips.ethereum.org/EIPS/eip-1193 diff --git a/jest.config.js b/jest.config.js index 67eab2f9..bbac3dd7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,16 +8,30 @@ const baseConfig = { // original implementations, between each test. It does not affect mocked // modules. restoreMocks: true, - testTimeout: 2500, }; module.exports = { + collectCoverage: true, + collectCoverageFrom: [ + '/**/src/**/*.ts', + '!/**/src/**/*.test.ts', + ], + coverageReporters: ['html', 'json-summary', 'text'], + coveragePathIgnorePatterns: ['/node_modules/', '/mocks/', '/test/'], + coverageThreshold: { + global: { + branches: 56.19, + functions: 53.33, + lines: 58.44, + statements: 58.73, + }, + }, projects: [ { ...baseConfig, - displayName: 'BaseProvider', + displayName: 'StreamProvider', testEnvironment: 'node', - testMatch: ['**/BaseProvider.test.ts'], + testMatch: ['**/StreamProvider.test.ts', '**/utils.test.ts'], }, { ...baseConfig, @@ -30,18 +44,6 @@ module.exports = { setupFilesAfterEnv: ['./jest.setup.js'], }, ], - collectCoverage: true, - collectCoverageFrom: ['./src/**.ts'], - coverageReporters: ['text', 'html'], - coveragePathIgnorePatterns: ['/node_modules/', '/mocks/'], - // TODO: Require coverage when we're closer to home. - // coverageThreshold: { - // global: { - // branches: 100, - // functions: 100, - // lines: 100, - // statements: 100, - // }, - // }, silent: true, + testTimeout: 2500, }; diff --git a/mocks/DuplexStream.ts b/mocks/DuplexStream.ts index 38864cba..868a8a7c 100644 --- a/mocks/DuplexStream.ts +++ b/mocks/DuplexStream.ts @@ -1,6 +1,6 @@ import { Duplex } from 'stream'; -export default class DuplexStream extends Duplex { +export class MockDuplexStream extends Duplex { constructor() { super({ objectMode: true, diff --git a/package.json b/package.json index 3d7dc036..c4e904ae 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "scripts": { "setup": "yarn install && yarn allow-scripts", "build": "mkdir -p dist && rm -rf dist/* && tsc --project .", - "test": "jest", + "test": "jest && jest-it-up", "test:watch": "yarn test --watch", "lint:eslint": "eslint . --cache --ext js,ts", "lint:misc": "prettier '**/*.json' '**/*.md' '!CHANGELOG.md' '**/*.yml' --single-quote --ignore-path .gitignore", @@ -76,6 +76,7 @@ "eslint-plugin-prettier": "^3.4.0", "jest": "^26.6.3", "jest-chrome": "^0.7.1", + "jest-it-up": "^2.0.2", "prettier": "^2.3.1", "ts-jest": "^26.5.6", "typescript": "^4.3.5" diff --git a/src/BaseProvider.test.ts b/src/BaseProvider.test.ts deleted file mode 100644 index d9f285db..00000000 --- a/src/BaseProvider.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -import MockDuplexStream from '../mocks/DuplexStream'; -import BaseProvider from './BaseProvider'; -import messages from './messages'; - -const MOCK_ERROR_MESSAGE = 'Did you specify a mock return value?'; - -function initializeProvider() { - const mockStream = new MockDuplexStream(); - const provider = new BaseProvider(mockStream); - (provider as any).mockStream = mockStream; - (provider as any).autoRefreshOnNetworkChange = false; - return provider; -} - -describe('BaseProvider: RPC', () => { - // mocking the underlying stream, and testing the basic functionality of - // .reqest, .sendAsync, and .send - describe('integration', () => { - let provider: BaseProvider; - const mockRpcEngineResponse = jest - .fn() - .mockReturnValue([new Error(MOCK_ERROR_MESSAGE), undefined]); - - const setNextRpcEngineResponse = (err: Error | null = null, res = {}) => { - mockRpcEngineResponse.mockReturnValueOnce([err, res]); - }; - - beforeEach(() => { - provider = initializeProvider(); - jest - .spyOn(provider as any, '_handleAccountsChanged') - .mockImplementation(); - jest - .spyOn((provider as any)._rpcEngine, 'handle') - // eslint-disable-next-line node/no-callback-literal - .mockImplementation((_payload, cb: any) => - cb(...mockRpcEngineResponse()), - ); - }); - - it('.request returns result on success', async () => { - setNextRpcEngineResponse(null, { result: 42 }); - const result = await provider.request({ method: 'foo', params: ['bar'] }); - expect((provider as any)._rpcEngine.handle).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'foo', - params: ['bar'], - }), - expect.any(Function), - ); - - expect(result).toBe(42); - }); - - it('.request throws on error', async () => { - setNextRpcEngineResponse(new Error('foo')); - - await expect( - provider.request({ method: 'foo', params: ['bar'] }), - ).rejects.toThrow('foo'); - - expect((provider as any)._rpcEngine.handle).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'foo', - params: ['bar'], - }), - expect.any(Function), - ); - }); - }); - - describe('.request', () => { - let provider: BaseProvider; - const mockRpcRequestResponse = jest - .fn() - .mockReturnValue([new Error(MOCK_ERROR_MESSAGE), undefined]); - - const setNextRpcRequestResponse = (err: any = null, res = {}) => { - mockRpcRequestResponse.mockReturnValueOnce([err, res]); - }; - - beforeEach(() => { - provider = initializeProvider(); - jest - .spyOn(provider as any, '_rpcRequest') - .mockImplementation( - (_payload: unknown, cb: any, _isInternal: unknown) => - // eslint-disable-next-line node/no-callback-literal - cb(...mockRpcRequestResponse()), - ); - }); - - it('returns result on success', async () => { - setNextRpcRequestResponse(null, { result: 42 }); - const result = await provider.request({ method: 'foo', params: ['bar'] }); - - expect(result).toBe(42); - - expect((provider as any)._rpcRequest).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'foo', - params: ['bar'], - }), - expect.any(Function), - ); - }); - - it('throws on error', async () => { - setNextRpcRequestResponse(new Error('foo')); - - await expect( - provider.request({ method: 'foo', params: ['bar'] }), - ).rejects.toThrow('foo'); - - expect((provider as any)._rpcRequest).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'foo', - params: ['bar'], - }), - expect.any(Function), - ); - }); - - it('throws on non-object args', async () => { - await expect(() => provider.request(undefined as any)).rejects.toThrow( - messages.errors.invalidRequestArgs(), - ); - - await expect(() => provider.request(null as any)).rejects.toThrow( - messages.errors.invalidRequestArgs(), - ); - - await expect(() => provider.request([] as any)).rejects.toThrow( - messages.errors.invalidRequestArgs(), - ); - - await expect(() => provider.request('foo' as any)).rejects.toThrow( - messages.errors.invalidRequestArgs(), - ); - }); - - it('throws on invalid args.method', async () => { - await expect(() => provider.request({} as any)).rejects.toThrow( - messages.errors.invalidRequestMethod(), - ); - - await expect(() => - provider.request({ method: null } as any), - ).rejects.toThrow(messages.errors.invalidRequestMethod()); - - await expect(() => - provider.request({ - method: 2 as any, - }), - ).rejects.toThrow(messages.errors.invalidRequestMethod()); - - await expect(() => provider.request({ method: '' })).rejects.toThrow( - messages.errors.invalidRequestMethod(), - ); - }); - - it('throws on invalid args.params', async () => { - await expect(() => - provider.request({ method: 'foo', params: null } as any), - ).rejects.toThrow(messages.errors.invalidRequestParams()); - - await expect(() => - provider.request({ method: 'foo', params: 2 } as any), - ).rejects.toThrow(messages.errors.invalidRequestParams()); - - await expect(() => - provider.request({ method: 'foo', params: true } as any), - ).rejects.toThrow(messages.errors.invalidRequestParams()); - - await expect(() => - provider.request({ method: 'foo', params: 'a' } as any), - ).rejects.toThrow(messages.errors.invalidRequestParams()); - }); - }); - - // this also tests sendAsync, it being effectively an alias for this method - describe('._rpcRequest', () => { - let provider: BaseProvider; - const mockRpcEngineResponse = jest - .fn() - .mockReturnValue([new Error(MOCK_ERROR_MESSAGE), undefined]); - - const setNextRpcEngineResponse = (err: Error | null = null, res = {}) => { - mockRpcEngineResponse.mockReturnValueOnce([err, res]); - }; - - beforeEach(() => { - provider = initializeProvider(); - jest - .spyOn(provider as any, '_handleAccountsChanged') - .mockImplementation(); - jest - .spyOn((provider as any)._rpcEngine, 'handle') - // eslint-disable-next-line node/no-callback-literal - .mockImplementation((_payload, cb: any) => - cb(...mockRpcEngineResponse()), - ); - }); - - it('returns response object on success', async () => { - setNextRpcEngineResponse(null, { result: 42 }); - await new Promise((done) => { - (provider as any)._rpcRequest( - { method: 'foo', params: ['bar'] }, - (err: Error | null, res: any) => { - expect((provider as any)._rpcEngine.handle).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'foo', - params: ['bar'], - }), - expect.any(Function), - ); - - expect(err).toBeNull(); - expect(res).toStrictEqual({ result: 42 }); - done(undefined); - }, - ); - }); - }); - - it('returns response object on error', async () => { - setNextRpcEngineResponse(new Error('foo'), { error: 'foo' }); - await new Promise((done) => { - (provider as any)._rpcRequest( - { method: 'foo', params: ['bar'] }, - (err: Error | null, res: any) => { - expect((provider as any)._rpcEngine.handle).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'foo', - params: ['bar'], - }), - expect.any(Function), - ); - - expect(err).toStrictEqual(new Error('foo')); - expect(res).toStrictEqual({ error: 'foo' }); - done(undefined); - }, - ); - }); - }); - - it('calls _handleAccountsChanged on request for eth_accounts', async () => { - setNextRpcEngineResponse(null, { result: ['0x1'] }); - await new Promise((done) => { - (provider as any)._rpcRequest( - { method: 'eth_accounts' }, - (err: Error | null, res: any) => { - expect((provider as any)._rpcEngine.handle).toHaveBeenCalledWith( - expect.objectContaining({ method: 'eth_accounts' }), - expect.any(Function), - ); - - expect( - (provider as any)._handleAccountsChanged, - ).toHaveBeenCalledWith(['0x1'], true); - - expect(err).toBeNull(); - expect(res).toStrictEqual({ result: ['0x1'] }); - done(undefined); - }, - ); - }); - }); - - it('calls _handleAccountsChanged with empty array on eth_accounts request returning error', async () => { - setNextRpcEngineResponse(new Error('foo'), { error: 'foo' }); - await new Promise((done) => { - (provider as any)._rpcRequest( - { method: 'eth_accounts' }, - (err: Error | null, res: any) => { - expect((provider as any)._rpcEngine.handle).toHaveBeenCalledWith( - expect.objectContaining({ method: 'eth_accounts' }), - expect.any(Function), - ); - - expect( - (provider as any)._handleAccountsChanged, - ).toHaveBeenCalledWith([], true); - - expect(err).toStrictEqual(new Error('foo')); - expect(res).toStrictEqual({ error: 'foo' }); - done(undefined); - }, - ); - }); - }); - }); - - describe('provider events', () => { - it('calls chainChanged when the chainId changes', async () => { - const mockStream = new MockDuplexStream(); - const baseProvider = new BaseProvider(mockStream); - (baseProvider as any)._state.initialized = true; - - await new Promise((resolve) => { - baseProvider.once('chainChanged', (newChainId) => { - expect(newChainId).toBe('0x1'); - resolve(undefined); - }); - - mockStream.push({ - name: 'metamask-provider', - data: { - jsonrpc: '2.0', - method: 'metamask_chainChanged', - params: { chainId: '0x1', networkVersion: '0x1' }, - }, - }); - }); - }); - }); -}); diff --git a/src/BaseProvider.ts b/src/BaseProvider.ts index fe544eb4..32ee6127 100644 --- a/src/BaseProvider.ts +++ b/src/BaseProvider.ts @@ -1,29 +1,20 @@ -import { Duplex } from 'stream'; -import pump from 'pump'; +import SafeEventEmitter from '@metamask/safe-event-emitter'; +import { ethErrors, EthereumRpcError } from 'eth-rpc-errors'; +import dequal from 'fast-deep-equal'; import { JsonRpcEngine, - createIdRemapMiddleware, JsonRpcRequest, JsonRpcId, JsonRpcVersion, JsonRpcSuccess, JsonRpcMiddleware, } from 'json-rpc-engine'; -import { createStreamMiddleware } from 'json-rpc-middleware-stream'; -import ObjectMultiplex from '@metamask/object-multiplex'; -import SafeEventEmitter from '@metamask/safe-event-emitter'; -import dequal from 'fast-deep-equal'; -import { ethErrors, EthereumRpcError } from 'eth-rpc-errors'; -import { duplex as isDuplex } from 'is-stream'; - import messages from './messages'; import { - createErrorMiddleware, - EMITTED_NOTIFICATIONS, getRpcPromiseCallback, - logStreamDisconnectWarning, ConsoleLike, Maybe, + isValidChainId, } from './utils'; export interface UnvalidatedJsonRpcRequest { @@ -34,11 +25,6 @@ export interface UnvalidatedJsonRpcRequest { } export interface BaseProviderOptions { - /** - * The name of the stream used to connect to the wallet. - */ - jsonRpcStreamName?: string; - /** * The logging API to use. */ @@ -48,6 +34,12 @@ export interface BaseProviderOptions { * The maximum number of event listeners. */ maxEventListeners?: number; + + /** + * `json-rpc-engine` middleware. The middleware will be inserted in the given + * order immediately after engine initialization. + */ + rpcMiddleware?: JsonRpcMiddleware[]; } export interface RequestArguments { @@ -66,21 +58,23 @@ export interface BaseProviderState { isPermanentlyDisconnected: boolean; } -export interface JsonRpcConnection { - events: SafeEventEmitter; - middleware: JsonRpcMiddleware; - stream: Duplex; -} - -export default class BaseProvider extends SafeEventEmitter { +/** + * An abstract class implementing the EIP-1193 interface. Implementers must: + * + * 1. At initialization, push a middleware to the internal `_rpcEngine` that + * hands off requests to the server and receives responses in return. + * 2. At initialization, retrieve initial state and call + * {@link BaseProvider._initializeState} **once**. + * 3. Ensure that the provider's state is synchronized with the wallet. + * 4. Ensure that notifications are received and emitted as appropriate. + */ +export abstract class BaseProvider extends SafeEventEmitter { protected readonly _log: ConsoleLike; protected _state: BaseProviderState; protected _rpcEngine: JsonRpcEngine; - protected _jsonRpcConnection: JsonRpcConnection; - protected static _defaultState: BaseProviderState = { accounts: null, isConnected: false, @@ -103,106 +97,52 @@ export default class BaseProvider extends SafeEventEmitter { public selectedAddress: string | null; /** - * @param connectionStream - A Node.js duplex stream * @param options - An options bag - * @param options.jsonRpcStreamName - The name of the internal JSON-RPC stream. - * Default: metamask-provider * @param options.logger - The logging API to use. Default: console * @param options.maxEventListeners - The maximum number of event * listeners. Default: 100 */ - constructor( - connectionStream: Duplex, - { - jsonRpcStreamName = 'metamask-provider', - logger = console, - maxEventListeners = 100, - }: BaseProviderOptions = {}, - ) { + constructor({ + logger = console, + maxEventListeners = 100, + rpcMiddleware = [], + }: BaseProviderOptions = {}) { super(); - if (!isDuplex(connectionStream)) { - throw new Error(messages.errors.invalidDuplexStream()); - } - this._log = logger; this.setMaxListeners(maxEventListeners); - // private state + // Private state this._state = { ...BaseProvider._defaultState, }; - // public state + // Public state this.selectedAddress = null; this.chainId = null; - // bind functions (to prevent consumers from making unbound calls) + // Bind functions to prevent consumers from making unbound calls this._handleAccountsChanged = this._handleAccountsChanged.bind(this); this._handleConnect = this._handleConnect.bind(this); this._handleChainChanged = this._handleChainChanged.bind(this); this._handleDisconnect = this._handleDisconnect.bind(this); - this._handleStreamDisconnect = this._handleStreamDisconnect.bind(this); this._handleUnlockStateChanged = this._handleUnlockStateChanged.bind(this); this._rpcRequest = this._rpcRequest.bind(this); this.request = this.request.bind(this); - // setup connectionStream multiplexing - const mux = new ObjectMultiplex(); - pump( - connectionStream, - mux as unknown as Duplex, - connectionStream, - this._handleStreamDisconnect.bind(this, 'MetaMask'), - ); - - // setup own event listeners - - // EIP-1193 connect + // EIP-1193 connect, emitted in _initializeState this.on('connect', () => { this._state.isConnected = true; }); - // setup RPC connection - - this._jsonRpcConnection = createStreamMiddleware(); - pump( - this._jsonRpcConnection.stream, - mux.createStream(jsonRpcStreamName) as unknown as Duplex, - this._jsonRpcConnection.stream, - this._handleStreamDisconnect.bind(this, 'MetaMask RpcProvider'), - ); - - // handle RPC requests via dapp-side rpc engine + // Handle RPC requests via dapp-side RPC engine. + // + // ATTN: Implementers must push a middleware that hands off requests to + // the server. const rpcEngine = new JsonRpcEngine(); - rpcEngine.push(createIdRemapMiddleware()); - rpcEngine.push(createErrorMiddleware(this._log)); - rpcEngine.push(this._jsonRpcConnection.middleware); + rpcMiddleware.forEach((middleware) => rpcEngine.push(middleware)); this._rpcEngine = rpcEngine; - - this._initializeState(); - - // handle JSON-RPC notifications - this._jsonRpcConnection.events.on('notification', (payload) => { - const { method, params } = payload; - if (method === 'metamask_accountsChanged') { - this._handleAccountsChanged(params); - } else if (method === 'metamask_unlockStateChanged') { - this._handleUnlockStateChanged(params); - } else if (method === 'metamask_chainChanged') { - this._handleChainChanged(params); - } else if (EMITTED_NOTIFICATIONS.includes(method)) { - this.emit('message', { - type: method, - data: params, - }); - } else if (method === 'METAMASK_STREAM_FAILURE') { - connectionStream.destroy( - new Error(messages.errors.permanentlyDisconnected()), - ); - } - }); } //==================== @@ -267,37 +207,43 @@ export default class BaseProvider extends SafeEventEmitter { //==================== /** - * Constructor helper. - * Populates initial state by calling 'metamask_getProviderState' and emits - * necessary events. + * **MUST** be called by child classes. + * + * Sets initial state if provided and marks this provider as initialized. + * Throws if called more than once. + * + * Permits the `networkVersion` field in the parameter object for + * compatibility with child classes that use this value. + * + * @param initialState - The provider's initial state. + * @emits BaseProvider#_initialized + * @emits BaseProvider#connect - If `initialState` is defined. */ - private async _initializeState() { - try { - const { accounts, chainId, isUnlocked, networkVersion } = - (await this.request({ - method: 'metamask_getProviderState', - })) as { - accounts: string[]; - chainId: string; - isUnlocked: boolean; - networkVersion: string; - }; + protected _initializeState(initialState?: { + accounts: string[]; + chainId: string; + isUnlocked: boolean; + networkVersion?: string; + }) { + if (this._state.initialized === true) { + throw new Error('Provider already initialized.'); + } - // indicate that we've connected, for EIP-1193 compliance + if (initialState) { + const { accounts, chainId, isUnlocked, networkVersion } = initialState; + + // EIP-1193 connect this.emit('connect', { chainId }); this._handleChainChanged({ chainId, networkVersion }); this._handleUnlockStateChanged({ accounts, isUnlocked }); this._handleAccountsChanged(accounts); - } catch (error) { - this._log.error( - 'MetaMask: Failed to get initial state. Please report this bug.', - error, - ); - } finally { - this._state.initialized = true; - this.emit('_initialized'); } + + // Mark provider as initialized regardless of whether initial state was + // retrieved. + this._state.initialized = true; + this.emit('_initialized'); } /** @@ -360,7 +306,7 @@ export default class BaseProvider extends SafeEventEmitter { * * @param isRecoverable - Whether the disconnection is recoverable. * @param errorMessage - A custom error message. - * @emits MetaMaskInpageProvider#disconnect + * @emits BaseProvider#disconnect */ protected _handleDisconnect(isRecoverable: boolean, errorMessage?: string) { if ( @@ -394,54 +340,31 @@ export default class BaseProvider extends SafeEventEmitter { } /** - * Called when connection is lost to critical streams. + * Upon receipt of a new `chainId`, emits the corresponding event and sets + * and sets relevant public state. Does nothing if the given `chainId` is + * equivalent to the existing value. * - * @emits MetamaskInpageProvider#disconnect - */ - protected _handleStreamDisconnect(streamName: string, error: Error) { - logStreamDisconnectWarning(this._log, streamName, error, this); - this._handleDisconnect(false, error ? error.message : undefined); - } - - /** - * Upon receipt of a new chainId and networkVersion, emits corresponding - * events and sets relevant public state. - * Does nothing if neither the chainId nor the networkVersion are different - * from existing values. + * Permits the `networkVersion` field in the parameter object for + * compatibility with child classes that use this value. * - * @emits MetamaskInpageProvider#chainChanged + * @emits BaseProvider#chainChanged * @param networkInfo - An object with network info. * @param networkInfo.chainId - The latest chain ID. - * @param networkInfo.networkVersion - The latest network ID. */ protected _handleChainChanged({ chainId, - networkVersion, }: { chainId?: string; networkVersion?: string } = {}) { - if ( - !chainId || - typeof chainId !== 'string' || - !chainId.startsWith('0x') || - !networkVersion || - typeof networkVersion !== 'string' - ) { - this._log.error( - 'MetaMask: Received invalid network parameters. Please report this bug.', - { chainId, networkVersion }, - ); + if (!isValidChainId(chainId)) { + this._log.error(messages.errors.invalidNetworkParams(), { chainId }); return; } - if (networkVersion === 'loading') { - this._handleDisconnect(true); - } else { - this._handleConnect(chainId); + this._handleConnect(chainId); - if (chainId !== this.chainId) { - this.chainId = chainId; - if (this._state.initialized) { - this.emit('chainChanged', this.chainId); - } + if (chainId !== this.chainId) { + this.chainId = chainId; + if (this._state.initialized) { + this.emit('chainChanged', this.chainId); } } } diff --git a/src/MetaMaskInpageProvider.test.ts b/src/MetaMaskInpageProvider.test.ts index 39148bcb..72e506cb 100644 --- a/src/MetaMaskInpageProvider.test.ts +++ b/src/MetaMaskInpageProvider.test.ts @@ -1,7 +1,50 @@ -import MockDuplexStream from '../mocks/DuplexStream'; -import MetaMaskInpageProvider from './MetaMaskInpageProvider'; +import { MockDuplexStream } from '../mocks/DuplexStream'; +import { + MetaMaskInpageProviderStreamName, + MetaMaskInpageProvider, +} from './MetaMaskInpageProvider'; import messages from './messages'; +/** + * For legacy purposes, MetaMaskInpageProvider retrieves state from the wallet + * in its constructor. This operation is asynchronous, and initiated via + * {@link MetaMaskInpageProvider._initializeStateAsync}. This helper function + * returns a provider initialized with the specified values. + * + * @param options - Options bag. See {@link MetaMaskInpageProvider._initializeState}. + * @returns A tuple of the initialized provider and its stream. + */ +async function getInitializedProvider({ + accounts = [], + chainId = '0x0', + isUnlocked = true, + networkVersion = '0', +}: Partial[0]> = {}) { + // This will be called via the constructor + const requestMock = jest + .spyOn(MetaMaskInpageProvider.prototype, 'request') + .mockImplementationOnce(async () => { + return { + accounts, + chainId, + isUnlocked, + networkVersion, + }; + }); + + const mockStream = new MockDuplexStream(); + const inpageProvider = new MetaMaskInpageProvider(mockStream); + + // Relinquish control of the event loop to ensure that the mocked state is + // retrieved. + await new Promise((resolve) => setTimeout(() => resolve(), 1)); + + expect(requestMock).toHaveBeenCalledTimes(1); // Sanity check + requestMock.mockRestore(); // Get rid of the mock + + return [inpageProvider, mockStream] as const; +} + describe('MetaMaskInpageProvider: RPC', () => { const MOCK_ERROR_MESSAGE = 'Did you specify a mock return value?'; @@ -618,49 +661,109 @@ describe('MetaMaskInpageProvider: RPC', () => { }); describe('provider events', () => { - it('calls chainChanged when it chainId changes ', async () => { - const mockStream = new MockDuplexStream(); - const inpageProvider = new MetaMaskInpageProvider(mockStream); - (inpageProvider as any)._state.initialized = true; + it('calls chainChanged when receiving a new chainId ', async () => { + const [inpageProvider, mockStream] = await getInitializedProvider(); await new Promise((resolve) => { - inpageProvider.on('chainChanged', (newChainId) => { + inpageProvider.once('chainChanged', (newChainId) => { expect(newChainId).toBe('0x1'); resolve(undefined); }); mockStream.push({ - name: 'metamask-provider', + name: MetaMaskInpageProviderStreamName, data: { jsonrpc: '2.0', method: 'metamask_chainChanged', - params: { chainId: '0x1', networkVersion: '0x1' }, + params: { chainId: '0x1', networkVersion: '1' }, }, }); }); }); - it('calls networkChanged when it networkVersion changes ', async () => { - const mockStream = new MockDuplexStream(); - const inpageProvider = new MetaMaskInpageProvider(mockStream); - (inpageProvider as any)._state.initialized = true; + it('calls networkChanged when receiving a new networkVersion ', async () => { + const [inpageProvider, mockStream] = await getInitializedProvider(); await new Promise((resolve) => { - inpageProvider.on('networkChanged', (newNetworkId) => { - expect(newNetworkId).toBe('0x1'); + inpageProvider.once('networkChanged', (newNetworkId) => { + expect(newNetworkId).toBe('1'); resolve(undefined); }); mockStream.push({ - name: 'metamask-provider', + name: MetaMaskInpageProviderStreamName, data: { jsonrpc: '2.0', method: 'metamask_chainChanged', - params: { chainId: '0x1', networkVersion: '0x1' }, + params: { chainId: '0x1', networkVersion: '1' }, }, }); }); }); + + it('handles chain changes with intermittent disconnection', async () => { + const [inpageProvider, mockStream] = await getInitializedProvider(); + + // We check this mostly for the readability of this test. + expect(inpageProvider.isConnected()).toBe(true); + expect(inpageProvider.chainId).toBe('0x0'); + expect(inpageProvider.networkVersion).toBe('0'); + + const emitSpy = jest.spyOn(inpageProvider, 'emit'); + + await new Promise((resolve) => { + inpageProvider.once('disconnect', (error) => { + expect((error as any).code).toBe(1013); + resolve(); + }); + + mockStream.push({ + name: MetaMaskInpageProviderStreamName, + data: { + jsonrpc: '2.0', + method: 'metamask_chainChanged', + // A "loading" networkVersion indicates the network is changing. + // Although the chainId is different, chainChanged should not be + // emitted in this case. + params: { chainId: '0x1', networkVersion: 'loading' }, + }, + }); + }); + + // Only once, for "disconnect". + expect(emitSpy).toHaveBeenCalledTimes(1); + emitSpy.mockClear(); // Clear the mock to avoid keeping a count. + + expect(inpageProvider.isConnected()).toBe(false); + // These should be unchanged. + expect(inpageProvider.chainId).toBe('0x0'); + expect(inpageProvider.networkVersion).toBe('0'); + + await new Promise((resolve) => { + inpageProvider.once('chainChanged', (newChainId) => { + expect(newChainId).toBe('0x1'); + resolve(); + }); + + mockStream.push({ + name: MetaMaskInpageProviderStreamName, + data: { + jsonrpc: '2.0', + method: 'metamask_chainChanged', + params: { chainId: '0x1', networkVersion: '1' }, + }, + }); + }); + + expect(emitSpy).toHaveBeenCalledTimes(3); + expect(emitSpy).toHaveBeenNthCalledWith(1, 'connect', { chainId: '0x1' }); + expect(emitSpy).toHaveBeenCalledWith('chainChanged', '0x1'); + expect(emitSpy).toHaveBeenCalledWith('networkChanged', '1'); + + expect(inpageProvider.isConnected()).toBe(true); + expect(inpageProvider.chainId).toBe('0x1'); + expect(inpageProvider.networkVersion).toBe('1'); + }); }); }); @@ -730,6 +833,30 @@ describe('MetaMaskInpageProvider: Miscellanea', () => { }), ).not.toThrow(); }); + + it('gets initial state', async () => { + // This will be called via the constructor + const requestMock = jest + .spyOn(MetaMaskInpageProvider.prototype, 'request') + .mockImplementationOnce(async () => { + return { + accounts: ['0xabc'], + chainId: '0x0', + isUnlocked: true, + networkVersion: '0', + }; + }); + + const mockStream = new MockDuplexStream(); + const inpageProvider = new MetaMaskInpageProvider(mockStream); + + await new Promise((resolve) => setTimeout(() => resolve(), 1)); + expect(requestMock).toHaveBeenCalledTimes(1); + expect(inpageProvider.chainId).toBe('0x0'); + expect(inpageProvider.networkVersion).toBe('0'); + expect(inpageProvider.selectedAddress).toBe('0xabc'); + expect(inpageProvider.isConnected()).toBe(true); + }); }); describe('isConnected', () => { diff --git a/src/MetaMaskInpageProvider.ts b/src/MetaMaskInpageProvider.ts index dcff2def..607c8f25 100644 --- a/src/MetaMaskInpageProvider.ts +++ b/src/MetaMaskInpageProvider.ts @@ -1,13 +1,19 @@ -import { Duplex } from 'stream'; -import { JsonRpcRequest, JsonRpcResponse } from 'json-rpc-engine'; +import type { Duplex } from 'stream'; +import type { JsonRpcRequest, JsonRpcResponse } from 'json-rpc-engine'; import { ethErrors } from 'eth-rpc-errors'; -import sendSiteMetadata from './siteMetadata'; +import { sendSiteMetadata } from './siteMetadata'; import messages from './messages'; -import { EMITTED_NOTIFICATIONS, getRpcPromiseCallback, NOOP } from './utils'; -import BaseProvider, { - BaseProviderOptions, - UnvalidatedJsonRpcRequest, -} from './BaseProvider'; +import { + EMITTED_NOTIFICATIONS, + getDefaultExternalMiddleware, + getRpcPromiseCallback, + NOOP, +} from './utils'; +import type { UnvalidatedJsonRpcRequest } from './BaseProvider'; +import { + AbstractStreamProvider, + StreamProviderOptions, +} from './StreamProvider'; export interface SendSyncJsonRpcRequest extends JsonRpcRequest { method: @@ -19,7 +25,8 @@ export interface SendSyncJsonRpcRequest extends JsonRpcRequest { type WarningEventName = keyof SentWarningsState['events']; -export interface MetaMaskInpageProviderOptions extends BaseProviderOptions { +export interface MetaMaskInpageProviderOptions + extends Partial> { /** * Whether the provider should send page metadata. */ @@ -40,7 +47,12 @@ interface SentWarningsState { }; } -export default class MetaMaskInpageProvider extends BaseProvider { +/** + * The name of the stream consumed by {@link MetaMaskInpageProvider}. + */ +export const MetaMaskInpageProviderStreamName = 'metamask-provider'; + +export class MetaMaskInpageProvider extends AbstractStreamProvider { protected _sentWarnings: SentWarningsState = { // methods enable: false, @@ -83,13 +95,23 @@ export default class MetaMaskInpageProvider extends BaseProvider { constructor( connectionStream: Duplex, { - jsonRpcStreamName = 'metamask-provider', + jsonRpcStreamName = MetaMaskInpageProviderStreamName, logger = console, - maxEventListeners = 100, - shouldSendMetadata = true, + maxEventListeners, + shouldSendMetadata, }: MetaMaskInpageProviderOptions = {}, ) { - super(connectionStream, { jsonRpcStreamName, logger, maxEventListeners }); + super(connectionStream, { + jsonRpcStreamName, + logger, + maxEventListeners, + rpcMiddleware: getDefaultExternalMiddleware(logger), + }); + + // We shouldn't perform asynchronous work in the constructor, but at one + // point we started doing so, and changing this class isn't worth it at + // the time of writing. + this._initializeStateAsync(); this.networkVersion = null; this.isMetaMask = true; @@ -139,7 +161,7 @@ export default class MetaMaskInpageProvider extends BaseProvider { * Submits an RPC request per the given JSON-RPC request object. * * @param payload - The RPC request object. - * @param cb - The callback function. + * @param callback - The callback function. */ sendAsync( payload: JsonRpcRequest, @@ -195,7 +217,7 @@ export default class MetaMaskInpageProvider extends BaseProvider { * * @param isRecoverable - Whether the disconnection is recoverable. * @param errorMessage - A custom error message. - * @emits MetaMaskInpageProvider#disconnect + * @emits BaseProvider#disconnect */ protected _handleDisconnect(isRecoverable: boolean, errorMessage?: string) { super._handleDisconnect(isRecoverable, errorMessage); @@ -399,11 +421,9 @@ export default class MetaMaskInpageProvider extends BaseProvider { /** * Upon receipt of a new chainId and networkVersion, emits corresponding - * events and sets relevant public state. - * Does nothing if neither the chainId nor the networkVersion are different - * from existing values. + * events and sets relevant public state. Does nothing if neither the chainId + * nor the networkVersion are different from existing values. * - * @emits MetamaskInpageProvider#chainChanged * @emits MetamaskInpageProvider#networkChanged * @param networkInfo - An object with network info. * @param networkInfo.chainId - The latest chain ID. @@ -413,14 +433,12 @@ export default class MetaMaskInpageProvider extends BaseProvider { chainId, networkVersion, }: { chainId?: string; networkVersion?: string } = {}) { + // This will validate the params and disconnect the provider if the + // networkVersion is 'loading'. super._handleChainChanged({ chainId, networkVersion }); - if ( - networkVersion && - networkVersion !== 'loading' && - networkVersion !== this.networkVersion - ) { - this.networkVersion = networkVersion; + if (this._state.isConnected && networkVersion !== this.networkVersion) { + this.networkVersion = networkVersion as string; if (this._state.initialized) { this.emit('networkChanged', this.networkVersion); } diff --git a/src/StreamProvider.test.ts b/src/StreamProvider.test.ts new file mode 100644 index 00000000..863fea3e --- /dev/null +++ b/src/StreamProvider.test.ts @@ -0,0 +1,474 @@ +import type { JsonRpcMiddleware } from 'json-rpc-engine'; +import { MockDuplexStream } from '../mocks/DuplexStream'; +import { StreamProvider } from './StreamProvider'; +import messages from './messages'; + +const mockErrorMessage = 'Did you specify a mock return value?'; + +const mockStreamName = 'mock-stream'; + +function getStreamProvider( + rpcMiddleware?: JsonRpcMiddleware[], +) { + const mockStream = new MockDuplexStream(); + const streamProvider = new StreamProvider(mockStream, { + jsonRpcStreamName: mockStreamName, + rpcMiddleware, + }); + streamProvider.initialize(); + + return [streamProvider, mockStream] as const; +} + +describe('StreamProvider', () => { + describe('constructor', () => { + it('initializes state and emits events', async () => { + const accounts = ['0xabc']; + const chainId = '0x1'; + const networkVersion = '1'; + const isUnlocked = true; + + const streamProvider = new StreamProvider(new MockDuplexStream(), { + jsonRpcStreamName: mockStreamName, + }); + + const requestMock = jest + .spyOn(streamProvider, 'request') + .mockImplementationOnce(async () => { + return { + accounts, + chainId, + isUnlocked, + networkVersion, + }; + }); + + await streamProvider.initialize(); + + expect(streamProvider.chainId).toBe(chainId); + expect(streamProvider.selectedAddress).toBe(accounts[0]); + expect(streamProvider.isConnected()).toBe(true); + + expect(requestMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith({ + method: 'metamask_getProviderState', + }); + }); + }); + + describe('RPC', () => { + // mocking the underlying stream, and testing the basic functionality of + // .request, .sendAsync, and .send + describe('integration', () => { + let streamProvider: StreamProvider; + const mockRpcEngineResponse = jest + .fn() + .mockReturnValue([new Error(mockErrorMessage), undefined]); + + const setNextRpcEngineResponse = (err: Error | null = null, res = {}) => { + mockRpcEngineResponse.mockReturnValueOnce([err, res]); + }; + + beforeEach(() => { + [streamProvider] = getStreamProvider(); + jest + .spyOn(streamProvider as any, '_handleAccountsChanged') + .mockImplementation(); + jest + .spyOn((streamProvider as any)._rpcEngine, 'handle') + // eslint-disable-next-line node/no-callback-literal + .mockImplementation((_payload, cb: any) => + cb(...mockRpcEngineResponse()), + ); + }); + + it('.request returns result on success', async () => { + setNextRpcEngineResponse(null, { result: 42 }); + const result = await streamProvider.request({ + method: 'foo', + params: ['bar'], + }); + expect((streamProvider as any)._rpcEngine.handle).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'foo', + params: ['bar'], + }), + expect.any(Function), + ); + + expect(result).toBe(42); + }); + + it('.request throws on error', async () => { + setNextRpcEngineResponse(new Error('foo')); + + await expect( + streamProvider.request({ method: 'foo', params: ['bar'] }), + ).rejects.toThrow('foo'); + + expect((streamProvider as any)._rpcEngine.handle).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'foo', + params: ['bar'], + }), + expect.any(Function), + ); + }); + }); + + describe('.request', () => { + let streamProvider: StreamProvider; + const mockRpcRequestResponse = jest + .fn() + .mockReturnValue([new Error(mockErrorMessage), undefined]); + + const setNextRpcRequestResponse = (err: any = null, res = {}) => { + mockRpcRequestResponse.mockReturnValueOnce([err, res]); + }; + + beforeEach(() => { + [streamProvider] = getStreamProvider(); + jest + .spyOn(streamProvider as any, '_rpcRequest') + .mockImplementation( + (_payload: unknown, cb: any, _isInternal: unknown) => + // eslint-disable-next-line node/no-callback-literal + cb(...mockRpcRequestResponse()), + ); + }); + + it('returns result on success', async () => { + setNextRpcRequestResponse(null, { result: 42 }); + const result = await streamProvider.request({ + method: 'foo', + params: ['bar'], + }); + + expect(result).toBe(42); + + expect((streamProvider as any)._rpcRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'foo', + params: ['bar'], + }), + expect.any(Function), + ); + }); + + it('throws on error', async () => { + setNextRpcRequestResponse(new Error('foo')); + + await expect( + streamProvider.request({ method: 'foo', params: ['bar'] }), + ).rejects.toThrow('foo'); + + expect((streamProvider as any)._rpcRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'foo', + params: ['bar'], + }), + expect.any(Function), + ); + }); + + it('throws on non-object args', async () => { + await expect(() => + streamProvider.request(undefined as any), + ).rejects.toThrow(messages.errors.invalidRequestArgs()); + + await expect(() => streamProvider.request(null as any)).rejects.toThrow( + messages.errors.invalidRequestArgs(), + ); + + await expect(() => streamProvider.request([] as any)).rejects.toThrow( + messages.errors.invalidRequestArgs(), + ); + + await expect(() => + streamProvider.request('foo' as any), + ).rejects.toThrow(messages.errors.invalidRequestArgs()); + }); + + it('throws on invalid args.method', async () => { + await expect(() => streamProvider.request({} as any)).rejects.toThrow( + messages.errors.invalidRequestMethod(), + ); + + await expect(() => + streamProvider.request({ method: null } as any), + ).rejects.toThrow(messages.errors.invalidRequestMethod()); + + await expect(() => + streamProvider.request({ + method: 2 as any, + }), + ).rejects.toThrow(messages.errors.invalidRequestMethod()); + + await expect(() => + streamProvider.request({ method: '' }), + ).rejects.toThrow(messages.errors.invalidRequestMethod()); + }); + + it('throws on invalid args.params', async () => { + await expect(() => + streamProvider.request({ method: 'foo', params: null } as any), + ).rejects.toThrow(messages.errors.invalidRequestParams()); + + await expect(() => + streamProvider.request({ method: 'foo', params: 2 } as any), + ).rejects.toThrow(messages.errors.invalidRequestParams()); + + await expect(() => + streamProvider.request({ method: 'foo', params: true } as any), + ).rejects.toThrow(messages.errors.invalidRequestParams()); + + await expect(() => + streamProvider.request({ method: 'foo', params: 'a' } as any), + ).rejects.toThrow(messages.errors.invalidRequestParams()); + }); + }); + + // this also tests sendAsync, it being effectively an alias for this method + describe('._rpcRequest', () => { + let streamProvider: StreamProvider; + const mockRpcEngineResponse = jest + .fn() + .mockReturnValue([new Error(mockErrorMessage), undefined]); + + const setNextRpcEngineResponse = (err: Error | null = null, res = {}) => { + mockRpcEngineResponse.mockReturnValueOnce([err, res]); + }; + + beforeEach(() => { + [streamProvider] = getStreamProvider(); + jest + .spyOn(streamProvider as any, '_handleAccountsChanged') + .mockImplementation(); + jest + .spyOn((streamProvider as any)._rpcEngine, 'handle') + // eslint-disable-next-line node/no-callback-literal + .mockImplementation((_payload, cb: any) => + cb(...mockRpcEngineResponse()), + ); + }); + + it('returns response object on success', async () => { + setNextRpcEngineResponse(null, { result: 42 }); + await new Promise((done) => { + (streamProvider as any)._rpcRequest( + { method: 'foo', params: ['bar'] }, + (err: Error | null, res: any) => { + expect( + (streamProvider as any)._rpcEngine.handle, + ).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'foo', + params: ['bar'], + }), + expect.any(Function), + ); + + expect(err).toBeNull(); + expect(res).toStrictEqual({ result: 42 }); + done(undefined); + }, + ); + }); + }); + + it('returns response object on error', async () => { + setNextRpcEngineResponse(new Error('foo'), { error: 'foo' }); + await new Promise((done) => { + (streamProvider as any)._rpcRequest( + { method: 'foo', params: ['bar'] }, + (err: Error | null, res: any) => { + expect( + (streamProvider as any)._rpcEngine.handle, + ).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'foo', + params: ['bar'], + }), + expect.any(Function), + ); + + expect(err).toStrictEqual(new Error('foo')); + expect(res).toStrictEqual({ error: 'foo' }); + done(undefined); + }, + ); + }); + }); + + it('calls _handleAccountsChanged on request for eth_accounts', async () => { + setNextRpcEngineResponse(null, { result: ['0x1'] }); + await new Promise((done) => { + (streamProvider as any)._rpcRequest( + { method: 'eth_accounts' }, + (err: Error | null, res: any) => { + expect( + (streamProvider as any)._rpcEngine.handle, + ).toHaveBeenCalledWith( + expect.objectContaining({ method: 'eth_accounts' }), + expect.any(Function), + ); + + expect( + (streamProvider as any)._handleAccountsChanged, + ).toHaveBeenCalledWith(['0x1'], true); + + expect(err).toBeNull(); + expect(res).toStrictEqual({ result: ['0x1'] }); + done(undefined); + }, + ); + }); + }); + + it('calls _handleAccountsChanged with empty array on eth_accounts request returning error', async () => { + setNextRpcEngineResponse(new Error('foo'), { error: 'foo' }); + await new Promise((done) => { + (streamProvider as any)._rpcRequest( + { method: 'eth_accounts' }, + (err: Error | null, res: any) => { + expect( + (streamProvider as any)._rpcEngine.handle, + ).toHaveBeenCalledWith( + expect.objectContaining({ method: 'eth_accounts' }), + expect.any(Function), + ); + + expect( + (streamProvider as any)._handleAccountsChanged, + ).toHaveBeenCalledWith([], true); + + expect(err).toStrictEqual(new Error('foo')); + expect(res).toStrictEqual({ error: 'foo' }); + done(undefined); + }, + ); + }); + }); + }); + + describe('events', () => { + it('calls chainChanged when the chainId changes', async () => { + const mockStream = new MockDuplexStream(); + const streamProvider = new StreamProvider(mockStream, { + jsonRpcStreamName: mockStreamName, + }); + + const requestMock = jest + .spyOn(streamProvider, 'request') + .mockImplementationOnce(async () => { + return { + accounts: [], + chainId: '0x0', + isUnlocked: true, + networkVersion: '0', + }; + }); + + await streamProvider.initialize(); + expect(requestMock).toHaveBeenCalledTimes(1); + + await new Promise((resolve) => { + streamProvider.once('chainChanged', (newChainId) => { + expect(newChainId).toBe('0x1'); + resolve(); + }); + + mockStream.push({ + name: mockStreamName, + data: { + jsonrpc: '2.0', + method: 'metamask_chainChanged', + params: { chainId: '0x1', networkVersion: '0x1' }, + }, + }); + }); + }); + + it('handles chain changes with intermittent disconnection', async () => { + const mockStream = new MockDuplexStream(); + const streamProvider = new StreamProvider(mockStream, { + jsonRpcStreamName: mockStreamName, + }); + + const requestMock = jest + .spyOn(streamProvider, 'request') + .mockImplementationOnce(async () => { + return { + accounts: [], + chainId: '0x0', + isUnlocked: true, + networkVersion: '0', + }; + }); + + await streamProvider.initialize(); + expect(requestMock).toHaveBeenCalledTimes(1); + + // We check this mostly for the readability of this test. + expect(streamProvider.isConnected()).toBe(true); + expect(streamProvider.chainId).toBe('0x0'); + + const emitSpy = jest.spyOn(streamProvider, 'emit'); + + await new Promise((resolve) => { + streamProvider.once('disconnect', (error) => { + expect((error as any).code).toBe(1013); + resolve(); + }); + + mockStream.push({ + name: mockStreamName, + data: { + jsonrpc: '2.0', + method: 'metamask_chainChanged', + // A "loading" networkVersion indicates the network is changing. + // Although the chainId is different, chainChanged should not be + // emitted in this case. + params: { chainId: '0x1', networkVersion: 'loading' }, + }, + }); + }); + + // Only once, for "disconnect". + expect(emitSpy).toHaveBeenCalledTimes(1); + emitSpy.mockClear(); // Clear the mock to avoid keeping a count. + + expect(streamProvider.isConnected()).toBe(false); + // These should be unchanged. + expect(streamProvider.chainId).toBe('0x0'); + + await new Promise((resolve) => { + streamProvider.once('chainChanged', (newChainId) => { + expect(newChainId).toBe('0x1'); + resolve(); + }); + + mockStream.push({ + name: mockStreamName, + data: { + jsonrpc: '2.0', + method: 'metamask_chainChanged', + // The networkVersion will be ignored here, we're just setting it + // to something other than 'loading'. + params: { chainId: '0x1', networkVersion: '1' }, + }, + }); + }); + + expect(emitSpy).toHaveBeenCalledTimes(2); + expect(emitSpy).toHaveBeenNthCalledWith(1, 'connect', { + chainId: '0x1', + }); + expect(emitSpy).toHaveBeenCalledWith('chainChanged', '0x1'); + + expect(streamProvider.isConnected()).toBe(true); + expect(streamProvider.chainId).toBe('0x1'); + }); + }); + }); +}); diff --git a/src/StreamProvider.ts b/src/StreamProvider.ts new file mode 100644 index 00000000..ee051504 --- /dev/null +++ b/src/StreamProvider.ts @@ -0,0 +1,204 @@ +import type { Duplex } from 'stream'; +import ObjectMultiplex from '@metamask/object-multiplex'; +import SafeEventEmitter from '@metamask/safe-event-emitter'; +import { duplex as isDuplex } from 'is-stream'; +import type { JsonRpcMiddleware } from 'json-rpc-engine'; +import { createStreamMiddleware } from 'json-rpc-middleware-stream'; +import pump from 'pump'; +import messages from './messages'; +import { + EMITTED_NOTIFICATIONS, + isValidChainId, + isValidNetworkVersion, +} from './utils'; +import { BaseProvider, BaseProviderOptions } from './BaseProvider'; + +export interface StreamProviderOptions extends BaseProviderOptions { + /** + * The name of the stream used to connect to the wallet. + */ + jsonRpcStreamName: string; +} + +export interface JsonRpcConnection { + events: SafeEventEmitter; + middleware: JsonRpcMiddleware; + stream: Duplex; +} + +/** + * An abstract EIP-1193 provider wired to some duplex stream via a + * `json-rpc-middleware-stream` JSON-RPC stream middleware. Implementers must + * directly call + */ +export abstract class AbstractStreamProvider extends BaseProvider { + protected _jsonRpcConnection: JsonRpcConnection; + + /** + * @param connectionStream - A Node.js duplex stream + * @param options - An options bag + * @param options.jsonRpcStreamName - The name of the internal JSON-RPC stream. + * @param options.logger - The logging API to use. Default: console + * @param options.maxEventListeners - The maximum number of event + * listeners. Default: 100 + */ + constructor( + connectionStream: Duplex, + { + jsonRpcStreamName, + logger, + maxEventListeners, + rpcMiddleware, + }: StreamProviderOptions, + ) { + super({ logger, maxEventListeners, rpcMiddleware }); + + if (!isDuplex(connectionStream)) { + throw new Error(messages.errors.invalidDuplexStream()); + } + + // Bind functions to prevent consumers from making unbound calls + this._handleStreamDisconnect = this._handleStreamDisconnect.bind(this); + + // Set up connectionStream multiplexing + const mux = new ObjectMultiplex(); + pump( + connectionStream, + mux as unknown as Duplex, + connectionStream, + this._handleStreamDisconnect.bind(this, 'MetaMask'), + ); + + // Set up RPC connection + this._jsonRpcConnection = createStreamMiddleware(); + pump( + this._jsonRpcConnection.stream, + mux.createStream(jsonRpcStreamName) as unknown as Duplex, + this._jsonRpcConnection.stream, + this._handleStreamDisconnect.bind(this, 'MetaMask RpcProvider'), + ); + + // Wire up the JsonRpcEngine to the JSON-RPC connection stream + this._rpcEngine.push(this._jsonRpcConnection.middleware); + + // Handle JSON-RPC notifications + this._jsonRpcConnection.events.on('notification', (payload) => { + const { method, params } = payload; + if (method === 'metamask_accountsChanged') { + this._handleAccountsChanged(params); + } else if (method === 'metamask_unlockStateChanged') { + this._handleUnlockStateChanged(params); + } else if (method === 'metamask_chainChanged') { + this._handleChainChanged(params); + } else if (EMITTED_NOTIFICATIONS.includes(method)) { + this.emit('message', { + type: method, + data: params, + }); + } else if (method === 'METAMASK_STREAM_FAILURE') { + connectionStream.destroy( + new Error(messages.errors.permanentlyDisconnected()), + ); + } + }); + } + + //==================== + // Private Methods + //==================== + + /** + * **MUST** be called by child classes. + * + * Calls `metamask_getProviderState` and passes the result to + * {@link BaseProvider._initializeState}. Logs an error if getting initial state + * fails. Throws if called after initialization has completed. + */ + protected async _initializeStateAsync() { + let initialState: Parameters[0]; + + try { + initialState = (await this.request({ + method: 'metamask_getProviderState', + })) as Parameters[0]; + } catch (error) { + this._log.error( + 'MetaMask: Failed to get initial state. Please report this bug.', + error, + ); + } + this._initializeState(initialState); + } + + /** + * Called when connection is lost to critical streams. Emits an 'error' event + * from the provider with the error message and stack if present. + * + * @emits BaseProvider#disconnect + */ + private _handleStreamDisconnect(streamName: string, error: Error) { + let warningMsg = `MetaMask: Lost connection to "${streamName}".`; + if (error?.stack) { + warningMsg += `\n${error.stack}`; + } + + this._log.warn(warningMsg); + if (this.listenerCount('error') > 0) { + this.emit('error', warningMsg); + } + + this._handleDisconnect(false, error ? error.message : undefined); + } + + /** + * Upon receipt of a new chainId and networkVersion, emits corresponding + * events and sets relevant public state. This class does not have a + * `networkVersion` property, but we rely on receiving a `networkVersion` + * with the value of `loading` to detect when the network is changing and + * a recoverable `disconnect` even has occurred. Child classes that use the + * `networkVersion` for other purposes must implement additional handling + * therefore. + * + * @emits BaseProvider#chainChanged + * @param networkInfo - An object with network info. + * @param networkInfo.chainId - The latest chain ID. + * @param networkInfo.networkVersion - The latest network ID. + */ + protected _handleChainChanged({ + chainId, + networkVersion, + }: { chainId?: string; networkVersion?: string } = {}) { + if (!isValidChainId(chainId) || !isValidNetworkVersion(networkVersion)) { + this._log.error(messages.errors.invalidNetworkParams(), { + chainId, + networkVersion, + }); + return; + } + + if (networkVersion === 'loading') { + this._handleDisconnect(true); + } else { + super._handleChainChanged({ chainId }); + } + } +} + +/** + * An EIP-1193 provider wired to some duplex stream via a + * `json-rpc-middleware-stream` JSON-RPC stream middleware. Consumers must + * call {@link StreamProvider.initialize} after instantiation to complete + * initialization. + */ +export class StreamProvider extends AbstractStreamProvider { + /** + * **MUST** be called after instantiation to complete initialization. + * + * Calls `metamask_getProviderState` and passes the result to + * {@link BaseProvider._initializeState}. Logs an error if getting initial state + * fails. Throws if called after initialization has completed. + */ + async initialize() { + return this._initializeStateAsync(); + } +} diff --git a/src/extension-provider/createExternalExtensionProvider.test.ts b/src/extension-provider/createExternalExtensionProvider.test.ts index 8fed5739..3480a640 100644 --- a/src/extension-provider/createExternalExtensionProvider.test.ts +++ b/src/extension-provider/createExternalExtensionProvider.test.ts @@ -1,5 +1,5 @@ -import BaseProvider from '../BaseProvider'; -import createExternalExtensionProvider from './createExternalExtensionProvider'; +import { StreamProvider } from '../StreamProvider'; +import { createExternalExtensionProvider } from './createExternalExtensionProvider'; describe('createExternalExtensionProvider', () => { beforeEach(() => { @@ -27,6 +27,6 @@ describe('createExternalExtensionProvider', () => { it('returns a MetaMaskInpageProvider', () => { const results = createExternalExtensionProvider(); - expect(results).toBeInstanceOf(BaseProvider); + expect(results).toBeInstanceOf(StreamProvider); }); }); diff --git a/src/extension-provider/createExternalExtensionProvider.ts b/src/extension-provider/createExternalExtensionProvider.ts index a61ab526..29f84a04 100644 --- a/src/extension-provider/createExternalExtensionProvider.ts +++ b/src/extension-provider/createExternalExtensionProvider.ts @@ -1,23 +1,36 @@ import PortStream from 'extension-port-stream'; import { detect } from 'detect-browser'; import { Runtime } from 'webextension-polyfill-ts'; -import BaseProvider from '../BaseProvider'; +import { MetaMaskInpageProviderStreamName } from '../MetaMaskInpageProvider'; +import { StreamProvider } from '../StreamProvider'; +import { getDefaultExternalMiddleware } from '../utils'; import config from './external-extension-config.json'; const browser = detect(); -export default function createMetaMaskExternalExtensionProvider() { +export function createExternalExtensionProvider() { let provider; + try { const currentMetaMaskId = getMetaMaskId(); const metamaskPort = chrome.runtime.connect( currentMetaMaskId, ) as Runtime.Port; + const pluginStream = new PortStream(metamaskPort); - provider = new BaseProvider(pluginStream); - } catch (e) { - console.dir(`Metamask connect error `, e); - throw e; + provider = new StreamProvider(pluginStream, { + jsonRpcStreamName: MetaMaskInpageProviderStreamName, + logger: console, + rpcMiddleware: getDefaultExternalMiddleware(console), + }); + + // This is asynchronous but merely logs an error and does not throw upon + // failure. Previously this just happened as a side-effect in the + // constructor. + provider.initialize(); + } catch (error) { + console.dir(`MetaMask connect error.`, error); + throw error; } return provider; } diff --git a/src/index.ts b/src/index.ts index 550b0cb2..5b356a9d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,23 @@ -import MetaMaskInpageProvider from './MetaMaskInpageProvider'; -import createExternalExtensionProvider from './extension-provider/createExternalExtensionProvider'; -import BaseProvider from './BaseProvider'; +import { BaseProvider } from './BaseProvider'; +import { createExternalExtensionProvider } from './extension-provider/createExternalExtensionProvider'; import { initializeProvider, setGlobalProvider, } from './initializeInpageProvider'; -import shimWeb3 from './shimWeb3'; +import { + MetaMaskInpageProvider, + MetaMaskInpageProviderStreamName, +} from './MetaMaskInpageProvider'; +import { shimWeb3 } from './shimWeb3'; +import { StreamProvider } from './StreamProvider'; export { + BaseProvider, + createExternalExtensionProvider, initializeProvider, + MetaMaskInpageProviderStreamName, MetaMaskInpageProvider, - BaseProvider, setGlobalProvider, shimWeb3, - createExternalExtensionProvider, + StreamProvider, }; diff --git a/src/initializeInpageProvider.ts b/src/initializeInpageProvider.ts index b51210a2..e725dcf8 100644 --- a/src/initializeInpageProvider.ts +++ b/src/initializeInpageProvider.ts @@ -1,8 +1,9 @@ import { Duplex } from 'stream'; -import MetaMaskInpageProvider, { +import { + MetaMaskInpageProvider, MetaMaskInpageProviderOptions, } from './MetaMaskInpageProvider'; -import shimWeb3 from './shimWeb3'; +import { shimWeb3 } from './shimWeb3'; interface InitializeProviderOptions extends MetaMaskInpageProviderOptions { /** @@ -42,27 +43,27 @@ export function initializeProvider({ shouldSetOnWindow = true, shouldShimWeb3 = false, }: InitializeProviderOptions): MetaMaskInpageProvider { - let provider = new MetaMaskInpageProvider(connectionStream, { + const provider = new MetaMaskInpageProvider(connectionStream, { jsonRpcStreamName, logger, maxEventListeners, shouldSendMetadata, }); - provider = new Proxy(provider, { + const proxiedProvider = new Proxy(provider, { // some common libraries, e.g. web3@1.x, mess with our API deleteProperty: () => true, }); if (shouldSetOnWindow) { - setGlobalProvider(provider); + setGlobalProvider(proxiedProvider); } if (shouldShimWeb3) { - shimWeb3(provider, logger); + shimWeb3(proxiedProvider, logger); } - return provider; + return proxiedProvider; } /** diff --git a/src/messages.ts b/src/messages.ts index aed52987..680f2cca 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -9,6 +9,8 @@ const messages = { unsupportedSync: (method: string) => `MetaMask: The MetaMask Ethereum provider does not support synchronous methods like ${method} without a callback parameter.`, invalidDuplexStream: () => 'Must provide a Node.js-style duplex stream.', + invalidNetworkParams: () => + 'MetaMask: Received invalid network parameters. Please report this bug.', invalidRequestArgs: () => `Expected a single, non-array, object argument.`, invalidRequestMethod: () => `'args.method' must be a non-empty string.`, invalidRequestParams: () => diff --git a/src/shimWeb3.ts b/src/shimWeb3.ts index 5b9cf11d..c30cba4a 100644 --- a/src/shimWeb3.ts +++ b/src/shimWeb3.ts @@ -1,4 +1,4 @@ -import MetaMaskInpageProvider from './MetaMaskInpageProvider'; +import { MetaMaskInpageProvider } from './MetaMaskInpageProvider'; import { ConsoleLike } from './utils'; /** @@ -8,7 +8,7 @@ import { ConsoleLike } from './utils'; * @param provider - The provider to set as window.web3.currentProvider. * @param log - The logging API to use. */ -export default function shimWeb3( +export function shimWeb3( provider: MetaMaskInpageProvider, log: ConsoleLike = console, ): void { diff --git a/src/siteMetadata.ts b/src/siteMetadata.ts index 576894a4..7f20aeb3 100644 --- a/src/siteMetadata.ts +++ b/src/siteMetadata.ts @@ -9,7 +9,7 @@ import { ConsoleLike, NOOP } from './utils'; * @param engine - The JSON RPC Engine to send metadata over. * @param log - The logging API to use. */ -export default async function sendSiteMetadata( +export async function sendSiteMetadata( engine: JsonRpcEngine, log: ConsoleLike, ): Promise { diff --git a/src/utils.test.ts b/src/utils.test.ts new file mode 100644 index 00000000..a8a5ef87 --- /dev/null +++ b/src/utils.test.ts @@ -0,0 +1,38 @@ +import { isValidChainId, isValidNetworkVersion } from './utils'; + +describe('utils', () => { + describe('isValidChainId', () => { + it('returns `true` for valid values', () => { + ['0x1', '0xabc', '0x999'].forEach((value) => { + expect(isValidChainId(value)).toBe(true); + }); + }); + + it('returns `false` for invalid values', () => { + ['', '0', 'x', '9', 'abc', null, undefined, true, 2, 0x1, {}].forEach( + (value) => { + expect(isValidChainId(value)).toBe(false); + }, + ); + }); + }); + + describe('isValidNetworkVersion', () => { + it('returns `true` for valid values', () => { + [ + '1', + '10', + '999', + 'loading', // this is a hack that we use + ].forEach((value) => { + expect(isValidNetworkVersion(value)).toBe(true); + }); + }); + + it('returns `false` for invalid values', () => { + ['', null, undefined, true, 2, 0x1, {}].forEach((value) => { + expect(isValidNetworkVersion(value)).toBe(false); + }); + }); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index a0f4caa0..0f453913 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,8 @@ -import { EventEmitter } from 'events'; -import { JsonRpcMiddleware, PendingJsonRpcResponse } from 'json-rpc-engine'; +import { + createIdRemapMiddleware, + JsonRpcMiddleware, + PendingJsonRpcResponse, +} from 'json-rpc-engine'; import { ethErrors } from 'eth-rpc-errors'; export type Maybe = Partial | null | undefined; @@ -9,15 +12,33 @@ export type ConsoleLike = Pick< 'log' | 'warn' | 'error' | 'debug' | 'info' | 'trace' >; -// utility functions +// Constants + +export const EMITTED_NOTIFICATIONS = Object.freeze([ + 'eth_subscription', // per eth-json-rpc-filters/subscriptionManager +]); + +// Utility functions + +/** + * Gets the default middleware for external providers, consisting of an ID + * remapping middleware and an error middleware. + * + * @param logger - The logger to use in the error middleware. + * @returns An array of json-rpc-engine middleware functions. + */ +export const getDefaultExternalMiddleware = (logger: ConsoleLike = console) => [ + createIdRemapMiddleware(), + createErrorMiddleware(logger), +]; /** * json-rpc-engine middleware that logs RPC errors and and validates req.method. * * @param log - The logging API to use. - * @returns json-rpc-engine middleware function + * @returns A json-rpc-engine middleware function. */ -export function createErrorMiddleware( +function createErrorMiddleware( log: ConsoleLike, ): JsonRpcMiddleware { return (req, res, next) => { @@ -58,34 +79,25 @@ export const getRpcPromiseCallback = }; /** - * Logs a stream disconnection error. Emits an 'error' if given an - * EventEmitter that has listeners for the 'error' event. + * Checks whether the given chain ID is valid, meaning if it is non-empty, + * '0x'-prefixed string. * - * @param log - The logging API to use. - * @param remoteLabel - The label of the disconnected stream. - * @param error - The associated error to log. - * @param emitter - The logging API to use. + * @param chainId - The chain ID to validate. + * @returns Whether the given chain ID is valid. */ -export function logStreamDisconnectWarning( - log: ConsoleLike, - remoteLabel: string, - error: Error, - emitter: EventEmitter, -): void { - let warningMsg = `MetaMask: Lost connection to "${remoteLabel}".`; - if (error?.stack) { - warningMsg += `\n${error.stack}`; - } - log.warn(warningMsg); - if (emitter && emitter.listenerCount('error') > 0) { - emitter.emit('error', warningMsg); - } -} - -export const NOOP = () => undefined; +export const isValidChainId = (chainId: unknown): chainId is string => + Boolean(chainId) && typeof chainId === 'string' && chainId.startsWith('0x'); -// constants +/** + * Checks whether the given network version is valid, meaning if it is non-empty + * string. + * + * @param networkVersion - The network version to validate. + * @returns Whether the given network version is valid. + */ +export const isValidNetworkVersion = ( + networkVersion: unknown, +): networkVersion is string => + Boolean(networkVersion) && typeof networkVersion === 'string'; -export const EMITTED_NOTIFICATIONS = [ - 'eth_subscription', // per eth-json-rpc-filters/subscriptionManager -]; +export const NOOP = () => undefined; diff --git a/yarn.lock b/yarn.lock index 365b96dc..b8cdbfe8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -321,6 +321,38 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@inquirer/confirm@^0.0.14-alpha.0": + version "0.0.14-alpha.0" + resolved "https://registry.yarnpkg.com/@inquirer/confirm/-/confirm-0.0.14-alpha.0.tgz#4a759c6def5ecd73bc239e090ee6197f74f52dbd" + integrity sha512-MTMCp/jUHJUB0IVkV5utQ1NUE3tqH2W0OtYXByW+ykoRXLiaYrv8vYtx6j0/rOiDHhNjNqTEIWomQx16w1x0uQ== + dependencies: + "@inquirer/core" "^0.0.15-alpha.0" + "@inquirer/input" "^0.0.15-alpha.0" + chalk "^4.1.1" + +"@inquirer/core@^0.0.15-alpha.0": + version "0.0.15-alpha.0" + resolved "https://registry.yarnpkg.com/@inquirer/core/-/core-0.0.15-alpha.0.tgz#08b6439f3998669d1ba0165c0c5f91736b0c7848" + integrity sha512-aytWU6/yM9HkZ09BrgfTJlVsZjmxoiO1cBL5tlkO/jYe4ZuU84rHWnFFxorRzkmT6gkTs1L9TUKaeK3tbyJmJw== + dependencies: + ansi-escapes "^4.2.1" + chalk "^4.1.1" + cli-spinners "^2.6.0" + cli-width "^3.0.0" + lodash "^4.17.21" + mute-stream "^0.0.8" + run-async "^2.3.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +"@inquirer/input@^0.0.15-alpha.0": + version "0.0.15-alpha.0" + resolved "https://registry.yarnpkg.com/@inquirer/input/-/input-0.0.15-alpha.0.tgz#60556547845775ac332d7b3406717f361b3ef721" + integrity sha512-h3mxEK9xTtdAX6a+S/pYRVRTxpnjOPQgQADpgFar/yQqklyBRM5+uX1YRRQG+uwU0IzpI18viPnEdibxrY7Kyw== + dependencies: + "@inquirer/core" "^0.0.15-alpha.0" + chalk "^4.1.1" + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -962,6 +994,11 @@ ajv@^8.0.1: require-from-string "^2.0.2" uri-js "^4.2.2" +ansi-colors@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" + integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== + ansi-colors@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" @@ -1335,6 +1372,14 @@ chalk@^4.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^4.1.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + char-regex@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" @@ -1365,6 +1410,16 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" +cli-spinners@^2.6.0: + version "2.6.1" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.6.1.tgz#adc954ebe281c37a6319bfa401e6dd2488ffb70d" + integrity sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g== + +cli-width@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" + integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== + cliui@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" @@ -1437,6 +1492,11 @@ combined-stream@^1.0.6, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" +commander@^9.0.0: + version "9.3.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-9.3.0.tgz#f619114a5a2d2054e0d9ff1b31d5ccf89255e26b" + integrity sha512-hv95iU5uXPbK83mjrJKuZyFM/LBAoCV/XhVGkS5Je6tl7sxr6A0ITMw5WoRV46/UaJ46Nllm3Xt7IaJhXTIkzw== + component-emitter@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" @@ -3062,6 +3122,15 @@ jest-haste-map@^26.6.2: optionalDependencies: fsevents "^2.1.2" +jest-it-up@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/jest-it-up/-/jest-it-up-2.0.2.tgz#c8c38d14fd4a9131c12f6947baa2063554c0738d" + integrity sha512-xup3Lv1uc+ihGwyFLjZOqY2L7m91TyBp/TRJxS7PYAVQc/vd3NbkPyypUlT59sQDfW9uULF9jLCedr7jABDNnA== + dependencies: + "@inquirer/confirm" "^0.0.14-alpha.0" + ansi-colors "^4.1.0" + commander "^9.0.0" + jest-jasmine2@^26.6.3: version "26.6.3" resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-26.6.3.tgz#adc3cf915deacb5212c93b9f3547cd12958f2edd" @@ -3546,7 +3615,7 @@ lodash.truncate@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM= -lodash@4.x, lodash@^4.17.15, lodash@^4.17.19: +lodash@4.x, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -3693,6 +3762,11 @@ ms@2.1.2, ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +mute-stream@^0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" + integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" @@ -4418,6 +4492,11 @@ rsvp@^4.8.4: resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" integrity sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA== +run-async@^2.3.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" + integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== + run-parallel@^1.1.9: version "1.1.10" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.10.tgz#60a51b2ae836636c81377df16cb107351bcd13ef"