Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,7 +29,7 @@ jobs:

- name: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 'true'
PUPPETEER_SKIP_DOWNLOAD: 'true'
run: npm i

- name: Check Lint
Expand All @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/deploy-s3-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:

- name: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: "true"
PUPPETEER_SKIP_DOWNLOAD: "true"
run: npm i

- name: Build
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy-s3-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:

- name: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: "true"
PUPPETEER_SKIP_DOWNLOAD: "true"
run: npm i

- name: Build
Expand Down
2 changes: 1 addition & 1 deletion integration/issue.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
83 changes: 77 additions & 6 deletions integration/metamask-init.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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();
Expand All @@ -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 {
Expand Down
81 changes: 81 additions & 0 deletions integration/prefetch-metamask.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 13 additions & 2 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -30,10 +31,20 @@ const App: FunctionComponent = () => {
};

if (!window.ethereum) {
return <MetaMaskError />;
return (
<>
<MigrationBanner />
<MetaMaskError />
</>
);
}

return metamaskConnected ? <MainPage /> : <MetaMaskPending />;
return (
<>
<MigrationBanner />
{metamaskConnected ? <MainPage /> : <MetaMaskPending />}
</>
);
};

export default App;
28 changes: 28 additions & 0 deletions src/components/common/migration-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React, { FunctionComponent } from "react";

const MIGRATION_GUIDE_URL = "https://docs.opencerts.io/docs/migrations/oa_to_trustvc";

export const MigrationBanner: FunctionComponent = () => (
<div
className="w-full py-1 text-center font-bold"
style={{ backgroundColor: "#ff9933", fontFamily: '"Source Sans Pro", sans-serif' }}
>
Please note that as of 1 October 2025, OpenAttestation (OA) has been deprecated. OpenCerts has since migrated to
TrustVC, which uses the W3C Verifiable Credentials (VC) format.
<br />
Documents previously issued in OA format remain verifiable — no action is required for existing certificates.
However, we recommend upgrading to the W3C VC format for all new issuances.
<br />
To migrate, please refer to our{" "}
<a
href={MIGRATION_GUIDE_URL}
target="_blank"
rel="noopener noreferrer"
className="underline cursor-pointer"
style={{ color: "#0000EE" }}
>
migration guide
</a>
.
</div>
);
Loading