mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-24 14:41:20 -08:00
fix(audio): [PKG-AUDIO-05] checkpoint zero-time resume and native buffering evidence
This commit is contained in:
1 parent
0218196df9
commit
d18c545675
25 files changed
+14373
-32
No files matched your search
@@ -146,6 +146,44 @@ for (const { name, factory } of implementations) {
|
||||
|
||||
const historical = implementations.find(item => item.name === 'published-main').factory
|
||||
for (const { name, factory: candidate } of implementations.filter(item => !item.name.startsWith('published'))) {
|
||||
test(`${name}: native playback at zero resumes after seek without overriding proxy playing`, (t) => {
|
||||
const { art } = audioHost(t)
|
||||
Object.assign(art.video, { currentTime: 0, paused: false, ended: false, readyState: 4 })
|
||||
const { audio } = candidate({ url: 'audio.aac' })(art)
|
||||
audio.calls.length = 0
|
||||
art.emit('video:seeking').emit('video:seeked')
|
||||
assert.deepEqual(audio.calls, [['pause'], ['play']])
|
||||
art.video.playing = false
|
||||
audio.calls.length = 0
|
||||
art.emit('video:seeked').emit('video:playing').emit('video:canplay')
|
||||
assert.deepEqual(audio.calls, [], 'A proxy explicit false is authoritative')
|
||||
art.video.playing = true
|
||||
art.emit('video:seeked')
|
||||
assert.deepEqual(audio.calls, [['play']])
|
||||
})
|
||||
|
||||
test(`${name}: canplay recovers a paused audio after seeked preceded host readiness`, (t) => {
|
||||
const { art } = audioHost(t)
|
||||
const { audio } = candidate({ url: 'audio.aac' })(art)
|
||||
art.currentTime = 3
|
||||
art.emit('video:seeking').emit('video:seeked')
|
||||
assert.equal(audio.currentTime, 3)
|
||||
audio.calls.length = 0
|
||||
art.emit('video:canplay')
|
||||
assert.deepEqual(audio.calls, [], 'A paused host must not resume audio')
|
||||
art.playing = true
|
||||
art.emit('video:canplay')
|
||||
assert.deepEqual(audio.calls, [['play']])
|
||||
audio.paused = false
|
||||
audio.calls.length = 0
|
||||
art.emit('video:canplay')
|
||||
assert.deepEqual(audio.calls, [], 'Already playing audio needs no additional play request')
|
||||
art.emit('destroy')
|
||||
audio.calls.length = 0
|
||||
art.emit('video:canplay')
|
||||
assert.deepEqual(audio.calls, [])
|
||||
})
|
||||
|
||||
test(`${name}: closed callbacks and retained update cannot revive resources`, (t) => {
|
||||
const { art, listeners } = audioHost(t)
|
||||
const plugin = candidate({ url: 'audio.aac' })(art)
|
||||
|
||||
@@ -97,3 +97,25 @@ The docs `assets/sample/video.mp4` remains an existing sample with its original
|
||||
tracked by BASE-MEDIA-01. The new synthetic fixtures do not resolve rights for existing media.
|
||||
Browser binaries/codec support vary by OS; these projects do not replace Safari/iOS/Android
|
||||
device checks. See the [official browser guidance](https://playwright.dev/docs/browsers).
|
||||
|
||||
## Withheld media diagnostics
|
||||
|
||||
`test/helpers/media-gate.js` binds a temporary HTTP server to loopback and withholds
|
||||
the tail of the pinned media until the test calls release(). Its default response
|
||||
keeps the full Content-Length and streams the prefix; the diagnostic completeRanges
|
||||
mode sends a bounded 206 response and holds the later range. Both preserve exact
|
||||
bytes and a strong ETag. Partial range semantics follow
|
||||
[RFC 9110 section 15.3.7](https://www.rfc-editor.org/rfc/rfc9110.html#name-partial-content).
|
||||
`node --test test/media-gate.test.js` verifies actual HTTP bytes, headers and release.
|
||||
|
||||
`audio-buffering.spec.js` requires trusted waiting after actual clock progression,
|
||||
then release, resumed clocks and synchronization across old/new core/plugin pairs.
|
||||
It currently has unresolved Windows WebKit failures; do not skip them or count
|
||||
the native diagnostic below as equivalent acceptance. See refactor/audio-validation.md.
|
||||
|
||||
`media-gate-native.spec.js` removes all ArtPlayer instances and uses a native element
|
||||
with preload=auto. It records whether playback and starvation were observed for
|
||||
both response modes. A passing diagnostic only proves observation plus successful
|
||||
playback after release; inspect its progressed/waiting flags before claiming buffering.
|
||||
These capability observation timeouts do not relax the integration assertions.
|
||||
Archive the report and results (including traces) before starting another browser suite.
|
||||
@@ -0,0 +1,62 @@
|
||||
import fs from 'node:fs'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { createMediaGate } from '../helpers/media-gate.js'
|
||||
import { useAudioFixture } from './audio-fixture.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const { openAudio } = useAudioFixture()
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
for (const plugin of ['published', 'source']) {
|
||||
for (const owner of ['video', 'audio']) {
|
||||
test(`${core} core + ${plugin} audio: real ${owner} starvation resumes after network release`, async ({ page }, testInfo) => {
|
||||
const file = owner === 'video' ? 'pattern.mp4' : 'audio-tone.m4a'
|
||||
const bytes = fs.readFileSync(new URL(`./media/${file}`, import.meta.url))
|
||||
const limit = owner === 'video' ? 96 * 1024 : 32 * 1024
|
||||
const gate = await createMediaGate(bytes, owner === 'video' ? 'video/mp4' : 'audio/mp4', limit)
|
||||
try {
|
||||
await openAudio(page, core, plugin, testInfo, owner === 'audio' ? gate.url : '/test/audio-tone.m4a')
|
||||
await page.evaluate((owner) => {
|
||||
window.starvation = []
|
||||
const media = owner === 'video' ? window.art.video : window.audioPlugin.audio
|
||||
media.addEventListener('waiting', (event) => {
|
||||
window.starvation.push({ trusted: event.isTrusted, time: media.currentTime, readyState: media.readyState, audioPaused: window.audioPlugin.audio.paused, videoTime: window.art.currentTime })
|
||||
})
|
||||
}, owner)
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime > 0.3 && window.art.currentTime > 0.3)).toBe(true)
|
||||
if (owner === 'video')
|
||||
await page.evaluate(url => window.art.switchUrl(url), gate.url)
|
||||
await expect.poll(() => page.evaluate(() => window.starvation.some(event => event.trusted && event.time > 0.3 && event.readyState < 3))).toBe(true)
|
||||
expect(gate.blocked).toBeGreaterThan(0)
|
||||
const stalled = await page.evaluate(() => ({ audio: window.audioPlugin.audio.currentTime, video: window.art.currentTime }))
|
||||
if (owner === 'video') {
|
||||
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
|
||||
}
|
||||
else {
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(stalled.video + 0.3)
|
||||
expect(await page.evaluate(() => window.art.video.paused)).toBe(false)
|
||||
}
|
||||
gate.release()
|
||||
await expect.poll(() => page.evaluate(() => !window.audioPlugin.audio.paused && !window.art.video.paused)).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(stalled.audio + 0.3)
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(stalled.video + 0.3)
|
||||
await expect.poll(() => page.evaluate(() => Math.abs(window.audioPlugin.audio.currentTime - window.art.currentTime))).toBeLessThan(0.5)
|
||||
expect(await page.evaluate(() => [window.art.video.error, window.audioPlugin.audio.error])).toEqual([null, null])
|
||||
}
|
||||
finally {
|
||||
try {
|
||||
await testInfo.attach('media-gate-requests', { contentType: 'application/json', body: JSON.stringify({ file, sha256: hash(bytes), bytes: bytes.length, limit, requests: gate.requests }) })
|
||||
const events = page.isClosed() ? { closed: true } : await page.evaluate(() => window.starvation)
|
||||
await testInfo.attach('media-gate', { contentType: 'application/json', body: JSON.stringify({ file, sha256: hash(bytes), bytes: bytes.length, limit, requests: gate.requests, events }) })
|
||||
if (!page.isClosed())
|
||||
await page.evaluate(() => window.art?.destroy())
|
||||
}
|
||||
finally {
|
||||
await gate.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,33 +80,64 @@ for (const core of ['published', 'candidate']) {
|
||||
|
||||
test(`${core} core + ${plugin} audio: native offset bounds recover to an in-range seek`, async ({ page }, testInfo) => {
|
||||
await openAudio(page, core, plugin, testInfo)
|
||||
await page.evaluate(() => {
|
||||
window.boundaryAudio = new Audio('/test/audio-tone.m4a?boundary=native')
|
||||
window.boundaryAudio.muted = true
|
||||
document.querySelector('#play').onclick = () => Promise.all([window.art.play(), window.boundaryAudio.play()])
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.boundaryAudio.readyState)).toBeGreaterThanOrEqual(2)
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(0.2)
|
||||
await page.evaluate(() => {
|
||||
window.art.pause()
|
||||
window.boundaryAudio.pause()
|
||||
window.audioPlugin.update({ offset: -2 })
|
||||
window.art.seek = 0.5
|
||||
window.boundaryAudio.currentTime = -1.5
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking)).toBe(true)
|
||||
const negative = await page.evaluate(() => ({ time: window.audioPlugin.audio.currentTime, error: window.audioPlugin.audio.error?.code || null }))
|
||||
expect(negative).toEqual({ time: 0, error: null })
|
||||
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking && !window.boundaryAudio.seeking)).toBe(true)
|
||||
const negative = await page.evaluate(() => {
|
||||
const state = audio => ({ time: audio.currentTime, error: audio.error?.code || null })
|
||||
return { audio: state(window.audioPlugin.audio), native: state(window.boundaryAudio) }
|
||||
})
|
||||
await testInfo.attach('negative-offset-native', { contentType: 'application/json', body: JSON.stringify(negative) })
|
||||
expect(negative.native.time).toBeGreaterThanOrEqual(0)
|
||||
expect(negative.native.time).toBeCloseTo(0, 1)
|
||||
expect(negative.audio.time).toBeGreaterThanOrEqual(0)
|
||||
expect(negative.audio.time).toBeCloseTo(negative.native.time, 1)
|
||||
expect([negative.audio.error, negative.native.error]).toEqual([null, null])
|
||||
await page.evaluate(() => {
|
||||
window.audioPlugin.update({ offset: 20 })
|
||||
window.art.seek = 1
|
||||
window.boundaryAudio.currentTime = 21
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking)).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBe(16)
|
||||
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking && !window.boundaryAudio.seeking)).toBe(true)
|
||||
const overflow = await page.evaluate(() => {
|
||||
const state = audio => ({ time: audio.currentTime, duration: audio.duration, error: audio.error?.code || null })
|
||||
return { audio: state(window.audioPlugin.audio), native: state(window.boundaryAudio) }
|
||||
})
|
||||
await testInfo.attach('overflow-offset-native', { contentType: 'application/json', body: JSON.stringify(overflow) })
|
||||
expect(overflow.native.duration).toBe(16)
|
||||
expect(overflow.native.time).toBeCloseTo(overflow.native.duration, 1)
|
||||
expect(overflow.audio.time).toBeCloseTo(overflow.native.time, 1)
|
||||
expect([overflow.audio.error, overflow.native.error]).toEqual([null, null])
|
||||
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
window.audioPlugin.update({ offset: -0.5 })
|
||||
window.art.seek = 3
|
||||
window.boundaryAudio.currentTime = 2.5
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeCloseTo(2.5, 1)
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(2.8)
|
||||
await expect.poll(() => page.evaluate(() => Math.abs(window.audioPlugin.audio.currentTime - window.art.currentTime + 0.5))).toBeLessThan(0.5)
|
||||
await testInfo.attach('offset-boundary', { contentType: 'application/json', body: JSON.stringify({ negative, overflow: 16, recovered: true }) })
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
await testInfo.attach('offset-boundary', { contentType: 'application/json', body: JSON.stringify({ negative, overflow, recovered: true }) })
|
||||
await page.evaluate(() => {
|
||||
window.art.destroy()
|
||||
window.boundaryAudio.pause()
|
||||
window.boundaryAudio.removeAttribute('src')
|
||||
window.boundaryAudio.load()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -72,8 +72,8 @@ export function useAudioFixture() {
|
||||
plugins: [window.artplayerPluginAudioTrack({ url, sync: 0.1 })],
|
||||
})
|
||||
window.audioPlugin = window.art.plugins.artplayerPluginAudioTrack
|
||||
for (const event of ['play', 'pause', 'seek', 'video:waiting', 'video:playing', 'video:seeking', 'video:seeked', 'video:pause', 'video:ended'])
|
||||
window.art.on(event, () => window.audioHostEvents.push({ event, videoTime: window.art.currentTime, audioTime: window.audioPlugin.audio.currentTime, audioPaused: window.audioPlugin.audio.paused }))
|
||||
for (const event of ['play', 'pause', 'seek', 'video:waiting', 'video:playing', 'video:canplay', 'video:seeking', 'video:seeked', 'video:pause', 'video:ended'])
|
||||
window.art.on(event, () => window.audioHostEvents.push({ event, videoTime: window.art.currentTime, videoPaused: window.art.video.paused, videoReadyState: window.art.video.readyState, playing: window.art.playing, audioTime: window.audioPlugin.audio.currentTime, audioPaused: window.audioPlugin.audio.paused }))
|
||||
for (const event of ['loadedmetadata', 'playing', 'pause', 'seeked', 'error', 'emptied'])
|
||||
window.audioPlugin.audio.addEventListener(event, () => window.audioEvents.push({ event, time: window.audioPlugin.audio.currentTime, src: window.audioPlugin.audio.currentSrc }))
|
||||
document.querySelector('#play').onclick = () => window.art.play()
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import fs from 'node:fs'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { createMediaGate } from '../helpers/media-gate.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
for (const owner of ['video', 'audio']) {
|
||||
for (const completeRanges of [false, true]) {
|
||||
test(`native ${owner} ${completeRanges ? 'complete ranges' : 'streamed response'} capability without ArtPlayer instances`, async ({ page }, testInfo) => {
|
||||
const file = owner === 'video' ? 'pattern.mp4' : 'audio-tone.m4a'
|
||||
const bytes = fs.readFileSync(new URL(`./media/${file}`, import.meta.url))
|
||||
const limit = owner === 'video' ? 96 * 1024 : 32 * 1024
|
||||
const gate = await createMediaGate(bytes, owner === 'video' ? 'video/mp4' : 'audio/mp4', limit, completeRanges)
|
||||
try {
|
||||
await page.goto('/test/player.html')
|
||||
await page.evaluate(({ owner, url }) => {
|
||||
window.nativeMedia = document.createElement(owner)
|
||||
window.nativeMedia.preload = 'auto'
|
||||
window.nativeMedia.muted = true
|
||||
window.nativeEvents = []
|
||||
for (const event of ['loadedmetadata', 'loadeddata', 'canplay', 'playing', 'waiting', 'stalled', 'pause', 'error'])
|
||||
window.nativeMedia.addEventListener(event, e => window.nativeEvents.push({ event, trusted: e.isTrusted, time: window.nativeMedia.currentTime, readyState: window.nativeMedia.readyState }))
|
||||
window.nativeMedia.src = url
|
||||
document.body.append(window.nativeMedia)
|
||||
document.querySelector('#play').onclick = () => window.nativeMedia.play()
|
||||
}, { owner, url: gate.url })
|
||||
await expect.poll(() => page.evaluate(() => window.nativeMedia.readyState >= 2 || Boolean(window.nativeMedia.error))).toBe(true)
|
||||
await page.locator('#play').click()
|
||||
let progressed = false
|
||||
let waiting = false
|
||||
try {
|
||||
await page.waitForFunction(() => window.nativeMedia.currentTime > 0.3, null, { timeout: 3000 })
|
||||
progressed = true
|
||||
await page.waitForFunction(() => window.nativeEvents.some(event => event.event === 'waiting' && event.trusted && event.time > 0.3 && event.readyState < 3), null, { timeout: 7000 })
|
||||
waiting = true
|
||||
}
|
||||
catch (error) {
|
||||
if (error.name !== 'TimeoutError')
|
||||
throw error
|
||||
}
|
||||
const before = await page.evaluate(() => ({ time: window.nativeMedia.currentTime, readyState: window.nativeMedia.readyState, paused: window.nativeMedia.paused, error: window.nativeMedia.error?.code || null, instances: window.Artplayer.instances.length, events: window.nativeEvents }))
|
||||
expect(before.instances).toBe(0)
|
||||
expect(gate.blocked).toBeGreaterThan(0)
|
||||
gate.release()
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.nativeMedia.currentTime)).toBeGreaterThan(before.time + 0.3)
|
||||
expect(await page.evaluate(() => window.nativeMedia.error)).toBeNull()
|
||||
await testInfo.attach('native-gate-capability', { contentType: 'application/json', body: JSON.stringify({ file, sha256: hash(bytes), limit, completeRanges, progressed, waiting, before, requests: gate.requests, after: await page.evaluate(() => ({ time: window.nativeMedia.currentTime, readyState: window.nativeMedia.readyState, paused: window.nativeMedia.paused })) }) })
|
||||
}
|
||||
finally {
|
||||
try {
|
||||
await page.evaluate(() => {
|
||||
window.nativeMedia?.pause()
|
||||
window.nativeMedia?.removeAttribute('src')
|
||||
window.nativeMedia?.load()
|
||||
})
|
||||
}
|
||||
finally {
|
||||
await gate.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import http from 'node:http'
|
||||
|
||||
// Serves real media bytes, withholding the tail until a test observes native starvation.
|
||||
export async function createMediaGate(bytes, contentType, limit, completeRanges = false) {
|
||||
assert(Number.isSafeInteger(limit) && limit > 0 && limit < bytes.length)
|
||||
const requests = []
|
||||
const pending = new Map()
|
||||
let released = false
|
||||
const etag = `"${createHash('sha256').update(bytes).digest('hex')}"`
|
||||
const server = http.createServer((req, res) => {
|
||||
if (!['GET', 'HEAD'].includes(req.method) || !req.url.startsWith('/media.')) {
|
||||
res.writeHead(404).end()
|
||||
return
|
||||
}
|
||||
let start = 0
|
||||
let end = bytes.length - 1
|
||||
if (req.headers.range) {
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range)
|
||||
start = match?.[1] ? Number(match[1]) : Math.max(0, bytes.length - Number(match?.[2]))
|
||||
end = match?.[1] && match[2] ? Math.min(bytes.length - 1, Number(match[2])) : bytes.length - 1
|
||||
if (!match || (!match[1] && !match[2]) || !Number.isSafeInteger(start) || start > end || start >= bytes.length) {
|
||||
res.writeHead(416, { 'Content-Range': `bytes */${bytes.length}` }).end()
|
||||
return
|
||||
}
|
||||
res.statusCode = 206
|
||||
if (completeRanges && !released && start < limit)
|
||||
end = Math.min(end, limit - 1)
|
||||
res.setHeader('Content-Range', `bytes ${start}-${end}/${bytes.length}`)
|
||||
}
|
||||
res.setHeader('Content-Type', contentType)
|
||||
res.setHeader('Content-Length', end - start + 1)
|
||||
res.setHeader('Accept-Ranges', 'bytes')
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.setHeader('ETag', etag)
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
const record = { method: req.method, range: req.headers.range || null, start, end, sent: 0, wasHeld: false, closed: false }
|
||||
requests.push(record)
|
||||
res.on('close', () => {
|
||||
record.closed = true
|
||||
pending.delete(res)
|
||||
})
|
||||
res.on('error', (error) => {
|
||||
record.error = error.message
|
||||
})
|
||||
if (req.method === 'HEAD') {
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const availableEnd = released ? end + 1 : Math.min(end + 1, limit)
|
||||
const sentEnd = Math.max(start, availableEnd)
|
||||
if (sentEnd > start) {
|
||||
res.write(bytes.subarray(start, sentEnd))
|
||||
record.sent += sentEnd - start
|
||||
}
|
||||
if (sentEnd > end) {
|
||||
res.end()
|
||||
}
|
||||
else {
|
||||
record.wasHeld = true
|
||||
pending.set(res, { offset: sentEnd, end, record })
|
||||
res.flushHeaders()
|
||||
}
|
||||
})
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}/media.${contentType === 'video/mp4' ? 'mp4' : 'm4a'}`,
|
||||
requests,
|
||||
get blocked() { return pending.size },
|
||||
release() {
|
||||
released = true
|
||||
for (const [res, { offset, end, record }] of pending) {
|
||||
if (!res.destroyed) {
|
||||
record.sent += end + 1 - offset
|
||||
res.end(bytes.subarray(offset, end + 1))
|
||||
}
|
||||
}
|
||||
pending.clear()
|
||||
},
|
||||
async close() {
|
||||
server.closeAllConnections()
|
||||
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { Buffer } from 'node:buffer'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Test the real local HTTP fixture with Node's runner.
|
||||
import test from 'node:test'
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { createMediaGate } from './helpers/media-gate.js'
|
||||
|
||||
test('media gate completes a bounded range and withholds the rest without altering bytes', async () => {
|
||||
const bytes = Buffer.from('0123456789abcdef')
|
||||
const gate = await createMediaGate(bytes, 'audio/mp4', 5, true)
|
||||
try {
|
||||
const first = await fetch(gate.url, { headers: { Range: 'bytes=0-' }, signal: AbortSignal.timeout(2000) })
|
||||
assert.equal(first.status, 206)
|
||||
assert.equal(first.headers.get('Content-Range'), 'bytes 0-4/16')
|
||||
assert.equal(first.headers.get('Content-Length'), '5')
|
||||
assert.deepEqual(Buffer.from(await first.arrayBuffer()), bytes.subarray(0, 5))
|
||||
const tail = await fetch(gate.url, { headers: { Range: 'bytes=5-' }, signal: AbortSignal.timeout(2000) })
|
||||
assert.equal(tail.status, 206)
|
||||
assert.equal(tail.headers.get('ETag'), first.headers.get('ETag'))
|
||||
assert.equal(gate.blocked, 1)
|
||||
let finished = false
|
||||
const remaining = tail.arrayBuffer().then((body) => {
|
||||
finished = true
|
||||
return Buffer.from(body)
|
||||
})
|
||||
await setImmediate()
|
||||
assert.equal(finished, false)
|
||||
gate.release()
|
||||
assert.deepEqual(await remaining, bytes.subarray(5))
|
||||
assert.equal(gate.blocked, 0)
|
||||
assert(gate.requests.some(request => request.wasHeld))
|
||||
}
|
||||
finally {
|
||||
await gate.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('media gate streams a prefix within a full declared response and releases the remaining bytes', async () => {
|
||||
const bytes = Buffer.from('0123456789abcdef')
|
||||
const gate = await createMediaGate(bytes, 'audio/mp4', 5)
|
||||
try {
|
||||
const response = await fetch(gate.url, { headers: { Range: 'bytes=0-' }, signal: AbortSignal.timeout(2000) })
|
||||
assert.equal(response.headers.get('Content-Range'), 'bytes 0-15/16')
|
||||
assert.equal(response.headers.get('Content-Length'), '16')
|
||||
const reader = response.body.getReader()
|
||||
const first = await reader.read()
|
||||
assert.deepEqual(Buffer.from(first.value), bytes.subarray(0, 5))
|
||||
let finished = false
|
||||
const tail = reader.read().then((value) => {
|
||||
finished = true
|
||||
return value
|
||||
})
|
||||
await setImmediate()
|
||||
assert.equal(finished, false)
|
||||
assert.equal(gate.blocked, 1)
|
||||
gate.release()
|
||||
assert.deepEqual(Buffer.from((await tail).value), bytes.subarray(5))
|
||||
assert.equal((await reader.read()).done, true)
|
||||
}
|
||||
finally {
|
||||
await gate.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('media gate preserves HEAD and rejects invalid ranges before serving the released suffix', async () => {
|
||||
const gate = await createMediaGate(Buffer.from('0123456789'), 'video/mp4', 4)
|
||||
try {
|
||||
const head = await fetch(gate.url, { method: 'HEAD' })
|
||||
assert.equal(head.status, 200)
|
||||
assert.equal(head.headers.get('Content-Length'), '10')
|
||||
assert.equal(await head.text(), '')
|
||||
assert.equal(gate.blocked, 0)
|
||||
const invalid = await fetch(gate.url, { headers: { Range: 'bytes=20-' } })
|
||||
assert.equal(invalid.status, 416)
|
||||
assert.equal(invalid.headers.get('Content-Range'), 'bytes */10')
|
||||
gate.release()
|
||||
const suffix = await fetch(gate.url, { headers: { Range: 'bytes=-3' } })
|
||||
assert.equal(suffix.headers.get('Content-Range'), 'bytes 7-9/10')
|
||||
assert.equal(await suffix.text(), '789')
|
||||
}
|
||||
finally {
|
||||
await gate.close()
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user