Skip to content

Commit

Permalink
Implement EIP-6963 (#263)
Browse files Browse the repository at this point in the history
* Implement EIP-6963

* Update lint config

* Parameterize provider detail, remove hard-coded values

* Add comments, validate uuid field

* Improve detail and info validation

* Remove 'walletId' field per spec updates

* Explicit distachEvent order testing

* Freeze ProviderDetails. Add validation

* lint

* Add providerInfo to initializeProvider

* add icon and rdns. rename options.providerInfo to options.shouldAnnounceProviderInfo in initializeProvider

* Add FQDN regex

* rename shouldAnnounceProviderInfo option to providerInfo

* Replace FQDN lookbehidn regex

* Remove type casts. Augment global WindowEventMap

* Flatten tests

* jsdoc

* add intention of usage comments to requestprovider() and announceProvider()

---------

Co-authored-by: Jiexi Luan <jiexiluan@gmail.com>
  • Loading branch information
rekmarks and jiexi authored Oct 11, 2023
1 parent 8a95e40 commit 215ae38
Show file tree
Hide file tree
Showing 11 changed files with 555 additions and 15 deletions.
22 changes: 21 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,32 @@ module.exports = {
},

{
files: ['*.test.ts', '*.test.js'],
files: ['*.test.ts', '*.test.js', 'jest.setup*.js'],
extends: [
'@metamask/eslint-config-jest',
'@metamask/eslint-config-nodejs',
],
},

{
files: ['EIP6963.test.ts', 'jest.setup.browser.js'],
rules: {
// We're mixing Node and browser environments in these files.
'no-restricted-globals': 'off',
},
},

{
files: ['jest.setup.browser.js'],
env: { browser: true },
// This file contains copypasta and we're not going to bother fixing these.
rules: {
'jest/require-top-level-describe': 'off',
'jsdoc/require-description': 'off',
'jsdoc/require-param-description': 'off',
'jsdoc/require-param-type': 'off',
},
},
],

