import { expect, test } from './fixtures.js' async function setup(page, core) { await page.goto(`/test/player.html?core=${core}&chapter=published`) await page.evaluate(() => window.createPlayer('/test/pattern.mp4')) 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) } for (const core of ['published', 'candidate']) { for (const mode of ['remove', 'destroy', 'source']) { test(`${core}: progress click reentry through ${mode} cannot continue an obsolete seek`, async ({ page }) => { await setup(page, core) const result = await page.evaluate((mode) => { const art = window.art let seeks = 0 let bars = 0 art.on('seek', () => seeks++) art.on('setBar', (type) => { if (type !== 'played' || bars++) return if (mode === 'remove') art.controls.remove('progress') else if (mode === 'destroy') art.destroy() else art.url = '/test/pattern.mp4?progress=reentrant' }) const progress = art.template.$progress progress.dispatchEvent(new MouseEvent('click', { clientX: progress.getBoundingClientRect().left + progress.clientWidth / 2 })) const result = { seeks, bars } art.destroy() return result }, mode) expect(result).toEqual({ seeks: core === 'published' ? 1 : 0, bars: 1 }) }) } test(`${core}: real progress click keeps setBar before seek and clamps the edges`, async ({ page }) => { await setup(page, core) const rect = await page.evaluate(() => { const art = window.art window.progressOrder = [] art.on('setBar', (type, percent) => { if (type === 'played') window.progressOrder.push(['setBar', percent]) }) art.on('seek', () => window.progressOrder.push(['seek'])) const rect = art.template.$progress.getBoundingClientRect() return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } }) await page.mouse.click(rect.x, rect.y) await expect.poll(() => page.evaluate(() => window.art.video.seeking)).toBe(false) expect(await page.evaluate(() => window.art.currentTime / window.art.duration)).toBeCloseTo(0.5, 1) const result = await page.evaluate(() => { const art = window.art const firstSeek = window.progressOrder.findIndex(item => item[0] === 'seek') const previous = window.progressOrder[firstSeek - 1] const progress = art.template.$progress progress.dispatchEvent(new MouseEvent('click', { clientX: progress.getBoundingClientRect().left - 100 })) const start = art.currentTime progress.dispatchEvent(new MouseEvent('click', { clientX: progress.getBoundingClientRect().right + 100 })) const end = art.currentTime const duration = art.duration art.destroy() return { previous, start, end, duration } }) expect(result.previous[0]).toBe('setBar') expect(result.previous[1]).toBeCloseTo(0.5, 1) expect(result.start).toBe(0) expect(result.end).toBeCloseTo(result.duration, 1) }) test(`${core}: a progress drag ends when its source is replaced and can restart on the new source`, async ({ page }) => { await setup(page, core) const stale = await page.evaluate(() => { const art = window.art window.dragSeeks = 0 art.on('seek', () => window.dragSeeks++) const progress = art.template.$progress progress.dispatchEvent(new MouseEvent('mousedown', { button: 0 })) art.url = '/test/pattern.mp4?progress=new-source' art.emit('document:mousemove', new MouseEvent('mousemove', { clientX: progress.getBoundingClientRect().left + progress.clientWidth / 2 })) return window.dragSeeks }) expect(stale).toBe(core === 'published' ? 1 : 0) await expect.poll(() => page.evaluate(() => window.art.video.readyState >= 2)).toBe(true) expect(await page.evaluate(() => { const art = window.art const progress = art.template.$progress progress.dispatchEvent(new MouseEvent('mousedown', { button: 0 })) art.emit('document:mousemove', new MouseEvent('mousemove', { clientX: progress.getBoundingClientRect().left + progress.clientWidth / 2 })) art.emit('document:mouseup', new MouseEvent('mouseup')) const seeks = window.dragSeeks art.destroy() return seeks })).toBe(stale + 1) }) test(`${core}: a caller-owned media Blob URL remains readable after switching and destroying the player`, async ({ page, browserName }, testInfo) => { await page.goto(`/test/player.html?core=${core}&chapter=published`) await page.evaluate(async () => { window.blobCapabilities = [] let blob let fallback for (const extension of ['mp4', 'webm']) { const candidate = await (await fetch(`/test/pattern.${extension}`)).blob() fallback ||= candidate const url = URL.createObjectURL(candidate) const video = document.createElement('video') const capability = await new Promise((resolve) => { video.onloadedmetadata = () => resolve({ extension, width: video.videoWidth, error: 0 }) video.onerror = () => resolve({ extension, width: video.videoWidth, error: video.error.code }) video.src = url video.load() }) window.blobCapabilities.push(capability) video.onloadedmetadata = null video.onerror = null video.removeAttribute('src') video.load() URL.revokeObjectURL(url) if (!capability.error) { blob = candidate break } } window.blobPlayable = !!blob blob ||= fallback window.sharedBlobSize = blob.size window.sharedBlobUrl = URL.createObjectURL(blob) window.borrowedRevocations = [] const original = URL.revokeObjectURL URL.revokeObjectURL = function (url) { window.borrowedRevocations.push(url) return original.call(this, url) } window.createPlayer(window.blobPlayable ? window.sharedBlobUrl : '/test/pattern.mp4') }) const capabilities = await page.evaluate(() => window.blobCapabilities) await testInfo.attach('native-blob-capabilities', { contentType: 'application/json', body: JSON.stringify(capabilities) }) if (!capabilities.some(item => item.error === 0)) { expect(browserName).toBe('webkit') expect(capabilities).toEqual([{ extension: 'mp4', width: 0, error: 4 }, { extension: 'webm', width: 0, error: 4 }]) } await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true) const result = await page.evaluate(async () => { const art = window.art // The Windows WebKit runtime cannot decode either Blob fixture natively. // Still exercise actual URL assignment/replacement and byte ownership there. if (!window.blobPlayable) art.url = window.sharedBlobUrl const assigned = art.video.src === window.sharedBlobUrl await art.switchUrl('/test/pattern.mp4?borrowed=replaced') const read = async () => { try { return (await (await fetch(window.sharedBlobUrl)).blob()).size } catch (error) { return error.name } } const afterSwitch = await read() art.destroy() const afterDestroy = await read() const revoked = window.borrowedRevocations.filter(url => url === window.sharedBlobUrl).length URL.revokeObjectURL(window.sharedBlobUrl) return { afterSwitch, afterDestroy, revoked, size: window.sharedBlobSize, assigned, decodedBlob: window.blobPlayable } }) expect(result.size).toBeGreaterThan(100) expect(result.assigned).toBe(true) await testInfo.attach('borrowed-url-evidence', { contentType: 'application/json', body: JSON.stringify(result) }) expect(result.revoked).toBe(core === 'published' ? 1 : 0) expect(result.afterSwitch).toBe(core === 'published' ? 'TypeError' : result.size) expect(result.afterDestroy).toBe(core === 'published' ? 'TypeError' : result.size) }) test(`${core}: quality selectors retain default labels, source position and empty-array behavior`, async ({ page, browserName }, testInfo) => { await setup(page, core) await page.evaluate(() => { const art = window.art window.qualityNativeEvents = [] for (const key of ['currentTime', 'playbackRate', 'src']) { const descriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, key) Object.defineProperty(art.video, key, { configurable: true, get() { return descriptor.get.call(this) }, set(value) { window.qualityNativeEvents.push({ key, value, time: this.currentTime, seeking: this.seeking, source: this.currentSrc }) descriptor.set.call(this, value) }, }) } for (const event of ['loadedmetadata', 'seeking', 'seeked', 'canplay', 'ratechange', 'pause', 'playing']) art.video.addEventListener(event, () => window.qualityNativeEvents.push({ event, time: art.currentTime, seeking: art.video.seeking, source: art.video.currentSrc })) art.playbackRate = 1.5 art.currentTime = 2 window.qualityRestarts = [] art.on('restart', url => window.qualityRestarts.push(url)) window.qualityOptions = [ { html: 'Quality A', url: '/test/pattern.mp4?quality=A' }, { html: 'Quality B', url: '/test/pattern.mp4?quality=B', default: true }, ] art.quality = window.qualityOptions }) await expect.poll(() => page.evaluate(() => Math.abs(window.art.currentTime - 2) < 0.05 && !window.art.video.seeking)).toBe(true) await expect(page.locator('.art-control-quality .art-selector-value')).toHaveText('Quality B') await page.locator('.art-control-quality').hover() await page.locator('.art-control-quality .art-selector-item').filter({ hasText: 'Quality A' }).click() await expect.poll(() => page.evaluate(() => window.art.url.includes('quality=A'))).toBe(true) await expect.poll(() => page.evaluate(() => window.qualityRestarts)).toEqual(['/test/pattern.mp4?quality=A']) await expect.poll(() => page.evaluate(() => window.art.video.readyState >= 3 && !window.art.video.seeking)).toBe(true) await expect(page.locator('.art-control-quality .art-selector-value')).toHaveText('Quality A') await testInfo.attach('quality-native-events', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => window.qualityNativeEvents)) }) const restoredTime = await page.evaluate(() => window.art.currentTime) if (core === 'published' && browserName === 'webkit') expect(restoredTime).toBeLessThan(0.05) else expect(restoredTime).toBeCloseTo(2, 1) expect(await page.evaluate(() => window.art.playbackRate)).toBe(1.5) expect(await page.evaluate(() => window.qualityOptions.map(item => !!item.default))).toEqual([true, false]) const empty = await page.evaluate(() => { const art = window.art art.quality = [] const result = { text: art.controls.quality.textContent, read: art.quality, hasGetter: !!Object.getOwnPropertyDescriptor(art, 'quality').get } art.destroy() return result }) expect(empty).toEqual({ text: '', read: undefined, hasGetter: false }) }) }