Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

feat: ability to install snap #154

Merged
merged 7 commits into from
Oct 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,16 @@
],
"license": "MIT",
"dependencies": {
"@metamask/providers": "^9.1.0",
"@types/chai-as-promised": "^7.1.5",
"chai-as-promised": "^7.1.1",
"node-stream-zip": "^1.13.0"
},
"devDependencies": {
"@chainsafe/eslint-config": "^1.0.0",
"@jest/types": "^27.1.1",
"@metamask/snap-types": "^0.22.0",
"@metamask/snaps-cli": "^0.22.0",
"@rushstack/eslint-patch": "^1.2.0",
"@types/chai": "^4.2.22",
"@types/mocha": "^9.1.1",
Expand Down
2 changes: 2 additions & 0 deletions src/snap/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { flaskOnly } from "./utils";
export { installSnap } from "./install";
91 changes: 91 additions & 0 deletions src/snap/install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { MetaMaskInpageProvider } from "@metamask/providers";
import { Page } from "puppeteer";
import {
clickOnButton,
clickOnElement,
clickOnLogo,
openProfileDropdown,
} from "../helpers";
import { flaskOnly } from "./utils";

declare let window: { ethereum: MetaMaskInpageProvider };

export type InstallStep = (page: Page) => Promise<void>;

export async function installSnap(
page: Page,
snapId: string,
opts: {
hasPermissions: boolean;
hasKeyPermissions: boolean;
customSteps?: InstallStep[];
version?: string;
}
): Promise<void> {
flaskOnly(page);
//need to open page to access window.ethereum
const installPage = await page.browser().newPage();
await installPage.goto("https://google.com");
const installAction = installPage.evaluate(
(opts: { snapId: string; version?: string }) =>
window.ethereum.request({
method: "wallet_enable",
params: [
{
[`wallet_snap_${opts.snapId}`]: {
version: opts.version ?? "latest",
},
},
],
}),
{ snapId, version: opts.version }
);

await page.bringToFront();
await page.reload();
await clickOnButton(page, "Connect");
if (opts.hasPermissions) {
await clickOnButton(page, "Approve & install");
if (opts.hasKeyPermissions) {
const checkbox = await page.waitForSelector(".checkbox-label", {
visible: true,
});
await checkbox.click();
await clickOnButton(page, "Confirm");
}
} else {
await clickOnButton(page, "Install");
}

await installAction;
await installPage.close({ runBeforeUnload: true });

for (const step of opts.customSteps ?? []) {
await step(page);
}
if (!(await isSnapInstalled(page, snapId))) {
throw new Error("Failed to install snap " + snapId);
}
}

export async function isSnapInstalled(
page: Page,
snapId: string
): Promise<boolean> {
await page.bringToFront();
await openProfileDropdown(page);

await clickOnElement(page, "Settings");
await clickOnElement(page, "Snaps");
let found = false;
try {
await page.waitForXPath(`//*[contains(text(), '${snapId}')]`, {
timeout: 5000,
});
found = true;
} catch (e) {
found = false;
}
await clickOnLogo(page);
return found;
}
10 changes: 10 additions & 0 deletions src/snap/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Page } from "puppeteer";
import { DappeteerBrowser } from "../setup";

export function flaskOnly(page: Page): void {
if ((page.browser() as DappeteerBrowser).flask == null) {
throw new Error(
"This method is only available when running Metamask Flask"
);
}
}
8 changes: 4 additions & 4 deletions test/constant.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import http from "http";

import { Provider, Server } from "ganache";
import { Browser } from "puppeteer";

import { Dappeteer } from "../src";
import { Dappeteer, DappeteerBrowser } from "../src";

import { Contract } from "./deploy";
import { Contract, Snaps } from "./deploy";

export type InjectableContext = Readonly<{
provider: Provider;
ethereum: Server<"ethereum">;
testPageServer: http.Server;
browser: Browser;
snapServers?: Record<Snaps, http.Server>;
browser: DappeteerBrowser;
metamask: Dappeteer;
contract: Contract;
flask: boolean;
Expand Down
58 changes: 58 additions & 0 deletions test/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import fs from "fs";
import * as http from "http";
import * as path from "path";
import { exec } from "child_process";

import ganache, { Provider, Server, ServerOptions } from "ganache";
import handler from "serve-handler";
import Web3 from "web3";

import { compileContracts } from "./contract";
import { toUrl } from "./utils/utils";

const counterContract: { address: string } | null = null;

Expand Down Expand Up @@ -77,3 +79,59 @@ export async function startTestServer(): Promise<http.Server> {
});
return server;
}

