import { expect, test } from './fixtures.js' async function setup(page, core) { await page.goto(`/test/player.html?core=${core}&chapter=published`) await page.evaluate(() => { const container = document.createElement('div') Object.assign(container.style, { width: '640px', height: '360px' }) document.body.append(container) window.art = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true }) window.subtitleRequests = new Map() window.subtitleURLs = [] window.subtitleRevoked = [] const fetchOriginal = window.fetch const create = URL.createObjectURL.bind(URL) const revoke = URL.revokeObjectURL.bind(URL) URL.createObjectURL = (blob) => { const url = create(blob) window.subtitleURLs.push(url) return url } URL.revokeObjectURL = (url) => { window.subtitleRevoked.push(url) revoke(url) } window.fetch = (url, options) => { if (!String(url).startsWith('/controlled-subtitle/')) return fetchOriginal(url, options) // Deliberately ignore AbortSignal: generation checks must work with custom fetch adapters. return new Promise((resolve, reject) => window.subtitleRequests.set(url, { resolve, reject, signal: options?.signal })) } window.completeSubtitle = (name, text) => window.subtitleRequests.get(`/controlled-subtitle/${name}.vtt`).resolve(new Response(`WEBVTT\n\n00:00:00.000 --> 00:00:07.000\n${text}\n`)) }) await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true) } for (const core of ['published', 'candidate']) { for (const format of ['srt', 'ass']) { test(`${core}: ${format} conversion reaches native cues with original callback binding`, async ({ page }) => { await setup(page, core) await page.evaluate(async (format) => { const art = window.art window.subtitleConversion = [] const request = art.subtitle.switch('/controlled-subtitle/convert.vtt', { type: format, onVttLoad(vtt) { window.subtitleConversion.push({ bound: this === art.subtitle.option, vtt: vtt.startsWith('WEBVTT') }) return vtt } }) const text = format === 'srt' ? '1\n00:00:01,200 --> 00:00:03,400\nConverted\n' : '[Events]\nDialogue: 0,0:00:01.20,0:00:03.40,Default,,0,0,0,,Converted\n' window.subtitleRequests.get('/controlled-subtitle/convert.vtt').resolve(new Response(text)) await request }, format) await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(1) expect(await page.evaluate(() => { const cue = window.art.subtitle.cues[0] return { text: cue.text, start: cue.startTime, end: cue.endTime, conversion: window.subtitleConversion } })).toEqual({ text: 'Converted', start: 1.2, end: 3.4, conversion: [{ bound: true, vtt: true }] }) await page.evaluate(() => { window.art.destroy() for (const url of window.subtitleURLs) URL.revokeObjectURL(url) }) }) } test(`${core}: video fullscreen signal rebuilds track kind while retaining its URL`, async ({ page }) => { await setup(page, core) const result = await page.evaluate(async () => { const art = window.art const request = art.subtitle.switch('/controlled-subtitle/mobile-fullscreen.vtt') window.completeSubtitle('mobile-fullscreen', 'Mobile fullscreen branch') const url = await request const kinds = [] // This drives the WebKit-specific signal branch; it does not emulate a physical iOS fullscreen surface. Object.defineProperty(art.template.$video, 'webkitDisplayingFullscreen', { configurable: true, value: true }) art.emit('video:timeupdate') kinds.push(art.template.$track.kind) const fullscreenTrack = art.template.$track art.emit('video:timeupdate') const stable = art.template.$track === fullscreenTrack Object.defineProperty(art.template.$video, 'webkitDisplayingFullscreen', { configurable: true, value: false }) art.emit('video:timeupdate') kinds.push(art.template.$track.kind) const result = { kinds, stable, sameURL: art.subtitle.url === url, notRevoked: !window.subtitleRevoked.includes(url), oldHandlerCleared: fullscreenTrack.onload === null } art.destroy() for (const item of window.subtitleURLs) URL.revokeObjectURL(item) return result }) expect(result).toEqual({ kinds: ['subtitles', 'metadata'], stable: true, sameURL: true, notRevoked: true, oldHandlerCleared: true }) }) test(`${core}: subtitle remains visible through real native fullscreen`, async ({ page }, testInfo) => { await setup(page, core) await page.evaluate(async () => { const request = window.art.subtitle.switch('/controlled-subtitle/fullscreen.vtt') window.completeSubtitle('fullscreen', 'Fullscreen cue') await request window.initialSubtitleTrack = window.art.template.$track document.querySelector('#play').onclick = () => { window.art.fullscreen = true } }) await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(1) await page.evaluate(() => { window.art.seek = 2 }) await expect.poll(() => page.evaluate(() => window.art.template.$subtitle.textContent.trim())).toBe('Fullscreen cue') const native = await page.evaluate(() => Boolean(document.fullscreenEnabled || document.webkitFullscreenEnabled)) await testInfo.attach('subtitle-fullscreen-capability', { contentType: 'application/json', body: JSON.stringify({ native }) }) expect(native, 'Desktop engine must execute native fullscreen in this test').toBe(true) await page.click('#play') await expect.poll(() => page.evaluate(() => (document.fullscreenElement || document.webkitFullscreenElement) === window.art.template.$player)).toBe(true) expect(await page.evaluate(() => ({ text: window.art.template.$subtitle.textContent.trim(), track: window.art.template.$track === window.initialSubtitleTrack, visible: window.art.subtitle.show }))).toEqual({ text: 'Fullscreen cue', track: true, visible: true }) await page.evaluate(() => { window.art.fullscreen = false }) await expect.poll(() => page.evaluate(() => !window.art.fullscreen)).toBe(true) await page.evaluate(() => { window.art.destroy() for (const url of window.subtitleURLs) URL.revokeObjectURL(url) }) }) test(`${core}: native subtitle cues, offset, styles and show contracts`, async ({ page }) => { await setup(page, core) await page.evaluate(async () => { const art = window.art window.subtitleLoads = [] art.on('subtitleLoad', (cues, option) => window.subtitleLoads.push({ array: Array.isArray(cues), native: cues[0] instanceof VTTCue, sameOption: option === art.subtitle.option })) const request = art.subtitle.switch('/controlled-subtitle/native.vtt', { style: { color: 'red' } }) window.completeSubtitle('native', 'One\nTwo') await request }) await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(1) await expect.poll(() => page.evaluate(() => window.subtitleLoads.length)).toBe(1) await page.evaluate(() => { window.art.seek = 2 }) await expect.poll(() => page.evaluate(() => window.art.subtitle.activeCues.length)).toBe(1) await page.evaluate(() => { window.art.subtitle.cues[0].text = 'One\n \nTwo' }) await expect.poll(() => page.evaluate(() => { window.art.subtitle.update() return window.art.template.$subtitle.querySelectorAll('.art-subtitle-line').length })).toBe(2) const result = await page.evaluate(() => { const art = window.art const before = art.subtitle.cues[0] const lines = Array.from(art.template.$subtitle.querySelectorAll('.art-subtitle-line'), element => ({ text: element.textContent.trim(), group: element.dataset.group })) const escaped = !art.template.$subtitle.querySelector('b') const style = art.subtitle.style('fontSize', '22px') === art.template.$subtitle const color = art.template.$subtitle.style.color art.option.subtitle.escape = false art.subtitle.update() const raw = art.template.$subtitle.querySelector('b')?.textContent art.subtitleOffset = 1 const offset = { start: before.startTime, end: before.endTime, value: art.subtitleOffset, original: before.originalStartTime, identity: before === art.subtitle.cues[0] } const showEvents = [] art.on('subtitle', value => showEvents.push(value)) art.subtitle.show = false art.subtitle.toggle() const shown = art.subtitle.show const result = { lines, escaped, style, color, offset, showEvents, shown, raw, loads: window.subtitleLoads } art.destroy() for (const url of window.subtitleURLs) URL.revokeObjectURL(url) return result }) expect(result).toEqual({ lines: [{ text: 'One', group: '0' }, { text: 'Two', group: '0' }], escaped: true, style: true, color: 'red', offset: { start: 1, end: 8, value: 1, original: 0, identity: true }, showEvents: [false, true], shown: true, raw: 'One', loads: [{ array: true, native: true, sameOption: true }] }) }) test(`${core}: subtitle latest request wins and cancelled calls settle`, async ({ page }) => { await setup(page, core) const result = await page.evaluate(async () => { const art = window.art const first = art.subtitle.switch('/controlled-subtitle/first.vtt', { name: 'First' }) const second = art.subtitle.switch('/controlled-subtitle/second.vtt', { name: 'Second' }) window.completeSubtitle('second', 'Second') const latest = await second window.completeSubtitle('first', 'First') const stale = await first const result = { winner: art.subtitle.url === latest, cancelled: stale === undefined, notice: art.template.$notice.textContent.trim(), aborted: window.subtitleRequests.get('/controlled-subtitle/first.vtt').signal?.aborted === true } art.destroy() for (const url of window.subtitleURLs) URL.revokeObjectURL(url) return result }) expect(result).toEqual(core === 'candidate' ? { winner: true, cancelled: true, notice: 'Switch Subtitle: Second', aborted: true } : { winner: false, cancelled: false, notice: 'Switch Subtitle: First', aborted: false }) }) test(`${core}: subtitle pending completion cannot replace track after destroy`, async ({ page }) => { await setup(page, core) const result = await page.evaluate(async () => { const art = window.art const track = art.template.$track const request = art.subtitle.switch('/controlled-subtitle/late.vtt') art.destroy(false) window.completeSubtitle('late', 'Late') const value = await request const result = { unchanged: art.template.$track === track, cancelled: value === undefined, created: window.subtitleURLs.length } for (const url of window.subtitleURLs) URL.revokeObjectURL(url) return result }) expect(result).toEqual(core === 'candidate' ? { unchanged: true, cancelled: true, created: 0 } : { unchanged: false, cancelled: false, created: 1 }) }) test(`${core}: subtitle owns generated URLs and leaves caller URLs alive`, async ({ page }) => { await setup(page, core) const result = await page.evaluate(async () => { const art = window.art const foreign = URL.createObjectURL(new Blob(['WEBVTT\n\n00:00:00.000 --> 00:00:07.000\nForeign\n'], { type: 'text/vtt' })) await art.subtitle.switch(foreign, { type: 'external' }) const request = art.subtitle.switch('/controlled-subtitle/owned.vtt') window.completeSubtitle('owned', 'Owned') const owned = await request art.destroy() const result = { foreignRevoked: window.subtitleRevoked.includes(foreign), ownedRevoked: window.subtitleRevoked.includes(owned) } for (const url of window.subtitleURLs) URL.revokeObjectURL(url) return result }) expect(result).toEqual(core === 'candidate' ? { foreignRevoked: false, ownedRevoked: true } : { foreignRevoked: true, ownedRevoked: false }) }) } test('candidate: subtitle cancellation settles before an ignored request or body finishes', async ({ page }) => { await setup(page, 'candidate') await page.evaluate(() => { const art = window.art window.cancelledSubtitles = [] art.subtitle.switch('/controlled-subtitle/never.vtt').then(value => window.cancelledSubtitles.push(value === undefined)) const second = art.subtitle.switch('/controlled-subtitle/body.vtt') second.then(value => window.cancelledSubtitles.push(value === undefined)) window.subtitleRequests.get('/controlled-subtitle/body.vtt').resolve({ ok: true, arrayBuffer: () => new Promise((resolve) => { window.finishSubtitleBody = resolve }) }) }) await expect.poll(() => page.evaluate(() => typeof window.finishSubtitleBody)).toBe('function') await page.evaluate(() => window.art.subtitle.switch('')) await expect.poll(() => page.evaluate(() => window.cancelledSubtitles)).toEqual([true, true]) expect(await page.evaluate(async () => { window.finishSubtitleBody(new TextEncoder().encode('WEBVTT').buffer) window.subtitleRequests.get('/controlled-subtitle/never.vtt').reject(new Error('obsolete transport')) await new Promise(resolve => setTimeout(resolve, 0)) const count = window.subtitleURLs.length window.art.destroy() return count })).toBe(0) }) test('candidate: subtitle conversion reentry preserves the newer operation and notice', async ({ page }) => { await setup(page, 'candidate') const result = await page.evaluate(async () => { const art = window.art let second const first = art.subtitle.switch('/controlled-subtitle/first.vtt', { name: 'Obsolete', onVttLoad(vtt) { second = art.subtitle.switch('/controlled-subtitle/second.vtt', { name: 'Winner' }) window.completeSubtitle('second', 'Winner') return vtt } }) window.completeSubtitle('first', 'Obsolete') const cancelled = await first const winner = await second const result = { cancelled: cancelled === undefined, winner: art.subtitle.url === winner, notice: art.template.$notice.textContent.trim(), generated: window.subtitleURLs.length } art.destroy() return result }) expect(result).toEqual({ cancelled: true, winner: true, notice: 'Switch Subtitle: Winner', generated: 1 }) }) test('candidate: active subtitle errors reject, preserve track and allow a later retry', async ({ page }) => { await setup(page, 'candidate') const result = await page.evaluate(async () => { const art = window.art const initial = art.template.$track const error = new Error('controlled subtitle failure') const promise = art.subtitle.switch('/controlled-subtitle/fail.vtt') const failure = promise.catch(caught => caught === error) window.subtitleRequests.get('/controlled-subtitle/fail.vtt').reject(error) const rejected = await failure const sameTrack = art.template.$track === initial const notice = art.template.$notice.textContent.trim() const retry = art.subtitle.switch('/controlled-subtitle/retry.vtt') window.completeSubtitle('retry', 'Retry') const url = await retry const success = art.subtitle.url === url art.destroy() return { rejected, sameTrack, notice, success } }) expect(result).toEqual({ rejected: true, sameTrack: true, notice: 'controlled subtitle failure', success: true }) }) test('candidate: subtitle HTTP failures do not become usable tracks', async ({ page }) => { await setup(page, 'candidate') const result = await page.evaluate(async () => { const art = window.art const track = art.template.$track const request = art.subtitle.switch('/controlled-subtitle/http.vtt').catch(error => error.message) window.subtitleRequests.get('/controlled-subtitle/http.vtt').resolve(new Response('not subtitles', { status: 503, statusText: 'Unavailable' })) const error = await request const result = { error, sameTrack: track === art.template.$track, created: window.subtitleURLs.length } art.destroy() return result }) expect(result).toEqual({ error: 'Failed to load subtitle: 503 Unavailable', sameTrack: true, created: 0 }) }) test('candidate: disabled native tracks expose empty cue arrays without throwing', async ({ page }) => { await setup(page, 'candidate') expect(await page.evaluate(() => { const art = window.art art.subtitle.textTrack.mode = 'disabled' const result = [art.subtitle.cues.length, art.subtitle.activeCues.length] art.subtitle.update() art.destroy() return result })).toEqual([0, 0]) }) test('candidate: native subtitle track failure is reported and obsolete callbacks are removed', async ({ page }) => { await setup(page, 'candidate') await page.evaluate(async () => { const request = window.art.subtitle.switch('/controlled-subtitle/track-error.vtt', { type: 'external' }) window.completeSubtitle('track-error', 'Not served to track') await request }) await expect.poll(() => page.evaluate(() => window.art.template.$notice.textContent.trim())).toBe('Failed to load subtitle track') const result = await page.evaluate(async () => { const art = window.art const previous = art.template.$track const oldError = previous.onerror const request = art.subtitle.switch('/controlled-subtitle/recovered.vtt', { name: 'Recovered' }) window.completeSubtitle('recovered', 'Recovered') await request oldError(new Event('error')) const result = { notice: art.template.$notice.textContent.trim(), callbacksCleared: previous.onload === null && previous.onerror === null } const current = art.template.$track art.destroy() result.destroyCleared = current.onload === null && current.onerror === null return result }) expect(result).toEqual({ notice: 'Switch Subtitle: Recovered', callbacksCleared: true, destroyCleared: true }) }) test('candidate: constructor and URL setter failures notify without unhandled promises', async ({ page }) => { await setup(page, 'candidate') await page.evaluate(() => { const container = window.art.option.container window.art.destroy() const art = window.art = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true, subtitle: { url: '/controlled-subtitle/constructor.vtt' } }) window.subtitleNoticeWrites = [] const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(art.notice), 'show') Object.defineProperty(art.notice, 'show', { get: descriptor.get, set(value) { descriptor.set.call(this, value) window.subtitleNoticeWrites.push(art.template.$notice.textContent.trim()) } }) window.subtitleRequests.get('/controlled-subtitle/constructor.vtt').reject(new Error('constructor subtitle error')) window.subtitleRetry = () => { art.subtitle.url = '/controlled-subtitle/setter.vtt' window.subtitleRequests.get('/controlled-subtitle/setter.vtt').reject(new Error('setter subtitle error')) } }) await expect.poll(() => page.evaluate(() => window.subtitleNoticeWrites)).toContain('constructor subtitle error') await page.evaluate(() => window.subtitleRetry()) await expect.poll(() => page.evaluate(() => window.subtitleNoticeWrites)).toContain('setter subtitle error') await page.evaluate(() => window.art.destroy()) }) for (const fail of [false, true]) { test(`candidate: reentrant track replacement keeps the winner when outer throws=${fail}`, async ({ page }) => { await setup(page, 'candidate') const result = await page.evaluate(async (fail) => { const art = window.art const proxy = art.events.proxy let winner const failure = new Error('outer track registration failure') art.events.proxy = (...args) => { art.events.proxy = proxy art.subtitle.createTrack('subtitles', art.subtitle.url) winner = art.template.$track if (fail) throw failure return proxy(...args) } const promise = art.subtitle.switch('/controlled-subtitle/reentry.vtt').catch(error => error === failure) window.completeSubtitle('reentry', 'Reentry') const settled = await promise let updates = 0 art.subtitle.update = () => updates++ winner.track.dispatchEvent(new Event('cuechange')) const result = { winner: art.template.$track === winner, kind: winner.kind, updates, settled: fail ? settled === true : settled === undefined, live: !window.subtitleRevoked.includes(winner.src) } art.destroy() result.released = window.subtitleRevoked.includes(winner.src) return result }, fail) expect(result).toEqual({ winner: true, kind: 'subtitles', updates: 1, settled: true, live: true, released: true }) }) } test('candidate: failed track insertion restores the old live track and releases the attempted URL', async ({ page }) => { await setup(page, 'candidate') const result = await page.evaluate(async () => { const art = window.art const first = art.subtitle.switch('/controlled-subtitle/old.vtt') window.completeSubtitle('old', 'Old') const oldURL = await first const track = art.template.$track const video = art.template.$video const append = video.appendChild const failure = new Error('insert failure') video.appendChild = () => { throw failure } const attempt = art.subtitle.switch('/controlled-subtitle/failed.vtt').catch(error => error === failure) window.completeSubtitle('failed', 'Failed') const rejected = await attempt video.appendChild = append let updates = 0 art.subtitle.update = () => updates++ track.track.dispatchEvent(new Event('cuechange')) const result = { rejected, same: art.template.$track === track, attached: track.parentNode === video, updates, oldLive: !window.subtitleRevoked.includes(oldURL), newReleased: window.subtitleRevoked.includes(window.subtitleURLs[1]) } art.destroy() return result }) expect(result).toEqual({ rejected: true, same: true, attached: true, updates: 1, oldLive: true, newReleased: true }) }) test('candidate: cleanup failure after track commit does not revoke the committed URL', async ({ page }) => { await setup(page, 'candidate') const result = await page.evaluate(async () => { const art = window.art const first = art.subtitle.switch('/controlled-subtitle/old.vtt') window.completeSubtitle('old', 'Old') const oldURL = await first const oldCleanup = art.subtitle.destroyEvent const remove = art.events.remove.bind(art.events) art.events.remove = (cleanup) => { remove(cleanup) if (cleanup === oldCleanup) throw new Error('old cleanup failed') } const attempt = art.subtitle.switch('/controlled-subtitle/committed.vtt').then(() => false, () => true) window.completeSubtitle('committed', 'Committed') const rejected = await attempt art.events.remove = remove const url = art.subtitle.url let updates = 0 art.subtitle.update = () => updates++ art.subtitle.textTrack.dispatchEvent(new Event('cuechange')) const result = { rejected, replaced: url !== oldURL, updates, oldReleased: window.subtitleRevoked.includes(oldURL), currentLive: !window.subtitleRevoked.includes(url) } art.destroy() result.released = window.subtitleRevoked.includes(url) return result }) expect(result).toEqual({ rejected: true, replaced: true, updates: 1, oldReleased: true, currentLive: true, released: true }) })