Some checks failed
Deploy Web Apps / deploy (push) Has been cancelled
Queue Release Build / prepare (push) Successful in 21s
Queue Release Build / build-linux (push) Successful in 27m44s
Queue Release Build / build-windows (push) Successful in 32m16s
Queue Release Build / finalize (push) Successful in 1m54s
203 lines
5.0 KiB
TypeScript
203 lines
5.0 KiB
TypeScript
import {
|
|
test as base,
|
|
chromium,
|
|
type Page,
|
|
type BrowserContext,
|
|
type Browser
|
|
} from '@playwright/test';
|
|
import { spawn, type ChildProcess } from 'node:child_process';
|
|
import { once } from 'node:events';
|
|
import { createServer } from 'node:net';
|
|
import { join } from 'node:path';
|
|
import { installTestServerEndpoint } from '../helpers/seed-test-endpoint';
|
|
|
|
export interface Client {
|
|
page: Page;
|
|
context: BrowserContext;
|
|
}
|
|
|
|
interface TestServerHandle {
|
|
port: number;
|
|
url: string;
|
|
stop: () => Promise<void>;
|
|
}
|
|
|
|
interface MultiClientFixture {
|
|
createClient: () => Promise<Client>;
|
|
testServer: TestServerHandle;
|
|
}
|
|
|
|
const FAKE_AUDIO_FILE = join(__dirname, 'test-tone.wav');
|
|
const CHROMIUM_FAKE_MEDIA_ARGS = [
|
|
'--use-fake-device-for-media-stream',
|
|
'--use-fake-ui-for-media-stream',
|
|
`--use-file-for-fake-audio-capture=${FAKE_AUDIO_FILE}`
|
|
];
|
|
const E2E_DIR = join(__dirname, '..');
|
|
const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js');
|
|
|
|
export const test = base.extend<MultiClientFixture>({
|
|
testServer: async ({ playwright: _playwright }, use: (testServer: TestServerHandle) => Promise<void>) => {
|
|
const testServer = await startTestServer();
|
|
|
|
await use(testServer);
|
|
await testServer.stop();
|
|
},
|
|
|
|
createClient: async ({ testServer }, use) => {
|
|
const browsers: Browser[] = [];
|
|
const clients: Client[] = [];
|
|
const factory = async (): Promise<Client> => {
|
|
// Launch a dedicated browser per client so each gets its own fake
|
|
// audio device - shared browsers can starve the first context's
|
|
// audio capture under load.
|
|
const browser = await chromium.launch({ args: CHROMIUM_FAKE_MEDIA_ARGS });
|
|
|
|
browsers.push(browser);
|
|
|
|
const context = await browser.newContext({
|
|
permissions: ['microphone', 'camera'],
|
|
baseURL: 'http://localhost:4200'
|
|
});
|
|
|
|
await installTestServerEndpoint(context, testServer.port);
|
|
|
|
const page = await context.newPage();
|
|
|
|
clients.push({ page, context });
|
|
return { page, context };
|
|
};
|
|
|
|
await use(factory);
|
|
|
|
for (const client of clients) {
|
|
await client.context.close();
|
|
}
|
|
|
|
for (const browser of browsers) {
|
|
await browser.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
export { expect } from '@playwright/test';
|
|
|
|
async function startTestServer(retries = 3): Promise<TestServerHandle> {
|
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
const port = await allocatePort();
|
|
const child = spawn(process.execPath, [START_SERVER_SCRIPT], {
|
|
cwd: E2E_DIR,
|
|
env: {
|
|
...process.env,
|
|
TEST_SERVER_PORT: String(port)
|
|
},
|
|
stdio: 'pipe'
|
|
});
|
|
|
|
child.stdout?.on('data', (chunk: Buffer | string) => {
|
|
process.stdout.write(chunk.toString());
|
|
});
|
|
|
|
child.stderr?.on('data', (chunk: Buffer | string) => {
|
|
process.stderr.write(chunk.toString());
|
|
});
|
|
|
|
try {
|
|
await waitForServerReady(port, child);
|
|
} catch (error) {
|
|
await stopServer(child);
|
|
|
|
if (attempt < retries) {
|
|
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
|
|
continue;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
port,
|
|
url: `http://localhost:${port}`,
|
|
stop: async () => {
|
|
await stopServer(child);
|
|
}
|
|
};
|
|
}
|
|
|
|
throw new Error('startTestServer: unreachable');
|
|
}
|
|
|
|
async function allocatePort(): Promise<number> {
|
|
return new Promise<number>((resolve, reject) => {
|
|
const probe = createServer();
|
|
|
|
probe.once('error', reject);
|
|
probe.listen(0, '127.0.0.1', () => {
|
|
const address = probe.address();
|
|
|
|
if (!address || typeof address === 'string') {
|
|
probe.close();
|
|
reject(new Error('Failed to resolve an ephemeral test server port'));
|
|
return;
|
|
}
|
|
|
|
const { port } = address;
|
|
|
|
probe.close((error) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
|
|
resolve(port);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function waitForServerReady(port: number, child: ChildProcess, timeoutMs = 30_000): Promise<void> {
|
|
const readyUrl = `http://127.0.0.1:${port}/api/servers?limit=1`;
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null) {
|
|
throw new Error(`Test server exited before becoming ready (exit code ${child.exitCode})`);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(readyUrl);
|
|
|
|
if (response.ok) {
|
|
return;
|
|
}
|
|
} catch {
|
|
// Server still starting.
|
|
}
|
|
|
|
await wait(250);
|
|
}
|
|
|
|
throw new Error(`Timed out waiting for test server on port ${port}`);
|
|
}
|
|
|
|
async function stopServer(child: ChildProcess): Promise<void> {
|
|
if (child.exitCode !== null) {
|
|
return;
|
|
}
|
|
|
|
child.kill('SIGTERM');
|
|
|
|
const exited = await Promise.race([once(child, 'exit').then(() => true), wait(3_000).then(() => false)]);
|
|
|
|
if (!exited && child.exitCode === null) {
|
|
child.kill('SIGKILL');
|
|
await once(child, 'exit');
|
|
}
|
|
}
|
|
|
|
function wait(durationMs: number): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, durationMs);
|
|
});
|
|
}
|