diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 09987b6..b7fb423 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -21,6 +21,7 @@ npm run test:e2e:race # @race scenarios, repeated 20 times | `extension.spec.mjs` | Loads `dist/chrome`, injects into a tab, applies remote play/pause/seek | | `room-sync.spec.mjs` | Starts a local relay and proves two packed clients, relay restart, and MV3 worker recovery | | `popup-accessibility.spec.mjs` | Checks visible control names and keyboard tab activation in the real popup | +| `global-setup.mjs` | Owns the fixture server for the complete Playwright run and closes it during teardown | | `fixture-server.mjs` | Static server for the fixtures, with byte-range support for media | | `fixtures/pages/` | One page per scenario | | `fixtures/media/` | Small generated clips (see below) | @@ -32,6 +33,11 @@ Chrome MV3 APIs and a persistent service-worker context. The scheduled `.github/workflows/race-tests.yml` lane repeats tests marked `@race` and uploads traces/results on failure. +Locally, global setup reuses an already running fixture server on the configured +port. CI always owns a fresh server so a port collision fails instead of testing +against an unknown process. Keeping the server inside Playwright's lifecycle +also prevents an orphaned `node` process from blocking teardown on Windows. + ## Two rules worth keeping **The specs run the shipped source, not a copy.** `helpers/content-source.mjs` diff --git a/tests/e2e/fixture-server.mjs b/tests/e2e/fixture-server.mjs index ce24c94..d11a154 100644 --- a/tests/e2e/fixture-server.mjs +++ b/tests/e2e/fixture-server.mjs @@ -8,8 +8,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'fixtures'); -const port = Number(process.argv[2] || 4173); +const modulePath = fileURLToPath(import.meta.url); +const root = path.resolve(path.dirname(modulePath), 'fixtures'); const TYPES = { '.html': 'text/html; charset=utf-8', @@ -18,62 +18,88 @@ const TYPES = { '.json': 'application/json' }; -const server = http.createServer((req, res) => { - const requested = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); - if (requested === '/redirect/hidden-player') { - res.writeHead(302, { - Location: `http://127.0.0.1:${port}/pages/frames/player-frame-2.html?redirected=hidden`, - 'Cache-Control': 'no-store' - }).end(); - return; - } - const filePath = path.resolve(root, `.${requested}`); +export function createFixtureServer(port) { + return http.createServer((req, res) => { + const requested = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); + if (requested === '/redirect/hidden-player') { + res.writeHead(302, { + Location: `http://127.0.0.1:${port}/pages/frames/player-frame-2.html?redirected=hidden`, + 'Cache-Control': 'no-store' + }).end(); + return; + } + const filePath = path.resolve(root, `.${requested}`); - if (!filePath.startsWith(root + path.sep)) { - res.writeHead(403).end('forbidden'); - return; - } - - fs.stat(filePath, (statErr, stat) => { - if (statErr || !stat.isFile()) { - res.writeHead(404).end('not found'); + if (!filePath.startsWith(root + path.sep)) { + res.writeHead(403).end('forbidden'); return; } - const contentType = TYPES[path.extname(filePath)] || 'application/octet-stream'; - // Media needs byte ranges: without them Chromium reports an empty - // seekable range and seeking silently does nothing, which would make - // the remote-seek test fail for a reason that has nothing to do with - // the extension. - const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || ''); - if (range) { - const start = range[1] ? Number(range[1]) : 0; - const end = range[2] ? Number(range[2]) : stat.size - 1; - if (Number.isNaN(start) || Number.isNaN(end) || start > end || end >= stat.size) { - res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }).end(); + fs.stat(filePath, (statErr, stat) => { + if (statErr || !stat.isFile()) { + res.writeHead(404).end('not found'); return; } - res.writeHead(206, { + + const contentType = TYPES[path.extname(filePath)] || 'application/octet-stream'; + // Media needs byte ranges: without them Chromium reports an empty + // seekable range and seeking silently does nothing, which would make + // the remote-seek test fail for a reason that has nothing to do with + // the extension. + const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || ''); + if (range) { + const start = range[1] ? Number(range[1]) : 0; + const end = range[2] ? Number(range[2]) : stat.size - 1; + if (Number.isNaN(start) || Number.isNaN(end) || start > end || end >= stat.size) { + res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }).end(); + return; + } + res.writeHead(206, { + 'Content-Type': contentType, + 'Content-Length': end - start + 1, + 'Content-Range': `bytes ${start}-${end}/${stat.size}`, + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'no-store' + }); + fs.createReadStream(filePath, { start, end }).pipe(res); + return; + } + + res.writeHead(200, { 'Content-Type': contentType, - 'Content-Length': end - start + 1, - 'Content-Range': `bytes ${start}-${end}/${stat.size}`, + 'Content-Length': stat.size, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' }); - fs.createReadStream(filePath, { start, end }).pipe(res); - return; - } - - res.writeHead(200, { - 'Content-Type': contentType, - 'Content-Length': stat.size, - 'Accept-Ranges': 'bytes', - 'Cache-Control': 'no-store' + fs.createReadStream(filePath).pipe(res); }); - fs.createReadStream(filePath).pipe(res); }); -}); +} -server.listen(port, '127.0.0.1', () => { +export async function startFixtureServer(port = Number(process.env.KOALA_E2E_PORT || 4173)) { + const server = createFixtureServer(port); + await new Promise((resolve, reject) => { + const onError = error => reject(error); + server.once('error', onError); + server.listen(port, '127.0.0.1', () => { + server.off('error', onError); + resolve(); + }); + }); + return server; +} + +export async function stopFixtureServer(server) { + if (!server?.listening) return; + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + server.closeAllConnections?.(); + }); +} + +const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(modulePath); +if (isMainModule) { + const port = Number(process.argv[2] || process.env.KOALA_E2E_PORT || 4173); + await startFixtureServer(port); console.log(`fixture server on http://localhost:${port}`); -}); +} diff --git a/tests/e2e/global-setup.mjs b/tests/e2e/global-setup.mjs new file mode 100644 index 0000000..ca231a9 --- /dev/null +++ b/tests/e2e/global-setup.mjs @@ -0,0 +1,35 @@ +import http from 'node:http'; +import { startFixtureServer, stopFixtureServer } from './fixture-server.mjs'; + +function fixtureIsRunning(port) { + return new Promise(resolve => { + let settled = false; + const finish = result => { + if (settled) return; + settled = true; + resolve(result); + }; + const request = http.get({ + hostname: '127.0.0.1', + port, + path: '/pages/simple-player.html' + }, response => { + response.resume(); + response.once('error', () => finish(false)); + response.once('end', () => finish(response.statusCode === 200)); + }); + request.once('error', () => finish(false)); + request.setTimeout(1000, () => { + request.destroy(); + finish(false); + }); + }); +} + +export default async function globalSetup() { + const port = Number(process.env.KOALA_E2E_PORT || 4173); + if (!process.env.CI && await fixtureIsRunning(port)) return undefined; + + const server = await startFixtureServer(port); + return async () => stopFixtureServer(server); +} diff --git a/tests/e2e/playwright.config.mjs b/tests/e2e/playwright.config.mjs index 4bc0d3a..da0625b 100644 --- a/tests/e2e/playwright.config.mjs +++ b/tests/e2e/playwright.config.mjs @@ -6,6 +6,7 @@ const PORT = Number(process.env.KOALA_E2E_PORT || 4173); export default defineConfig({ testDir: '.', testMatch: '**/*.spec.mjs', + globalSetup: fileURLToPath(new URL('./global-setup.mjs', import.meta.url)), // Extension tests drive a persistent context and a service worker; running // them in parallel makes the profile directories fight each other. workers: 1, @@ -46,12 +47,5 @@ export default defineConfig({ name: 'extension-chromium', testIgnore: 'detection.spec.mjs' } - ], - webServer: { - command: `node "${fileURLToPath(new URL('./fixture-server.mjs', import.meta.url))}" ${PORT}`, - url: `http://localhost:${PORT}/pages/simple-player.html`, - reuseExistingServer: !process.env.CI, - stdout: 'ignore', - stderr: 'pipe' - } + ] });