-
Notifications
You must be signed in to change notification settings - Fork 4
test: setup local e2e tests that use playwright to inspect actual requests being made #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
109 changes: 109 additions & 0 deletions
109
packages/unplugin-skew-protection/test/e2e/browser.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| } | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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' | ||
| }) | ||
11 changes: 11 additions & 0 deletions
11
packages/unplugin-skew-protection/test/fixtures/index.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| <!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8" /> | ||
| <title>skew protection e2e</title> | ||
| </head> | ||
| <body> | ||
| <div id="app">loading</div> | ||
| <script type="module" src="./entry.js"></script> | ||
| </body> | ||
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export default 'lazy chunk loaded' |
122 changes: 122 additions & 0 deletions
122
packages/unplugin-skew-protection/test/support/builders.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 `<script>`. Plain Rollup and Rolldown emit no HTML, so the entry tag in a | ||
| * hand-authored page stays unpinned and only the dynamic import is stamped. | ||
| */ | ||
| expectedUnstamped: string[] | ||
| build: (root: string, outDir: string) => Promise<void> | ||
| name: string | ||
| } | ||
|
|
||
| export async function createFixture(): Promise<string> { | ||
| const root = await mkdtemp(join(tmpdir(), 'skew-protection-e2e-')) | ||
| // The fixture is a flat directory, so copying file by file avoids `fs.cp`, which is still | ||
| // experimental below Node 22.3 and this package supports Node 20. | ||
| const entries = await readdir(FIXTURE_DIR) | ||
| await Promise.all(entries.map((entry) => copyFile(join(FIXTURE_DIR, entry), join(root, entry)))) | ||
| return root | ||
| } | ||
|
|
||
| export const BUNDLERS: BundlerCase[] = [ | ||
| { | ||
| name: 'vite', | ||
| expectedUnstamped: [], | ||
| build: async (root, outDir) => { | ||
| await viteBuild({ | ||
| root, | ||
| logLevel: 'silent', | ||
| plugins: [vitePlugin({ baseDir: root, token: TOKEN })], | ||
| build: { outDir, emptyOutDir: true }, | ||
| }) | ||
| }, | ||
| }, | ||
| { | ||
| name: 'rollup', | ||
| expectedUnstamped: [], | ||
| build: async (root, outDir) => { | ||
| const bundle = await rollup({ | ||
| input: join(root, 'entry.js'), | ||
| plugins: [rollupHtmlPlugin(), rollupPlugin({ baseDir: root, token: TOKEN })], | ||
| }) | ||
| await bundle.write({ dir: outDir, format: 'es', entryFileNames: 'entry.js', chunkFileNames: '[name].js' }) | ||
| await bundle.close() | ||
| }, | ||
| }, | ||
| { | ||
| name: 'rolldown', | ||
| expectedUnstamped: [], | ||
| build: async (root, outDir) => { | ||
| const bundle = await rolldown({ | ||
| input: join(root, 'entry.js'), | ||
| plugins: [rollupHtmlPlugin(), rolldownPlugin({ baseDir: root, token: TOKEN })], | ||
| }) | ||
| await bundle.write({ dir: outDir, format: 'es', entryFileNames: 'entry.js', chunkFileNames: '[name].js' }) | ||
| await bundle.close() | ||
| }, | ||
| }, | ||
| { | ||
| name: 'webpack', | ||
| expectedUnstamped: [], | ||
| build: async (root, outDir) => { | ||
| // html-webpack-plugin injects its own tag for the entry, so the template must not carry | ||
| // the fixture's `<script src="./entry.js">` as well. | ||
| const template = join(root, 'template.html') | ||
| const html = await readFile(join(root, 'index.html'), 'utf8') | ||
| await writeFile(template, html.replace(/\s*<script\b[^>]*><\/script>/i, '')) | ||
|
|
||
| await new Promise<void>((resolve, reject) => { | ||
| webpack( | ||
| { | ||
| context: root, | ||
| entry: join(root, 'entry.js'), | ||
| mode: 'production', | ||
| optimization: { minimize: false }, | ||
| output: { chunkFilename: '[name].chunk.js', filename: 'main.js', path: outDir }, | ||
| plugins: [new HtmlWebpackPlugin({ template }), webpackPlugin({ baseDir: root, token: TOKEN })], | ||
| }, | ||
| (err, stats) => { | ||
| if (err) { | ||
| reject(err) | ||
| return | ||
| } | ||
|
|
||
| if (stats?.hasErrors()) { | ||
| reject(new Error(stats.toString({ errorDetails: true }))) | ||
| return | ||
| } | ||
|
|
||
| resolve() | ||
| }, | ||
| ) | ||
| }) | ||
| }, | ||
| }, | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { createServer, type Server } from 'node:http' | ||
| import { readFile } from 'node:fs/promises' | ||
| import { join, normalize } from 'node:path' | ||
|
|
||
| const CONTENT_TYPES: Record<string, string> = { | ||
| '.css': 'text/css', | ||
| '.html': 'text/html', | ||
| '.js': 'text/javascript', | ||
| '.mjs': 'text/javascript', | ||
| } | ||
|
|
||
| export interface StaticServer { | ||
| close: () => Promise<void> | ||
| url: string | ||
| } | ||
|
|
||
| /** | ||
| * 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`. | ||
| */ | ||
| export async function serveStatic(root: string): Promise<StaticServer> { | ||
| const server = createServer((req, res) => { | ||
| // `req.url` is a path plus an optional query string, so a fixed base is enough to parse it. | ||
| const { pathname } = new URL(req.url ?? '/', 'http://localhost') | ||
| const relativePath = pathname === '/' ? 'index.html' : decodeURIComponent(pathname).replace(/^\/+/, '') | ||
|
|
||
| // Reject traversal outside the served directory rather than reading an arbitrary file. | ||
| if (normalize(relativePath).startsWith('..')) { | ||
| res.writeHead(403).end() | ||
| return | ||
| } | ||
|
|
||
| readFile(join(root, relativePath)) | ||
| .then((body) => { | ||
| const extension = relativePath.slice(relativePath.lastIndexOf('.')) | ||
| res.writeHead(200, { 'content-type': CONTENT_TYPES[extension] ?? 'application/octet-stream' }).end(body) | ||
| }) | ||
| .catch(() => { | ||
| res.writeHead(404).end() | ||
| }) | ||
| }) | ||
|
|
||
| await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)) | ||
|
|
||
| const address = server.address() | ||
|
|
||
| if (address === null || typeof address === 'string') { | ||
| throw new Error('static server did not bind to a TCP port') | ||
| } | ||
|
|
||
| return { | ||
| close: () => closeServer(server), | ||
| url: `http://127.0.0.1:${String(address.port)}`, | ||
| } | ||
| } | ||
|
|
||
| function closeServer(server: Server): Promise<void> { | ||
| return new Promise((resolve, reject) => { | ||
| server.close((error) => { | ||
| if (error) { | ||
| reject(error) | ||
| return | ||
| } | ||
|
|
||
| resolve() | ||
| }) | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
rollup html plugin doesn't use index.html as a template so it produces generic html without mount point (with script pointing to entry). There is community (?) plugin https://modern-web.dev/docs/building/rollup-plugin-html/ that would do it, but it doesn't work with rolldown