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
24 changes: 19 additions & 5 deletions .github/workflows/build-lint-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,37 @@ jobs:
build-lint-test:
name: Build, Lint, and Test
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
strategy:
matrix:
node-version: [20.x]
node-version: [24.22.2]
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: ${{ matrix.node-version }}
- run: yarn --frozen-lockfile
- name: Install Yarn
run: npm install --global yarn@1.22.22
- name: Verify toolchain
run: |
test "$(node --version)" = "v${{ matrix.node-version }}"
test "$(yarn --version)" = "1.22.22"
- run: yarn install --frozen-lockfile
- run: yarn allow-scripts
- run: yarn build
- run: yarn lint
- name: Smoke-test production artifact
run: yarn test:smoke
- run: yarn test
all-jobs-pass:
name: All jobs pass
runs-on: ubuntu-latest
timeout-minutes: 2
permissions: {}
needs:
- build-lint-test
steps:
Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v20.19.1
v24.22.2
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,18 @@ It can be used by navigating to `/request.html?method=${METHOD}&params=${PARAMS}

### Setup

- Install [Node.js](https://nodejs.org) version 20.19.0 or later
- If you are using [nvm](https://github.com/creationix/nvm#installation) (recommended) running `nvm use` will automatically choose the right node version for you.
- Install [Yarn v1](https://yarnpkg.com/en/docs/install)
- Install [Node.js](https://nodejs.org) >=24.22.1
- If you are using [nvm](https://github.com/creationix/nvm#installation) (recommended), running `nvm use` will automatically choose the version from `.nvmrc`.
- Node.js 24.22.1 or later is required, including when installing the published package.
- Install [Yarn 1.22.22](https://classic.yarnpkg.com/en/docs/install)
- Run `yarn setup` to install dependencies and run any required post-install scripts
- **Warning:** Do not use the `yarn` / `yarn install` command directly. Use `yarn setup` instead. The normal install command will skip required post-install scripts, leaving your development environment in an invalid state.

### Testing and Linting

Run `yarn lint` to run the linter, or run `yarn lint:fix` to run the linter and fix any automatically fixable issues.

This package has no tests.
Run `yarn build && yarn test:smoke` to check that the production artifact contains both pages and their local assets. This does not execute the browser JavaScript, test wallet flows, or verify the deployed GitHub Pages site.

### Deploying

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "9.10.0",
"description": "A simple dapp used in MetaMask e2e tests.",
"engines": {
"node": ">=20.19.0"
"node": ">=24.22.1"
},
"scripts": {
"setup": "yarn install && yarn allow-scripts",
Expand All @@ -14,6 +14,7 @@
"lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write",
"start": "webpack-dev-server",
"build": "webpack --mode production",
"test:smoke": "node scripts/smoke-test.js",
"test": "yarn lint",
"update-changelog": "auto-changelog update",
"prepublishOnly": "yarn test && yarn build"
Expand Down
220 changes: 220 additions & 0 deletions scripts/smoke-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
const fs = require('fs');
const http = require('http');
const path = require('path');

const fsPromises = fs.promises;
const artifactDirectory = path.resolve(process.argv[2] || 'dist');
const requestTimeout = 5000;
const testTimeout = 30000;
let activeServer;

const contentTypes = {
'.css': 'text/css',
'.html': 'text/html',
'.js': 'text/javascript',
'.json': 'application/json',
'.svg': 'image/svg+xml',
};

function fail(message) {
throw new Error(message);
}

async function createServer() {
const artifacts = new Map();

// Only filesystem-discovered files may be served. Never follow symlinks or
// construct filesystem paths from incoming request URLs.
async function loadArtifacts(directory, urlPrefix) {
const entries = await fsPromises.readdir(directory, {
withFileTypes: true,
});

for (const entry of entries) {
const filePath = path.join(directory, entry.name);
const urlPath = `${urlPrefix}/${entry.name}`;

if (entry.isDirectory()) {
await loadArtifacts(filePath, urlPath);
} else if (entry.isFile()) {
artifacts.set(urlPath, {
body: await fsPromises.readFile(filePath),
contentType:
contentTypes[path.extname(entry.name)] ||
'application/octet-stream',
});
}
}
}

await loadArtifacts(artifactDirectory, '');

return http.createServer((incomingRequest, response) => {
let urlPath;

try {
urlPath = decodeURIComponent(incomingRequest.url.split('?')[0]);
} catch {
response.writeHead(400);
response.end();
return;
}

const artifact = artifacts.get(urlPath === '/' ? '/index.html' : urlPath);

if (!artifact) {
response.writeHead(404);
response.end();
return;
}

response.writeHead(200, { 'Content-Type': artifact.contentType });
response.end(artifact.body);
});
}

function request(serverUrl, urlPath) {
return new Promise((resolve, reject) => {
const clientRequest = http.get(`${serverUrl}${urlPath}`, (response) => {
const chunks = [];

response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => {
resolve({
body: Buffer.concat(chunks),
contentType: response.headers['content-type'] || '',
statusCode: response.statusCode,
});
});
});

clientRequest.setTimeout(requestTimeout, () => {
clientRequest.destroy(new Error(`Request timed out: ${urlPath}`));
});
clientRequest.on('error', reject);
});
}

function localReferences(html) {
const references = new Set();
const referencePattern = /(?:src|href)="([^"]+)"/gu;
let match;

while ((match = referencePattern.exec(html))) {
const reference = match[1];
if (!/^(?:[a-z]+:|\/\/|#|data:)/iu.test(reference)) {
references.add(new URL(reference, 'http://localhost/').pathname);
}
}

return references;
}

async function runSmokeTest() {
try {
if (!(await fsPromises.stat(artifactDirectory)).isDirectory()) {
fail(`Artifact path is not a directory: ${artifactDirectory}`);
}
} catch (error) {
if (error.code === 'ENOENT') {
fail(`Artifact directory does not exist: ${artifactDirectory}`);
}
throw error;
}

const server = await createServer();
activeServer = server;

await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});

const address = server.address();
const serverUrl = `http://127.0.0.1:${address.port}`;

