diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dc3e733..b031230 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,7 +8,7 @@ jobs:
test:
name: Lint & Test
runs-on: ubuntu-latest
- timeout-minutes: 10
+ timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
@@ -29,7 +29,7 @@ jobs:
- name: Install Dependencies
env:
- PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 'true'
+ PUPPETEER_SKIP_DOWNLOAD: 'true'
run: npm i
- name: Check Lint
@@ -47,3 +47,11 @@ jobs:
CI: "true"
with:
args: npm run integration:dappeteer
+
+ - name: Upload integration debug screenshot
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: integration-debug
+ path: integration/debug-metamask-timeout.png
+ if-no-files-found: ignore
diff --git a/.github/workflows/deploy-s3-dev.yml b/.github/workflows/deploy-s3-dev.yml
new file mode 100644
index 0000000..030f67f
--- /dev/null
+++ b/.github/workflows/deploy-s3-dev.yml
@@ -0,0 +1,64 @@
+name: Deploy to S3 (Dev)
+
+on:
+ push:
+ branches: [develop]
+
+concurrency:
+ group: deploy-s3-dev-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ name: Build and Deploy to S3 (Dev)
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: "18"
+
+ - name: Cache npm
+ uses: actions/cache@v3
+ env:
+ cache-name: cache-node-modules
+ with:
+ path: ~/.npm
+ key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-build-${{ env.cache-name }}
+ ${{ runner.os }}-build
+ ${{ runner.os }}
+
+ - name: Install Dependencies
+ env:
+ PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: "true"
+ run: npm i
+
+ - name: Build
+ run: npm run build
+
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ aws-access-key-id: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}
+ aws-region: ${{ secrets.AWS_REGION }}
+
+ - name: Sync assets to S3
+ run: |
+ aws s3 sync ./dist "s3://${{ secrets.S3_BUCKET_DEV }}/" --delete
+
+ - name: Invalidate CloudFront (optional)
+ env:
+ CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID_DEV }}
+ if: ${{ env.CLOUDFRONT_DISTRIBUTION_ID != '' }}
+ run: |
+ aws cloudfront create-invalidation \
+ --distribution-id "${{ env.CLOUDFRONT_DISTRIBUTION_ID }}" \
+ --paths "/*"
diff --git a/.github/workflows/deploy-s3-prod.yml b/.github/workflows/deploy-s3-prod.yml
new file mode 100644
index 0000000..1a07265
--- /dev/null
+++ b/.github/workflows/deploy-s3-prod.yml
@@ -0,0 +1,63 @@
+name: Deploy to S3 (Prod)
+
+on:
+ workflow_dispatch:
+
+concurrency:
+ group: deploy-s3-prod-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ name: Build and Deploy to S3 (Prod)
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: "18"
+
+ - name: Cache npm
+ uses: actions/cache@v3
+ env:
+ cache-name: cache-node-modules
+ with:
+ path: ~/.npm
+ key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-build-${{ env.cache-name }}
+ ${{ runner.os }}-build
+ ${{ runner.os }}
+
+ - name: Install Dependencies
+ env:
+ PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: "true"
+ run: npm i
+
+ - name: Build
+ run: npm run build
+
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-region: ${{ secrets.AWS_REGION }}
+
+ - name: Sync assets to S3
+ run: |
+ aws s3 sync ./dist "s3://${{ secrets.S3_BUCKET }}/" --delete
+
+ - name: Invalidate CloudFront (optional)
+ env:
+ CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID_PROD }}
+ if: ${{ env.CLOUDFRONT_DISTRIBUTION_ID != '' }}
+ run: |
+ aws cloudfront create-invalidation \
+ --distribution-id "${{ env.CLOUDFRONT_DISTRIBUTION_ID }}" \
+ --paths "/*"
diff --git a/integration/issue.mjs b/integration/issue.mjs
index 29b9308..ce6dd0c 100644
--- a/integration/issue.mjs
+++ b/integration/issue.mjs
@@ -4,7 +4,7 @@ import { sleep, waitForAndClickElement, waitForElement } from "./utils.mjs";
export const issue = async (metamask, browser) => {
try {
const page = await browser.newPage();
- await page.goto("http://localhost:4173/", { waitUntil: "networkidle" });
+ await page.goto("http://localhost:4173/", { waitUntil: "networkidle0" });
// START - approve application once, subsequent tests no longer need
await metamask.approve();
diff --git a/integration/metamask-init.mjs b/integration/metamask-init.mjs
index 54256c7..cabd2d8 100644
--- a/integration/metamask-init.mjs
+++ b/integration/metamask-init.mjs
@@ -2,6 +2,53 @@ import dappeteer from "@chainsafe/dappeteer";
import { addNetwork } from "./addNetwork.mjs";
import { sleep } from "./utils.mjs";
+// dappeteer's setupMetaMask() waits on a browser "targetcreated" event with no timeout
+// and no fallback if the extension never opens its home.html tab. Wrap it so a stuck
+// extension fails fast with diagnostics instead of hanging until the CI job is killed.
+const withDiagnosticTimeout = (promise, rawBrowser, ms) =>
+ new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ dumpDiagnostics(rawBrowser).finally(() => {
+ reject(new Error(`Timed out after ${ms}ms waiting for MetaMask setup`));
+ });
+ }, ms);
+ promise.then(
+ (value) => {
+ clearTimeout(timer);
+ resolve(value);
+ },
+ (error) => {
+ clearTimeout(timer);
+ reject(error);
+ }
+ );
+ });
+
+const dumpDiagnostics = async (rawBrowser) => {
+ try {
+ const pages = await rawBrowser.pages();
+ console.info(`Open pages at timeout (${pages.length}):`);
+ for (const [i, page] of pages.entries()) {
+ console.info(` [${i}] ${page.url()}`);
+ }
+ const lastPage = pages[pages.length - 1];
+ if (lastPage) {
+ await lastPage.screenshot({ path: "integration/debug-metamask-timeout.png", fullPage: true });
+ console.info("Saved screenshot to integration/debug-metamask-timeout.png");
+ }
+ } catch (diagError) {
+ console.error("Failed to capture MetaMask timeout diagnostics:", diagError);
+ }
+};
+
+const wireConsoleLogging = (page, label) => {
+ page.on("console", (msg) => console.info(`[console ${label}] ${msg.text()}`));
+ page.on("pageerror", (err) => console.info(`[pageerror ${label}] ${err}`));
+ page.on("requestfailed", (req) => {
+ console.info(`[requestfailed ${label}] ${req.url()} ${req.failure()?.errorText ?? ""}`);
+ });
+};
+
export const metamaskInit = async () => {
try {
const browser = await dappeteer.launch({
@@ -10,11 +57,31 @@ export const metamaskInit = async () => {
headless: false,
puppeteerOptions: {
args: ["--no-sandbox"],
- executablePath: process.env.PUPPETEER_EXEC_PATH,
+ // Deliberately NOT using process.env.PUPPETEER_EXEC_PATH here: the
+ // puppeteer-headful Docker image points it at the system's current
+ // google-chrome-stable, which no longer supports the Manifest V2
+ // extension format this pinned MetaMask build uses. Puppeteer's own
+ // bundled Chrome (pinned to a version from when MV2 still worked)
+ // is fetched by the integration:prefetch-chromium script instead.
defaultViewport: null,
slowMo: process.argv[2] || 0,
},
});
+ const rawBrowser = browser.getSource();
+
+ // Surface every target and its console/page errors as soon as they happen, so a
+ // failed extension load is visible instead of a silent hang.
+ rawBrowser.on("targetcreated", async (target) => {
+ console.info(`[target created] type=${target.type()} url=${target.url()}`);
+ const page = await target.page().catch(() => null);
+ if (page) {
+ wireConsoleLogging(page, target.url());
+ }
+ });
+ for (const page of await rawBrowser.pages()) {
+ console.info(`[existing target] url=${page.url()}`);
+ wireConsoleLogging(page, page.url());
+ }
await sleep(2000);
const pages = await browser.pages();
@@ -25,15 +92,19 @@ export const metamaskInit = async () => {
}
// Get metamask
- const metamask = await dappeteer.setupMetaMask(browser, {
- seed: "indicate swing place chair flight used hammer soon photo region volume shuffle",
- showTestNets: true,
- });
+ const metamask = await withDiagnosticTimeout(
+ dappeteer.setupMetaMask(browser, {
+ seed: "indicate swing place chair flight used hammer soon photo region volume shuffle",
+ showTestNets: true,
+ }),
+ rawBrowser,
+ 45000
+ );
// Add network
// https://github.com/ChainSafe/dappeteer/blob/b79ab4c74fab87747933d8f428624dcbffc3dd19/test/basic.spec.ts#L117-L119
const dappPage = await browser.newPage();
- await dappPage.goto("https://github.com", { waitUntil: "networkidle" });
+ await dappPage.goto("https://github.com", { waitUntil: "networkidle0" });
dappPage.evaluate(addNetwork);
try {
diff --git a/integration/prefetch-metamask.mjs b/integration/prefetch-metamask.mjs
new file mode 100644
index 0000000..37938b2
--- /dev/null
+++ b/integration/prefetch-metamask.mjs
@@ -0,0 +1,81 @@
+import https from "https";
+import fs from "fs";
+import path from "path";
+import { execSync } from "child_process";
+
+// dappeteer's own downloader only reads page 1 (30 releases) of the GitHub API with no
+// pagination, so an older pinned version falls off the list as MetaMask keeps shipping
+// releases. Pre-populating dappeteer's cache directory here makes it skip that search
+// entirely (it only searches when the extraction directory is missing).
+const METAMASK_VERSION = "v10.31.0";
+const CACHE_DIR = path.resolve("node_modules", ".cache", ".metamask");
+const EXTRACT_DIR = path.resolve(CACHE_DIR, METAMASK_VERSION.replace(/\./g, "_"));
+const DOWNLOAD_DIR = path.resolve(CACHE_DIR, "download");
+const RELEASES_URL = "https://api.github.com/repos/metamask/metamask-extension/releases";
+const MAX_PAGES = 10;
+
+const getJson = (url) =>
+ new Promise((resolve, reject) => {
+ https
+ .get(url, { headers: { "User-Agent": "Mozilla/5.0" } }, (res) => {
+ let body = "";
+ res.on("data", (chunk) => (body += chunk));
+ res.on("end", () => resolve(JSON.parse(body)));
+ })
+ .on("error", reject);
+ });
+
+const findChromeAssetUrl = async () => {
+ for (let page = 1; page <= MAX_PAGES; page++) {
+ const releases = await getJson(`${RELEASES_URL}?per_page=100&page=${page}`);
+ if (!Array.isArray(releases) || releases.length === 0) break;
+
+ const release = releases.find((r) => r.tag_name === METAMASK_VERSION);
+ if (release) {
+ const asset = release.assets.find((a) => a.name.startsWith("metamask-chrome-"));
+ if (asset) return asset.browser_download_url;
+ }
+ }
+ throw new Error(`Could not find MetaMask ${METAMASK_VERSION} chrome release asset`);
+};
+
+const download = (url, dest) =>
+ new Promise((resolve, reject) => {
+ const request = (requestUrl) =>
+ https
+ .get(requestUrl, (res) => {
+ if (res.statusCode === 301 || res.statusCode === 302) {
+ request(res.headers.location);
+ return;
+ }
+ const file = fs.createWriteStream(dest);
+ res.pipe(file);
+ file.on("finish", () => file.close(resolve));
+ })
+ .on("error", reject);
+ request(url);
+ });
+
+const main = async () => {
+ if (fs.existsSync(EXTRACT_DIR)) {
+ console.info(`MetaMask ${METAMASK_VERSION} already cached at ${EXTRACT_DIR}`);
+ return;
+ }
+
+ console.info(`Searching MetaMask releases for ${METAMASK_VERSION}...`);
+ const downloadUrl = await findChromeAssetUrl();
+
+ fs.mkdirSync(DOWNLOAD_DIR, { recursive: true });
+ const zipPath = path.resolve(DOWNLOAD_DIR, `${METAMASK_VERSION}.zip`);
+ console.info(`Downloading ${downloadUrl}`);
+ await download(downloadUrl, zipPath);
+
+ fs.mkdirSync(EXTRACT_DIR, { recursive: true });
+ execSync(`unzip -q -o "${zipPath}" -d "${EXTRACT_DIR}"`);
+ console.info(`Extracted MetaMask ${METAMASK_VERSION} to ${EXTRACT_DIR}`);
+};
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/package.json b/package.json
index 20c415a..9ffd6cd 100644
--- a/package.json
+++ b/package.json
@@ -9,13 +9,15 @@
"start": "npm-run-all -p start:*",
"start:css": "postcss src/tailwind.css -o src/index.css --watch",
"start:dev": "sleep 3 && vite",
- "serve": "npm run build && vite preview",
+ "serve": "npm run build && vite preview --host 127.0.0.1 --port 4173",
"test": "jest",
"test:watch": "jest --watch",
"integration:blockchain": "ganache --mnemonic \"indicate swing place chair flight used hammer soon photo region volume shuffle\"",
"integration:local": "npm run serve",
- "integration:metamask": "npm-run-all --serial integration:wait \"integration:transfers {1} \" --",
- "integration:wait": "wait-on -l -i 1000 tcp:8545 http-get://localhost:4173",
+ "integration:metamask": "npm-run-all --serial integration:prefetch-metamask integration:prefetch-chromium integration:wait \"integration:transfers {1} \" --",
+ "integration:prefetch-metamask": "node ./integration/prefetch-metamask.mjs",
+ "integration:prefetch-chromium": "node node_modules/puppeteer/install.mjs",
+ "integration:wait": "wait-on -l -i 1000 -t 60000 tcp:8545 http-get://127.0.0.1:4173",
"integration:transfers": "node --experimental-modules ./integration/index.mjs",
"integration:dappeteer": "npm-run-all --parallel integration:blockchain integration:local \"integration:metamask {1}\" --race --",
"lint": "eslint ./src ./integration --ext .js,.ts,.tsx,.mjs --max-warnings 0",
diff --git a/src/app.tsx b/src/app.tsx
index fe6b7b7..d5a191f 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -4,6 +4,7 @@ import { MainPage } from "./page/main-page";
import { MetaMaskError } from "./page/metamask-error";
import { MetaMaskPending } from "./page/metamask-pending";
import { EthereumProviderError } from "eth-rpc-errors";
+import { MigrationBanner } from "./components/common/migration-banner";
const App: FunctionComponent = () => {
const [metamaskConnected, setMetamaskConnected] = useState(false);
@@ -30,10 +31,20 @@ const App: FunctionComponent = () => {
};
if (!window.ethereum) {
- return