diff --git a/packages/unplugin-skew-protection/test/e2e/browser.test.ts b/packages/unplugin-skew-protection/test/e2e/browser.test.ts index f53e391d..8646d15f 100644 --- a/packages/unplugin-skew-protection/test/e2e/browser.test.ts +++ b/packages/unplugin-skew-protection/test/e2e/browser.test.ts @@ -4,7 +4,7 @@ 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 { BUNDLERS, PAGES, TOKEN, createFixture } from '../support/builders.js' import { serveStatic, type StaticServer } from '../support/serve.js' const EXPECTED_QUERY = `?nfdpl=${TOKEN}` @@ -35,75 +35,82 @@ describe.each(BUNDLERS)('$name', ({ build, expectedUnstamped }) => { 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() + test('serves pages whose asset requests are pinned to the deploy', async () => { 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) + const rendered: { path: string; text: string | null }[] = [] + + for (const { path } of PAGES) { + // Each document gets a browser page of its own: `newPage` opens a fresh context, so the chunk + // the two documents share is requested again on the second visit instead of being served out + // of the first visit's cache, where it would never be observed at all. + const page = await browser.newPage() + + 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(`${path} -> ${String(response.status())} ${response.url()}`) + } + }) + + try { + await page.goto(`${server.url}${path}`) + + // The fixture marks `data-state` on both the success and failure paths, so a chunk that + // never loads leaves the text at " loading" for the comparison below to report, + // rather than stalling the whole test -- which keeps every page and assertion in play and + // shows all the problems in one go. + await page.waitForSelector('#app[data-state]', { timeout: 10_000 }).catch(() => null) + + rendered.push({ path, text: await page.textContent('#app') }) + } finally { + await page.close() } - }) - - 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() } + + expect + .soft( + failedResponses, + 'these pages requested assets that the server could not serve, so a stamped URL does not point at a file this build emitted', + ) + .toEqual([]) + + expect + .soft( + rendered, + 'these pages did not settle with both of their shared chunks evaluated, so a stamped specifier does not resolve to a working module', + ) + .toEqual(PAGES.map(({ path, text }) => ({ path, text }))) + + // Deduplicated because the two pages request the chunk they share separately, and sorted so the + // comparison does not depend on the order the browser happened to fetch things in. + const unstamped = [ + ...new Set(assetRequests.filter((url) => url.search !== EXPECTED_QUERY).map((url) => url.pathname)), + ].sort() + 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) + 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) }) }) diff --git a/packages/unplugin-skew-protection/test/fixtures/entry.js b/packages/unplugin-skew-protection/test/fixtures/entry.js deleted file mode 100644 index 0ec14abf..00000000 --- a/packages/unplugin-skew-protection/test/fixtures/entry.js +++ /dev/null @@ -1,21 +0,0 @@ -// 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 index d4385e6e..c1944106 100644 --- a/packages/unplugin-skew-protection/test/fixtures/index.html +++ b/packages/unplugin-skew-protection/test/fixtures/index.html @@ -2,10 +2,10 @@ - skew protection e2e + skew protection e2e - index
loading
- + diff --git a/packages/unplugin-skew-protection/test/fixtures/index.js b/packages/unplugin-skew-protection/test/fixtures/index.js new file mode 100644 index 00000000..0cae5c67 --- /dev/null +++ b/packages/unplugin-skew-protection/test/fixtures/index.js @@ -0,0 +1,3 @@ +import { renderPage } from './shared-static.js' + +renderPage('index', () => import('./shared-dynamic.js')) diff --git a/packages/unplugin-skew-protection/test/fixtures/lazy.js b/packages/unplugin-skew-protection/test/fixtures/lazy.js deleted file mode 100644 index 68b7c47e..00000000 --- a/packages/unplugin-skew-protection/test/fixtures/lazy.js +++ /dev/null @@ -1 +0,0 @@ -export default 'lazy chunk loaded' diff --git a/packages/unplugin-skew-protection/test/fixtures/second.html b/packages/unplugin-skew-protection/test/fixtures/second.html new file mode 100644 index 00000000..a4a803d5 --- /dev/null +++ b/packages/unplugin-skew-protection/test/fixtures/second.html @@ -0,0 +1,11 @@ + + + + + skew protection e2e - second + + +
loading
+ + + diff --git a/packages/unplugin-skew-protection/test/fixtures/second.js b/packages/unplugin-skew-protection/test/fixtures/second.js new file mode 100644 index 00000000..e51d2319 --- /dev/null +++ b/packages/unplugin-skew-protection/test/fixtures/second.js @@ -0,0 +1,3 @@ +import { renderPage } from './shared-static.js' + +renderPage('second', () => import('./shared-dynamic.js')) diff --git a/packages/unplugin-skew-protection/test/fixtures/shared-dynamic.js b/packages/unplugin-skew-protection/test/fixtures/shared-dynamic.js new file mode 100644 index 00000000..ff98e009 --- /dev/null +++ b/packages/unplugin-skew-protection/test/fixtures/shared-dynamic.js @@ -0,0 +1 @@ +export default 'shared dynamic chunk loaded' diff --git a/packages/unplugin-skew-protection/test/fixtures/shared-static.js b/packages/unplugin-skew-protection/test/fixtures/shared-static.js new file mode 100644 index 00000000..ad928ae3 --- /dev/null +++ b/packages/unplugin-skew-protection/test/fixtures/shared-static.js @@ -0,0 +1,37 @@ +// Statically imported by both entries, so every bundler under test hoists this module into a chunk +// that the two pages share -- a request that has to carry the deploy-pinning parameter just like an +// entry or a dynamically imported chunk does. +const SHARED_STATIC = 'shared static chunk loaded' + +/** + * Renders one fixture page. Both entries go through here so the browser test can treat every page + * and every bundler alike: find or create the `#app` mount point, then settle it to a terminal + * `data-state` once the page's dynamic chunk has either resolved or rejected. + * + * `importDynamic` is a callback rather than a specifier because bundlers only rewrite literal + * `import()` specifiers: passing the specifier in would leave a single call site here in the + * shared chunk instead of one per page. + */ +export function renderPage(name, importDynamic) { + // 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 = `${name} loading` + + importDynamic() + .then(({ default: sharedDynamic }) => { + app.textContent = `${name}: ${SHARED_STATIC}, ${sharedDynamic}` + app.dataset.state = 'loaded' + }) + .catch((error) => { + app.textContent = `failed: ${error.message}` + app.dataset.state = 'failed' + }) +} diff --git a/packages/unplugin-skew-protection/test/support/builders.ts b/packages/unplugin-skew-protection/test/support/builders.ts index 48543546..f299100f 100644 --- a/packages/unplugin-skew-protection/test/support/builders.ts +++ b/packages/unplugin-skew-protection/test/support/builders.ts @@ -23,13 +23,46 @@ export const TOKEN = 'e2e-token-123' const FIXTURE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'fixtures') +export interface FixturePage { + /** Base name shared by the page's HTML document and its entry module. */ + name: string + /** Path to request from the static server. */ + path: string + /** `#app` text content once the page's dynamically imported chunk has evaluated. */ + text: string +} + +/** + * The fixture ships two pages whose entries import a common module, so every build under test has + * to emit two HTML documents, two entry chunks, and one chunk shared between them — the shared + * chunk being the case a single-page fixture cannot reach at all. + * + * `text` is what `test/fixtures/shared-static.js` leaves in `#app` once the page has settled, so + * matching it proves both shared chunks -- the statically imported one and the dynamically imported + * one -- evaluated in the browser. + */ +export const PAGES: FixturePage[] = [ + { + name: 'index', + path: '/', + text: 'index: shared static chunk loaded, shared dynamic chunk loaded', + }, + { + name: 'second', + path: '/second.html', + text: 'second: shared static chunk loaded, shared dynamic chunk loaded', + }, +] + export interface BundlerCase { /** - * Asset paths that are expected to be served *without* the skew protection parameter. + * Asset paths that are expected to be served *without* the skew protection parameter, deduped + * and sorted across every page in `PAGES`. * - * Only Vite and webpack expose an HTML hook to this plugin, so only they can pin the - * initial ``), + ), + }) + }), + ) +} diff --git a/packages/unplugin-skew-protection/test/support/serve.ts b/packages/unplugin-skew-protection/test/support/serve.ts index 8847ceff..d2547105 100644 --- a/packages/unplugin-skew-protection/test/support/serve.ts +++ b/packages/unplugin-skew-protection/test/support/serve.ts @@ -17,7 +17,7 @@ export interface StaticServer { /** * Serves `root` over HTTP for the duration of a test. The query string is ignored when * resolving a file, which is what makes the skew protection parameter transparent to a - * static host: `/assets/lazy-abc.js?nfdpl=token` has to serve `/assets/lazy-abc.js`. + * static host: `/assets/shared-abc.js?nfdpl=token` has to serve `/assets/shared-abc.js`. */ export async function serveStatic(root: string): Promise { const server = createServer((req, res) => {