export enum Snaps {
BASE_SNAP = "base-snap",
KEYS_SNAP = "keys-snap",
PERMISSIONS_SNAP = "permissions-snap",
}

export async function startSnapServers(): Promise<Record<Snaps, http.Server>> {
return {
[Snaps.BASE_SNAP]: await startSnapServer(Snaps.BASE_SNAP),
[Snaps.KEYS_SNAP]: await startSnapServer(Snaps.KEYS_SNAP),
[Snaps.PERMISSIONS_SNAP]: await startSnapServer(Snaps.PERMISSIONS_SNAP),
};
}

async function startSnapServer(snap: Snaps): Promise<http.Server> {
console.log(`Building ${snap}...`);
await new Promise((resolve, reject) => {
exec(`cd ./test/flask/${snap} && npx mm-snap build`, (error, stdout) => {
if (error) {
reject(error);
return;
}
resolve(stdout);
});
});
console.log(`Starting ${snap} server...`);
const server = http.createServer((req, res) => {
void handler(req, res, {
public: path.resolve(__dirname, `./flask/${snap}`),
headers: [
{
source: "**/*",
headers: [
{
key: "Cache-Control",
value: "no-cache",
},
{
key: "Access-Control-Allow-Origin",
value: "*",
},
],
},
],
});
});

await new Promise<void>((resolve) => {
server.listen(0, () => {
console.log(`Server for ${snap} running at `, toUrl(server.address()));
resolve();
});
});
return server;
}
65 changes: 65 additions & 0 deletions test/flask/base-snap/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<!doctype html>
<html>
</head>
<title>Hello, Snaps!</title>
<link rel="icon" type="image/svg" href="./images/icon.svg"/>
</head>

<body>
<h1>Hello, Snaps!</h1>
<details>
<summary>Instructions</summary>
<ul>
<li>First, click "Connect". Then, try out the other buttons!</li>
<li>Please note that:</li>
<ul>
<li>
The <code>snap.manifest.json</code> and <code>package.json</code> must be located in the server root directory...
</li>
<li>
The Snap bundle must be hosted at the location specified by the <code>location</code> field of <code>snap.manifest.json</code>.
</li>
</ul>
</ul>
</details>
<br/>

<button class="connect">Connect</button>
<button class="sendHello">Send Hello</button>
</body>

<script>
const snapId = `local:${window.location.href}`;

const connectButton = document.querySelector('button.connect')
const sendButton = document.querySelector('button.sendHello')

connectButton.addEventListener('click', connect)
sendButton.addEventListener('click', send)

// here we get permissions to interact with and install the snap
async function connect () {
await ethereum.request({
method: 'wallet_enable',
params: [{
wallet_snap: { [snapId]: {} },
}]
})
}

// here we call the snap's "hello" method
async function send () {
try {
await ethereum.request({
method: 'wallet_invokeSnap',
params: [snapId, {
method: 'hello'
}]
})
} catch (err) {
console.error(err)
alert('Problem happened: ' + err.message || err)
}
}
</script>
</html>
18 changes: 18 additions & 0 deletions test/flask/base-snap/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "base-snap",
"version": "1.0.0",
"description": "",
"main": "src/index.ts",
"scripts": {
"prefix": "mm-snap build",
"fix": "mm-snap manifest --fix"
},
"devDependencies": {
"@metamask/snaps-cli": "^0.22.0"
},
"author": "",
"license": "ISC",
"dependencies": {
"@metamask/snap-types": "^0.22.0"
}
}
6 changes: 6 additions & 0 deletions test/flask/base-snap/snap.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
cliOptions: {
src: './src/index.ts',
port: 8080,
},
};
17 changes: 17 additions & 0 deletions test/flask/base-snap/snap.manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"version": "1.0.0",
"description": "An example Snap written in TypeScript.",
"proposedName": "Base Snap\n",
"source": {
"shasum": "sApNS/DJjjs8psniSQzWlOdkzJ75IsbtlpdipfKarRI=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
"packageName": "base-snap",
"registry": "https://registry.npmjs.org/"
}
}
},
"initialPermissions": {},
"manifestVersion": "0.1"
}
21 changes: 21 additions & 0 deletions test/flask/base-snap/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { OnRpcRequestHandler } from "@metamask/snap-types";

export const onRpcRequest: OnRpcRequestHandler = ({ origin, request }) => {
switch (request.method) {
case "hello":
return wallet.request({
method: "snap_confirm",
params: [
{
prompt: `Hello, ${origin}!`,
description:
"This custom confirmation is just for display purposes.",
textAreaContent:
"But you can edit the snap source code to make it do something, if you want to!",
},
],
});
default:
throw new Error("Method not found.");
}
};