From 089e951a9e1e44f3d92d217641d0ce5d9d99a78e Mon Sep 17 00:00:00 2001 From: Anton Lykhoyda Date: Mon, 28 Nov 2022 14:53:38 +0100 Subject: [PATCH] feat: snap notifications 137 (#187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: node engine requirements (#184) (#186) set engines compatibility for 16 and above Co-authored-by: Bernard Stojanović * pair improvements * fix lint * feat: add notify Snap method * go back after getting all notifications * remove timeout * wip * Add notification observe method * update test * remove unused code * wip notifiction observer * wip observer based notifications * wip observer based notifications * remove unused dependency * cleanup * fixes after merge * revert method names * clean waitForNotification method * emitter solution - FP * emitter solution - ClassBased * Cleanup class based emitter * fix lint * fixes after merge * remove p-event library * remove eslint comment * update test configuration * return NotificationList from waitForNotification * add getNotificationEmitter method * remove back button Co-authored-by: Marin Petrunić Co-authored-by: Bernard Stojanović Co-authored-by: Marin Petrunic --- package.json | 9 ++- src/metamask/index.ts | 5 +- src/page.ts | 30 ++++++- src/playwright/page.ts | 16 ++++ src/puppeteer/page.ts | 13 ++++ src/snap/NotificationsEmitter.ts | 78 +++++++++++++++++++ src/snap/getAllNotifications.ts | 8 +- src/snap/getNotificationEmitter.ts | 9 +++ src/snap/types.ts | 3 +- src/types.ts | 13 +++- test/basic.spec.ts | 4 +- test/flask/methods-snap/snap.manifest.json | 2 +- test/flask/methods-snap/src/index.ts | 2 +- .../flask/permissions-snap/snap.manifest.json | 2 +- test/flask/permissions-snap/src/index.ts | 11 +++ test/flask/snaps.spec.ts | 31 ++++++-- tsconfig.json | 2 +- yarn.lock | 9 ++- 18 files changed, 215 insertions(+), 32 deletions(-) create mode 100644 src/snap/NotificationsEmitter.ts create mode 100644 src/snap/getNotificationEmitter.ts diff --git a/package.json b/package.json index 1a9525b2..c3511705 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "dependencies": { "@metamask/providers": "^9.1.0", "node-stream-zip": "^1.13.0", - "serve-handler": "5.0.8" + "serve-handler": "5.0.8", + "strict-event-emitter": "^0.2.8" }, "devDependencies": { "@chainsafe/eslint-config": "^1.0.0", @@ -70,8 +71,8 @@ "web3": "1.3.4" }, "peerDependencies": { - "puppeteer": ">13", - "playwright": ">=1" + "playwright": ">=1", + "puppeteer": ">13" }, "peerDependenciesMeta": { "soy-puppeteer": { @@ -81,4 +82,4 @@ "optional": true } } -} \ No newline at end of file +} diff --git a/src/metamask/index.ts b/src/metamask/index.ts index 4efe2223..8890e967 100644 --- a/src/metamask/index.ts +++ b/src/metamask/index.ts @@ -1,10 +1,10 @@ import { Dappeteer } from ".."; import { DappeteerBrowser } from "../browser"; import { DappeteerPage } from "../page"; - import { acceptDialog } from "../snap/acceptDialog"; import { rejectDialog } from "../snap/rejectDialog"; import { getAllNotifications, installSnap, invokeSnap } from "../snap"; +import { getNotificationEmitter } from "../snap/getNotificationEmitter"; import { acceptAddNetwork, rejectAddNetwork } from "./addNetwork"; import { approve } from "./approve"; import { confirmTransaction } from "./confirmTransaction"; @@ -59,10 +59,11 @@ export const getMetaMask = (page: DappeteerPage): Promise => { deleteNetwork: deleteNetwork(page), }, snaps: { + invokeSnap, + getNotificationEmitter: getNotificationEmitter(page), getAllNotifications: getAllNotifications(page), acceptDialog: acceptDialog(page), rejectDialog: rejectDialog(page), - invokeSnap, installSnap: installSnap(page), }, page, diff --git a/src/page.ts b/src/page.ts index 4a9fa980..1b9fa45d 100644 --- a/src/page.ts +++ b/src/page.ts @@ -1,21 +1,29 @@ -import { DappeteerBrowser } from "./browser"; import { DappeteerElementHandle } from "./element"; +import { DappeteerBrowser } from "./browser"; export interface DappeteerPage

{ $(selector: string): Promise; + $eval( selector: string, evalFn: (e: HTMLElement) => Promise | T ): Promise; + $$eval( selector: string, evalFn: (e: HTMLElement[]) => Promise | T[] ): Promise; + $$(selector: string): Promise; + getSource(): P; + url(): string; + browser(): DappeteerBrowser; + bringToFront(): Promise; + goto( url: string, options?: { @@ -23,16 +31,22 @@ export interface DappeteerPage

{ waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit"; } ): Promise; + title(): Promise; + close(options?: { runBeforeUnload?: boolean }): Promise; + reload(): Promise; + setViewport(opts: { height: number; width: number }): Promise; + waitForResponse( urlOrPredicate: string | ((res: Response) => boolean | Promise), options?: { timeout?: number; } ): Promise; + waitForSelector( selector: string, opts?: Partial<{ @@ -47,16 +61,30 @@ export interface DappeteerPage

{ selector: string, opts?: Partial<{ timeout: number }> ): Promise; + waitForXPath( xpath: string, opts?: Partial<{ visible: boolean; timeout: number }> ): Promise; + waitForTimeout(timeout: number): Promise; + evaluate( evaluateFn: (params: Unboxed) => Result, params?: Params ): Promise; + screenshot(path: string): Promise; + + waitForFunction( + pageFunction: Function | string, + params?: Params + ): Promise; + + exposeFunction( + name: string, + callback: Function | { default: Function } + ): Promise; } export type Unboxed = Arg extends DappeteerElementHandle diff --git a/src/playwright/page.ts b/src/playwright/page.ts index 3aa99ebf..e90b8e9f 100644 --- a/src/playwright/page.ts +++ b/src/playwright/page.ts @@ -113,9 +113,11 @@ export class DPlaywrightPage implements DappeteerPage { ): Promise> { return this.waitForSelector(xpath, opts); } + waitForTimeout(timeout: number): Promise { return this.page.waitForTimeout(timeout); } + evaluate( evaluateFn: (params?: Unboxed) => Result | Promise, params?: Params @@ -123,4 +125,18 @@ export class DPlaywrightPage implements DappeteerPage { //@ts-expect-error return this.page.evaluate(evaluateFn, params); } + + async waitForFunction( + pageFunction: (params?: Unboxed) => void | string, + params?: Args + ): Promise { + await this.page.waitForFunction(pageFunction, {}, params); + } + + exposeFunction( + name: string, + callback: Function | { default: Function } + ): Promise { + return this.page.exposeFunction(name, callback); + } } diff --git a/src/puppeteer/page.ts b/src/puppeteer/page.ts index e2583bad..811b84c4 100644 --- a/src/puppeteer/page.ts +++ b/src/puppeteer/page.ts @@ -129,9 +129,11 @@ export class DPupeteerPage implements DappeteerPage { await this.page.waitForXPath(xpath, opts) ); } + waitForTimeout(timeout: number): Promise { return this.page.waitForTimeout(timeout); } + evaluate( evaluateFn: (params?: Unboxed) => Result | Promise, params?: Params @@ -141,4 +143,15 @@ export class DPupeteerPage implements DappeteerPage { params ) as Promise; } + + async waitForFunction( + pageFunction: (params?: Unboxed) => void | string, + params?: Params + ): Promise { + await this.page.waitForFunction(pageFunction, {}, params); + } + + exposeFunction(name: string, callback: Function): Promise { + return this.page.exposeFunction(name, callback); + } } diff --git a/src/snap/NotificationsEmitter.ts b/src/snap/NotificationsEmitter.ts new file mode 100644 index 00000000..2c5e1b7b --- /dev/null +++ b/src/snap/NotificationsEmitter.ts @@ -0,0 +1,78 @@ +import { StrictEventEmitter } from "strict-event-emitter"; +import { DappeteerPage } from "../page"; +import { clickOnElement, profileDropdownClick } from "../helpers"; +import { NotificationItem, NotificationList } from "./types"; + +interface EventsMap { + notification: (notification: NotificationItem) => void; +} + +class NotificationsEmitter extends StrictEventEmitter { + private notificationsTab: DappeteerPage; + + constructor( + private page: DappeteerPage, + private notificationTimeout: number = 30000 + ) { + super(); + } + + private async exposeEmitNotificationToWindow(): Promise { + await this.notificationsTab.exposeFunction( + "emitNotification", + (notification: NotificationItem) => { + this.emit("notification", notification); + } + ); + } + + private async openNotificationPage(): Promise { + const newPage = await this.page.browser().newPage(); + await newPage.goto(this.page.url()); + + await profileDropdownClick(newPage); + await clickOnElement(newPage, "Notifications"); + + await newPage.waitForSelector(".notifications__container", { + timeout: this.notificationTimeout, + }); + this.notificationsTab = newPage; + } + + private async observeNotificationsMutation(): Promise { + await this.notificationsTab.evaluate(() => { + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.addedNodes.length) { + const element = mutation.addedNodes[0] as HTMLElement; + window.emitNotification({ message: element.innerText }); + observer.disconnect(); + } + } + }); + observer.observe(document.querySelector(".notifications__container"), { + childList: true, + }); + }); + } + + public async setup(): Promise { + await this.openNotificationPage(); + await this.exposeEmitNotificationToWindow(); + await this.observeNotificationsMutation(); + } + + public async cleanup(): Promise { + this.removeAllListeners("notification"); + await this.notificationsTab.close(); + } + + public async waitForNotification(): Promise { + return (await NotificationsEmitter.once( + this, + "notification" + )) as NotificationList; + } +} + +export default NotificationsEmitter; diff --git a/src/snap/getAllNotifications.ts b/src/snap/getAllNotifications.ts index 082e89ec..d6854a0d 100644 --- a/src/snap/getAllNotifications.ts +++ b/src/snap/getAllNotifications.ts @@ -8,15 +8,9 @@ export const getAllNotifications = await profileDropdownClick(page); await clickOnElement(page, "Notifications"); await page.waitForSelector(".notifications__item__details__message"); - const notificationList: NotificationList = await page.$$eval( + return await page.$$eval( ".notifications__item__details__message", (elements) => elements.map((element) => ({ message: element.textContent })) ); - const backButton = await page.waitForSelector( - ".notifications__header__title-container__back-button" - ); - - await backButton.click(); - return notificationList; }; diff --git a/src/snap/getNotificationEmitter.ts b/src/snap/getNotificationEmitter.ts new file mode 100644 index 00000000..e4b02c84 --- /dev/null +++ b/src/snap/getNotificationEmitter.ts @@ -0,0 +1,9 @@ +import { DappeteerPage } from "../page"; +import NotificationsEmitter from "./NotificationsEmitter"; + +export const getNotificationEmitter = + (page: DappeteerPage) => async (): Promise => { + const emitter = new NotificationsEmitter(page); + await emitter.setup(); + return emitter; + }; diff --git a/src/snap/types.ts b/src/snap/types.ts index 9001d4de..1eecb529 100644 --- a/src/snap/types.ts +++ b/src/snap/types.ts @@ -19,4 +19,5 @@ export interface InstallSnapResult { }; } -export type NotificationList = { message: string }[]; +export type NotificationItem = { message: string }; +export type NotificationList = NotificationItem[]; diff --git a/src/types.ts b/src/types.ts index 0fe439c7..46dd3193 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,7 +4,8 @@ import type { launch as puppeteerLaunch } from "puppeteer"; import { DappeteerPage, Serializable } from "./page"; import { Path } from "./setup/utils/metaMaskDownloader"; import { InstallStep } from "./snap/install"; -import { NotificationList } from "./snap/types"; +import { NotificationItem, NotificationList } from "./snap/types"; +import NotificationsEmitter from "./snap/NotificationsEmitter"; import { RECOMMENDED_METAMASK_VERSION } from "./index"; export type DappeteerLaunchOptions = { @@ -27,6 +28,7 @@ export type DappeteerLaunchOptions = { declare global { interface Window { ethereum: MetaMaskInpageProvider; + emitNotification: (notification: NotificationItem) => void; } } @@ -63,6 +65,10 @@ export type Dappeteer = { }; page: DappeteerPage; snaps: { + /** + * Returns emitter to listen for notifications appearance in notification page + */ + getNotificationEmitter: () => Promise; /** * Returns all notifications in Metamask notifications page */ @@ -73,7 +79,7 @@ export type Dappeteer = { * @param page Browser page where injected Metamask provider will be available. * For most snaps, openning google.com will suffice. * @param snapId id of your installed snap (result of invoking `installSnap` method) - * @param method snap method you wan't to invoke + * @param method snap method you want to invoke * @param params required parameters of snap method */ invokeSnap: ( @@ -82,12 +88,11 @@ export type Dappeteer = { method: string, params?: Params ) => Promise>; - /** * Installs snap. Function will throw if there is an error while installing snap. * @param snapIdOrLocation either pass in snapId or full path to your snap directory * where we can find bundled snap (you need to ensure snap is built) - * @param opts {Object} snap method you wan't to invoke + * @param opts {Object} snap method you want to invoke * @param opts.hasPermissions Set to true if snap uses some permissions * @param opts.hasKeyPermissions Set to true if snap uses key permissions * @param installationSnapUrl url of your dapp. Defaults to google.com diff --git a/test/basic.spec.ts b/test/basic.spec.ts index 00c25b4e..e2ea7fb0 100644 --- a/test/basic.spec.ts +++ b/test/basic.spec.ts @@ -1,10 +1,8 @@ import { expect, use } from "chai"; import chaiAsPromised from "chai-as-promised"; - import * as dappeteer from "../src"; import { profileDropdownClick } from "../src/helpers"; -import { DappeteerPage } from "../src/page"; - +import { DappeteerPage } from "../src"; import { PASSWORD, TestContext } from "./constant"; import { clickElement } from "./utils/utils"; diff --git a/test/flask/methods-snap/snap.manifest.json b/test/flask/methods-snap/snap.manifest.json index d30b5edb..f02c55ad 100644 --- a/test/flask/methods-snap/snap.manifest.json +++ b/test/flask/methods-snap/snap.manifest.json @@ -3,7 +3,7 @@ "description": "An example Snap written in TypeScript.", "proposedName": "Methods Snap\n", "source": { - "shasum": "xxu7kMfZ4zKgviSZ5K4gZKSDDGEYi2VqOK5bKAqoybo=", + "shasum": "65ylJ5v6kEUqE7jJEezI49brIzKhs3dSY8Y2uRxIIEY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/test/flask/methods-snap/src/index.ts b/test/flask/methods-snap/src/index.ts index 772789de..18ba67cb 100644 --- a/test/flask/methods-snap/src/index.ts +++ b/test/flask/methods-snap/src/index.ts @@ -25,7 +25,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ params: [ { type: "inApp", - message: `Hello, in App notification`, + message: `Hello from methods snap in App notification`, }, ], }); diff --git a/test/flask/permissions-snap/snap.manifest.json b/test/flask/permissions-snap/snap.manifest.json index 5fc8b335..ac4e2397 100644 --- a/test/flask/permissions-snap/snap.manifest.json +++ b/test/flask/permissions-snap/snap.manifest.json @@ -3,7 +3,7 @@ "description": "An example Snap written in TypeScript.", "proposedName": "Permissions Snap\n", "source": { - "shasum": "sApNS/DJjjs8psniSQzWlOdkzJ75IsbtlpdipfKarRI=", + "shasum": "LSrgdJ2wzwzWkfVsqwATqBpEFxjjanFr9JdZHu0DmdU=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/test/flask/permissions-snap/src/index.ts b/test/flask/permissions-snap/src/index.ts index 40c41346..ed83a92b 100644 --- a/test/flask/permissions-snap/src/index.ts +++ b/test/flask/permissions-snap/src/index.ts @@ -15,6 +15,17 @@ export const onRpcRequest: OnRpcRequestHandler = ({ origin, request }) => { }, ], }); + case "notify_inApp": { + return wallet.request({ + method: "snap_notify", + params: [ + { + type: "inApp", + message: `Hello from permissions snap in App notification`, + }, + ], + }); + } default: throw new Error("Method not found."); } diff --git a/test/flask/snaps.spec.ts b/test/flask/snaps.spec.ts index b7c8008c..8cef9789 100644 --- a/test/flask/snaps.spec.ts +++ b/test/flask/snaps.spec.ts @@ -1,6 +1,6 @@ import { expect } from "chai"; import * as dappeteer from "../../src"; -import { DappeteerPage } from "../../src/page"; +import { DappeteerPage } from "../../src"; import { TestContext } from "../constant"; import { Snaps } from "../deploy"; @@ -62,7 +62,6 @@ describe("snaps", function () { hasKeyPermissions: false, } ); - testPage = await metamask.page.browser().newPage(); await testPage.goto("https://google.com"); return testPage; @@ -86,18 +85,40 @@ describe("snaps", function () { snapId, "confirm" ); - await metamask.snaps.rejectDialog(); expect(await invokeAction).to.equal(false); }); - it("should invoke IN APP NOTIFICATIONS and check for a text", async function (this: TestContext) { + it("should return all notifications", async function (this: TestContext) { + const permissionSnapId = await metamask.snaps.installSnap( + this.snapServers[Snaps.PERMISSIONS_SNAP], + { + hasPermissions: true, + hasKeyPermissions: false, + } + ); + + const emitter = await metamask.snaps.getNotificationEmitter(); + const notificationPromise = emitter.waitForNotification(); + await metamask.snaps.invokeSnap(testPage, snapId, "notify_inApp"); + await metamask.snaps.invokeSnap( + testPage, + permissionSnapId, + "notify_inApp" + ); + await notificationPromise; const notifications = await metamask.snaps.getAllNotifications(); - expect(notifications[0].message).to.equal("Hello, in App notification"); + expect(notifications[0].message).to.contain( + "Hello from permissions snap in App notification" + ); + expect(notifications[1].message).to.contain( + "Hello from methods snap in App notification" + ); + await emitter.cleanup(); }); }); }); diff --git a/tsconfig.json b/tsconfig.json index 148f9397..88b1b246 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2021", "module": "commonjs", "moduleResolution": "node", - "lib": ["es2017", "dom"], + "lib": ["es2021", "dom"], "typeRoots": ["./node_modules/@types", "./types"], "declaration": true, "esModuleInterop": true, diff --git a/yarn.lock b/yarn.lock index d2db95fa..c040fc55 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3451,7 +3451,7 @@ eventemitter3@4.0.4: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.4.tgz#b5463ace635a083d018bdc7c917b4c5f10a85384" integrity sha512-rlaVLnVxtxvoyLsQQFBx53YmXHDxRIzzTLbdfxqi4yocpSjAxXwkU0cScM5JgSKMqEhrZpnvQ2D9gjylR0AimQ== -events@^3.0.0: +events@^3.0.0, events@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== @@ -6488,6 +6488,13 @@ stream-splicer@^2.0.0: inherits "^2.0.1" readable-stream "^2.0.2" +strict-event-emitter@^0.2.8: + version "0.2.8" + resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.2.8.tgz#b4e768927c67273c14c13d20e19d5e6c934b47ca" + integrity sha512-KDf/ujU8Zud3YaLtMCcTI4xkZlZVIYxTLr+XIULexP+77EEVWixeXroLUXQXiVtH4XH2W7jr/3PT1v3zBuvc3A== + dependencies: + events "^3.3.0" + strict-uri-encode@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"