test: Add playwright main usage test
Some checks failed
Queue Release Build / prepare (push) Successful in 38s
Deploy Web Apps / deploy (push) Successful in 13m34s
Queue Release Build / build-linux (push) Successful in 45m20s
Queue Release Build / build-windows (push) Failing after 3h8m14s
Queue Release Build / finalize (push) Has been cancelled
Some checks failed
Queue Release Build / prepare (push) Successful in 38s
Deploy Web Apps / deploy (push) Successful in 13m34s
Queue Release Build / build-linux (push) Successful in 45m20s
Queue Release Build / build-windows (push) Failing after 3h8m14s
Queue Release Build / finalize (push) Has been cancelled
This commit is contained in:
77
e2e/helpers/seed-test-endpoint.ts
Normal file
77
e2e/helpers/seed-test-endpoint.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { type BrowserContext, type Page } from '@playwright/test';
|
||||
|
||||
const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
|
||||
const REMOVED_DEFAULT_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys';
|
||||
|
||||
type SeededEndpointStorageState = {
|
||||
key: string;
|
||||
removedKey: string;
|
||||
endpoints: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
status: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
function buildSeededEndpointStorageState(
|
||||
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
|
||||
): SeededEndpointStorageState {
|
||||
const endpoint = {
|
||||
id: 'e2e-test-server',
|
||||
name: 'E2E Test Server',
|
||||
url: `http://localhost:${port}`,
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
status: 'unknown'
|
||||
};
|
||||
|
||||
return {
|
||||
key: SERVER_ENDPOINTS_STORAGE_KEY,
|
||||
removedKey: REMOVED_DEFAULT_KEYS_STORAGE_KEY,
|
||||
endpoints: [endpoint]
|
||||
};
|
||||
}
|
||||
|
||||
function applySeededEndpointStorageState(storageState: SeededEndpointStorageState): void {
|
||||
try {
|
||||
const storage = window.localStorage;
|
||||
|
||||
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints));
|
||||
storage.setItem(storageState.removedKey, JSON.stringify(['default', 'toju-primary', 'toju-sweden']));
|
||||
} catch {
|
||||
// about:blank and some Playwright UI pages deny localStorage access.
|
||||
}
|
||||
}
|
||||
|
||||
export async function installTestServerEndpoint(
|
||||
context: BrowserContext,
|
||||
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
|
||||
): Promise<void> {
|
||||
const storageState = buildSeededEndpointStorageState(port);
|
||||
|
||||
await context.addInitScript(applySeededEndpointStorageState, storageState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed localStorage with a single signal endpoint pointing at the test server.
|
||||
* Must be called AFTER navigating to the app origin (localStorage is per-origin)
|
||||
* but BEFORE the app reads from storage (i.e. before the Angular bootstrap is
|
||||
* relied upon — calling it in the first goto() landing page is fine since the
|
||||
* page will re-read on next navigation/reload).
|
||||
*
|
||||
* Typical usage:
|
||||
* await page.goto('/');
|
||||
* await seedTestServerEndpoint(page);
|
||||
* await page.reload(); // App now picks up the test endpoint
|
||||
*/
|
||||
export async function seedTestServerEndpoint(
|
||||
page: Page,
|
||||
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
|
||||
): Promise<void> {
|
||||
const storageState = buildSeededEndpointStorageState(port);
|
||||
|
||||
await page.evaluate(applySeededEndpointStorageState, storageState);
|
||||
}
|
||||
95
e2e/helpers/start-test-server.js
Normal file
95
e2e/helpers/start-test-server.js
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Launches an isolated MetoYou signaling server for E2E tests.
|
||||
*
|
||||
* Creates a temporary data directory so the test server gets its own
|
||||
* fresh SQLite database. The server process inherits stdio so Playwright
|
||||
* can watch stdout for readiness and the developer can see logs.
|
||||
*
|
||||
* Cleanup: the temp directory is removed when the process exits.
|
||||
*/
|
||||
const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
const { tmpdir } = require('os');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const TEST_PORT = process.env.TEST_SERVER_PORT || '3099';
|
||||
const SERVER_DIR = join(__dirname, '..', '..', 'server');
|
||||
const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
|
||||
const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json');
|
||||
|
||||
// ── Create isolated temp data directory ──────────────────────────────
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
|
||||
const dataDir = join(tmpDir, 'data');
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(dataDir, 'variables.json'),
|
||||
JSON.stringify({
|
||||
serverPort: parseInt(TEST_PORT, 10),
|
||||
serverProtocol: 'http',
|
||||
serverHost: '',
|
||||
klipyApiKey: '',
|
||||
releaseManifestUrl: '',
|
||||
linkPreview: { enabled: false, cacheTtlMinutes: 60, maxCacheSizeMb: 10 },
|
||||
})
|
||||
);
|
||||
|
||||
console.log(`[E2E Server] Temp data dir: ${tmpDir}`);
|
||||
console.log(`[E2E Server] Starting on port ${TEST_PORT}...`);
|
||||
|
||||
// ── Spawn the server with cwd = temp dir ─────────────────────────────
|
||||
// process.cwd() is used by getRuntimeBaseDir() in the server, so data/
|
||||
// (database, variables.json) will resolve to our temp directory.
|
||||
// Module resolution (require/import) uses __dirname, so server source
|
||||
// and node_modules are found from the real server/ directory.
|
||||
const child = spawn(
|
||||
'npx',
|
||||
['ts-node', '--project', SERVER_TSCONFIG, SERVER_ENTRY],
|
||||
{
|
||||
cwd: tmpDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: TEST_PORT,
|
||||
SSL: 'false',
|
||||
NODE_ENV: 'test',
|
||||
DB_SYNCHRONIZE: 'true',
|
||||
},
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
}
|
||||
);
|
||||
|
||||
child.on('error', (err) => {
|
||||
console.error('[E2E Server] Failed to start:', err.message);
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
console.log(`[E2E Server] Exited with code ${code}`);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ── Cleanup on signals ───────────────────────────────────────────────
|
||||
function cleanup() {
|
||||
try {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
console.log(`[E2E Server] Cleaned up temp dir: ${tmpDir}`);
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
function shutdown() {
|
||||
child.kill('SIGTERM');
|
||||
// Give child 3s to exit, then force kill
|
||||
setTimeout(() => {
|
||||
if (!child.killed) child.kill('SIGKILL');
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
}, 3_000);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('exit', cleanup);
|
||||
134
e2e/helpers/webrtc-helpers.ts
Normal file
134
e2e/helpers/webrtc-helpers.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
|
||||
* Tracks all created peer connections and their remote tracks so tests
|
||||
* can inspect WebRTC state via `page.evaluate()`.
|
||||
*
|
||||
* Call immediately after page creation, before any `goto()`.
|
||||
*/
|
||||
export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const connections: RTCPeerConnection[] = [];
|
||||
|
||||
(window as any).__rtcConnections = connections;
|
||||
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
|
||||
|
||||
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
||||
|
||||
(window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) {
|
||||
const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args);
|
||||
|
||||
connections.push(pc);
|
||||
|
||||
pc.addEventListener('connectionstatechange', () => {
|
||||
(window as any).__lastRtcState = pc.connectionState;
|
||||
});
|
||||
|
||||
pc.addEventListener('track', (event: RTCTrackEvent) => {
|
||||
(window as any).__rtcRemoteTracks.push({
|
||||
kind: event.track.kind,
|
||||
id: event.track.id,
|
||||
readyState: event.track.readyState
|
||||
});
|
||||
});
|
||||
|
||||
return pc;
|
||||
} as any;
|
||||
|
||||
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
|
||||
Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until at least one RTCPeerConnection reaches the 'connected' state.
|
||||
*/
|
||||
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => (window as any).__rtcConnections?.some(
|
||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||
) ?? false,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that a peer connection is still in 'connected' state (not failed/disconnected).
|
||||
*/
|
||||
export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
() => (window as any).__rtcConnections?.some(
|
||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get outbound and inbound audio RTP stats from the first peer connection.
|
||||
*/
|
||||
export async function getAudioStats(page: Page): Promise<{
|
||||
outbound: { bytesSent: number; packetsSent: number } | null;
|
||||
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||
}> {
|
||||
return page.evaluate(async () => {
|
||||
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length)
|
||||
return { outbound: null, inbound: null };
|
||||
|
||||
let outbound: { bytesSent: number; packetsSent: number } | null = null;
|
||||
let inbound: { bytesReceived: number; packetsReceived: number } | null = null;
|
||||
|
||||
for (const pc of connections) {
|
||||
if (pc.connectionState !== 'connected')
|
||||
continue;
|
||||
|
||||
const stats = await pc.getStats();
|
||||
|
||||
stats.forEach((report: any) => {
|
||||
const reportMediaType = report.kind ?? report.mediaType;
|
||||
|
||||
if (report.type === 'outbound-rtp' && reportMediaType === 'audio' && !outbound) {
|
||||
outbound = {
|
||||
bytesSent: report.bytesSent ?? 0,
|
||||
packetsSent: report.packetsSent ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
if (report.type === 'inbound-rtp' && reportMediaType === 'audio' && !inbound) {
|
||||
inbound = {
|
||||
bytesReceived: report.bytesReceived ?? 0,
|
||||
packetsReceived: report.packetsReceived ?? 0
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
if (outbound && inbound)
|
||||
break;
|
||||
}
|
||||
|
||||
return { outbound, inbound };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot audio stats, wait `durationMs`, snapshot again, and return the delta.
|
||||
* Useful for verifying audio is actively flowing (bytes increasing).
|
||||
*/
|
||||
export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promise<{
|
||||
outboundBytesDelta: number;
|
||||
inboundBytesDelta: number;
|
||||
}> {
|
||||
const before = await getAudioStats(page);
|
||||
|
||||
await page.waitForTimeout(durationMs);
|
||||
|
||||
const after = await getAudioStats(page);
|
||||
|
||||
return {
|
||||
outboundBytesDelta: (after.outbound?.bytesSent ?? 0) - (before.outbound?.bytesSent ?? 0),
|
||||
inboundBytesDelta: (after.inbound?.bytesReceived ?? 0) - (before.inbound?.bytesReceived ?? 0)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user