Files
av-tools/tests/browser/ui-workflows.spec.ts

198 lines
6.9 KiB
TypeScript

/// <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 page
.getByRole('button', { name: 'Inspect details for pattern-av.mp4' })
.click();
await expect(page.locator('.source-summary .phase--ready')).toHaveText(
'Details 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 page
.getByRole('button', { name: 'Inspect details for compatible-b.mp4' })
.click();
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 page
.getByRole('button', { name: 'Inspect details for pattern-av.mp4' })
.click();
await page
.getByRole('button', { name: 'Inspect details for compatible-b.mp4' })
.click();
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),
}
);
}