test: harden release and browser gates

This commit is contained in:
KoalaDev
2026-08-21 15:49:51 +02:00
parent 230e7f5932
commit 7286a6db3d
32 changed files with 1150 additions and 201 deletions
+87 -17
View File
@@ -7,30 +7,83 @@ import { test as base, chromium } from '@playwright/test';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
export const extensionPath = path.join(repoRoot, 'dist/chrome');
function isLocalTestUrl(rawUrl) {
try {
const url = new URL(rawUrl);
if (!['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)) return true;
return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1';
} catch (_error) {
return false;
}
}
export async function launchExtensionContext() {
if (!fs.existsSync(path.join(extensionPath, 'manifest.json'))) {
throw new Error('dist/chrome is missing. Run: npm run build:extension');
}
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-e2e-'));
let context;
try {
context = await chromium.launchPersistentContext(userDataDir, {
channel: 'chromium',
headless: true,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
'--autoplay-policy=no-user-gesture-required',
'--host-resolver-rules=MAP * 0.0.0.0, EXCLUDE localhost, EXCLUDE 127.0.0.1'
]
});
await context.route('**/*', route => {
if (isLocalTestUrl(route.request().url())) return route.continue();
return route.abort('blockedbyclient');
});
if (typeof context.routeWebSocket === 'function') {
await context.routeWebSocket(/.*/u, webSocketRoute => {
if (isLocalTestUrl(webSocketRoute.url())) {
webSocketRoute.connectToServer();
} else {
webSocketRoute.close({ code: 1008, reason: 'External network blocked by E2E harness' });
}
});
}
let [worker] = context.serviceWorkers();
if (!worker) worker = await context.waitForEvent('serviceworker');
const extensionId = worker.url().split('/')[2];
return {
context,
extensionId,
async close() {
try {
await context.close();
} finally {
fs.rmSync(userDataDir, { recursive: true, force: true });
}
}
};
} catch (error) {
if (context) await context.close().catch(() => {});
if (fs.existsSync(userDataDir)) {
fs.rmSync(userDataDir, { recursive: true, force: true });
}
throw error;
}
}
/**
* A browser with the packed extension loaded, plus its extension id. Each test
* gets a throwaway profile so storage from one test cannot leak into the next.
*/
export const test = base.extend({
context: async ({}, use) => {
if (!fs.existsSync(path.join(extensionPath, 'manifest.json'))) {
throw new Error('dist/chrome is missing. Run: npm run build:extension');
// The headless shell does not run MV3 service workers; the full
// Chromium build in new headless mode does.
const launched = await launchExtensionContext();
try {
await use(launched.context);
} finally {
await launched.close();
}
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-e2e-'));
const context = await chromium.launchPersistentContext(userDataDir, {
// The headless shell does not run MV3 service workers; the full
// Chromium build in new headless mode does.
channel: 'chromium',
headless: true,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
'--autoplay-policy=no-user-gesture-required'
]
});
await use(context);
await context.close();
fs.rmSync(userDataDir, { recursive: true, force: true });
},
extensionId: async ({ context }, use) => {
let [worker] = context.serviceWorkers();
@@ -85,3 +138,20 @@ export async function readStorage(page, keys) {
export async function writeStorage(page, values) {
return page.evaluate(v => chrome.storage.local.set(v), values);
}
export async function terminateServiceWorker(context, extensionId) {
const page = await context.newPage();
let session;
try {
await page.goto(`chrome-extension://${extensionId}/audio-options.html`);
session = await context.newCDPSession(page);
const { targetInfos } = await session.send('Target.getTargets');
const worker = targetInfos.find(target => target.type === 'service_worker'
&& target.url.startsWith(`chrome-extension://${extensionId}/`));
if (!worker) throw new Error(`service worker target missing for ${extensionId}`);
await session.send('Target.closeTarget', { targetId: worker.targetId });
} finally {
if (session) await session.detach().catch(() => {});
await page.close().catch(() => {});
}
}
+62
View File
@@ -0,0 +1,62 @@
import { spawn } from 'node:child_process';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
export async function reservePort() {
const server = net.createServer();
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
const port = typeof address === 'object' && address ? address.port : null;
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
if (!port) throw new Error('failed to reserve relay port');
return port;
}
export async function startRelay(port) {
const output = [];
const child = spawn(process.execPath, ['server/index.js'], {
cwd: repoRoot,
env: {
...process.env,
PORT: String(port),
SERVER_SALT: 'koalasync-e2e-relay-salt-with-more-than-thirty-two-chars'
},
stdio: ['ignore', 'pipe', 'pipe']
});
child.stdout.on('data', chunk => output.push(String(chunk)));
child.stderr.on('data', chunk => output.push(String(chunk)));
const deadline = Date.now() + 15000;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`relay exited with ${child.exitCode}: ${output.join('')}`);
}
try {
const response = await fetch(`http://127.0.0.1:${port}/health`);
if (response.ok) return { child, output };
} catch (_error) {
// Relay is still starting.
}
await new Promise(resolve => setTimeout(resolve, 100));
}
child.kill('SIGTERM');
throw new Error(`relay did not become healthy: ${output.join('')}`);
}
export async function stopRelay(relay) {
if (!relay || relay.child.exitCode !== null) return;
relay.child.kill('SIGTERM');
const stopped = await Promise.race([
new Promise(resolve => relay.child.once('exit', () => resolve(true))),
new Promise(resolve => setTimeout(() => resolve(false), 7000))
]);
if (stopped) return;
relay.child.kill('SIGKILL');
await new Promise(resolve => relay.child.once('exit', resolve));
throw new Error(`relay required SIGKILL: ${relay.output.join('')}`);
}