try {
const pages = [
{ path: '/', title: 'E2E Test Dapp', script: 'main.js' },
{
path: '/request.html',
title: 'E2E Test Dapp - Send Individual Request',
script: 'request.js',
},
];
const references = new Set(['/constants.json']);

for (const page of pages) {
const response = await request(serverUrl, page.path);
const html = response.body.toString();

if (
response.statusCode !== 200 ||
!response.contentType.startsWith('text/html')
) {
fail(`Expected HTML page at ${page.path}`);
}
if (!html.includes(`<title>${page.title}</title>`)) {
fail(`Unexpected title at ${page.path}`);
}
if (!html.includes(`src="${page.script}"`)) {
fail(`Expected script ${page.script} in ${page.path}`);
}

for (const reference of localReferences(html)) {
references.add(reference);
}
}

for (const reference of references) {
const response = await request(serverUrl, reference);
if (response.statusCode !== 200 || response.body.length === 0) {
fail(`Expected a non-empty local asset at ${reference}`);
}

const extension = path.extname(reference);
const expectedContentType = contentTypes[extension];
if (
expectedContentType &&
!response.contentType.startsWith(expectedContentType)
) {
fail(
`Unexpected content type for ${reference}: ${response.contentType}`,
);
}
}

const constants = await request(serverUrl, '/constants.json');
JSON.parse(constants.body.toString());

const missingRoute = await request(serverUrl, '/does-not-exist');
if (missingRoute.statusCode !== 404) {
fail(
`Expected unknown route to return 404, got ${missingRoute.statusCode}`,
);
}
} finally {
await new Promise((resolve) => server.close(resolve));
activeServer = undefined;
}
}

const timeout = new Promise((_, reject) => {
const timer = setTimeout(() => {
if (activeServer) {
activeServer.closeAllConnections();
activeServer.close();
}
reject(new Error('Smoke test timed out'));
}, testTimeout);
timer.unref();
});

Promise.race([runSmokeTest(), timeout])
.then(() => {
console.log(`Production artifact smoke test passed: ${artifactDirectory}`);
})
.catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
Loading