Files
wehub-resource-sync 1b8708893a
Security Scan / tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:12:26 +08:00

238 lines
9.9 KiB
JavaScript

import { test, expect } from './coverage-fixtures.js'
function mockCapabilities(page, capabilities) {
return page.route('**/api/models/capabilities', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ data: capabilities }),
})
})
}
// Returns a (Promise, resolver) pair that records the multipart form fields
// the page submitted to /audio/transformations. The handler returns a tiny
// fake WAV blob so the page can render its result waveforms.
function mockAudioTransform(page, filename = 'transformed.wav') {
let resolveSubmit
const submitted = new Promise((resolve) => { resolveSubmit = resolve })
page.route('**/audio/transformations', (route) => {
if (route.request().method() !== 'POST') return route.continue()
const req = route.request()
const body = req.postData() || ''
resolveSubmit({
contentType: req.headers()['content-type'] || '',
bodySize: body.length,
// Naive multipart field name extraction so a test can assert the
// form-data shape without parsing the multipart body.
fields: Array.from(body.matchAll(/name="([^"]+)"/g)).map((m) => m[1]),
})
const wavHeader = new Uint8Array(44) // 44-byte RIFF/WAVE skeleton
route.fulfill({
status: 200,
headers: {
'Content-Type': 'audio/wav',
'Content-Disposition': `attachment; filename="${filename}"`,
},
body: Buffer.from(wavHeader),
})
})
return submitted
}
// Build a tiny in-memory WAV file (44-byte header, 4 silent samples) so
// Playwright's setInputFiles + the page's audio decoder both have valid
// bytes to chew on. Returns { name, mimeType, buffer } for setInputFiles.
function makeFakeWav(name) {
const sampleRate = 16000
const samples = 4
const dataLen = samples * 2
const buf = Buffer.alloc(44 + dataLen)
buf.write('RIFF', 0)
buf.writeUInt32LE(36 + dataLen, 4)
buf.write('WAVE', 8)
buf.write('fmt ', 12)
buf.writeUInt32LE(16, 16) // PCM chunk size
buf.writeUInt16LE(1, 20) // PCM format
buf.writeUInt16LE(1, 22) // channels = 1
buf.writeUInt32LE(sampleRate, 24) // sample rate
buf.writeUInt32LE(sampleRate * 2, 28)// byte rate
buf.writeUInt16LE(2, 32) // block align
buf.writeUInt16LE(16, 34) // bits per sample
buf.write('data', 36)
buf.writeUInt32LE(dataLen, 40)
// body left as zeros (silence)
return { name, mimeType: 'audio/wav', buffer: buf }
}
// Build a WAV carrying a real sine tone, long enough that the spectrogram
// STFT produces several frames (a few thousand samples). Used to exercise the
// FFT / heatmap path, which the 4-sample silent fixture can't.
function makeToneWav(name, freq = 1000, seconds = 0.4, sampleRate = 16000) {
const samples = Math.floor(seconds * sampleRate)
const dataLen = samples * 2
const buf = Buffer.alloc(44 + dataLen)
buf.write('RIFF', 0)
buf.writeUInt32LE(36 + dataLen, 4)
buf.write('WAVE', 8)
buf.write('fmt ', 12)
buf.writeUInt32LE(16, 16)
buf.writeUInt16LE(1, 20)
buf.writeUInt16LE(1, 22)
buf.writeUInt32LE(sampleRate, 24)
buf.writeUInt32LE(sampleRate * 2, 28)
buf.writeUInt16LE(2, 32)
buf.writeUInt16LE(16, 34)
buf.write('data', 36)
buf.writeUInt32LE(dataLen, 40)
for (let i = 0; i < samples; i++) {
const v = Math.round(Math.sin((2 * Math.PI * freq * i) / sampleRate) * 16000)
buf.writeInt16LE(v, 44 + i * 2)
}
return { name, mimeType: 'audio/wav', buffer: buf }
}
test.describe('Audio Transform', () => {
test.beforeEach(async ({ page }) => {
await mockCapabilities(page, [
{ id: 'localvqe', capabilities: ['FLAG_AUDIO_TRANSFORM'] },
])
})
test('audio input has Upload + Record tabs', async ({ page }) => {
await page.goto('/app/transform')
await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 })
// The Audio (required) input should expose both tabs.
const tabs = page.getByRole('tab')
await expect(tabs.filter({ hasText: 'Upload' }).first()).toBeVisible()
await expect(tabs.filter({ hasText: 'Record' }).first()).toBeVisible()
})
test('echo-test button only appears once a reference is loaded', async ({ page }) => {
await page.goto('/app/transform')
await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 })
// No reference yet → echo button hidden.
await expect(page.getByRole('button', { name: /Echo test/ })).toHaveCount(0)
// Upload a reference into the second AudioInput's file picker.
await page.locator('input[type="file"]').nth(1).setInputFiles(makeFakeWav('ref.wav'))
await expect(page.getByRole('button', { name: /Echo test/ })).toBeVisible()
})
test('renders the AudioTransform page directly', async ({ page }) => {
await page.goto('/app/transform')
await expect(page.getByRole('heading', { name: /Audio Transform/ })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible()
// Audio (required) + Reference (optional) drop zones
await expect(page.getByText(/Audio \(required\)/)).toBeVisible()
await expect(page.getByText(/Reference \(optional\)/)).toBeVisible()
})
test('uploads an audio file, posts multipart, renders enhanced waveform', async ({ page }) => {
const submitted = mockAudioTransform(page, 'enhanced.wav')
await page.goto('/app/transform')
await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 })
// Upload mic file via the hidden file input under "Audio (required)".
const audioInput = page.locator('input[type="file"]').first()
await audioInput.setInputFiles(makeFakeWav('mic.wav'))
await expect(page.getByText('mic.wav')).toBeVisible()
// Set a backend tuning param so the form posts params[noise_gate]=true.
await page.locator('.textarea').fill('noise_gate=true')
await page.getByRole('button', { name: /Transform/ }).last().click()
const form = await submitted
expect(form.contentType).toContain('multipart/form-data')
expect(form.fields).toContain('model')
expect(form.fields).toContain('audio')
expect(form.fields).toContain('params[noise_gate]')
// After processing, the output WaveformPlayer mounts with a download button.
await expect(page.getByRole('link', { name: /Download/ })).toBeVisible({ timeout: 10_000 })
})
test('reference file is forwarded as a multipart field when provided', async ({ page }) => {
const submitted = mockAudioTransform(page)
await page.goto('/app/transform')
await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 })
const inputs = page.locator('input[type="file"]')
await inputs.nth(0).setInputFiles(makeFakeWav('mic.wav'))
// After the audio file is set, that AudioInput collapses to a filename +
// Clear button and removes its <input>. The reference AudioInput, which
// was at nth(1), is now the sole remaining input — query afresh.
await inputs.first().setInputFiles(makeFakeWav('loopback.wav'))
await page.getByRole('button', { name: /Transform/ }).last().click()
const form = await submitted
expect(form.fields).toContain('audio')
expect(form.fields).toContain('reference')
})
test('history entry appears after a successful transform and persists across navigation', async ({ page }) => {
mockAudioTransform(page, 'enhanced.wav')
await page.goto('/app/transform')
await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 })
await page.locator('input[type="file"]').first().setInputFiles(makeFakeWav('mic.wav'))
await page.getByRole('button', { name: /Transform/ }).last().click()
await expect(page.getByRole('link', { name: /Download/ })).toBeVisible({ timeout: 10_000 })
await expect(page.getByTestId('media-history-item')).toHaveCount(1)
await expect(page.getByTestId('media-history-item')).toContainText('mic.wav')
// Persist across page reloads via localStorage.
await page.waitForTimeout(600)
await page.goto('/app/transform')
await expect(page.getByTestId('media-history-item')).toHaveCount(1)
})
test('renders an input spectrogram on upload and an output one after transform', async ({ page }) => {
mockAudioTransform(page, 'enhanced.wav')
await page.goto('/app/transform')
await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 })
// Choosing a clip should render its input spectrogram immediately — no
// backend round-trip needed (it's computed client-side from the bytes).
await page.locator('input[type="file"]').first().setInputFiles(makeToneWav('tone.wav'))
await expect(page.getByTestId('spectrogram-input')).toBeVisible({ timeout: 10_000 })
// Until a transform runs the output side shows a "compare" placeholder.
await expect(page.getByText(/Transform to compare/)).toBeVisible()
await page.getByRole('button', { name: /Transform/ }).last().click()
// After processing, the output spectrum panel appears alongside the input.
await expect(page.getByText('Output spectrum')).toBeVisible({ timeout: 10_000 })
})
test('shows an error banner when the backend returns 4xx', async ({ page }) => {
await page.route('**/audio/transformations', (route) => {
if (route.request().method() !== 'POST') return route.continue()
route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ error: { message: 'audio sample rate 44100 != model 16000' } }),
})
})
await page.goto('/app/transform')
await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 })
await page.locator('input[type="file"]').first().setInputFiles(makeFakeWav('mic.wav'))
await page.getByRole('button', { name: /Transform/ }).last().click()
await expect(page.getByText(/sample rate/)).toBeVisible({ timeout: 10_000 })
})
})