import { createHash } from 'node:crypto'; import { test as base, type Page, type Route } from '@playwright/test'; import { type EgressListItem, type EgressSummary, FeatureKey, type InfoResponse, type IngressListItem, type IngressSummary, type ListResponse, type OverviewResponse, ParticipantKind, type ParticipantListItem, type ParticipantsSummary, type RoomListItem, type RoomSummary, type StatusResponse } from '@openvidu-dashboard/typings'; import { ANALYTICS_SUMMARY, analyticsBreakdowns, analyticsCsv, type AnalyticsFixtures, analyticsSeries } from './analytics.js'; import { activeOverview, activeStatus } from './overview.js'; export type LoginOutcome = 'ok' | 'invalid' | 'rate-limited' | 'unreachable'; /** Answers `GET ` of a route the CE mock does not know, such as a PRO one. */ export type ApiRouteHandler = (route: Route, params: URLSearchParams) => Promise; export interface ApiOptions { /** A refresh cookie exists: the silent restore at start succeeds. */ signedIn: boolean; edition: 'ce' | 'pro'; /** Fields merged into the full info document. */ info: Partial; /** `GET /api/v1/info` answers 503. */ infoFails: boolean; login: LoginOutcome; /** `Retry-After` of a rate-limited login. */ retryAfter: number; /** Rooms of `GET rooms`, filtered by `status`, `features` and `q` (a SID or a name prefix). */ rooms: RoomListItem[]; /** Body of `GET rooms/summary`. */ roomSummary: RoomSummary; /** Fields merged into every `GET rooms` page, e.g. `nextCursor`. */ roomsPage?: Partial>; /** Sessions of `GET participants` (filtered by `q`, `kind`, `status`, `room`) and, of kind AGENT, `GET agents`. */ participants: ParticipantListItem[]; /** Egresses of `GET egresses`, filtered by `q` (an id or a room name prefix), `status`, `type` and `room`. */ egresses?: EgressListItem[]; /** Body of `GET egresses/summary`. */ egressSummary?: EgressSummary; /** Ingresses of `GET ingresses`, filtered by `q` (an id or a name prefix), `status` and `inputType`. */ ingresses?: IngressListItem[]; /** Body of `GET ingresses/summary`. */ ingressSummary?: IngressSummary; /** Handlers of the paths the CE mock does not answer, such as the PRO routes, by path; read on every request. */ routes?: Record; /** Values of the series and keys of the breakdowns the CE fixtures do not define, such as the PRO ones. */ analytics?: AnalyticsFixtures; /** Analytics paths (`breakdowns`, `export.csv`…) that answer `500` with a request id. */ analyticsFail?: string[]; /** * Body of `GET overview`, built per request (an active deployment by default); its ETag is the body without * `generatedAt`, so an unchanged body answers `304` to `If-None-Match`. */ overview?: () => OverviewResponse; /** `GET overview` answers `500` with a request id. */ overviewFails?: boolean; /** Body of `GET status`, built per request (a healthy writer by default). */ status?: () => StatusResponse; } export interface RecordedRequest { method: string; path: string; /** Query parameters of the request. */ params: URLSearchParams; authorization: string | null; } export const DEFAULT_API: ApiOptions = { signedIn: true, edition: 'ce', info: {}, infoFails: false, login: 'ok', retryAfter: 3, rooms: [], participants: [], roomSummary: { activeRooms: 0, roomsInRange: 0, participantSessions: 0, activeParticipants: 0, approximate: false } }; const TOKEN = 'e2e-access-token'; const EMPTY_INGRESS_SUMMARY: IngressSummary = { publishingNow: 0, ingressesInRange: 0, byInputType: { rtmp: 0, whip: 0, url: 0 }, sessionsInRange: 0, totalDurationMs: 0, approximate: false }; const EMPTY_EGRESS_SUMMARY: EgressSummary = { active: 0, startedInRange: 0, byType: {}, byEndStatus: {}, totalDurationMs: 0, approximate: false }; const EXPIRES_AT = '2099-01-01T00:00:00.000Z'; /** An error answer in the API's shape. */ export function error(code: string, status: number, headers: Record = {}) { return { status, headers, json: { error: { code, message: code } } }; } /** The dashboard API mocked with `page.route()`: auth endpoints, info, empty lists; every request is recorded. */ export class MockApi { readonly requests: RecordedRequest[] = []; private signedIn: boolean; private accessToken = TOKEN; private readonly proOnly = new Set(); private roomsFail = false; constructor( private readonly page: Page, readonly options: ApiOptions ) { this.signedIn = options.signedIn; } async install(): Promise { await this.page.route('**/api/v1/**', (route) => this.handle(route)); } /** Paths of the recorded requests, e.g. `GET rooms`. */ calls(): string[] { return this.requests.map((request) => `${request.method} ${request.path}`); } /** Makes `GET rooms` answer `500` with a request id until called with `false`. */ failRooms(fail = true): void { this.roomsFail = fail; } /** Makes the next request with the current token answer 401, as when the access token expires. */ expireToken(): void { this.accessToken = `${TOKEN}-rotated-${String(Date.now())}`; } /** Answers `403 PRO_FEATURE` to `GET ` and switches the info document to CE, as a licence downgrade would. */ downgrade(path: string): void { this.proOnly.add(path); } fullInfo(): InfoResponse { const edition = this.proOnly.size > 0 ? 'ce' : this.options.edition; return { edition, versions: { dashboard: '3.10.0', server: '3.10.0' }, schema: { dashboard: 2, writer: 2, status: 'ok' }, migration: { state: 'completed', progress: { processed: 1_200_000, total: 1_200_000, finishedAt: '2026-10-12T10:04:00.000Z' } }, retention: { eventsMs: 7 * 86_400_000, statsMs: 7 * 86_400_000, tracksMs: 7 * 86_400_000, entitiesMs: 90 * 86_400_000, rollups5MinMs: 7 * 86_400_000, rollupsHourlyMs: 400 * 86_400_000, rollupsDailyMs: null }, database: { version: '8.0.29', timeseriesSupported: true }, storage: { rooms: { documents: 1203, storageBytes: 1_234_567, indexBytes: 234_567, oldest: '2026-09-01T00:00:00.000Z' } }, features: edition === 'pro' ? Object.values(FeatureKey) : [], limits: { rankedSortMaxRangeMs: 7 * 86_400_000, participantRankedSortMaxRangeMs: 86_400_000 }, openapiDocsEnabled: false, ...this.options.info }; } private async handle(route: Route): Promise { const request = route.request(); const url = new URL(request.url()); const path = url.pathname.replace(/^.*\/api\/v1\//, ''); const authorization = request.headers().authorization ?? null; const method = request.method(); const handler = method === 'GET' ? this.options.routes?.[path] : undefined; this.requests.push({ method, path, params: url.searchParams, authorization }); const authorized = this.signedIn && authorization === `Bearer ${this.accessToken}`; switch (`${method} ${path}`) { case 'POST auth/refresh': if (this.signedIn) { this.accessToken = `${TOKEN}-${String(this.requests.length)}`; await route.fulfill({ json: { accessToken: this.accessToken, expiresAt: EXPIRES_AT } }); } else { await route.fulfill(error('UNAUTHORIZED', 401)); } return; case 'POST auth/login': await this.login(route); return; case 'POST auth/logout': this.signedIn = false; await route.fulfill({ status: 204 }); return; case 'GET auth/me': await route.fulfill( authorized ? { json: { username: 'admin', edition: this.options.edition, expiresAt: EXPIRES_AT } } : error('UNAUTHORIZED', 401) ); return; case 'GET info': if (this.options.infoFails) { await route.fulfill(error('NOT_READY', 503)); } else if (authorization !== null && !authorized) { await route.fulfill(error('UNAUTHORIZED', 401)); } else { await route.fulfill({ json: authorized ? this.fullInfo() : { edition: this.options.edition, versions: { dashboard: '3.10.0' } } }); } return; default: if (!authorized) { await route.fulfill(error('UNAUTHORIZED', 401)); } else if (method === 'GET' && this.proOnly.has(path)) { await route.fulfill(error('PRO_FEATURE', 403)); } else if (handler !== undefined) { await handler(route, url.searchParams); } else if (method === 'GET' && path === 'rooms') { await this.rooms(route, new URL(request.url()).searchParams); } else if (method === 'GET' && path === 'rooms/summary') { await route.fulfill({ json: this.options.roomSummary }); } else if (method === 'GET' && path === 'participants/summary') { await route.fulfill({ json: this.participantsSummary() }); } else if (method === 'GET' && (path === 'participants' || path === 'agents')) { await this.participants(route, path, new URL(request.url()).searchParams); } else if (method === 'GET' && path === 'egresses') { await this.egresses(route, new URL(request.url()).searchParams); } else if (method === 'GET' && path === 'egresses/summary') { await route.fulfill({ json: this.options.egressSummary ?? EMPTY_EGRESS_SUMMARY }); } else if (method === 'GET' && path === 'ingresses') { await this.ingresses(route, new URL(request.url()).searchParams); } else if (method === 'GET' && path === 'ingresses/summary') { await route.fulfill({ json: this.options.ingressSummary ?? EMPTY_INGRESS_SUMMARY }); } else if (method === 'GET' && path === 'overview') { await this.overview(route); } else if (method === 'GET' && path === 'status') { await route.fulfill({ json: (this.options.status ?? activeStatus)() }); } else if (method === 'GET' && path.startsWith('analytics/')) { await this.analytics(route, path.slice('analytics/'.length), url.searchParams); } else { await route.fulfill(error('NOT_FOUND', 404)); } } } private async overview(route: Route): Promise { if (this.options.overviewFails === true) { await route.fulfill(error('INTERNAL_ERROR', 500, { 'X-Request-Id': 'e2e-overview' })); return; } const body = (this.options.overview ?? activeOverview)(); const { generatedAt: _generatedAt, ...stable } = body; const etag = `W/"${createHash('sha1').update(JSON.stringify(stable)).digest('base64url')}"`; await route.fulfill( route.request().headers()['if-none-match'] === etag ? { status: 304, headers: { ETag: etag } } : { json: body, headers: { ETag: etag } } ); } private async analytics(route: Route, path: string, params: URLSearchParams): Promise { if (this.options.analyticsFail?.includes(path)) { await route.fulfill(error('INTERNAL_ERROR', 500, { 'X-Request-Id': `e2e-${path}` })); return; } switch (path) { case 'series': await route.fulfill({ json: analyticsSeries(params, this.options.analytics) }); break; case 'breakdowns': await route.fulfill({ json: analyticsBreakdowns(params, this.options.analytics) }); break; case 'summary': await route.fulfill({ json: { ...ANALYTICS_SUMMARY, scope: params.get('scope') ?? 'global' } }); break; case 'export.csv': { const csv = analyticsCsv(params); await route.fulfill({ body: csv.body, headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename=${csv.fileName}` } }); break; } default: await route.fulfill(error('NOT_FOUND', 404)); } } private async rooms(route: Route, params: URLSearchParams): Promise { if (this.roomsFail) { await route.fulfill(error('INTERNAL_ERROR', 500, { 'X-Request-Id': 'e2e-request-1' })); return; } const q = params.get('q')?.toLowerCase() ?? ''; const status = params.get('status') ?? 'all'; const features = (params.get('features') ?? '').split(',').filter((feature) => feature !== ''); const items = this.options.rooms.filter((room) => { if (q.startsWith('rm_')) { return room.sid.toLowerCase() === q; } return ( (status === 'all' || room.status === status) && (q === '' || room.name.toLowerCase().startsWith(q)) && (features.length === 0 || features.some((feature) => room.features[feature as keyof RoomListItem['features']])) ); }); const body: ListResponse = { items, nextCursor: null, prevCursor: null, ...(q.startsWith('rm_') ? { total: items.length } : this.options.roomsPage) }; await route.fulfill({ json: body }); } private async ingresses(route: Route, params: URLSearchParams): Promise { const q = params.get('q')?.toLowerCase() ?? ''; const status = params.get('status'); const inputType = params.get('inputType'); const items = (this.options.ingresses ?? []).filter((item) => { if (q.startsWith('in_')) { return item.ingressId.toLowerCase() === q; } return ( (q === '' || (item.name ?? '').toLowerCase().startsWith(q)) && (status === null || status === 'all' || item.status === status) && (inputType === null || item.inputType === inputType) ); }); await route.fulfill({ json: { items, nextCursor: null, prevCursor: null, total: items.length } }); } private async egresses(route: Route, params: URLSearchParams): Promise { const q = params.get('q')?.toLowerCase() ?? ''; const csv = (name: string) => (params.get(name) ?? '').split(',').filter((value) => value !== ''); const [statuses, types] = [csv('status'), csv('type')]; const room = params.get('room'); const items = (this.options.egresses ?? []).filter((item) => { if (q.startsWith('eg_')) { return item.egressId.toLowerCase() === q; } return ( (q === '' || (item.roomName ?? '').toLowerCase().startsWith(q)) && (statuses.length === 0 || statuses.includes(item.status)) && (types.length === 0 || types.includes(item.type)) && (room === null || item.roomSid === room) ); }); await route.fulfill({ json: { items, nextCursor: null, prevCursor: null, total: items.length } }); } /** Counts of the participant fixtures: STANDARD as participants, every other kind as services. */ private participantsSummary(): ParticipantsSummary { const kinds = Object.values(ParticipantKind); const count = (items: readonly ParticipantListItem[]) => Object.fromEntries( kinds.map((kind) => [kind, items.filter((item) => item.kind === kind).length]) ) as Record; const active = count(this.options.participants.filter((item) => item.status === 'active')); const sessions = count(this.options.participants); const total = Object.values(active).reduce((sum, n) => sum + n, 0); return { active: { participants: active.STANDARD, services: total - active.STANDARD, byKind: active }, sessions: { participants: sessions.STANDARD, byKind: sessions }, approximate: false }; } private async participants(route: Route, path: string, params: URLSearchParams): Promise { const q = params.get('q')?.toLowerCase() ?? ''; const kinds = path === 'agents' ? ['AGENT'] : (params.get('kind') ?? '').split(',').filter((kind) => kind !== ''); const status = params.get('status'); const room = params.get('room'); const items = this.options.participants.filter((item) => { if (q.startsWith('pa_')) { return item.sid.toLowerCase() === q; } return ( (q === '' || item.identity.toLowerCase().startsWith(q)) && (kinds.length === 0 || kinds.includes(item.kind)) && (status === null || item.status === status) && (room === null || item.roomSid === room) ); }); await route.fulfill({ json: { items, nextCursor: null, prevCursor: null, total: items.length } }); } private async login(route: Route): Promise { switch (this.options.login) { case 'ok': this.signedIn = true; this.accessToken = `${TOKEN}-login`; await route.fulfill({ json: { accessToken: this.accessToken, expiresAt: EXPIRES_AT } }); return; case 'invalid': await route.fulfill(error('INVALID_CREDENTIALS', 401)); return; case 'rate-limited': await route.fulfill(error('RATE_LIMITED', 429, { 'Retry-After': String(this.options.retryAfter) })); return; default: await route.abort('connectionrefused'); } } } export const test = base.extend<{ apiOptions: Partial; api: MockApi }>({ apiOptions: [{}, { option: true }], api: [ async ({ page, apiOptions }, use) => { const api = new MockApi(page, { ...DEFAULT_API, ...apiOptions }); await api.install(); await use(api); }, { auto: true } ] }); export { expect, type Page } from '@playwright/test';