feat: publish av-tools 0.1.0
This commit is contained in:
256
tests/browser/application.spec.ts
Normal file
256
tests/browser/application.spec.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
observeRuntime,
|
||||
startNestedStaticServer,
|
||||
type NestedStaticServer,
|
||||
} from './browser-helpers';
|
||||
|
||||
test.describe('static application and Toolbox shell', () => {
|
||||
test('runs standalone, stays lazy and supports keyboard-operated shell controls', async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtime = observeRuntime(page);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page).toHaveTitle('Audio & Video Tools');
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: 'Audio & Video Tools',
|
||||
})
|
||||
).toBeVisible();
|
||||
await expect(page.locator('[data-toolbox-context]')).toHaveAttribute(
|
||||
'data-toolbox-context',
|
||||
'standalone'
|
||||
);
|
||||
await expect(page.getByRole('main')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('tab', { name: 'Quick Convert' })
|
||||
).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Choose files' })
|
||||
).toBeEnabled();
|
||||
|
||||
expect(
|
||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/'))
|
||||
).toBe(false);
|
||||
expect(
|
||||
runtime.requests.some((url) => /\/assets\/worker-[^/]+\.js$/u.test(url))
|
||||
).toBe(false);
|
||||
|
||||
const helpButton = page.getByRole('button', { name: 'Help' });
|
||||
await helpButton.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const helpDialog = page.getByRole('dialog', {
|
||||
name: 'Audio & Video Tools',
|
||||
});
|
||||
await expect(helpDialog).toBeVisible();
|
||||
await expect(
|
||||
helpDialog.getByRole('heading', {
|
||||
level: 2,
|
||||
name: 'Audio & Video Tools',
|
||||
})
|
||||
).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(helpDialog).toBeHidden();
|
||||
|
||||
const personalizeButton = page.getByRole('button', {
|
||||
name: 'Personalize',
|
||||
});
|
||||
await personalizeButton.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: 'Personalize your toolbox',
|
||||
})
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Dark' }).click();
|
||||
await expect(page.locator('.toolbox-shell').first()).toHaveAttribute(
|
||||
'data-toolbox-theme',
|
||||
'dark'
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(personalizeButton).toBeFocused();
|
||||
|
||||
const networkUrls = runtime.requests.filter((url) => /^https?:/u.test(url));
|
||||
expect(
|
||||
networkUrls.every(
|
||||
(url) => new URL(url).origin === 'http://127.0.0.1:4173'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(runtime.failedRequests).toEqual([]);
|
||||
expect(runtime.pageErrors).toEqual([]);
|
||||
expect(runtime.consoleErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps preview primary and exposes accessible Edit drawers on narrow screens', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
await page.getByRole('tab', { name: 'Edit' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Preview' })).toBeVisible();
|
||||
const mediaDrawer = page.locator('#edit-media-drawer');
|
||||
const inspectorDrawer = page.locator('#edit-inspector-drawer');
|
||||
await expect(mediaDrawer).toBeHidden();
|
||||
await expect(inspectorDrawer).toBeHidden();
|
||||
|
||||
const sources = page.getByRole('button', { name: 'Sources' });
|
||||
await sources.click();
|
||||
await expect(mediaDrawer).toBeVisible();
|
||||
await expect(
|
||||
mediaDrawer.getByRole('button', { name: 'Close media bin' })
|
||||
).toBeFocused();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(mediaDrawer).toBeHidden();
|
||||
await expect(sources).toBeFocused();
|
||||
|
||||
const inspector = page.getByRole('button', { name: 'Inspector' });
|
||||
await inspector.click();
|
||||
await expect(inspectorDrawer).toBeVisible();
|
||||
const closeInspector = inspectorDrawer.getByRole('button', {
|
||||
name: 'Close inspector',
|
||||
});
|
||||
await expect(closeInspector).toBeFocused();
|
||||
await closeInspector.click();
|
||||
await expect(inspectorDrawer).toBeHidden();
|
||||
await expect(inspector).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('nested static deployment and Toolbox context', () => {
|
||||
let nestedServer: NestedStaticServer;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
nestedServer = await startNestedStaticServer();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await nestedServer.close();
|
||||
});
|
||||
|
||||
test('loads a valid same-origin catalog and all runtime assets below the nested path', async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto(`${nestedServer.appUrl}?toolbox=../toolbox.catalog.json`);
|
||||
|
||||
await expect(page.locator('[data-toolbox-context]')).toHaveAttribute(
|
||||
'data-toolbox-context',
|
||||
'connected'
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'add·ideas Toolbox' })
|
||||
).toHaveAttribute('href', `${nestedServer.origin}/deep/nested/`);
|
||||
|
||||
await page.getByRole('button', { name: 'Apps' }).click();
|
||||
const appSwitcher = page.getByRole('navigation', {
|
||||
name: 'Toolbox applications',
|
||||
});
|
||||
await expect(
|
||||
appSwitcher.getByRole('link', { name: 'Browser Test Tool' })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
appSwitcher.getByRole('link', { name: 'Audio & Video Tools' })
|
||||
).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
expect(
|
||||
runtime.requests.some((url) =>
|
||||
/\/deep\/nested\/av\/assets\/index-[^/]+\.js$/u.test(url)
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
runtime.requests.some((url) =>
|
||||
/\/deep\/nested\/av\/assets\/index-[^/]+\.css$/u.test(url)
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/'))
|
||||
).toBe(false);
|
||||
|
||||
await page.getByText('Engine & browser').click();
|
||||
await page.getByLabel('Engine mode').selectOption('force-single-thread');
|
||||
await page.getByRole('button', { name: 'Initialize engine' }).click();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('.engine-pill')
|
||||
.filter({ hasText: 'Single-threaded compatibility mode' })
|
||||
).toBeVisible({ timeout: 120_000 });
|
||||
await expect(page.getByText('0.12.15 / 0.12.10')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Encoders').locator('..').locator('dd')
|
||||
).not.toHaveText('0');
|
||||
await expect(
|
||||
page.getByText('Filters').locator('..').locator('dd')
|
||||
).not.toHaveText('0');
|
||||
|
||||
const coreScript = runtime.responses.find((response) =>
|
||||
response.url.endsWith(
|
||||
'/deep/nested/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.js'
|
||||
)
|
||||
);
|
||||
const coreWasm = runtime.responses.find((response) =>
|
||||
response.url.endsWith(
|
||||
'/deep/nested/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm'
|
||||
)
|
||||
);
|
||||
expect(coreScript).toMatchObject({
|
||||
status: 200,
|
||||
contentType: 'text/javascript; charset=utf-8',
|
||||
});
|
||||
expect(coreWasm).toMatchObject({
|
||||
status: 200,
|
||||
contentType: 'application/wasm',
|
||||
});
|
||||
expect(
|
||||
runtime.responses.some((response) =>
|
||||
/\/deep\/nested\/av\/assets\/worker-[^/]+\.js$/u.test(response.url)
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
const networkUrls = runtime.requests.filter((url) => /^https?:/u.test(url));
|
||||
expect(
|
||||
networkUrls.every((url) => new URL(url).origin === nestedServer.origin)
|
||||
).toBe(true);
|
||||
expect(runtime.failedRequests).toEqual([]);
|
||||
expect(runtime.pageErrors).toEqual([]);
|
||||
expect(runtime.consoleErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('rejects an invalid same-origin catalog and remains usable standalone', async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto(`${nestedServer.appUrl}?toolbox=../invalid-catalog.json`);
|
||||
|
||||
await expect
|
||||
.poll(() => runtime.consoleWarnings.length, { timeout: 10_000 })
|
||||
.toBeGreaterThan(0);
|
||||
await expect(page.locator('[data-toolbox-context]')).toHaveAttribute(
|
||||
'data-toolbox-context',
|
||||
'standalone'
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Choose files' })
|
||||
).toBeEnabled();
|
||||
expect(
|
||||
runtime.consoleWarnings.some((warning) =>
|
||||
warning.includes('Toolbox context unavailable; continuing standalone.')
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
runtime.requests.some(
|
||||
(url) =>
|
||||
url === `${nestedServer.origin}/deep/nested/invalid-catalog.json`
|
||||
)
|
||||
).toBe(true);
|
||||
expect(runtime.failedRequests).toEqual([]);
|
||||
expect(runtime.pageErrors).toEqual([]);
|
||||
expect(runtime.consoleErrors).toEqual([]);
|
||||
});
|
||||
});
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
359
tests/browser/ffmpeg-runtime.spec.ts
Normal file
359
tests/browser/ffmpeg-runtime.spec.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { basename, join } from 'node:path';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
createCancellationFixture,
|
||||
GENERATED_FIXTURES,
|
||||
observeRuntime,
|
||||
PRIMARY_FIXTURE,
|
||||
type TemporaryMediaFixture,
|
||||
} from './browser-helpers';
|
||||
|
||||
function mediaInput(page: import('@playwright/test').Page) {
|
||||
return page.locator('input[type="file"][accept*="audio"]');
|
||||
}
|
||||
|
||||
async function selectSingleThread(
|
||||
page: import('@playwright/test').Page
|
||||
): Promise<void> {
|
||||
await page.getByText('Engine & browser').click();
|
||||
await page.getByLabel('Engine mode').selectOption('force-single-thread');
|
||||
}
|
||||
|
||||
function assertLocalOnly(runtime: ReturnType<typeof observeRuntime>): void {
|
||||
const networkUrls = runtime.requests.filter((url) => /^https?:/u.test(url));
|
||||
expect(
|
||||
networkUrls.every((url) => new URL(url).origin === 'http://127.0.0.1:4173')
|
||||
).toBe(true);
|
||||
const materialFailures = runtime.failedRequests.filter(
|
||||
(failure) =>
|
||||
!(failure.startsWith('blob:') && failure.endsWith('net::ERR_ABORTED'))
|
||||
);
|
||||
// Chromium aborts the superseded local preview blob when React switches from
|
||||
// the source object URL to the generated result object URL.
|
||||
expect(materialFailures).toEqual([]);
|
||||
expect(runtime.pageErrors).toEqual([]);
|
||||
expect(runtime.consoleErrors).toEqual([]);
|
||||
}
|
||||
|
||||
test.describe.serial('real pinned FFmpeg browser runtime', () => {
|
||||
let cancellationFixture: TemporaryMediaFixture;
|
||||
|
||||
test.beforeAll(() => {
|
||||
cancellationFixture = createCancellationFixture();
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
cancellationFixture.dispose();
|
||||
});
|
||||
|
||||
test('loads and queries the multithread core under cross-origin isolation', async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto('/');
|
||||
const environment = await page.evaluate(() => ({
|
||||
isolated: globalThis.crossOriginIsolated,
|
||||
sharedArrayBuffer: typeof globalThis.SharedArrayBuffer,
|
||||
}));
|
||||
test.skip(
|
||||
!environment.isolated || environment.sharedArrayBuffer !== 'function',
|
||||
'Chromium did not expose SharedArrayBuffer in this server context'
|
||||
);
|
||||
|
||||
await page.getByText('Engine & browser').click();
|
||||
await page.getByRole('button', { name: 'Initialize engine' }).click();
|
||||
|
||||
await expect(
|
||||
page.locator('.engine-pill').filter({ hasText: 'Multithreaded' })
|
||||
).toBeVisible({ timeout: 120_000 });
|
||||
await expect(page.getByText('0.12.15 / 0.12.10')).toBeVisible();
|
||||
await expect(page.getByText('Isolation').locator('..')).toContainText(
|
||||
'Enabled'
|
||||
);
|
||||
|
||||
const expectedAssets = [
|
||||
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js',
|
||||
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm',
|
||||
'/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js',
|
||||
];
|
||||
for (const suffix of expectedAssets) {
|
||||
expect(
|
||||
runtime.responses.some(
|
||||
(response) => response.status === 200 && response.url.endsWith(suffix)
|
||||
)
|
||||
).toBe(true);
|
||||
}
|
||||
const wasm = runtime.responses.find((response) =>
|
||||
response.url.endsWith('/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm')
|
||||
);
|
||||
expect(wasm?.contentType).toContain('application/wasm');
|
||||
expect(
|
||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/0.12.10/st/'))
|
||||
).toBe(false);
|
||||
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
||||
const result = page.locator('.result-card');
|
||||
await expect(result).toContainText('pattern-av-convert.mp4', {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await expect(result).toContainText(/created · verified|completed locally/u);
|
||||
const outputBytes = await page
|
||||
.locator('.quick-preview video')
|
||||
.evaluate(async (element) => {
|
||||
const response = await fetch((element as HTMLVideoElement).src);
|
||||
return (await response.arrayBuffer()).byteLength;
|
||||
});
|
||||
expect(outputBytes).toBeGreaterThan(1_000);
|
||||
assertLocalOnly(runtime);
|
||||
});
|
||||
|
||||
test('falls back to the single-thread core when the preferred multithread core cannot load', async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtime = observeRuntime(page);
|
||||
await page.route('**/vendor/ffmpeg/0.12.10/mt/**', (route) =>
|
||||
route.abort('failed')
|
||||
);
|
||||
await page.goto('/');
|
||||
const environment = await page.evaluate(() => ({
|
||||
isolated: globalThis.crossOriginIsolated,
|
||||
sharedArrayBuffer: typeof globalThis.SharedArrayBuffer,
|
||||
}));
|
||||
test.skip(
|
||||
!environment.isolated || environment.sharedArrayBuffer !== 'function',
|
||||
'Chromium did not expose SharedArrayBuffer in this server context'
|
||||
);
|
||||
|
||||
await page.getByText('Engine & browser').click();
|
||||
await page.getByLabel('Engine mode').selectOption('prefer-multithread');
|
||||
await page.getByRole('button', { name: 'Initialize engine' }).click();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('.engine-pill')
|
||||
.filter({ hasText: 'Single-threaded compatibility mode' })
|
||||
).toBeVisible({ timeout: 120_000 });
|
||||
await expect(
|
||||
page.getByText(
|
||||
'The multithread core failed to load. The app recovered with the single-thread compatibility core.'
|
||||
)
|
||||
).toBeVisible();
|
||||
expect(
|
||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/0.12.10/mt/'))
|
||||
).toBe(true);
|
||||
expect(
|
||||
runtime.responses.some(
|
||||
(response) =>
|
||||
response.status === 200 &&
|
||||
response.url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
||||
)
|
||||
).toBe(true);
|
||||
expect(runtime.pageErrors).toEqual([]);
|
||||
expect(runtime.consoleErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('probes H.264/AAC media and produces a verified H.264/AAC result with the single-thread core', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
Reflect.deleteProperty(globalThis, 'showSaveFilePicker');
|
||||
});
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto('/');
|
||||
await selectSingleThread(page);
|
||||
|
||||
expect(
|
||||
runtime.requests.some((url) => url.includes('/vendor/ffmpeg/'))
|
||||
).toBe(false);
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await expect(page.locator('.source-summary')).toContainText(
|
||||
'pattern-av.mp4'
|
||||
);
|
||||
await expect(page.locator('.source-summary')).toContainText('0:02.00');
|
||||
await expect(
|
||||
page.getByRole('checkbox', { name: /#0 · h264/u })
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
page.getByRole('checkbox', { name: /#1 · aac/u })
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
page
|
||||
.locator('.engine-pill')
|
||||
.filter({ hasText: 'Single-threaded compatibility mode' })
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
||||
const result = page.locator('.result-card');
|
||||
await expect(result).toBeVisible({ timeout: 120_000 });
|
||||
await expect(result).toContainText('pattern-av-convert.mp4');
|
||||
await expect(result).toContainText(/created · verified|completed locally/u);
|
||||
|
||||
const resultVideo = page.locator('.quick-preview video');
|
||||
await expect(resultVideo).toHaveAttribute('src', /^blob:/u);
|
||||
const outputBytes = await resultVideo.evaluate(async (element) => {
|
||||
const video = element as HTMLVideoElement;
|
||||
const response = await fetch(video.src);
|
||||
return new Uint8Array(await response.arrayBuffer()).length;
|
||||
});
|
||||
expect(outputBytes).toBeGreaterThan(1_000);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Save result' }).click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe('pattern-av-convert.mp4');
|
||||
const downloadedPath = await download.path();
|
||||
expect(downloadedPath).not.toBeNull();
|
||||
const probeJson = execFileSync(
|
||||
'ffprobe',
|
||||
[
|
||||
'-v',
|
||||
'error',
|
||||
'-show_entries',
|
||||
'stream=codec_name,codec_type',
|
||||
'-of',
|
||||
'json',
|
||||
downloadedPath as string,
|
||||
],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
const nativeProbe = JSON.parse(probeJson) as {
|
||||
streams?: Array<{ codec_name?: string; codec_type?: string }>;
|
||||
};
|
||||
expect(nativeProbe.streams).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
codec_name: 'h264',
|
||||
codec_type: 'video',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
codec_name: 'aac',
|
||||
codec_type: 'audio',
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
expect(
|
||||
runtime.requests.some((url) =>
|
||||
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
||||
)
|
||||
).toBe(true);
|
||||
assertLocalOnly(runtime);
|
||||
});
|
||||
|
||||
test('refreshes retained tile-filter state before a fourth contact sheet', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto('/');
|
||||
await selectSingleThread(page);
|
||||
await page.getByRole('tab', { name: 'Edit' }).click();
|
||||
await mediaInput(page)
|
||||
.first()
|
||||
.setInputFiles(join(GENERATED_FIXTURES, 'compatible-a.mp4'));
|
||||
await expect(
|
||||
page
|
||||
.locator('.media-card')
|
||||
.filter({ hasText: 'compatible-a.mp4' })
|
||||
.locator('.phase--ready')
|
||||
).toBeVisible({ timeout: 120_000 });
|
||||
|
||||
const advanced = page.locator('.advanced-operations');
|
||||
await advanced
|
||||
.getByRole('tab', { name: 'Thumbnails and contact sheets' })
|
||||
.click();
|
||||
await advanced
|
||||
.getByRole('spinbutton', { name: 'Frames', exact: true })
|
||||
.fill('4');
|
||||
await advanced
|
||||
.getByRole('spinbutton', { name: 'Rows', exact: true })
|
||||
.fill('2');
|
||||
await advanced
|
||||
.getByRole('spinbutton', { name: 'Columns', exact: true })
|
||||
.fill('2');
|
||||
await advanced
|
||||
.getByRole('spinbutton', { name: /^Cell width/u })
|
||||
.fill('128');
|
||||
|
||||
const results = page.locator('.result-card');
|
||||
const create = advanced.getByRole('button', {
|
||||
name: 'Generate contact sheet',
|
||||
});
|
||||
for (let index = 1; index <= 4; index += 1) {
|
||||
await create.click();
|
||||
await expect(results).toHaveCount(index, { timeout: 120_000 });
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
}
|
||||
|
||||
await expect(
|
||||
results.filter({ hasText: 'compatible-a-contact-sheet' })
|
||||
).toHaveCount(4);
|
||||
expect(
|
||||
runtime.requests.filter((url) =>
|
||||
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
||||
)
|
||||
).toHaveLength(2);
|
||||
assertLocalOnly(runtime);
|
||||
});
|
||||
|
||||
test('cancels an active transcode, recreates the engine and completes another job', async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto('/');
|
||||
await selectSingleThread(page);
|
||||
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
||||
const cancelButton = page.getByRole('button', {
|
||||
name: 'Cancel & recover',
|
||||
});
|
||||
await expect(cancelButton).toBeVisible({ timeout: 30_000 });
|
||||
await cancelButton.click();
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText(
|
||||
'The operation was cancelled and the engine was recreated.',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await expect(
|
||||
page
|
||||
.locator('.engine-pill')
|
||||
.filter({ hasText: 'Single-threaded compatibility mode' })
|
||||
).toBeVisible({ timeout: 120_000 });
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: `Remove ${basename(cancellationFixture.filePath)}`,
|
||||
})
|
||||
.click();
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
||||
await expect(page.locator('.result-card')).toContainText(
|
||||
'pattern-av-convert.mp4',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
assertLocalOnly(runtime);
|
||||
});
|
||||
});
|
||||
143
tests/browser/fixture-recovery.spec.ts
Normal file
143
tests/browser/fixture-recovery.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import {
|
||||
GENERATED_FIXTURES,
|
||||
observeRuntime,
|
||||
PRIMARY_FIXTURE,
|
||||
type RuntimeObservation,
|
||||
} from './browser-helpers';
|
||||
|
||||
function mediaInput(page: Page) {
|
||||
return page.locator('input[type="file"][accept*="audio"]').first();
|
||||
}
|
||||
|
||||
async function initializeSingleThread(page: Page): Promise<void> {
|
||||
await page.getByText('Engine & browser').click();
|
||||
await page.getByLabel('Engine mode').selectOption('force-single-thread');
|
||||
await page.getByRole('button', { name: 'Initialize engine' }).click();
|
||||
await expect(
|
||||
page
|
||||
.locator('.engine-pill')
|
||||
.filter({ hasText: 'Single-threaded compatibility mode' })
|
||||
).toBeVisible({ timeout: 120_000 });
|
||||
}
|
||||
|
||||
function assertLocalOnly(runtime: RuntimeObservation): void {
|
||||
const networkUrls = runtime.requests.filter((url) => /^https?:/u.test(url));
|
||||
expect(
|
||||
networkUrls.every((url) => new URL(url).origin === 'http://127.0.0.1:4173')
|
||||
).toBe(true);
|
||||
const materialFailures = runtime.failedRequests.filter(
|
||||
(failure) =>
|
||||
!(failure.startsWith('blob:') && failure.endsWith('net::ERR_ABORTED'))
|
||||
);
|
||||
expect(materialFailures).toEqual([]);
|
||||
expect(runtime.pageErrors).toEqual([]);
|
||||
expect(runtime.consoleErrors).toEqual([]);
|
||||
}
|
||||
|
||||
test.describe
|
||||
.serial('generated edge-case fixtures in pinned ffmpeg.wasm', () => {
|
||||
test('probes attached cover art without treating it as the playback video stream', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto('/');
|
||||
await initializeSingleThread(page);
|
||||
|
||||
await mediaInput(page).setInputFiles(
|
||||
join(GENERATED_FIXTURES, 'attached-cover.mp3')
|
||||
);
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
{ timeout: 60_000 }
|
||||
);
|
||||
await expect(page.locator('.source-summary')).toContainText(
|
||||
'attached-cover.mp3'
|
||||
);
|
||||
await expect(page.locator('.source-summary')).toContainText('0:01.08');
|
||||
await expect(page.locator('.quick-preview audio')).toHaveAttribute(
|
||||
'src',
|
||||
/^blob:/u
|
||||
);
|
||||
await expect(page.locator('.quick-preview video')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('checkbox', { name: /#0 · mp3/u })
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
page.getByRole('checkbox', { name: /#1 · png/u })
|
||||
).toBeChecked();
|
||||
|
||||
await page.getByRole('tab', { name: 'Edit' }).click();
|
||||
const inspectorStreams = page.locator('.inspector-streams');
|
||||
await expect(
|
||||
inspectorStreams.locator('li').filter({ hasText: '#0 · mp3' })
|
||||
).toContainText('audio');
|
||||
await expect(
|
||||
inspectorStreams.locator('li').filter({ hasText: '#1 · png' })
|
||||
).toContainText('video');
|
||||
|
||||
const advanced = page.locator('.advanced-operations');
|
||||
await advanced.getByRole('tab', { name: 'Metadata policies' }).click();
|
||||
await expect(advanced.getByLabel('Cover art')).toHaveValue('keep');
|
||||
await advanced
|
||||
.getByText('Other stream dispositions', { exact: true })
|
||||
.nth(1)
|
||||
.click();
|
||||
await expect(
|
||||
advanced.getByRole('checkbox', {
|
||||
name: 'Attached pic stream #1',
|
||||
})
|
||||
).toBeChecked();
|
||||
|
||||
assertLocalOnly(runtime);
|
||||
});
|
||||
|
||||
test('bounds a truncated-file probe failure, cleans up, and probes a valid file next', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto('/');
|
||||
await initializeSingleThread(page);
|
||||
|
||||
const probeStartedAt = Date.now();
|
||||
await mediaInput(page).setInputFiles(
|
||||
join(GENERATED_FIXTURES, 'truncated.mp4')
|
||||
);
|
||||
const malformedCard = page
|
||||
.locator('.media-card')
|
||||
.filter({ hasText: 'truncated.mp4' });
|
||||
await expect(malformedCard.locator('.phase--error')).toContainText(
|
||||
'ffprobe did not find any media streams.',
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
expect(Date.now() - probeStartedAt).toBeLessThan(30_000);
|
||||
await expect(
|
||||
page
|
||||
.locator('.engine-pill')
|
||||
.filter({ hasText: 'Single-threaded compatibility mode' })
|
||||
).toBeVisible();
|
||||
|
||||
await mediaInput(page).setInputFiles(PRIMARY_FIXTURE);
|
||||
const validCard = page
|
||||
.locator('.media-card')
|
||||
.filter({ hasText: 'pattern-av.mp4' });
|
||||
await expect(validCard.locator('.phase--ready')).toContainText('1 audio', {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await validCard.click();
|
||||
await expect(
|
||||
page.getByRole('checkbox', { name: /#0 · h264/u })
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
page.getByRole('checkbox', { name: /#1 · aac/u })
|
||||
).toBeChecked();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
|
||||
assertLocalOnly(runtime);
|
||||
});
|
||||
});
|
||||
996
tests/browser/operation-matrix.spec.ts
Normal file
996
tests/browser/operation-matrix.spec.ts
Normal file
@@ -0,0 +1,996 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { basename, join } from 'node:path';
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type Download,
|
||||
type Locator,
|
||||
type Page,
|
||||
} from '@playwright/test';
|
||||
import {
|
||||
createCancellationFixture,
|
||||
GENERATED_FIXTURES,
|
||||
observeRuntime,
|
||||
type RuntimeObservation,
|
||||
type TemporaryMediaFixture,
|
||||
} from './browser-helpers';
|
||||
|
||||
const fixture = (name: string): string => join(GENERATED_FIXTURES, name);
|
||||
|
||||
function mediaInput(page: Page): Locator {
|
||||
return page.locator('input[type="file"][accept*="audio"]').first();
|
||||
}
|
||||
|
||||
async function importMedia(
|
||||
page: Page,
|
||||
names: readonly string[]
|
||||
): Promise<void> {
|
||||
await mediaInput(page).setInputFiles(names.map((name) => fixture(name)));
|
||||
for (const name of names) {
|
||||
const card = page.locator('.media-card').filter({ hasText: name });
|
||||
await expect(card.locator('.phase--ready')).toBeVisible({
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeSingleThread(page: Page): Promise<void> {
|
||||
await page.getByText('Engine & browser').click();
|
||||
await page.getByLabel('Engine mode').selectOption('force-single-thread');
|
||||
await page.getByRole('button', { name: 'Initialize engine' }).click();
|
||||
await expect(
|
||||
page
|
||||
.locator('.engine-pill')
|
||||
.filter({ hasText: 'Single-threaded compatibility mode' })
|
||||
).toBeVisible({ timeout: 120_000 });
|
||||
}
|
||||
|
||||
const RESULT_SNAPSHOT_ATTRIBUTE = 'data-playwright-existing-result';
|
||||
|
||||
async function markExistingResults(page: Page): Promise<void> {
|
||||
await page.locator('.result-card').evaluateAll((cards) => {
|
||||
for (const card of cards) {
|
||||
card.setAttribute('data-playwright-existing-result', '');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function newResultCards(page: Page): Locator {
|
||||
return page.locator(`.result-card:not([${RESULT_SNAPSHOT_ATTRIBUTE}])`);
|
||||
}
|
||||
|
||||
function accumulatedOutputName(fileName: string): RegExp {
|
||||
const dot = fileName.lastIndexOf('.');
|
||||
const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
|
||||
const extension = dot > 0 ? fileName.slice(dot) : '';
|
||||
const escape = (value: string): string =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
||||
return new RegExp(`^${escape(stem)}(?:-\\d+)?${escape(extension)}$`, 'u');
|
||||
}
|
||||
|
||||
async function runForResults(
|
||||
page: Page,
|
||||
action: () => Promise<void>,
|
||||
expectedFileNames: readonly string[],
|
||||
options: { verifyMedia?: boolean } = {}
|
||||
): Promise<readonly Locator[]> {
|
||||
await markExistingResults(page);
|
||||
await action();
|
||||
|
||||
const cards = expectedFileNames.map((_, index) =>
|
||||
newResultCards(page).nth(index)
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if ((await cards[0]?.locator('strong').count()) === 1) {
|
||||
return 'result';
|
||||
}
|
||||
if ((await page.getByRole('alert').count()) > 0) {
|
||||
const alert = (await page.getByRole('alert').allTextContents()).join(
|
||||
' | '
|
||||
);
|
||||
const diagnostics =
|
||||
(await page.locator('.diagnostics pre').textContent()) ?? '';
|
||||
throw new Error(
|
||||
`${alert}\n\nFFmpeg diagnostics:\n${diagnostics
|
||||
.split('\n')
|
||||
.slice(-160)
|
||||
.join('\n')}`
|
||||
);
|
||||
}
|
||||
return 'waiting';
|
||||
},
|
||||
{ timeout: 120_000 }
|
||||
)
|
||||
.toBe('result');
|
||||
for (const [index, card] of cards.entries()) {
|
||||
await expect(card.locator('strong')).toHaveText(
|
||||
accumulatedOutputName(expectedFileNames[index] as string),
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await expect(card).toContainText(
|
||||
/created · (?:verified|verification warning)|completed locally/u,
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
if (options.verifyMedia) {
|
||||
await expect(card).toContainText(/verified stream/u, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
}
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
return cards;
|
||||
}
|
||||
|
||||
async function downloadResult(
|
||||
page: Page,
|
||||
card: Locator
|
||||
): Promise<{ download: Download; filePath: string }> {
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await card.locator('button.button--primary').click();
|
||||
const download = await downloadPromise;
|
||||
const filePath = await download.path();
|
||||
expect(filePath).not.toBeNull();
|
||||
expect(statSync(filePath as string).size).toBeGreaterThan(0);
|
||||
return { download, filePath: filePath as string };
|
||||
}
|
||||
|
||||
function probe(filePath: string): {
|
||||
streams?: Array<{
|
||||
codec_name?: string;
|
||||
codec_type?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
avg_frame_rate?: string;
|
||||
tags?: Record<string, string>;
|
||||
disposition?: Record<string, number>;
|
||||
}>;
|
||||
format?: { tags?: Record<string, string> };
|
||||
chapters?: Array<{ tags?: Record<string, string> }>;
|
||||
} {
|
||||
return JSON.parse(
|
||||
execFileSync(
|
||||
'ffprobe',
|
||||
[
|
||||
'-v',
|
||||
'error',
|
||||
'-show_streams',
|
||||
'-show_format',
|
||||
'-show_chapters',
|
||||
'-of',
|
||||
'json',
|
||||
filePath,
|
||||
],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
) as ReturnType<typeof probe>;
|
||||
}
|
||||
|
||||
function assertLocalOnly(runtime: RuntimeObservation): void {
|
||||
const networkUrls = runtime.requests.filter((url) => /^https?:/u.test(url));
|
||||
expect(
|
||||
networkUrls.every((url) => new URL(url).origin === 'http://127.0.0.1:4173')
|
||||
).toBe(true);
|
||||
const materialFailures = runtime.failedRequests.filter(
|
||||
(failure) =>
|
||||
!(failure.startsWith('blob:') && failure.endsWith('net::ERR_ABORTED'))
|
||||
);
|
||||
expect(materialFailures).toEqual([]);
|
||||
expect(runtime.pageErrors).toEqual([]);
|
||||
expect(runtime.consoleErrors).toEqual([]);
|
||||
}
|
||||
|
||||
test.describe.serial('real ffmpeg.wasm operation matrix', () => {
|
||||
let cancellationFixture: TemporaryMediaFixture;
|
||||
|
||||
test.beforeAll(() => {
|
||||
cancellationFixture = createCancellationFixture();
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
cancellationFixture.dispose();
|
||||
});
|
||||
|
||||
test('executes conversion, structural, analysis, image, metadata, chapter and subtitle workflows in one local engine session', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(300_000);
|
||||
page.setDefaultTimeout(15_000);
|
||||
await page.addInitScript(() => {
|
||||
Reflect.deleteProperty(globalThis, 'showSaveFilePicker');
|
||||
});
|
||||
const runtime = observeRuntime(page);
|
||||
await page.goto('/');
|
||||
await initializeSingleThread(page);
|
||||
|
||||
await test.step('queues a second browser job, cancels the active job and runs the waiting job after recovery', async () => {
|
||||
const sourceName = basename(cancellationFixture.filePath);
|
||||
await mediaInput(page).setInputFiles(cancellationFixture.filePath);
|
||||
await expect(
|
||||
page
|
||||
.locator('.media-card')
|
||||
.filter({ hasText: sourceName })
|
||||
.locator('.phase--ready')
|
||||
).toBeVisible({ timeout: 120_000 });
|
||||
|
||||
await page.getByRole('button', { name: 'Convert', exact: true }).click();
|
||||
const enqueueButton = page.getByRole('button', {
|
||||
name: 'Add to queue',
|
||||
});
|
||||
await expect(enqueueButton).toBeVisible();
|
||||
await enqueueButton.click();
|
||||
await expect(page.getByText('1 waiting', { exact: true })).toBeVisible();
|
||||
|
||||
const firstCancelButton = page.getByRole('button', {
|
||||
name: 'Cancel & recover',
|
||||
});
|
||||
const firstCancelElement = await firstCancelButton.elementHandle();
|
||||
await firstCancelButton.click();
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
firstCancelElement?.evaluate((element) => element.isConnected) ??
|
||||
false,
|
||||
{ timeout: 120_000 }
|
||||
)
|
||||
.toBe(false);
|
||||
await expect(page.getByText('1 waiting', { exact: true })).toBeHidden({
|
||||
timeout: 120_000,
|
||||
});
|
||||
const secondCancelButton = page.getByRole('button', {
|
||||
name: 'Cancel & recover',
|
||||
});
|
||||
await expect(secondCancelButton).toBeVisible({
|
||||
timeout: 120_000,
|
||||
});
|
||||
await secondCancelButton.click();
|
||||
await expect(secondCancelButton).toBeHidden({ timeout: 120_000 });
|
||||
await expect(page.getByRole('alert')).toContainText(
|
||||
'The operation was cancelled and the engine was recreated.',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await expect(
|
||||
page
|
||||
.locator('.engine-pill')
|
||||
.filter({ hasText: 'Single-threaded compatibility mode' })
|
||||
).toBeVisible({ timeout: 120_000 });
|
||||
await page.getByRole('button', { name: 'New', exact: true }).click();
|
||||
});
|
||||
|
||||
await test.step('audio conversion and WebM remux/re-encode', async () => {
|
||||
await importMedia(page, ['tone.flac']);
|
||||
await page.getByLabel('Export preset').selectOption('audio-mp3');
|
||||
const [mp3Card] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
page.getByRole('button', { name: 'Convert', exact: true }).click(),
|
||||
['tone-convert.mp3'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
const mp3 = await downloadResult(page, mp3Card as Locator);
|
||||
expect(probe(mp3.filePath).streams).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
codec_name: 'mp3',
|
||||
codec_type: 'audio',
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
await page.getByLabel('Export preset').selectOption('audio-ogg-vorbis');
|
||||
const [oggCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
page.getByRole('button', { name: 'Convert', exact: true }).click(),
|
||||
['tone-convert.ogg'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
expect(
|
||||
probe((await downloadResult(page, oggCard as Locator)).filePath).streams
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
codec_name: 'vorbis',
|
||||
codec_type: 'audio',
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
await page.getByLabel('Export preset').selectOption('audio-flac');
|
||||
const [flacCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
page.getByRole('button', { name: 'Convert', exact: true }).click(),
|
||||
['tone-convert.flac'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
expect(
|
||||
probe((await downloadResult(page, flacCard as Locator)).filePath)
|
||||
.streams
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
codec_name: 'flac',
|
||||
codec_type: 'audio',
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
await page.getByLabel('Export preset').selectOption('audio-wav-pcm');
|
||||
const [wavCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
page.getByRole('button', { name: 'Convert', exact: true }).click(),
|
||||
['tone-convert.wav'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
expect(
|
||||
probe((await downloadResult(page, wavCard as Locator)).filePath).streams
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
codec_name: 'pcm_s16le',
|
||||
codec_type: 'audio',
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
await importMedia(page, ['pattern-av.mp4']);
|
||||
await page
|
||||
.locator('.media-card')
|
||||
.filter({ hasText: 'pattern-av.mp4' })
|
||||
.click();
|
||||
await page.getByLabel('Export preset').selectOption('audio-mp3');
|
||||
await page.getByRole('checkbox', { name: /#0 · h264/u }).uncheck();
|
||||
const [extractedAudioCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
page.getByRole('button', { name: 'Convert', exact: true }).click(),
|
||||
['pattern-av-convert.mp3'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
const extractedAudio = probe(
|
||||
(await downloadResult(page, extractedAudioCard as Locator)).filePath
|
||||
);
|
||||
expect(
|
||||
extractedAudio.streams?.map((stream) => stream.codec_type)
|
||||
).toEqual(['audio']);
|
||||
expect(extractedAudio.streams?.[0]?.codec_name).toBe('mp3');
|
||||
|
||||
await page
|
||||
.getByLabel('Export preset')
|
||||
.selectOption('mp4-h264-compatibility');
|
||||
await page.getByRole('checkbox', { name: /#0 · h264/u }).check();
|
||||
await page.getByRole('checkbox', { name: /#1 · aac/u }).uncheck();
|
||||
const [videoOnlyCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
page.getByRole('button', { name: 'Convert', exact: true }).click(),
|
||||
['pattern-av-convert.mp4'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
const videoOnly = probe(
|
||||
(await downloadResult(page, videoOnlyCard as Locator)).filePath
|
||||
);
|
||||
expect(videoOnly.streams?.map((stream) => stream.codec_type)).toEqual([
|
||||
'video',
|
||||
]);
|
||||
expect(videoOnly.streams?.[0]?.codec_name).toBe('h264');
|
||||
|
||||
await importMedia(page, ['pattern.webm']);
|
||||
await page
|
||||
.locator('.media-card')
|
||||
.filter({ hasText: 'pattern.webm' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Fast remux' }).click();
|
||||
await page.getByLabel('Target container').selectOption('matroska');
|
||||
const [remuxCard] = await runForResults(
|
||||
page,
|
||||
() => page.getByRole('button', { name: 'Remux', exact: true }).click(),
|
||||
['pattern-remux.mkv'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
const remux = await downloadResult(page, remuxCard as Locator);
|
||||
expect(probe(remux.filePath).streams).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ codec_name: 'vp8', codec_type: 'video' }),
|
||||
expect.objectContaining({
|
||||
codec_name: 'vorbis',
|
||||
codec_type: 'audio',
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Re-encode' }).click();
|
||||
await page.getByLabel('Export preset').selectOption('webm-vp8-vorbis');
|
||||
const [webmCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
page.getByRole('button', { name: 'Convert', exact: true }).click(),
|
||||
['pattern-convert.webm'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
const webm = await downloadResult(page, webmCard as Locator);
|
||||
expect(probe(webm.filePath).streams).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ codec_name: 'vp8', codec_type: 'video' }),
|
||||
expect.objectContaining({
|
||||
codec_name: 'vorbis',
|
||||
codec_type: 'audio',
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
await importMedia(page, ['metadata.mp4']);
|
||||
await page
|
||||
.locator('.media-card')
|
||||
.filter({ hasText: 'metadata.mp4' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Fast remux' }).click();
|
||||
await page.getByLabel('Target container').selectOption('mp4');
|
||||
await page.getByRole('checkbox', { name: 'Remove metadata' }).check();
|
||||
const [metadataRemovalCard] = await runForResults(
|
||||
page,
|
||||
() => page.getByRole('button', { name: 'Remux', exact: true }).click(),
|
||||
['metadata-remux.mp4'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
const metadataRemovalProbe = probe(
|
||||
(await downloadResult(page, metadataRemovalCard as Locator)).filePath
|
||||
);
|
||||
expect(metadataRemovalProbe.format?.tags?.title).toBeUndefined();
|
||||
expect(metadataRemovalProbe.format?.tags?.artist).toBeUndefined();
|
||||
expect(metadataRemovalProbe.format?.tags?.comment).toBeUndefined();
|
||||
});
|
||||
|
||||
await test.step('fast and accurate trim plus fast and normalized concat', async () => {
|
||||
await page.getByRole('button', { name: 'New', exact: true }).click();
|
||||
await page.getByRole('tab', { name: 'Edit' }).click();
|
||||
await importMedia(page, ['compatible-a.mp4', 'compatible-b.mp4']);
|
||||
|
||||
const selectedTimelineClip = page
|
||||
.locator('.timeline-clip')
|
||||
.filter({ hasText: 'compatible-a.mp4' });
|
||||
await selectedTimelineClip.locator('.timeline-clip__main').click();
|
||||
await selectedTimelineClip.getByLabel('In').fill('0.1');
|
||||
await selectedTimelineClip.getByLabel('Out').fill('0.8');
|
||||
|
||||
const structural = page.locator('.structural-operations');
|
||||
const [fastTrimCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
structural.getByRole('button', { name: 'Export fast trim' }).click(),
|
||||
['compatible-a-trim.mp4'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
expect(
|
||||
probe((await downloadResult(page, fastTrimCard as Locator)).filePath)
|
||||
.streams
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ codec_name: 'h264', codec_type: 'video' }),
|
||||
])
|
||||
);
|
||||
|
||||
await structural.getByLabel('Accurate re-encode').check();
|
||||
await structural
|
||||
.getByLabel('Accurate trim preset')
|
||||
.selectOption('mp4-h264-compatibility');
|
||||
const [accurateTrimCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
structural.getByRole('button', { name: 'Export exact trim' }).click(),
|
||||
['compatible-a-trim.mp4'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
expect(
|
||||
probe(
|
||||
(await downloadResult(page, accurateTrimCard as Locator)).filePath
|
||||
).streams
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ codec_name: 'h264', codec_type: 'video' }),
|
||||
expect.objectContaining({ codec_name: 'aac', codec_type: 'audio' }),
|
||||
])
|
||||
);
|
||||
|
||||
await expect(
|
||||
structural.getByRole('heading', {
|
||||
name: 'Fast compatibility passed',
|
||||
})
|
||||
).toBeVisible();
|
||||
const [fastConcatCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
structural
|
||||
.getByRole('button', { name: 'Export fast concat' })
|
||||
.click(),
|
||||
['compatible-a-concat.mp4'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
expect(
|
||||
probe((await downloadResult(page, fastConcatCard as Locator)).filePath)
|
||||
.streams
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ codec_name: 'h264', codec_type: 'video' }),
|
||||
expect.objectContaining({ codec_name: 'aac', codec_type: 'audio' }),
|
||||
])
|
||||
);
|
||||
|
||||
await structural.getByLabel('Normalized re-encode').check();
|
||||
await structural
|
||||
.getByLabel('Normalized concat preset')
|
||||
.selectOption('mp4-h264-compatibility');
|
||||
await structural.getByLabel('Insert silence').check();
|
||||
await structural.getByLabel('Reject subtitle input').check();
|
||||
const [normalizedConcatCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
structural
|
||||
.getByRole('button', { name: 'Export normalized concat' })
|
||||
.click(),
|
||||
['compatible-a-concat.mp4'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
expect(
|
||||
probe(
|
||||
(await downloadResult(page, normalizedConcatCard as Locator)).filePath
|
||||
).streams
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ codec_name: 'h264', codec_type: 'video' }),
|
||||
expect.objectContaining({ codec_name: 'aac', codec_type: 'audio' }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
const advanced = page.locator('.advanced-operations');
|
||||
await test.step('fast/accurate split and waveform/loudness analysis', async () => {
|
||||
await advanced.getByLabel('Marker time (seconds)').fill('0.5');
|
||||
await advanced.getByRole('button', { name: 'Add marker' }).click();
|
||||
await runForResults(
|
||||
page,
|
||||
() =>
|
||||
advanced.getByRole('button', { name: 'Queue 2 segments' }).click(),
|
||||
['compatible-a-split-001.mp4', 'compatible-a-split-002.mp4'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
|
||||
await advanced.getByLabel('Accurate re-encode').check();
|
||||
await advanced
|
||||
.getByLabel('Accurate split preset')
|
||||
.selectOption('mp4-h264-compatibility');
|
||||
await runForResults(
|
||||
page,
|
||||
() =>
|
||||
advanced.getByRole('button', { name: 'Queue 2 segments' }).click(),
|
||||
['compatible-a-split-001.mp4', 'compatible-a-split-002.mp4'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
|
||||
await advanced
|
||||
.getByRole('tab', { name: 'Waveform and loudness' })
|
||||
.click();
|
||||
const [waveformCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
advanced
|
||||
.getByRole('button', { name: 'Generate static waveform' })
|
||||
.click(),
|
||||
['compatible-a-waveform-static.png']
|
||||
);
|
||||
expect(
|
||||
statSync((await downloadResult(page, waveformCard as Locator)).filePath)
|
||||
.size
|
||||
).toBeGreaterThan(1_000);
|
||||
|
||||
await advanced
|
||||
.getByRole('button', { name: 'Analyze peak waveform' })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('region', {
|
||||
name: 'Waveform editor for compatible-a.mp4',
|
||||
})
|
||||
).toBeVisible({ timeout: 120_000 });
|
||||
|
||||
await advanced.getByRole('button', { name: 'Measure EBU R128' }).click();
|
||||
await expect(page.locator('.project-notice')).toContainText('Measured', {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await expect(advanced.getByText('Measured loudness')).toBeVisible();
|
||||
await advanced
|
||||
.getByLabel('Normalization preset')
|
||||
.selectOption('audio-mp3');
|
||||
|
||||
const [normalizedAudioCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
advanced
|
||||
.getByRole('button', { name: 'Apply measured normalization' })
|
||||
.click(),
|
||||
['compatible-a-normalize.mp3'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
expect(
|
||||
probe(
|
||||
(await downloadResult(page, normalizedAudioCard as Locator)).filePath
|
||||
).streams
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ codec_name: 'mp3', codec_type: 'audio' }),
|
||||
])
|
||||
);
|
||||
|
||||
const [peakAudioCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
advanced
|
||||
.getByRole('button', { name: 'Apply peak normalization' })
|
||||
.click(),
|
||||
['compatible-a-peak-normalize.mp3'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
expect(
|
||||
probe((await downloadResult(page, peakAudioCard as Locator)).filePath)
|
||||
.streams
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ codec_name: 'mp3', codec_type: 'audio' }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
await test.step('thumbnail series and contact sheet', async () => {
|
||||
await advanced
|
||||
.getByRole('tab', { name: 'Thumbnails and contact sheets' })
|
||||
.click();
|
||||
await advanced.getByLabel('Number of frames').fill('3');
|
||||
await runForResults(
|
||||
page,
|
||||
() =>
|
||||
advanced
|
||||
.getByRole('button', { name: 'Generate thumbnail series' })
|
||||
.click(),
|
||||
[
|
||||
'compatible-a-thumbnail-001-250ms.jpg',
|
||||
'compatible-a-thumbnail-002-500ms.jpg',
|
||||
'compatible-a-thumbnail-003-750ms.jpg',
|
||||
]
|
||||
);
|
||||
|
||||
await advanced
|
||||
.getByRole('spinbutton', { name: 'Frames', exact: true })
|
||||
.fill('4');
|
||||
await advanced
|
||||
.getByRole('spinbutton', { name: 'Rows', exact: true })
|
||||
.fill('2');
|
||||
await advanced
|
||||
.getByRole('spinbutton', { name: 'Columns', exact: true })
|
||||
.fill('2');
|
||||
await advanced
|
||||
.getByRole('spinbutton', { name: /^Cell width/u })
|
||||
.fill('128');
|
||||
const [sheetCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
advanced
|
||||
.getByRole('button', { name: 'Generate contact sheet' })
|
||||
.click(),
|
||||
['compatible-a-contact-sheet.jpg']
|
||||
);
|
||||
expect(
|
||||
statSync((await downloadResult(page, sheetCard as Locator)).filePath)
|
||||
.size
|
||||
).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
await test.step('single frame and a transformed timeline with audio/video fades', async () => {
|
||||
const inspector = page.locator('.inspector');
|
||||
await inspector.getByRole('tab', { name: 'Frames' }).click();
|
||||
const inspectorBody = inspector.locator('.inspector__body');
|
||||
await inspectorBody
|
||||
.getByRole('spinbutton', { name: 'Time (seconds)', exact: true })
|
||||
.fill('0.2');
|
||||
await inspectorBody
|
||||
.getByRole('spinbutton', { name: 'Width', exact: true })
|
||||
.fill('96');
|
||||
await inspectorBody
|
||||
.getByRole('combobox', { name: 'Format', exact: true })
|
||||
.selectOption('png');
|
||||
const [singleFrameCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
inspectorBody.getByRole('button', { name: 'Extract frame' }).click(),
|
||||
['compatible-a-thumbnail-200ms.png']
|
||||
);
|
||||
expect(
|
||||
statSync(
|
||||
(await downloadResult(page, singleFrameCard as Locator)).filePath
|
||||
).size
|
||||
).toBeGreaterThan(100);
|
||||
|
||||
await inspector.getByRole('tab', { name: 'Transform' }).click();
|
||||
const cropFields = inspector.locator('.crop-editor__numeric');
|
||||
await cropFields
|
||||
.getByRole('spinbutton', { name: 'X', exact: true })
|
||||
.fill('4');
|
||||
await cropFields
|
||||
.getByRole('spinbutton', { name: 'Y', exact: true })
|
||||
.fill('2');
|
||||
await cropFields
|
||||
.getByRole('spinbutton', { name: 'Width', exact: true })
|
||||
.fill('120');
|
||||
await cropFields
|
||||
.getByRole('spinbutton', { name: 'Height', exact: true })
|
||||
.fill('68');
|
||||
|
||||
const resizeFields = inspector.locator('.inspector-section > .mini-grid');
|
||||
await resizeFields
|
||||
.getByRole('spinbutton', { name: 'Width', exact: true })
|
||||
.fill('96');
|
||||
await resizeFields
|
||||
.getByRole('spinbutton', { name: 'Height', exact: true })
|
||||
.fill('54');
|
||||
await resizeFields
|
||||
.getByRole('combobox', { name: 'Rotate', exact: true })
|
||||
.selectOption('90');
|
||||
await resizeFields
|
||||
.getByRole('combobox', { name: 'Scale algorithm', exact: true })
|
||||
.selectOption('bicubic');
|
||||
await resizeFields
|
||||
.getByRole('textbox', { name: 'Padding colour', exact: true })
|
||||
.fill('#112233');
|
||||
await page
|
||||
.getByRole('spinbutton', { name: 'Output frame rate', exact: true })
|
||||
.fill('12');
|
||||
|
||||
const videoFades = inspector
|
||||
.locator('fieldset.subpanel')
|
||||
.filter({ hasText: 'Video fades' });
|
||||
await videoFades
|
||||
.getByRole('spinbutton', { name: 'Fade in from black (s)' })
|
||||
.fill('0.1');
|
||||
await videoFades
|
||||
.getByRole('spinbutton', { name: 'Fade out to black (s)' })
|
||||
.fill('0.1');
|
||||
|
||||
await inspector.getByRole('tab', { name: 'Audio' }).click();
|
||||
await expect(
|
||||
inspectorBody.getByRole('checkbox', { name: 'Include audio' })
|
||||
).toBeChecked();
|
||||
await inspectorBody
|
||||
.getByRole('spinbutton', { name: 'Fade in (s)', exact: true })
|
||||
.fill('0.1');
|
||||
await inspectorBody
|
||||
.getByRole('spinbutton', { name: 'Fade out (s)', exact: true })
|
||||
.fill('0.1');
|
||||
|
||||
const [timelineCard] = await runForResults(
|
||||
page,
|
||||
() => page.getByRole('button', { name: 'Render timeline' }).click(),
|
||||
['output.mp4'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
const timelineProbe = probe(
|
||||
(await downloadResult(page, timelineCard as Locator)).filePath
|
||||
);
|
||||
const timelineVideo = timelineProbe.streams?.find(
|
||||
(stream) => stream.codec_type === 'video'
|
||||
);
|
||||
expect(timelineVideo).toMatchObject({
|
||||
codec_name: 'h264',
|
||||
width: 96,
|
||||
height: 54,
|
||||
avg_frame_rate: '12/1',
|
||||
});
|
||||
expect(
|
||||
timelineProbe.streams?.some(
|
||||
(stream) =>
|
||||
stream.codec_type === 'audio' && stream.codec_name === 'aac'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
await test.step('metadata and chapters survive round-trip probing', async () => {
|
||||
await advanced.getByRole('tab', { name: 'Metadata policies' }).click();
|
||||
const metadataPanel = advanced.getByRole('tabpanel');
|
||||
await metadataPanel
|
||||
.getByRole('textbox', { name: 'Title', exact: true })
|
||||
.fill('Browser Matrix');
|
||||
await metadataPanel
|
||||
.getByRole('textbox', { name: 'Artist', exact: true })
|
||||
.fill('av-tools Playwright');
|
||||
const [metadataCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
advanced
|
||||
.getByRole('button', { name: 'Apply metadata policies' })
|
||||
.click(),
|
||||
['compatible-a-metadata.mp4'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
const metadataProbe = probe(
|
||||
(await downloadResult(page, metadataCard as Locator)).filePath
|
||||
);
|
||||
expect(metadataProbe.format?.tags).toMatchObject({
|
||||
title: 'Browser Matrix',
|
||||
artist: 'av-tools Playwright',
|
||||
});
|
||||
|
||||
await advanced.getByRole('tab', { name: 'Chapter execution' }).click();
|
||||
await advanced
|
||||
.getByLabel('Create output chapters from')
|
||||
.selectOption('fixed');
|
||||
await advanced.getByLabel('Chapter interval').fill('0.5');
|
||||
const [chapterCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
advanced
|
||||
.getByRole('button', { name: 'Replace output chapters' })
|
||||
.click(),
|
||||
['compatible-a-chapters.mp4'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
const chapterProbe = probe(
|
||||
(await downloadResult(page, chapterCard as Locator)).filePath
|
||||
);
|
||||
expect(chapterProbe.chapters).toHaveLength(2);
|
||||
expect(
|
||||
chapterProbe.chapters?.map((chapter) => chapter.tags?.title)
|
||||
).toEqual(['Chapter 1', 'Chapter 2']);
|
||||
});
|
||||
|
||||
await test.step('subtitle convert, mux, burn-in and extraction', async () => {
|
||||
await advanced.getByRole('tab', { name: 'Subtitle operations' }).click();
|
||||
const subtitlePanel = advanced.getByRole('tabpanel');
|
||||
const subtitleOperation = subtitlePanel
|
||||
.locator('label.advanced-field')
|
||||
.filter({ hasText: /^Operation/u })
|
||||
.locator('select');
|
||||
const outputTextFormat = subtitlePanel
|
||||
.locator('label.advanced-field')
|
||||
.filter({ hasText: /^Output text format/u })
|
||||
.locator('select');
|
||||
await subtitleOperation.selectOption('convert');
|
||||
const localSubtitleInput = subtitlePanel.locator(
|
||||
'label.advanced-file-field input[type="file"]'
|
||||
);
|
||||
await markExistingResults(page);
|
||||
await localSubtitleInput.setInputFiles({
|
||||
name: 'invalid-subtitle.srt',
|
||||
mimeType: 'application/x-subrip',
|
||||
buffer: Buffer.from('this is not a timed subtitle\n'),
|
||||
});
|
||||
await subtitlePanel
|
||||
.getByRole('button', { name: 'Convert subtitle' })
|
||||
.click();
|
||||
await expect(
|
||||
advanced.locator('.advanced-operations__status')
|
||||
).toContainText(/FFmpeg (?:exited|produced)/u, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await expect(newResultCards(page)).toHaveCount(0);
|
||||
await expect(
|
||||
page
|
||||
.locator('.engine-pill')
|
||||
.filter({ hasText: 'Single-threaded compatibility mode' })
|
||||
).toBeVisible({ timeout: 120_000 });
|
||||
|
||||
await localSubtitleInput.setInputFiles(fixture('subtitle.srt'));
|
||||
await outputTextFormat.selectOption('vtt');
|
||||
await subtitlePanel.getByLabel('Time offset').fill('0.1');
|
||||
const [convertedCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
subtitlePanel
|
||||
.getByRole('button', { name: 'Convert subtitle' })
|
||||
.click(),
|
||||
['subtitle-subtitle.vtt']
|
||||
);
|
||||
const converted = await downloadResult(page, convertedCard as Locator);
|
||||
expect(readFileSync(converted.filePath, 'utf8')).toContain('WEBVTT');
|
||||
|
||||
await subtitleOperation.selectOption('soft-mux');
|
||||
await subtitlePanel
|
||||
.locator('label.advanced-field')
|
||||
.filter({ hasText: /^Target container/u })
|
||||
.locator('select')
|
||||
.selectOption('matroska');
|
||||
await subtitlePanel
|
||||
.getByRole('textbox', { name: 'Language', exact: true })
|
||||
.fill('eng');
|
||||
await subtitlePanel
|
||||
.getByRole('textbox', { name: 'Track title', exact: true })
|
||||
.fill('Browser subtitle');
|
||||
await subtitlePanel
|
||||
.getByRole('checkbox', { name: 'Default track' })
|
||||
.check();
|
||||
const [muxedCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
subtitlePanel
|
||||
.getByRole('button', { name: 'Mux soft subtitle' })
|
||||
.click(),
|
||||
['compatible-a-subtitled.mkv'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
const muxed = await downloadResult(page, muxedCard as Locator);
|
||||
expect(probe(muxed.filePath).streams).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
codec_name: 'subrip',
|
||||
codec_type: 'subtitle',
|
||||
tags: expect.objectContaining({
|
||||
language: 'eng',
|
||||
title: 'Browser subtitle',
|
||||
}),
|
||||
disposition: expect.objectContaining({ default: 1 }),
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
await subtitleOperation.selectOption('burn-in');
|
||||
await subtitlePanel
|
||||
.locator('label.advanced-field')
|
||||
.filter({ hasText: /^Video export preset/u })
|
||||
.locator('select')
|
||||
.selectOption('mp4-h264-compatibility');
|
||||
const [burnedCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
subtitlePanel
|
||||
.getByRole('button', { name: 'Burn subtitle into video' })
|
||||
.click(),
|
||||
['compatible-a-subtitle-burn.mp4'],
|
||||
{ verifyMedia: true }
|
||||
);
|
||||
expect(
|
||||
probe((await downloadResult(page, burnedCard as Locator)).filePath)
|
||||
.streams
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ codec_name: 'h264', codec_type: 'video' }),
|
||||
])
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'New', exact: true }).click();
|
||||
await mediaInput(page).setInputFiles({
|
||||
name: 'subtitled-source.mkv',
|
||||
mimeType: 'video/x-matroska',
|
||||
buffer: readFileSync(muxed.filePath),
|
||||
});
|
||||
await expect(
|
||||
page
|
||||
.locator('.media-card')
|
||||
.filter({ hasText: 'subtitled-source.mkv' })
|
||||
.locator('.phase--ready')
|
||||
).toBeVisible({ timeout: 120_000 });
|
||||
await subtitleOperation.selectOption('extract');
|
||||
await outputTextFormat.selectOption('srt');
|
||||
const [extractedCard] = await runForResults(
|
||||
page,
|
||||
() =>
|
||||
subtitlePanel
|
||||
.getByRole('button', { name: 'Extract subtitle' })
|
||||
.click(),
|
||||
['subtitled-source-subtitle-eng.srt']
|
||||
);
|
||||
const extracted = await downloadResult(page, extractedCard as Locator);
|
||||
expect(readFileSync(extracted.filePath, 'utf8')).toContain(
|
||||
'Generated subtitle'
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
runtime.requests.some((url) =>
|
||||
url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm')
|
||||
)
|
||||
).toBe(true);
|
||||
assertLocalOnly(runtime);
|
||||
});
|
||||
});
|
||||
242
tests/browser/performance.spec.ts
Normal file
242
tests/browser/performance.spec.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/// <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 expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'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;
|
||||
}
|
||||
185
tests/browser/ui-workflows.spec.ts
Normal file
185
tests/browser/ui-workflows.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { GENERATED_FIXTURES, PRIMARY_FIXTURE } from './browser-helpers';
|
||||
|
||||
test('covers drop, editor keyboard/drag controls, cache, and project reattachment', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'canPlayType', {
|
||||
configurable: true,
|
||||
value: () => '',
|
||||
});
|
||||
Reflect.deleteProperty(globalThis, 'showSaveFilePicker');
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.getByText('Engine & browser').click();
|
||||
await page.getByLabel('Engine mode').selectOption('force-single-thread');
|
||||
|
||||
await dropFixture(page, PRIMARY_FIXTURE, 'video/mp4');
|
||||
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
|
||||
'Ready',
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
await expect(page.locator('.quick-preview video')).toHaveAttribute(
|
||||
'src',
|
||||
/^blob:/u
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Create proxy' }).click();
|
||||
await expect(
|
||||
page.locator('.result-card').filter({ hasText: 'pattern-av-preview.mp4' })
|
||||
).toContainText(/created · verified|completed locally/u, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await page.getByText('Derived cache').click();
|
||||
const clearCache = page.getByRole('button', { name: 'Clear cache' });
|
||||
await expect(clearCache).toBeEnabled({ timeout: 10_000 });
|
||||
await clearCache.click();
|
||||
await expect(page.getByRole('status')).toContainText(
|
||||
'Derived cache cleared.'
|
||||
);
|
||||
|
||||
await page.getByRole('tab', { name: 'Edit' }).click();
|
||||
const compatibleB = join(GENERATED_FIXTURES, 'compatible-b.mp4');
|
||||
await mediaInput(page).setInputFiles(compatibleB);
|
||||
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
const clips = page.locator('.timeline-clip');
|
||||
await expect(clips).toHaveCount(2);
|
||||
const secondName = await clips.nth(1).locator('strong').textContent();
|
||||
await clips.nth(1).dragTo(clips.nth(0));
|
||||
await expect(clips.nth(0).locator('strong')).toHaveText(
|
||||
secondName ?? 'compatible-b.mp4'
|
||||
);
|
||||
|
||||
await clips
|
||||
.filter({ hasText: 'compatible-b.mp4' })
|
||||
.locator('.timeline-clip__main')
|
||||
.click();
|
||||
const inspector = page.locator('.inspector');
|
||||
await inspector.getByRole('tab', { name: 'Transform' }).click();
|
||||
const crop = inspector.locator('.crop-editor__numeric');
|
||||
await crop.getByRole('spinbutton', { name: 'Width' }).fill('100');
|
||||
await crop.getByRole('spinbutton', { name: 'Height' }).fill('60');
|
||||
const cropMove = inspector.getByRole('button', {
|
||||
name: 'Move crop rectangle',
|
||||
});
|
||||
await cropMove.focus();
|
||||
await page.keyboard.press('Shift+ArrowRight');
|
||||
await expect(crop.getByRole('spinbutton', { name: 'X' })).toHaveValue('10');
|
||||
|
||||
const resizeRight = inspector.getByRole('button', {
|
||||
name: 'Resize crop from right',
|
||||
});
|
||||
const handleBox = await resizeRight.boundingBox();
|
||||
expect(handleBox).not.toBeNull();
|
||||
const widthBefore = Number(
|
||||
await crop.getByRole('spinbutton', { name: 'Width' }).inputValue()
|
||||
);
|
||||
const handleCenterX = (handleBox?.x ?? 0) + (handleBox?.width ?? 0) / 2;
|
||||
const handleCenterY = (handleBox?.y ?? 0) + (handleBox?.height ?? 0) / 2;
|
||||
await page.mouse.move(handleCenterX, handleCenterY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(handleCenterX + 16, handleCenterY);
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
Number(await crop.getByRole('spinbutton', { name: 'Width' }).inputValue())
|
||||
)
|
||||
.not.toBe(widthBefore);
|
||||
|
||||
await crop.getByRole('spinbutton', { name: 'X' }).fill('999');
|
||||
await expect(inspector.locator('.crop-editor__errors')).toHaveAttribute(
|
||||
'role',
|
||||
'alert'
|
||||
);
|
||||
await inspector.getByRole('button', { name: 'Reset crop' }).click();
|
||||
|
||||
const advanced = page.locator('.advanced-operations');
|
||||
await advanced.getByRole('tab', { name: 'Waveform and loudness' }).click();
|
||||
await advanced.getByRole('button', { name: 'Analyze peak waveform' }).click();
|
||||
const inHandle = page.getByRole('slider', { name: 'In trim handle' });
|
||||
await expect(inHandle).toBeVisible({ timeout: 120_000 });
|
||||
const inBefore = Number(await inHandle.getAttribute('aria-valuenow'));
|
||||
await inHandle.focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect
|
||||
.poll(async () => Number(await inHandle.getAttribute('aria-valuenow')))
|
||||
.toBeGreaterThan(inBefore);
|
||||
|
||||
const projectDownload = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Save project' }).click();
|
||||
const projectPath = await (await projectDownload).path();
|
||||
expect(projectPath).not.toBeNull();
|
||||
await page.getByRole('button', { name: 'New', exact: true }).click();
|
||||
await page
|
||||
.locator('.project-actions input[type="file"]')
|
||||
.setInputFiles(projectPath as string);
|
||||
await expect(page.locator('.timeline-clip')).toHaveCount(2);
|
||||
await expect(page.locator('.media-card')).toHaveCount(0);
|
||||
|
||||
page.on('dialog', (dialog) => void dialog.accept());
|
||||
await mediaInput(page).setInputFiles([PRIMARY_FIXTURE, compatibleB]);
|
||||
await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await expect(page.locator('.timeline-clip')).toHaveCount(2);
|
||||
});
|
||||
|
||||
function mediaInput(page: import('@playwright/test').Page) {
|
||||
return page.locator('input[type="file"][accept*="audio"]').first();
|
||||
}
|
||||
|
||||
async function dropFixture(
|
||||
page: import('@playwright/test').Page,
|
||||
filePath: string,
|
||||
mimeType: string
|
||||
): Promise<void> {
|
||||
const bytes = readFileSync(filePath).toString('base64');
|
||||
const stats = statSync(filePath);
|
||||
await page
|
||||
.locator('.drop-zone')
|
||||
.first()
|
||||
.evaluate(
|
||||
(element, fixture) => {
|
||||
const binary = atob(fixture.base64);
|
||||
const data = Uint8Array.from(binary, (character) =>
|
||||
character.charCodeAt(0)
|
||||
);
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(
|
||||
new File([data], fixture.name, {
|
||||
type: fixture.mimeType,
|
||||
lastModified: fixture.lastModified,
|
||||
})
|
||||
);
|
||||
element.dispatchEvent(
|
||||
new DragEvent('dragenter', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
dataTransfer: transfer,
|
||||
})
|
||||
);
|
||||
element.dispatchEvent(
|
||||
new DragEvent('drop', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
dataTransfer: transfer,
|
||||
})
|
||||
);
|
||||
},
|
||||
{
|
||||
base64: bytes,
|
||||
name: filePath.split('/').at(-1) ?? 'fixture.mp4',
|
||||
mimeType,
|
||||
lastModified: Math.trunc(stats.mtimeMs),
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user