feat: publish av-tools 0.1.0
This commit is contained in:
298
tests/browser/browser-helpers.ts
Normal file
298
tests/browser/browser-helpers.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
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';
|
||||
|
||||
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');
|
||||
|
||||
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<Record<string, string>> = {
|
||||
'.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<string, unknown> {
|
||||
return JSON.parse(
|
||||
readFileSync(join(DIST_ROOT, 'toolbox-app.json'), 'utf8')
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
return new Promise((resolveClose, rejectClose) => {
|
||||
server.close((error) => {
|
||||
if (error) rejectClose(error);
|
||||
else resolveClose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface NestedStaticServer {
|
||||
origin: string;
|
||||
appUrl: string;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<NestedStaticServer> {
|
||||
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/zemion/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<void>((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 }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user