diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml index 6c38b104..43b8f841 100644 --- a/.github/workflows/build-lint-test.yml +++ b/.github/workflows/build-lint-test.yml @@ -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: diff --git a/.nvmrc b/.nvmrc index f62f0b29..86456ddf 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.19.1 +v24.22.2 diff --git a/README.md b/README.md index a44155e0..37a518b8 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,10 @@ It can be used by navigating to `/request.html?method=${METHOD}¶ms=${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. @@ -32,7 +33,7 @@ It can be used by navigating to `/request.html?method=${METHOD}¶ms=${PARAMS} 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 diff --git a/package.json b/package.json index b1718842..71fdd885 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" diff --git a/scripts/smoke-test.js b/scripts/smoke-test.js new file mode 100644 index 00000000..44cf21f4 --- /dev/null +++ b/scripts/smoke-test.js @@ -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(`