Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/unplugin-skew-protection/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
109 changes: 109 additions & 0 deletions packages/unplugin-skew-protection/test/e2e/browser.test.ts
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()
}
})
})
21 changes: 21 additions & 0 deletions packages/unplugin-skew-protection/test/fixtures/entry.js
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)
}
Comment on lines +5 to +9

Copy link
Copy Markdown
Contributor Author

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


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 packages/unplugin-skew-protection/test/fixtures/index.html
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>
1 change: 1 addition & 0 deletions packages/unplugin-skew-protection/test/fixtures/lazy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default 'lazy chunk loaded'
122 changes: 122 additions & 0 deletions packages/unplugin-skew-protection/test/support/builders.ts
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()
},
)
})
},
},
]
69 changes: 69 additions & 0 deletions packages/unplugin-skew-protection/test/support/serve.ts
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()
})
})
}
3 changes: 1 addition & 2 deletions packages/unplugin-skew-protection/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@
"moduleResolution": "NodeNext",
"outDir": "./dist",
"resolveJsonModule": true,
"rootDir": "./src",
"skipLibCheck": true,
"strict": true,
"target": "ES2022"
},
"include": ["src"]
"include": ["src", "test"]
}
3 changes: 3 additions & 0 deletions packages/unplugin-skew-protection/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@ export default defineConfig({
env: {
NO_COLOR: 'true',
},
// The e2e suite builds the fixture with four bundlers and drives a real browser.
hookTimeout: 60_000,
testTimeout: 60_000,
},
})
Loading