diff --git a/.eslintrc.js b/.eslintrc.js index 38f345b4..7accf466 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -45,6 +45,8 @@ module.exports = { 'EIP6963.test.ts', 'CAIP294.test.ts', 'initializeInpageProvider.test.ts', + 'MetaMaskInpageProvider.test.ts', + 'siteMetadata.test.ts', 'jest.setup.browser.js', ], rules: { diff --git a/CHANGELOG.md b/CHANGELOG.md index e675f10a..a7efd7bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Prevent `document is not defined` error when constructing the provider in non-DOM environments, such as extension background pages and service workers ([#428](https://github.com/MetaMask/providers/pull/428)) + ## [22.1.1] ### Changed diff --git a/jest.config.js b/jest.config.js index e7856371..d3996845 100644 --- a/jest.config.js +++ b/jest.config.js @@ -227,6 +227,7 @@ const browserConfig = { '**/*ExtensionProvider.test.ts', '**/EIP6963.test.ts', '**/CAIP294.test.ts', + '**/siteMetadata.test.ts', ], setupFilesAfterEnv: ['./jest.setup.browser.js'], }; diff --git a/src/MetaMaskInpageProvider.test.ts b/src/MetaMaskInpageProvider.test.ts index 0de88c22..09e782b2 100644 --- a/src/MetaMaskInpageProvider.test.ts +++ b/src/MetaMaskInpageProvider.test.ts @@ -8,6 +8,7 @@ import { MetaMaskInpageProviderStreamName, MetaMaskInpageProvider, } from './MetaMaskInpageProvider'; +import * as siteMetadata from './siteMetadata'; import { MockConnectionStream } from '../test/mocks/MockConnectionStream'; /** @@ -1127,6 +1128,62 @@ describe('MetaMaskInpageProvider: Miscellanea', () => { expect(inpageProvider.selectedAddress).toBe('0xabc'); expect(inpageProvider.isConnected()).toBe(true); }); + + describe('site metadata', () => { + it('sends site metadata immediately if the document has already loaded', () => { + const sendSiteMetadataSpy = jest + .spyOn(siteMetadata, 'sendSiteMetadata') + .mockResolvedValue(undefined); + + expect( + () => + new MetaMaskInpageProvider(new MockConnectionStream(), { + shouldSendMetadata: true, + }), + ).not.toThrow(); + + expect(sendSiteMetadataSpy).toHaveBeenCalledTimes(1); + }); + + it('sends site metadata on DOMContentLoaded if the document is still loading', () => { + const sendSiteMetadataSpy = jest + .spyOn(siteMetadata, 'sendSiteMetadata') + .mockResolvedValue(undefined); + jest + .spyOn(globalThis.document, 'readyState', 'get') + .mockReturnValue('loading'); + + expect( + () => + new MetaMaskInpageProvider(new MockConnectionStream(), { + shouldSendMetadata: true, + }), + ).not.toThrow(); + expect(sendSiteMetadataSpy).not.toHaveBeenCalled(); + + globalThis.window.dispatchEvent(new Event('DOMContentLoaded')); + expect(sendSiteMetadataSpy).toHaveBeenCalledTimes(1); + }); + + it('sends site metadata immediately if there is no document, e.g. in an extension background script', () => { + const sendSiteMetadataSpy = jest + .spyOn(siteMetadata, 'sendSiteMetadata') + .mockResolvedValue(undefined); + const documentSpy = jest + .spyOn(globalThis, 'document', 'get') + .mockReturnValue(undefined as unknown as Document); + + expect( + () => + new MetaMaskInpageProvider(new MockConnectionStream(), { + shouldSendMetadata: true, + }), + ).not.toThrow(); + expect(sendSiteMetadataSpy).toHaveBeenCalledTimes(1); + + documentSpy.mockRestore(); + }); + }); }); describe('isConnected', () => { diff --git a/src/MetaMaskInpageProvider.ts b/src/MetaMaskInpageProvider.ts index 15928294..ec3f1f2f 100644 --- a/src/MetaMaskInpageProvider.ts +++ b/src/MetaMaskInpageProvider.ts @@ -135,7 +135,14 @@ export class MetaMaskInpageProvider extends AbstractStreamProvider { // send website metadata if (shouldSendMetadata) { - if (document.readyState === 'complete') { + // The `document` global is not available in non-DOM environments, such + // as extension background pages and service workers. In such + // environments, there is nothing to wait for, so we treat the site + // metadata as immediately available. + if ( + typeof document === 'undefined' || + document.readyState === 'complete' + ) { // eslint-disable-next-line @typescript-eslint/no-floating-promises sendSiteMetadata(this._rpcEngine, this._log); } else { diff --git a/src/siteMetadata.test.ts b/src/siteMetadata.test.ts new file mode 100644 index 00000000..6978c7d2 --- /dev/null +++ b/src/siteMetadata.test.ts @@ -0,0 +1,50 @@ +import type { JsonRpcEngine } from '@metamask/json-rpc-engine'; + +import { sendSiteMetadata } from './siteMetadata'; + +describe('sendSiteMetadata', () => { + it('sends site metadata from the DOM', async () => { + const meta = globalThis.document.createElement('meta'); + meta.setAttribute('property', 'og:site_name'); + meta.content = 'Test Site'; + globalThis.document.head.appendChild(meta); + + const handle = jest.fn(); + await sendSiteMetadata({ handle } as unknown as JsonRpcEngine, console); + + expect(handle).toHaveBeenCalledTimes(1); + expect(handle).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'metamask_sendDomainMetadata', + params: { + name: 'Test Site', + icon: null, + }, + }), + expect.any(Function), + ); + }); + + it('falls back to the hostname if there is no document, e.g. in an extension background script', async () => { + const documentSpy = jest + .spyOn(globalThis, 'document', 'get') + .mockReturnValue(undefined as unknown as Document); + + const handle = jest.fn(); + await sendSiteMetadata({ handle } as unknown as JsonRpcEngine, console); + + expect(handle).toHaveBeenCalledTimes(1); + expect(handle).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'metamask_sendDomainMetadata', + params: { + name: globalThis.location.hostname, + icon: null, + }, + }), + expect.any(Function), + ); + + documentSpy.mockRestore(); + }); +}); diff --git a/src/siteMetadata.ts b/src/siteMetadata.ts index b8df50cb..2cc06823 100644 --- a/src/siteMetadata.ts +++ b/src/siteMetadata.ts @@ -41,8 +41,8 @@ export async function sendSiteMetadata( */ async function getSiteMetadata() { return { - name: getSiteName(window), - icon: await getSiteIcon(window), + name: getSiteName(globalThis), + icon: await getSiteIcon(globalThis), }; } @@ -52,8 +52,13 @@ async function getSiteMetadata() { * @param windowObject - The window object to extract the site name from. * @returns The site name. */ -function getSiteName(windowObject: typeof window): string { +function getSiteName(windowObject: typeof globalThis): string { const { document } = windowObject; + // The `document` global is not available in non-DOM environments, such as + // extension background pages and service workers. + if (typeof document === 'undefined') { + return windowObject.location.hostname; + } const siteName: HTMLMetaElement | null = document.querySelector( 'head > meta[property="og:site_name"]', @@ -73,7 +78,7 @@ function getSiteName(windowObject: typeof window): string { return document.title; } - return window.location.hostname; + return windowObject.location.hostname; } /** @@ -83,9 +88,14 @@ function getSiteName(windowObject: typeof window): string { * @returns An icon URL, if one exists. */ async function getSiteIcon( - windowObject: typeof window, + windowObject: typeof globalThis, ): Promise { const { document } = windowObject; + // The `document` global is not available in non-DOM environments, such as + // extension background pages and service workers. + if (typeof document === 'undefined') { + return null; + } const icons: NodeListOf = document.querySelectorAll( 'head > link[rel~="icon"]',