Files
ArtPlayer/test/browser/asr-capture-cors.spec.js

206 lines
12 KiB
JavaScript

import fs from 'node:fs'
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { browserCandidate } from '../helpers/browser-candidate.js'
import { expect, test } from './fixtures.js'
let provenance
let code
test.beforeAll(async () => {
const candidate = await browserCandidate('artplayer-plugin-asr', process.env.ARTPLAYER_ASR_ARTIFACT)
code = candidate.code
provenance = candidate.provenance
})
test.afterEach(async ({ page }, testInfo) => {
const observation = await page.evaluate(() => window.captureCorsObservation?.()).catch(error => ({ error: error.message }))
await testInfo.attach('capture-cors-final-observation', { contentType: 'application/json', body: JSON.stringify(observation ?? null) })
await page.evaluate(() => {
if (window.art && !window.art.isDestroy)
window.art.destroy()
})
})
for (const core of ['published', 'candidate']) {
test(`ASR explicit capture + ${core}: opaque media keeps native routing and same-origin capture recovers`, async ({ page }, testInfo) => {
const responses = []
const consoleErrors = []
page.on('response', (response) => {
if (new URL(response.url()).pathname === '/test/audio-tone.m4a')
responses.push({ url: response.url(), status: response.status(), allowOrigin: response.headers()['access-control-allow-origin'] || null })
})
page.on('console', (message) => {
if (message.type() === 'error')
consoleErrors.push(message.text())
})
await page.goto(`/test/player.html?core=${core}`)
const capabilities = await page.evaluate(() => ({ context: typeof (window.AudioContext || window.webkitAudioContext), worklet: typeof window.AudioWorkletNode, capture: typeof HTMLMediaElement.prototype.captureStream, mozCapture: typeof HTMLMediaElement.prototype.mozCaptureStream }))
await testInfo.attach('capture-cors-inputs', { contentType: 'application/json', body: JSON.stringify({ core, capabilities, provenance, pluginSha256: hash(code), mediaSha256: hash(fs.readFileSync(new URL('./media/audio-tone.m4a', import.meta.url))), nativeCapture: true, forcedException: false, analyserBinding: false, physicalAudioOutput: false, scope: 'Zero direct bindings and native media clock progression preserve routing; no claim about physical speaker output' }) })
if (capabilities.context === 'undefined') {
expect(process.platform).toBe('win32')
expect(testInfo.project.name).toBe('webkit')
expect(capabilities.worklet).toBe('undefined')
test.skip(true, 'Windows WebKit lacks AudioContext/AudioWorklet; this is not Safari/device CORS acceptance')
}
expect(capabilities.worklet).toBe('function')
expect([capabilities.capture, capabilities.mozCapture]).toContain('function')
await page.addScriptTag({ content: code })
await page.evaluate(() => {
const NativeContext = window.AudioContext || window.webkitAudioContext
window.captureContexts = []
window.captureStreams = []
window.captureTracks = []
window.captureAttempts = []
window.directBindings = 0
window.captureChunks = []
window.AudioContext = class extends NativeContext {
constructor(options) {
super(options)
window.captureContexts.push(this)
}
createMediaElementSource(element) {
window.directBindings++
return super.createMediaElementSource(element)
}
}
const prototype = HTMLMediaElement.prototype
const method = prototype.captureStream ? 'captureStream' : 'mozCaptureStream'
const nativeCapture = prototype[method]
prototype[method] = function (...args) {
const attempt = { source: this.currentSrc, error: null }
window.captureAttempts.push(attempt)
try {
const stream = nativeCapture.apply(this, args)
window.captureStreams.push(stream)
const recordTrack = (track) => {
if (!window.captureTracks.includes(track))
window.captureTracks.push(track)
}
stream.getTracks().forEach(recordTrack)
stream.addEventListener('addtrack', event => recordTrack(event.track))
return stream
}
catch (error) {
attempt.error = { name: error.name, message: error.message }
throw error
}
}
const opaque = new URL('/test/audio-tone.m4a?capture-cors=opaque', location.href)
opaque.hostname = 'localhost'
window.art = new window.Artplayer({
container: '.player',
url: opaque.href,
volume: 0.6,
muted: false,
loop: true,
plugins: [window.artplayerPluginAsr({
audioInput: { type: 'capture' },
onAudioChunk({ pcm, wav }) {
let peak = 0
for (const sample of new Int16Array(pcm))
peak = Math.max(peak, Math.abs(sample))
window.captureChunks.push({ source: window.art.video.currentSrc, peak, pcmBytes: pcm.byteLength, wavBytes: wav.byteLength })
},
})],
})
window.captureVideoProperties = () => {
const video = window.art.video
return {
src: video.getAttribute('src'),
crossOrigin: video.crossOrigin,
crossOriginAttribute: video.getAttribute('crossorigin'),
muted: video.muted,
defaultMuted: video.defaultMuted,
volume: video.volume,
playbackRate: video.playbackRate,
autoplay: video.autoplay,
controls: video.controls,
loop: video.loop,
preload: video.preload,
playsInline: video.playsInline,
}
}
window.captureCorsObservation = () => ({
attempts: window.captureAttempts,
directBindings: window.directBindings,
contexts: window.captureContexts.map(context => context.state),
streams: window.captureStreams.map(stream => ({ active: stream.active, tracks: stream.getTracks().map(track => ({ kind: track.kind, muted: track.muted, enabled: track.enabled, readyState: track.readyState })) })),
retainedTracks: window.captureTracks.map(track => ({ kind: track.kind, muted: track.muted, enabled: track.enabled, readyState: track.readyState })),
chunks: window.captureChunks,
})
document.querySelector('#play').onclick = () => window.art.play()
document.querySelector('#pause').onclick = () => window.art.pause()
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
const initialProperties = await page.evaluate(() => window.captureVideoProperties())
expect(initialProperties.crossOrigin).toBeNull()
expect(initialProperties.crossOriginAttribute).toBeNull()
expect(initialProperties.muted).toBe(false)
expect(new URL(initialProperties.src, page.url()).origin).not.toBe(new URL(page.url()).origin)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.captureAttempts.length)).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(1)
const opaqueStart = await page.evaluate(() => window.art.currentTime)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(opaqueStart + 0.5)
const opaque = await page.evaluate(() => window.captureCorsObservation())
expect(opaque.directBindings).toBe(0)
expect(opaque.chunks.every(chunk => chunk.peak === 0)).toBe(true)
expect(await page.evaluate(() => window.art.video.error)).toBeNull()
expect(await page.evaluate(() => window.art.video.paused)).toBe(false)
expect(await page.evaluate(() => window.captureVideoProperties())).toEqual(initialProperties)
expect(responses.some(response => new URL(response.url).hostname === 'localhost' && [200, 206].includes(response.status))).toBe(true)
expect(responses.every(response => response.allowOrigin === null)).toBe(true)
expect(opaque.chunks).toEqual([])
expect(opaque.attempts).toHaveLength(1)
if (testInfo.project.name === 'chromium') {
expect(opaque.attempts[0].error.name).toBe('SecurityError')
expect(opaque.contexts).toEqual(['closed'])
expect(opaque.streams).toEqual([])
expect(consoleErrors).toHaveLength(1)
expect(consoleErrors[0]).toContain('[artplayerPluginAsr] Initialization failed: SecurityError:')
}
else {
expect(testInfo.project.name).toBe('firefox')
expect(opaque.attempts[0].error).toBeNull()
expect(opaque.contexts).toEqual(['running'])
expect(opaque.streams).toHaveLength(1)
expect(opaque.retainedTracks).toHaveLength(1)
expect(opaque.retainedTracks[0].kind).toBe('audio')
expect(opaque.retainedTracks[0].readyState).toBe('live')
expect(consoleErrors).toEqual([])
}
await testInfo.attach('opaque-native-capture-observation', { contentType: 'application/json', body: JSON.stringify({ opaque, responses: [...responses], consoleErrors: [...consoleErrors], properties: initialProperties, clockAdvanced: true }) })
await page.evaluate(() => {
window.art.url = '/test/audio-tone.m4a?capture-cors=recovery'
})
await expect.poll(() => page.evaluate(() => window.art.video.currentSrc.includes('capture-cors=recovery') && window.art.video.readyState >= 2)).toBe(true)
const recoveryProperties = await page.evaluate(() => window.captureVideoProperties())
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.captureChunks.filter(chunk => chunk.source.includes('capture-cors=recovery') && chunk.peak > 0).length)).toBeGreaterThanOrEqual(3)
expect(await page.evaluate(() => window.directBindings)).toBe(0)
expect(await page.evaluate(() => window.captureVideoProperties())).toEqual(recoveryProperties)
expect(await page.evaluate(() => window.captureChunks.filter(chunk => chunk.source.includes('capture-cors=opaque')).every(chunk => chunk.peak === 0))).toBe(true)
expect(await page.evaluate(() => window.captureAttempts.length)).toBeGreaterThanOrEqual(2)
await page.evaluate(() => window.art.plugins.artplayerPluginAsr.stop())
await expect.poll(() => page.evaluate(() => window.captureContexts.every(context => context.state === 'closed'))).toBe(true)
expect(await page.evaluate(() => window.captureTracks.every(track => track.readyState === 'ended'))).toBe(true)
const stopped = await page.evaluate(() => ({ chunks: window.captureChunks.length, time: window.art.currentTime }))
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(stopped.time + 0.4)
expect(await page.evaluate(() => window.captureChunks.length)).toBe(stopped.chunks)
expect(await page.evaluate(() => window.art.video.paused)).toBe(false)
expect(await page.evaluate(() => window.captureVideoProperties())).toEqual(recoveryProperties)
await page.locator('#pause').click()
await page.locator('#play').click()
await expect.poll(() => page.evaluate(start => window.captureChunks.slice(start).filter(chunk => chunk.peak > 0).length, stopped.chunks)).toBeGreaterThanOrEqual(3)
expect(await page.evaluate(() => window.directBindings)).toBe(0)
await testInfo.attach('same-origin-capture-recovery', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => window.captureCorsObservation())) })
await page.evaluate(() => window.art.destroy())
await expect.poll(() => page.evaluate(() => window.captureContexts.every(context => context.state === 'closed'))).toBe(true)
expect(await page.evaluate(() => window.captureTracks.every(track => track.readyState === 'ended'))).toBe(true)
expect(await page.evaluate(() => window.directBindings)).toBe(0)
await expect(page.locator('.art-video-player')).toHaveCount(0)
})
}