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 @@ + + +
+ +