ignorePatterns: [
Expand Down
16 changes: 10 additions & 6 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ const baseConfig = {
// An object that configures minimum threshold enforcement for coverage results
coverageThreshold: {
global: {
branches: 61.9,
functions: 61.79,
lines: 65.18,
statements: 65.3,
branches: 64.78,
functions: 65.65,
lines: 66.66,
statements: 66.74,
},
},

Expand Down Expand Up @@ -222,8 +222,12 @@ const browserConfig = {
...baseConfig,
displayName: 'Browser Providers',
testEnvironment: 'jsdom',
testMatch: ['**/*InpageProvider.test.ts', '**/*ExtensionProvider.test.ts'],
setupFilesAfterEnv: ['./jest.setup.js'],
testMatch: [
'**/*InpageProvider.test.ts',
'**/*ExtensionProvider.test.ts',
'**/EIP6963.test.ts',
],
setupFilesAfterEnv: ['./jest.setup.browser.js'],
};

module.exports = {
Expand Down
87 changes: 87 additions & 0 deletions jest.setup.browser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
Object.assign(global, require('jest-chrome'));

// Clean up jsdom between tests.
// Courtesy @jhildenbiddle
// https://github.com/jestjs/jest/issues/1224#issuecomment-716075260
const sideEffects = {
document: {
addEventListener: {
fn: document.addEventListener,
refs: [],
},
keys: Object.keys(document),
},
window: {
addEventListener: {
fn: window.addEventListener,
refs: [],
},
keys: Object.keys(window),
},
};

// Lifecycle Hooks
// -----------------------------------------------------------------------------
beforeAll(async () => {
// Spy addEventListener
['document', 'window'].forEach((obj) => {
const { fn, refs } = sideEffects[obj].addEventListener;

/**
*
* @param type
* @param listener
* @param options
*/
function addEventListenerSpy(type, listener, options) {
// Store listener reference so it can be removed during reset
refs.push({ type, listener, options });
// Call original window.addEventListener
fn(type, listener, options);
}

// Add to default key array to prevent removal during reset
sideEffects[obj].keys.push('addEventListener');

// Replace addEventListener with mock
global[obj].addEventListener = addEventListenerSpy;
});
});

// Reset JSDOM. This attempts to remove side effects from tests, however it does
// not reset all changes made to globals like the window and document
// objects. Tests requiring a full JSDOM reset should be stored in separate
// files, which is only way to do a complete JSDOM reset with Jest.
beforeEach(async () => {
const rootElm = document.documentElement;

// Remove attributes on root element
[...rootElm.attributes].forEach((attr) => rootElm.removeAttribute(attr.name));

// Remove elements (faster than setting innerHTML)
while (rootElm.firstChild) {
rootElm.removeChild(rootElm.firstChild);
}

// Remove global listeners and keys
['document', 'window'].forEach((obj) => {
const { refs } = sideEffects[obj].addEventListener;

// Listeners
while (refs.length) {
const { type, listener, options } = refs.pop();
global[obj].removeEventListener(type, listener, options);
}

// This breaks coverage.
// // Keys
// Object.keys(global[obj])
// .filter((key) => !sideEffects[obj].keys.includes(key))
// .forEach((key) => {
// delete global[obj][key];
// });
});

// Restore base elements
rootElm.innerHTML = '<head></head><body></body>';
});
1 change: 0 additions & 1 deletion jest.setup.js

This file was deleted.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"@types/jest": "^28.1.6",
"@types/node": "^17.0.23",
"@types/readable-stream": "^2.3.15",
"@types/uuid": "^9.0.1",
"@types/webextension-polyfill": "^0.10.0",
"@typescript-eslint/eslint-plugin": "^5.43.0",
"@typescript-eslint/parser": "^5.43.0",
Expand Down
179 changes: 179 additions & 0 deletions src/EIP6963.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { announceProvider, requestProvider } from './EIP6963';

const getProviderInfo = () => ({
uuid: '350670db-19fa-4704-a166-e52e178b59d2',
name: 'Example Wallet',
icon: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>',
rdns: 'com.example.wallet',
});

const providerInfoValidationError = () =>
new Error(
'Invalid EIP-6963 ProviderDetail object. See https://eips.ethereum.org/EIPS/eip-6963 for requirements.',
);

describe('EIP-6963', () => {
describe('provider info validation', () => {
it('throws if the provider info is not a plain object', () => {
[null, undefined, Symbol('bar'), []].forEach((invalidInfo) => {
expect(() => announceProvider(invalidInfo as any)).toThrow(
providerInfoValidationError(),
);
});
});

it('throws if the `icon` field is invalid', () => {
[
null,
undefined,
'',
'not-a-data-uri',
'https://example.com/logo.png',
'data:text/plain;blah',
Symbol('bar'),
].forEach((invalidIcon) => {
const provider: any = { name: 'test' };
const providerDetail = { info: getProviderInfo(), provider };
providerDetail.info.icon = invalidIcon as any;

expect(() => announceProvider(providerDetail)).toThrow(
providerInfoValidationError(),
);
});
});

it('throws if the `name` field is invalid', () => {
[null, undefined, '', {}, [], Symbol('bar')].forEach((invalidName) => {
const provider: any = { name: 'test' };
const providerDetail = { info: getProviderInfo(), provider };
providerDetail.info.name = invalidName as any;

expect(() => announceProvider(providerDetail)).toThrow(
providerInfoValidationError(),
);
});
});

it('throws if the `uuid` field is invalid', () => {
[null, undefined, '', 'foo', Symbol('bar')].forEach((invalidUuid) => {
const provider: any = { name: 'test' };
const providerDetail = { info: getProviderInfo(), provider };
providerDetail.info.uuid = invalidUuid as any;

expect(() => announceProvider(providerDetail)).toThrow(
providerInfoValidationError(),
);
});
});

it('throws if the `rdns` field is invalid', () => {
[
null,
undefined,
'',
'not-a-valid-domain',
'..com',
'com.',
Symbol('bar'),
].forEach((invalidRdns) => {
const provider: any = { name: 'test' };
const providerDetail = { info: getProviderInfo(), provider };
providerDetail.info.rdns = invalidRdns as any;

expect(() => announceProvider(providerDetail)).toThrow(
providerInfoValidationError(),
);
});
});
});

it('provider is initialized before dapp', async () => {
const provider: any = { name: 'test' };
const providerDetail = { info: getProviderInfo(), provider };
const handleProvider = jest.fn();
const dispatchEvent = jest.spyOn(window, 'dispatchEvent');
const addEventListener = jest.spyOn(window, 'addEventListener');

announceProvider(providerDetail);
requestProvider(handleProvider);
await delay();

expect(dispatchEvent).toHaveBeenCalledTimes(3);
expect(dispatchEvent).toHaveBeenNthCalledWith(
1,
new CustomEvent('eip6963:announceProvider'),
);
expect(dispatchEvent).toHaveBeenNthCalledWith(
2,
new Event('eip6963:requestProvider'),
);
expect(dispatchEvent).toHaveBeenNthCalledWith(
3,
new CustomEvent('eip6963:announceProvider'),
);

expect(addEventListener).toHaveBeenCalledTimes(2);
expect(addEventListener).toHaveBeenCalledWith(
'eip6963:announceProvider',
expect.any(Function),
);
expect(addEventListener).toHaveBeenCalledWith(
'eip6963:requestProvider',
expect.any(Function),
);

expect(handleProvider).toHaveBeenCalledTimes(1);
expect(handleProvider).toHaveBeenCalledWith({
info: getProviderInfo(),
provider,
});
});

it('dapp is initialized before provider', async () => {
const provider: any = { name: 'test' };
const providerDetail = { info: getProviderInfo(), provider };
const handleProvider = jest.fn();
const dispatchEvent = jest.spyOn(window, 'dispatchEvent');
const addEventListener = jest.spyOn(window, 'addEventListener');

requestProvider(handleProvider);
announceProvider(providerDetail);
await delay();

expect(dispatchEvent).toHaveBeenCalledTimes(2);
expect(dispatchEvent).toHaveBeenNthCalledWith(
1,
new Event('eip6963:requestProvider'),
);
expect(dispatchEvent).toHaveBeenNthCalledWith(
2,
new CustomEvent('eip6963:announceProvider'),
);

expect(addEventListener).toHaveBeenCalledTimes(2);
expect(addEventListener).toHaveBeenCalledWith(
'eip6963:announceProvider',
expect.any(Function),
);
expect(addEventListener).toHaveBeenCalledWith(
'eip6963:requestProvider',
expect.any(Function),
);

expect(handleProvider).toHaveBeenCalledTimes(1);
expect(handleProvider).toHaveBeenCalledWith({
info: getProviderInfo(),
provider,
});
});
});

/**
* Delay for a number of milliseconds by awaiting a promise
* resolved after the specified number of milliseconds.
*
* @param ms - The number of milliseconds to delay for.
*/
async function delay(ms = 1) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Loading

0 comments on commit 215ae38

Please sign in to comment.