Files
av-tools/tests/browser/performance.spec.ts

244 lines
7.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// <reference types="node" />
import { expect, test, type Browser, type Page } from '@playwright/test';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PRIMARY_FIXTURE } from './browser-helpers';
interface BenchmarkResult {
readonly mode: 'single-thread' | 'multithread';
readonly source: 'small' | 'medium';
readonly sourceDescription: string;
readonly cacheCondition: string;
readonly initMs: number;
readonly probeMs: number;
readonly conversionMs: number;
readonly outputReadMs: number;
readonly cleanupMs: number;
readonly exportEndToEndMs: number;
readonly outputBytes: number;
readonly hardwareConcurrency: number;
readonly crossOriginIsolated: boolean;
readonly inputBytes: number;
readonly usedJsHeapBytes?: number;
}
test.describe('opt-in pinned-core performance record', () => {
test.skip(
process.env.AV_BENCHMARK !== '1',
'Run with AV_BENCHMARK=1 through npm run benchmark:browser.'
);
test('records comparable fresh-context ST and MT end-to-end timings', async ({
browser,
}, testInfo) => {
test.setTimeout(600_000);
const medium = createMediumFixture();
const results: BenchmarkResult[] = [];
try {
results.push(
await runMode(browser, 'single-thread', PRIMARY_FIXTURE, 'small')
);
results.push(
await runMode(browser, 'multithread', PRIMARY_FIXTURE, 'small')
);
results.push(
await runMode(browser, 'single-thread', medium.filePath, 'medium')
);
results.push(
await runMode(browser, 'multithread', medium.filePath, 'medium')
);
await testInfo.attach('av-tools-browser-benchmark.json', {
body: Buffer.from(`${JSON.stringify(results, null, 2)}\n`),
contentType: 'application/json',
});
process.stdout.write(`AV_BENCHMARK_RESULT ${JSON.stringify(results)}\n`);
} finally {
medium.dispose();
}
});
});
async function runMode(
browser: Browser,
mode: BenchmarkResult['mode'],
fixturePath: string,
source: BenchmarkResult['source']
): Promise<BenchmarkResult> {
const context = await browser.newContext();
const page = await context.newPage();
try {
page.on('dialog', (dialog) => void dialog.accept());
await page.addInitScript(() => {
Reflect.deleteProperty(globalThis, 'showSaveFilePicker');
});
await page.goto('http://127.0.0.1:4173/');
const environment = await page.evaluate(() => ({
hardwareConcurrency: navigator.hardwareConcurrency,
crossOriginIsolated: globalThis.crossOriginIsolated,
}));
if (mode === 'multithread') {
expect(environment.crossOriginIsolated).toBe(true);
}
await page.getByText('Engine & browser').click();
await page
.getByLabel('Engine mode')
.selectOption(
mode === 'single-thread' ? 'force-single-thread' : 'prefer-multithread'
);
const initStarted = await monotonicNow(page);
await page.getByRole('button', { name: 'Initialize engine' }).click();
await expect(
page.locator('.engine-pill').filter({
hasText:
mode === 'single-thread'
? 'Single-threaded compatibility mode'
: 'Multithreaded',
})
).toBeVisible({ timeout: 120_000 });
const initMs = (await monotonicNow(page)) - initStarted;
const probeStarted = await monotonicNow(page);
await mediaInput(page).setInputFiles(fixturePath);
await page.getByRole('button', { name: /^Inspect details for /u }).click();
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
'Details ready',
{ timeout: 120_000 }
);
const probeMs = (await monotonicNow(page)) - probeStarted;
const exportStarted = await monotonicNow(page);
await page.getByRole('button', { name: 'Convert', exact: true }).click();
const result = page.locator('.result-card').last();
await expect(result).toContainText(
/created · verified|completed locally/u,
{
timeout: 180_000,
}
);
const exportEndToEndMs = (await monotonicNow(page)) - exportStarted;
const reportDownload = page.waitForEvent('download');
await page
.getByRole('button', { name: 'Export validation report' })
.click();
const reportPath = await (await reportDownload).path();
expect(reportPath).not.toBeNull();
const report = JSON.parse(
readFileSync(reportPath as string, 'utf8')
) as PerformanceReport;
const outputBytes = await page
.locator('.quick-preview video')
.evaluate(async (element) => {
const response = await fetch((element as HTMLVideoElement).src);
return (await response.arrayBuffer()).byteLength;
});
const usedJsHeapBytes = await page.evaluate(() => {
const memory = (
performance as Performance & {
memory?: { usedJSHeapSize?: number };
}
).memory;
return memory?.usedJSHeapSize;
});
const benchmarkSample: BenchmarkResult = {
mode,
source,
sourceDescription:
source === 'small'
? '2 s, 160×90 H.264/AAC synthetic pattern'
: '6 s, 640×360 H.264/AAC synthetic loop',
cacheCondition:
'fresh browser context; browser HTTP cache may be warm after the first run',
initMs: rounded(initMs),
probeMs: rounded(probeMs),
conversionMs: rounded(report.execution.conversionMilliseconds),
outputReadMs: rounded(report.execution.outputReadMilliseconds),
cleanupMs: rounded(report.execution.cleanupMilliseconds),
exportEndToEndMs: rounded(exportEndToEndMs),
outputBytes,
hardwareConcurrency: environment.hardwareConcurrency,
crossOriginIsolated: environment.crossOriginIsolated,
inputBytes: statSync(fixturePath).size,
...(usedJsHeapBytes === undefined ? {} : { usedJsHeapBytes }),
};
process.stdout.write(
`AV_BENCHMARK_SAMPLE ${JSON.stringify(benchmarkSample)}\n`
);
return benchmarkSample;
} finally {
await context.close();
}
}
interface PerformanceReport {
readonly execution: {
readonly conversionMilliseconds: number;
readonly outputReadMilliseconds: number;
readonly cleanupMilliseconds: number;
};
}
function createMediumFixture(): {
readonly filePath: string;
readonly dispose: () => void;
} {
const directory = mkdtempSync(join(tmpdir(), 'av-tools-benchmark-'));
const filePath = join(directory, 'medium-pattern.mp4');
try {
execFileSync(
'ffmpeg',
[
'-hide_banner',
'-loglevel',
'error',
'-y',
'-stream_loop',
'2',
'-i',
PRIMARY_FIXTURE,
'-t',
'6',
'-vf',
'scale=640:360',
'-r',
'24',
'-c:v',
'libx264',
'-preset',
'ultrafast',
'-crf',
'30',
'-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 }),
};
}
function mediaInput(page: Page) {
return page.locator('input[type="file"][accept*="audio"]').first();
}
async function monotonicNow(page: Page): Promise<number> {
return page.evaluate(() => performance.now());
}
function rounded(value: number): number {
return Math.round(value * 10) / 10;
}