/// import { createServer, type Server } from 'node:http'; import { execFileSync } from 'node:child_process'; import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, extname, join, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { ConsoleMessage, Page, Request, Response } from '@playwright/test'; import { expect } from '@playwright/test'; export const REPOSITORY_ROOT = resolve( dirname(fileURLToPath(import.meta.url)), '../..' ); export const DIST_ROOT = join(REPOSITORY_ROOT, 'dist'); export const GENERATED_FIXTURES = join( REPOSITORY_ROOT, 'tests/fixtures/generated' ); export const PRIMARY_FIXTURE = join(GENERATED_FIXTURES, 'pattern-av.mp4'); export async function inspectMediaDetails( page: Page, fileName: string ): Promise { const card = page.locator('.media-card').filter({ hasText: fileName }); const inspect = page.getByRole('button', { name: `Inspect details for ${fileName}`, }); if ((await inspect.count()) > 0) await inspect.click(); await expect(card.locator('.phase--ready')).toBeVisible({ timeout: 120_000, }); } const NESTED_PREFIX = '/deep/nested/av/'; const SECURITY_HEADERS = { 'Cross-Origin-Embedder-Policy': 'require-corp', 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Resource-Policy': 'same-origin', }; function contentType(filePath: string): string { const types: Readonly> = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.wasm': 'application/wasm', }; return types[extname(filePath).toLowerCase()] ?? 'application/octet-stream'; } function readApplicationManifest(): Record { return JSON.parse( readFileSync(join(DIST_ROOT, 'toolbox-app.json'), 'utf8') ) as Record; } function writeJson( response: import('node:http').ServerResponse, status: number, value: unknown ): void { response.writeHead(status, { ...SECURITY_HEADERS, 'Cache-Control': 'no-store', 'Content-Type': 'application/json; charset=utf-8', }); response.end(JSON.stringify(value)); } function serveFile( response: import('node:http').ServerResponse, filePath: string ): void { try { const stats = statSync(filePath); if (!stats.isFile()) { response.writeHead(404, SECURITY_HEADERS); response.end('Not found'); return; } response.writeHead(200, { ...SECURITY_HEADERS, 'Cache-Control': 'no-store', 'Content-Length': String(stats.size), 'Content-Type': contentType(filePath), }); response.end(readFileSync(filePath)); } catch { response.writeHead(404, SECURITY_HEADERS); response.end('Not found'); } } function closeServer(server: Server): Promise { return new Promise((resolveClose, rejectClose) => { server.close((error) => { if (error) rejectClose(error); else resolveClose(); }); }); } export interface NestedStaticServer { origin: string; appUrl: string; close: () => Promise; } /** * Serves the built artifact exactly as it will be assembled below a portal * path. This catches root-absolute URLs that a root-only preview cannot. */ export async function startNestedStaticServer(): Promise { const applicationManifest = readApplicationManifest(); const otherManifest = { ...applicationManifest, id: 'de.add-ideas.browser-test-tool', name: 'Browser Test Tool', description: 'A same-origin catalog peer used by browser validation.', entry: './', actions: [], }; const catalog = { $schema: 'https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json', schemaVersion: 1, id: 'de.add-ideas.browser-test-toolbox', name: 'Browser Test Toolbox', home: './', theme: { mode: 'system', brand: 'Browser test' }, apps: [ { manifest: './av/toolbox-app.json', enabled: true }, { manifest: './other/toolbox-app.json', enabled: true }, ], }; const distWithSeparator = `${resolve(DIST_ROOT)}${sep}`; const server = createServer((request, response) => { const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); const { pathname } = requestUrl; if (pathname === '/deep/nested/toolbox.catalog.json') { writeJson(response, 200, catalog); return; } if (pathname === '/deep/nested/invalid-catalog.json') { writeJson(response, 200, { schemaVersion: 999, id: 'invalid', }); return; } if (pathname === '/deep/nested/other/toolbox-app.json') { writeJson(response, 200, otherManifest); return; } if (pathname === '/deep/nested/favicon.svg') { serveFile(response, join(DIST_ROOT, 'favicon.svg')); return; } if (!pathname.startsWith(NESTED_PREFIX)) { response.writeHead(404, SECURITY_HEADERS); response.end('Not found'); return; } const relativePath = decodeURIComponent( pathname.slice(NESTED_PREFIX.length) ); const requestedPath = resolve( DIST_ROOT, relativePath === '' ? 'index.html' : relativePath ); if ( requestedPath !== resolve(DIST_ROOT) && !requestedPath.startsWith(distWithSeparator) ) { response.writeHead(400, SECURITY_HEADERS); response.end('Invalid path'); return; } serveFile(response, requestedPath); }); await new Promise((resolveListen, rejectListen) => { server.once('error', rejectListen); server.listen(0, '127.0.0.1', () => { server.off('error', rejectListen); resolveListen(); }); }); const address = server.address(); if (!address || typeof address === 'string') { await closeServer(server); throw new Error('Nested test server did not expose a TCP port'); } const origin = `http://127.0.0.1:${address.port}`; return { origin, appUrl: `${origin}${NESTED_PREFIX}`, close: () => closeServer(server), }; } export interface RuntimeObservation { requests: string[]; responses: Array<{ url: string; status: number; contentType: string | undefined; }>; failedRequests: string[]; pageErrors: string[]; consoleErrors: string[]; consoleWarnings: string[]; } export function observeRuntime(page: Page): RuntimeObservation { const observation: RuntimeObservation = { requests: [], responses: [], failedRequests: [], pageErrors: [], consoleErrors: [], consoleWarnings: [], }; page.on('request', (request: Request) => { observation.requests.push(request.url()); }); page.on('response', (response: Response) => { observation.responses.push({ url: response.url(), status: response.status(), contentType: response.headers()['content-type'], }); }); page.on('requestfailed', (request: Request) => { observation.failedRequests.push( `${request.url()}: ${request.failure()?.errorText ?? 'unknown failure'}` ); }); page.on('pageerror', (error: Error) => { observation.pageErrors.push(error.stack ?? error.message); }); page.on('console', (message: ConsoleMessage) => { if (message.type() === 'error') { observation.consoleErrors.push(message.text()); } if (message.type() === 'warning') { observation.consoleWarnings.push(message.text()); } }); return observation; } export interface TemporaryMediaFixture { filePath: string; dispose: () => void; } export function createCancellationFixture(): TemporaryMediaFixture { const directory = mkdtempSync(join(tmpdir(), 'av-tools-browser-')); const filePath = join(directory, 'long-cancellation-source.mp4'); try { execFileSync( 'ffmpeg', [ '-hide_banner', '-loglevel', 'error', '-y', '-stream_loop', '14', '-i', PRIMARY_FIXTURE, '-t', '30', '-vf', 'scale=1280:720', '-r', '30', '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '32', '-c:a', 'aac', '-b:a', '64k', filePath, ], { stdio: 'pipe' } ); } catch (error) { rmSync(directory, { recursive: true, force: true }); throw error; } return { filePath, dispose: () => rmSync(directory, { recursive: true, force: true }), }; }