mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-09 11:56:15 -08:00
224 lines
11 KiB
JavaScript
224 lines
11 KiB
JavaScript
import { expect, test } from './fixtures.js'
|
|
|
|
for (const core of ['published', 'candidate']) {
|
|
test(`${core}: rapid real-media switches identify the last restart and settle superseded work`, async ({ page }) => {
|
|
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
|
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?source=initial'))
|
|
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
|
const result = await page.evaluate(async () => {
|
|
const { art } = window
|
|
const restarts = []
|
|
const order = []
|
|
art.on('restart', url => restarts.push(url))
|
|
art.on('video:canplay', () => order.push('canplay'))
|
|
art.on('restart', () => order.push('restart'))
|
|
let firstSettled = false
|
|
const first = art.switchUrl('/test/pattern.mp4?source=first').then(() => {
|
|
firstSettled = true
|
|
})
|
|
const second = art.switchUrl('/test/pattern.mp4?source=second')
|
|
await Promise.resolve()
|
|
const early = firstSettled
|
|
await Promise.all([first, second])
|
|
const result = { early, restarts, order, current: new URL(art.video.currentSrc).search, error: art.video.error?.code || 0, time: art.currentTime }
|
|
art.destroy()
|
|
return result
|
|
})
|
|
expect(result.early).toBe(core === 'candidate')
|
|
expect(result.restarts).toEqual(core === 'candidate'
|
|
? ['/test/pattern.mp4?source=second']
|
|
: ['/test/pattern.mp4?source=first', '/test/pattern.mp4?source=second'])
|
|
expect(result.order[0]).toBe('canplay')
|
|
expect(result.current).toBe('?source=second')
|
|
expect(result.error).toBe(0)
|
|
expect(result.time).toBeCloseTo(0, 1)
|
|
})
|
|
|
|
test(`${core}: destroy settles the candidate switch without waiting for detached media`, async ({ page }) => {
|
|
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
|
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?destroy=initial'))
|
|
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
|
const result = await page.evaluate(async () => {
|
|
const { art } = window
|
|
let settled = false
|
|
let rejected = false
|
|
art.switchUrl('/test/pattern.mp4?destroy=pending').then(() => {
|
|
settled = true
|
|
}, () => {
|
|
rejected = true
|
|
})
|
|
art.destroy()
|
|
// Retain the published baseline's bounded pending observation, not an infinite-pending claim.
|
|
await new Promise(resolve => setTimeout(resolve, 250))
|
|
return { settled, rejected, destroyed: art.isDestroy, instances: window.Artplayer.instances.length }
|
|
})
|
|
expect(result).toEqual({ settled: core === 'candidate', rejected: false, destroyed: true, instances: 0 })
|
|
})
|
|
}
|
|
|
|
test('candidate: quality switch restores position/rate and preserves real customType identities', async ({ page }, testInfo) => {
|
|
await page.goto('/test/player.html?core=candidate&chapter=published')
|
|
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?custom=initial'))
|
|
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
|
// A real gesture starts WebKit's media pipeline before the paused seek setup.
|
|
await page.locator('#play').click()
|
|
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
|
|
await page.locator('#pause').click()
|
|
await expect.poll(() => page.evaluate(() => window.art.video.paused)).toBe(true)
|
|
await testInfo.attach('initial-media-ranges', {
|
|
body: JSON.stringify(await page.evaluate(() => {
|
|
const { video } = window.art
|
|
const ranges = value => Array.from({ length: value.length }, (_, index) => [value.start(index), value.end(index)])
|
|
return { buffered: ranges(video.buffered), seekable: ranges(video.seekable), readyState: video.readyState, duration: video.duration, currentTime: video.currentTime }
|
|
})),
|
|
contentType: 'application/json',
|
|
})
|
|
await expect.poll(() => page.evaluate(() => {
|
|
const ranges = window.art.video.seekable
|
|
return Array.from({ length: ranges.length }, (_, index) => index).some(index => ranges.start(index) <= 3 && ranges.end(index) >= 3)
|
|
})).toBe(true)
|
|
await page.evaluate(() => {
|
|
window.switchMediaEvents = []
|
|
for (const name of ['loadedmetadata', 'seeking', 'seeked', 'canplay', 'pause', 'playing']) {
|
|
window.art.video.addEventListener(name, () => window.switchMediaEvents.push({ name, time: window.art.currentTime, seeking: window.art.video.seeking, source: window.art.video.currentSrc }))
|
|
}
|
|
window.art.currentTime = 3
|
|
})
|
|
await expect.poll(() => page.evaluate(() => window.art.video.seeking)).toBe(false)
|
|
const result = await page.evaluate(async () => {
|
|
const { art } = window
|
|
const calls = []
|
|
art.option.type = 'controlled'
|
|
art.option.customType.controlled = function (video, url, owner) {
|
|
calls.push({ thisIdentity: this === art, ownerIdentity: owner === art, videoIdentity: video === art.video, count: arguments.length, url })
|
|
video.src = url
|
|
}
|
|
art.playbackRate = 1.5
|
|
art.aspectRatio = '16:9'
|
|
const restart = []
|
|
art.on('restart', url => restart.push(url))
|
|
const beforeSwitch = { time: art.currentTime, seeking: art.video.seeking, paused: art.video.paused }
|
|
await art.switchQuality('/test/pattern.mp4?custom=next')
|
|
const result = { beforeSwitch, mediaEvents: window.switchMediaEvents, calls, restart, time: art.currentTime, rate: art.playbackRate, ratio: art.aspectRatio, paused: art.video.paused, error: art.video.error?.code || 0 }
|
|
art.destroy()
|
|
return result
|
|
})
|
|
await testInfo.attach('quality-switch-media-state', { body: JSON.stringify(result), contentType: 'application/json' })
|
|
expect(result.beforeSwitch.time).toBeCloseTo(3, 1)
|
|
expect(result.beforeSwitch.seeking).toBe(false)
|
|
expect(result.beforeSwitch.paused).toBe(true)
|
|
expect(result.calls).toEqual([{ thisIdentity: true, ownerIdentity: true, videoIdentity: true, count: 3, url: '/test/pattern.mp4?custom=next' }])
|
|
expect(result.restart).toEqual(['/test/pattern.mp4?custom=next'])
|
|
expect(result.time).toBeCloseTo(3, 1)
|
|
expect(result.rate).toBe(1.5)
|
|
expect(result.ratio).toBe('16:9')
|
|
expect(result.paused).toBe(true)
|
|
expect(result.error).toBe(0)
|
|
})
|
|
|
|
test('candidate: direct currentTime during a native quality seek is not overwritten by automatic restoration', async ({ page }, testInfo) => {
|
|
await page.goto('/test/player.html?core=candidate&chapter=published')
|
|
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?position=initial'))
|
|
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
|
await page.locator('#play').click()
|
|
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
|
|
await page.locator('#pause').click()
|
|
await expect.poll(() => page.evaluate(() => window.art.video.paused && !window.art.video.seeking)).toBe(true)
|
|
await page.evaluate(() => {
|
|
window.art.currentTime = 2
|
|
})
|
|
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && Math.abs(window.art.currentTime - 2) < 0.05)).toBe(true)
|
|
const result = await page.evaluate(async () => {
|
|
const { art } = window
|
|
let overrides = 0
|
|
const seeks = []
|
|
const writes = []
|
|
for (const name of ['currentTime', 'playbackRate']) {
|
|
const descriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, name)
|
|
Object.defineProperty(art.video, name, {
|
|
configurable: true,
|
|
get() { return descriptor.get.call(this) },
|
|
set(value) {
|
|
writes.push({ name, value, before: this.currentTime, seeking: this.seeking })
|
|
descriptor.set.call(this, value)
|
|
writes.push({ name: `${name}:after`, value: this.currentTime, seeking: this.seeking })
|
|
},
|
|
})
|
|
}
|
|
art.once('video:seeked', () => {
|
|
overrides++
|
|
art.currentTime = 4
|
|
})
|
|
art.on('video:seeked', () => seeks.push({ time: art.currentTime, seeking: art.video.seeking }))
|
|
await art.switchQuality('/test/pattern.mp4?position=consumer')
|
|
const result = { overrides, seeks, writes, time: art.currentTime, paused: art.video.paused, seeking: art.video.seeking }
|
|
art.destroy()
|
|
return result
|
|
})
|
|
await testInfo.attach('quality-direct-position', { contentType: 'application/json', body: JSON.stringify(result) })
|
|
expect(result.overrides).toBe(1)
|
|
// Assert the player write contract separately from native seek precision.
|
|
// WebKit may finish this reentrant native seek at a different media clock.
|
|
const positions = result.writes.filter(item => item.name === 'currentTime')
|
|
expect(positions).toHaveLength(2)
|
|
expect(positions[0].value).toBeCloseTo(2, 1)
|
|
expect(positions[1].value).toBe(4)
|
|
expect(result.paused).toBe(true)
|
|
expect(result.seeking).toBe(false)
|
|
})
|
|
|
|
test('candidate: real switch settles when the controlled native resume rejects', async ({ page }) => {
|
|
await page.goto('/test/player.html?core=candidate&chapter=published')
|
|
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?resume=initial'))
|
|
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
|
await page.locator('#play').click()
|
|
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
|
|
const result = await page.evaluate(async () => {
|
|
const { art } = window
|
|
const error = new DOMException('controlled resume rejection', 'NotAllowedError')
|
|
let calls = 0
|
|
Object.defineProperty(art.video, 'play', { configurable: true, value() {
|
|
calls++
|
|
return Promise.reject(error)
|
|
} })
|
|
const value = await art.switchUrl('/test/pattern.mp4?resume=next')
|
|
const result = { settledUndefined: value === undefined, calls, source: new URL(art.video.currentSrc).search, paused: art.video.paused }
|
|
delete art.video.play
|
|
art.destroy()
|
|
return result
|
|
})
|
|
expect(result).toEqual({ settledUndefined: true, calls: 1, source: '?resume=next', paused: true })
|
|
})
|
|
|
|
test('candidate: late native play completion cannot publish stale player effects', async ({ page }) => {
|
|
await page.goto('/test/player.html?core=candidate&chapter=published')
|
|
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?late=initial'))
|
|
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
|
await page.locator('#play').click()
|
|
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
|
|
const result = await page.evaluate(async () => {
|
|
const { art } = window
|
|
const plays = []
|
|
art.on('play', () => plays.push('play'))
|
|
let resolveNative
|
|
let started
|
|
const invoked = new Promise(resolve => started = resolve)
|
|
Object.defineProperty(art.video, 'play', { configurable: true, value() {
|
|
started()
|
|
return new Promise(resolve => resolveNative = resolve)
|
|
} })
|
|
const switching = art.switchUrl('/test/pattern.mp4?late=resume')
|
|
await invoked
|
|
art.url = '/test/pattern.mp4?late=latest'
|
|
await switching
|
|
art.notice.show = 'Latest source notice'
|
|
resolveNative(42)
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
const result = { plays, notice: art.template.$noticeInner.textContent }
|
|
delete art.video.play
|
|
art.destroy()
|
|
return result
|
|
})
|
|
expect(result).toEqual({ plays: [], notice: 'Latest source notice' })
|
|
})
|