From e4da4679c45b25cc7a87ebba283cb1ebbd5f3644 Mon Sep 17 00:00:00 2001 From: pieh Date: Thu, 20 Aug 2026 15:33:47 +0200 Subject: [PATCH] test: setup local e2e tests that use playwright to inspect actual requests being made --- package-lock.json | 1 + .../unplugin-skew-protection/package.json | 1 + .../test/e2e/browser.test.ts | 109 ++++++++++++++++ .../test/fixtures/entry.js | 21 +++ .../test/fixtures/index.html | 11 ++ .../test/fixtures/lazy.js | 1 + .../test/support/builders.ts | 122 ++++++++++++++++++ .../test/support/serve.ts | 69 ++++++++++ .../unplugin-skew-protection/tsconfig.json | 3 +- .../unplugin-skew-protection/vitest.config.ts | 3 + 10 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 packages/unplugin-skew-protection/test/e2e/browser.test.ts create mode 100644 packages/unplugin-skew-protection/test/fixtures/entry.js create mode 100644 packages/unplugin-skew-protection/test/fixtures/index.html create mode 100644 packages/unplugin-skew-protection/test/fixtures/lazy.js create mode 100644 packages/unplugin-skew-protection/test/support/builders.ts create mode 100644 packages/unplugin-skew-protection/test/support/serve.ts diff --git a/package-lock.json b/package-lock.json index 785994d8..f1ff1701 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33110,6 +33110,7 @@ "@rollup/plugin-html": "^2.0.0", "@types/node": "^20.19.43", "html-webpack-plugin": "^5.6.8", + "playwright": "^1.61.0", "rolldown": "^1.2.5", "rollup": "^4.62.4", "tsup": "^8.5.1", diff --git a/packages/unplugin-skew-protection/package.json b/packages/unplugin-skew-protection/package.json index f59e9570..dacf91e8 100644 --- a/packages/unplugin-skew-protection/package.json +++ b/packages/unplugin-skew-protection/package.json @@ -47,6 +47,7 @@ "@rollup/plugin-html": "^2.0.0", "@types/node": "^20.19.43", "html-webpack-plugin": "^5.6.8", + "playwright": "^1.61.0", "rolldown": "^1.2.5", "rollup": "^4.62.4", "tsup": "^8.5.1", diff --git a/packages/unplugin-skew-protection/test/e2e/browser.test.ts b/packages/unplugin-skew-protection/test/e2e/browser.test.ts new file mode 100644 index 00000000..f53e391d --- /dev/null +++ b/packages/unplugin-skew-protection/test/e2e/browser.test.ts @@ -0,0 +1,109 @@ +import { join } from 'node:path' +import { rm } from 'node:fs/promises' + +import { afterAll, beforeAll, describe, expect, test } from 'vitest' +import { chromium, type Browser } from 'playwright' + +import { BUNDLERS, TOKEN, createFixture } from '../support/builders.js' +import { serveStatic, type StaticServer } from '../support/serve.js' + +const EXPECTED_QUERY = `?nfdpl=${TOKEN}` + +let browser: Browser + +beforeAll(async () => { + browser = await chromium.launch() +}) + +afterAll(async () => { + await browser.close() +}) + +describe.each(BUNDLERS)('$name', ({ build, expectedUnstamped }) => { + let root: string + let server: StaticServer + + beforeAll(async () => { + root = await createFixture() + const outDir = join(root, 'dist') + await build(root, outDir) + server = await serveStatic(outDir) + }) + + afterAll(async () => { + await server.close() + await rm(root, { force: true, recursive: true }) + }) + + test('serves an app whose asset requests are pinned to the deploy', async () => { + const page = await browser.newPage() + const assetRequests: URL[] = [] + const failedResponses: string[] = [] + + page.on('request', (request) => { + const url = new URL(request.url()) + + if (/\.(css|js|mjs)$/.test(url.pathname)) { + assetRequests.push(url) + } + }) + + page.on('response', (response) => { + // The browser asks for a favicon that the fixture does not ship; every other 4xx/5xx + // means a stamped URL failed to resolve, which is the failure mode worth catching. + if (response.status() >= 400 && !response.url().endsWith('/favicon.ico')) { + failedResponses.push(`${String(response.status())} ${response.url()}`) + } + }) + + try { + await page.goto(`${server.url}/`) + + // The fixture marks `data-state` on both the success and failure paths, so a chunk that + // never loads is reported as a soft failure here instead of stalling the whole test -- + // which keeps the assertions below running and shows every problem in one go. + const reachedTerminalState = await page + .waitForSelector('#app[data-state]', { timeout: 10_000 }) + .then(() => true) + .catch(() => false) + + expect + .soft(reachedTerminalState, 'the app never finished loading: its dynamic import neither resolved nor rejected') + .toBe(true) + + expect + .soft( + failedResponses, + 'the page requested assets that the server could not serve, so a stamped URL does not point at a file this build emitted', + ) + .toEqual([]) + + expect + .soft( + await page.textContent('#app'), + 'the lazily imported chunk did not evaluate in the browser, so its stamped specifier does not resolve to a working module', + ) + .toBe('lazy chunk loaded') + + const unstamped = assetRequests.filter((url) => url.search !== EXPECTED_QUERY).map((url) => url.pathname) + expect + .soft( + unstamped, + 'these assets were requested without the deploy-pinning query parameter, so they are not pinned to this deploy', + ) + .toEqual(expectedUnstamped) + + // Guards against a vacuous pass: with no asset requests at all, the comparison above is + // satisfied by an empty list for the bundlers that are expected to stamp everything. + const stamped = assetRequests.filter((url) => url.search === EXPECTED_QUERY).map((url) => url.pathname) + expect + .soft( + stamped.length, + `no asset was requested with the deploy-pinning query parameter (all requests: ${assetRequests.map((url) => url.pathname).join(', ') || 'none'})`, + ) + .toBeGreaterThan(0) + } finally { + await page.close() + } + }) +}) diff --git a/packages/unplugin-skew-protection/test/fixtures/entry.js b/packages/unplugin-skew-protection/test/fixtures/entry.js new file mode 100644 index 00000000..0ec14abf --- /dev/null +++ b/packages/unplugin-skew-protection/test/fixtures/entry.js @@ -0,0 +1,21 @@ +// Bundlers that generate their own HTML may not carry the fixture's markup, so the mount +// point is created here when the page does not already provide one. +let app = document.querySelector('#app') + +if (!app) { + app = document.createElement('div') + app.id = 'app' + document.body.prepend(app) +} + +app.textContent = 'entry loaded' + +import('./lazy.js') + .then(({ default: message }) => { + app.textContent = message + app.dataset.state = 'loaded' + }) + .catch((error) => { + app.textContent = `failed: ${error.message}` + app.dataset.state = 'failed' + }) diff --git a/packages/unplugin-skew-protection/test/fixtures/index.html b/packages/unplugin-skew-protection/test/fixtures/index.html new file mode 100644 index 00000000..d4385e6e --- /dev/null +++ b/packages/unplugin-skew-protection/test/fixtures/index.html @@ -0,0 +1,11 @@ + + + + + skew protection e2e + + +
loading
+ + + diff --git a/packages/unplugin-skew-protection/test/fixtures/lazy.js b/packages/unplugin-skew-protection/test/fixtures/lazy.js new file mode 100644 index 00000000..68b7c47e --- /dev/null +++ b/packages/unplugin-skew-protection/test/fixtures/lazy.js @@ -0,0 +1 @@ +export default 'lazy chunk loaded' diff --git a/packages/unplugin-skew-protection/test/support/builders.ts b/packages/unplugin-skew-protection/test/support/builders.ts new file mode 100644 index 00000000..48543546 --- /dev/null +++ b/packages/unplugin-skew-protection/test/support/builders.ts @@ -0,0 +1,122 @@ +import { copyFile, mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import HtmlWebpackPlugin from 'html-webpack-plugin' +import webpack from 'webpack' +import { build as viteBuild } from 'vite' +import { rolldown } from 'rolldown' +import { rollup } from 'rollup' +import rollupHtmlPluginModule from '@rollup/plugin-html' + +import vitePlugin from '../../src/vite.js' +import rollupPlugin from '../../src/rollup.js' +import rolldownPlugin from '../../src/rolldown.js' +import webpackPlugin from '../../src/webpack.js' + +// `@rollup/plugin-html` ships CJS-flavoured types for its ESM build, so under NodeNext the +// default import is typed as the module namespace rather than the plugin factory. +const rollupHtmlPlugin = rollupHtmlPluginModule as unknown as typeof rollupHtmlPluginModule.default + +export const TOKEN = 'e2e-token-123' + +const FIXTURE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'fixtures') + +export interface BundlerCase { + /** + * Asset paths that are expected to be served *without* the skew protection parameter. + * + * Only Vite and webpack expose an HTML hook to this plugin, so only they can pin the + * initial `