mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-30 12:29:27 +00:00
test(e2e): close fixture server reliably
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user