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 candidate test.beforeAll(async () => { const input = await browserCandidate('artplayer-plugin-chromecast', process.env.ARTPLAYER_CHROMECAST_ARTIFACT) candidate = input.code provenance = input.provenance }) async function setup(page, core, testInfo, loaded = true) { const external = [] await page.route('**/*', (route) => { const url = new URL(route.request().url()) const local = ['127.0.0.1', 'localhost'].includes(url.hostname) const localBlob = url.protocol === 'blob:' && url.origin === new URL(testInfo.project.use.baseURL).origin if (!local && !localBlob) { external.push(url.href) return route.abort() } return route.continue() }) await page.goto(`/test/player.html?core=${core}`) await page.addScriptTag({ content: candidate }) await page.evaluate(async (loaded) => { const listeners = new Map() const stub = window.castStub = { requests: [], loads: [], options: [], callbacks: [[], []], starts: [0, 0], errors: [[], []], current: null, deferredRequest: false, previousCalls: [], emit(type, event) { for (const listener of [...(listeners.get(type) || [])]) listener(event) }, count() { return [...listeners.values()].reduce((sum, set) => sum + set.size, 0) }, sessionState(value) { this.emit('session', { sessionState: value, session: this.current }) }, } const session = { loadMedia(request) { return new Promise((resolve, reject) => stub.loads.push({ request, resolve, reject })) }, } const context = { setOptions(option) { stub.options.push(option) }, getCurrentSession() { return stub.current }, addEventListener(type, listener) { if (!listeners.has(type)) listeners.set(type, new Set()) listeners.get(type).add(listener) }, removeEventListener(type, listener) { listeners.get(type)?.delete(listener) }, requestSession() { return new Promise((resolve) => { const accept = () => { stub.current = session stub.sessionState('SESSION_STARTED') // The real SDK resolves without returning the CastSession. resolve(undefined) } stub.requests.push({ accept }) if (!stub.deferredRequest) accept() }) }, } window.installCastStub = () => { window.cast = { framework: { CastContext: { getInstance: () => context }, CastContextEventType: { SESSION_STATE_CHANGED: 'session', CAST_STATE_CHANGED: 'availability' }, SessionState: Object.fromEntries(['NO_SESSION', 'SESSION_STARTING', 'SESSION_STARTED', 'SESSION_RESUMED', 'SESSION_ENDING', 'SESSION_ENDED', 'SESSION_START_FAILED'].map(name => [name, name])), CastState: Object.fromEntries(['NO_DEVICES_AVAILABLE', 'NOT_CONNECTED', 'CONNECTING', 'CONNECTED'].map(name => [name, name])), } } window.chrome ||= {} window.chrome.cast = { AutoJoinPolicy: { ORIGIN_SCOPED: 'origin' }, media: { DEFAULT_MEDIA_RECEIVER_APP_ID: 'controlled-receiver', MediaInfo: class { constructor(contentId, contentType) { Object.assign(this, { contentId, contentType }) } }, LoadRequest: class { constructor(media) { this.media = media } }, }, } } if (loaded) window.installCastStub() window.previousCastCallback = (...args) => stub.previousCalls.push(args) window.__onGCastApiAvailable = window.previousCastCallback window.castPlayers = [] window.registrationBefore = [] for (let index = 0; index < 2; index++) { const container = index === 0 ? document.querySelector('.player') : document.body.appendChild(document.createElement('div')) container.id = `cast-player-${index}` container.style.cssText = 'width:480px;height:270px' const art = new window.Artplayer({ container, url: `/test/pattern.mp4?player=${index}`, muted: true, mutex: false }) window.castPlayers.push(art) const registration = art.plugins.add(window.artplayerPluginChromecast({ sdk: '/test/controlled-cast-sdk.js', onStateChange: state => stub.callbacks[index].push(state), onCastStart: () => stub.starts[index]++, onError: error => stub.errors[index].push(String(error)), })) window.registrationBefore.push({ promise: registration instanceof Promise, absent: !art.plugins.artplayerPluginChromecast }) await registration art.controls.show = true } }, loaded) await expect.poll(() => page.evaluate(() => window.castPlayers.every(art => art.isReady))).toBe(true) await testInfo.attach('chromecast-controlled-browser-inputs', { contentType: 'application/json', body: JSON.stringify({ core, provenance, sha256: hash(candidate), sdk: 'controlled stub; no receiver, discovery, real SDK or Cast media playback acceptance', native: 'ArtPlayer core, DOM controls, browser click dispatch and local video' }) }) return external } const button = (page, index) => page.locator(`#cast-player-${index} .art-control-chromecast`) const icon = (page, index) => page.locator(`#cast-player-${index} .art-icon-cast`) const loads = page => page.evaluate(() => window.castStub.loads.length) test.afterEach(async ({ page }, testInfo) => { await page.evaluate(() => { for (const art of window.castPlayers || []) { if (!art.isDestroy) art.destroy() } }) await testInfo.attach('chromecast-controlled-browser-state', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({ version: window.Artplayer?.version, listeners: window.castStub?.count(), starts: window.castStub?.starts, errors: window.castStub?.errors, callbacks: window.castStub?.callbacks, controls: document.querySelectorAll('.art-control-chromecast').length }))) }) expect(await page.evaluate(() => window.castStub?.count() ?? 0)).toBe(0) }) for (const core of ['published-5.3.0', 'published', 'candidate']) { test(`${core}: Cast async registration and native clicks update the owning control`, async ({ page }, testInfo) => { const external = await setup(page, core, testInfo) expect(await page.evaluate(() => window.registrationBefore)).toEqual([{ promise: true, absent: true }, { promise: true, absent: true }]) await expect(button(page, 0)).toHaveCount(1) await expect(button(page, 1)).toHaveCount(1) await button(page, 1).click() await expect.poll(() => loads(page)).toBe(1) await expect(icon(page, 1)).toHaveCSS('color', 'rgb(255, 0, 0)') expect(await icon(page, 0).evaluate(node => node.style.color)).toBe('') expect(await page.evaluate(() => window.castStub.loads[0].request.media)).toEqual({ contentId: '/test/pattern.mp4?player=1', contentType: 'video/mp4' }) await button(page, 1).click() expect(await loads(page)).toBe(1) await page.evaluate(() => window.castStub.loads[0].resolve(undefined)) await expect.poll(() => page.evaluate(() => window.castStub.starts)).toEqual([0, 1]) await button(page, 0).click() await expect.poll(() => loads(page)).toBe(2) expect(await page.evaluate(() => window.castStub.requests.length)).toBe(1) await page.evaluate(() => { window.castStub.sessionState('SESSION_RESUMED') window.castStub.loads[1].resolve(undefined) }) await expect(icon(page, 0)).toHaveCSS('color', 'rgb(255, 0, 0)') await expect.poll(() => page.evaluate(() => window.castStub.starts)).toEqual([1, 1]) expect(await page.evaluate(() => [window.castStub.options.length, window.castStub.count()])).toEqual([1, 4]) expect(external).toEqual([]) }) test(`${core}: concurrent Cast controls share one local SDK load and release a destroyed waiter`, async ({ page }, testInfo) => { let sdkRoute let finish const held = new Promise(resolve => finish = resolve) const external = await setup(page, core, testInfo, false) const requests = [] await page.route('**/test/controlled-cast-sdk.js', async (route) => { requests.push(route.request().url()) sdkRoute = route await held await route.fulfill({ contentType: 'text/javascript', body: 'window.installCastStub(); window.__onGCastApiAvailable(true);' }) }) try { await button(page, 0).click() await button(page, 1).click() await expect.poll(() => Boolean(sdkRoute)).toBe(true) expect(requests).toHaveLength(1) expect(await page.locator('script[src="/test/controlled-cast-sdk.js"]').count()).toBe(1) await page.evaluate(() => window.castPlayers[0].destroy()) finish() await expect.poll(() => loads(page)).toBe(1) expect(await page.evaluate(() => [window.castStub.options.length, window.castStub.count(), window.__onGCastApiAvailable === window.previousCastCallback])).toEqual([1, 2, true]) expect(await page.evaluate(() => window.castStub.previousCalls)).toEqual([[true, undefined]]) await page.evaluate(() => window.castStub.loads[0].resolve(undefined)) await expect.poll(() => page.evaluate(() => window.castStub.starts)).toEqual([0, 1]) expect(await page.evaluate(() => window.castStub.callbacks[0])).toEqual([]) expect(external).toEqual([]) } finally { finish() } }) test(`${core}: Cast terminal states clear native controls and suppress stale media success`, async ({ page }, testInfo) => { const external = await setup(page, core, testInfo) for (const [index, state] of ['SESSION_ENDED', 'SESSION_START_FAILED', 'NO_SESSION'].entries()) { await button(page, 1).click() await expect.poll(() => loads(page)).toBe(index + 1) await expect(icon(page, 1)).toHaveCSS('color', 'rgb(255, 0, 0)') await page.evaluate(() => window.castStub.sessionState('SESSION_ENDING')) await expect(icon(page, 1)).toHaveCSS('color', 'rgb(255, 165, 0)') await page.evaluate((state) => { window.castStub.current = null window.castStub.sessionState(state) }, state) await expect(icon(page, 1)).toHaveCSS('color', 'rgb(255, 255, 255)') expect(await page.evaluate(() => { const plugin = window.castPlayers[1].plugins.artplayerPluginChromecast return [plugin.getCastState(), plugin.isCasting()] })).toEqual([state, false]) await page.evaluate(index => window.castStub.loads[index].resolve(undefined), index) expect(await page.evaluate(() => window.castStub.starts)).toEqual([0, 0]) } await button(page, 1).click() await expect.poll(() => loads(page)).toBe(4) await page.evaluate(() => window.castStub.loads[3].resolve(undefined)) await expect.poll(() => page.evaluate(() => window.castStub.starts)).toEqual([0, 1]) expect(external).toEqual([]) }) test(`${core}: destroy unbinds Cast listeners during pending session and media operations`, async ({ page }, testInfo) => { const external = await setup(page, core, testInfo) await page.evaluate(() => window.castStub.deferredRequest = true) await button(page, 0).click() await expect.poll(() => page.evaluate(() => window.castStub.requests.length)).toBe(1) expect(await page.evaluate(() => window.castStub.count())).toBe(2) await page.evaluate(() => { window.castPlayers[0].destroy() window.castStub.requests[0].accept() }) expect(await loads(page)).toBe(0) expect(await page.evaluate(() => [window.castStub.count(), window.castPlayers[0].plugins.artplayerPluginChromecast.isCasting()])).toEqual([0, false]) await button(page, 1).click() await expect.poll(() => loads(page)).toBe(1) await page.evaluate(() => { window.castPlayers[1].destroy() window.castStub.loads[0].resolve(undefined) window.castStub.sessionState('SESSION_STARTED') }) expect(await page.evaluate(() => [window.castStub.count(), window.castStub.starts, window.castStub.callbacks])).toEqual([0, [0, 0], [[], []]]) await expect(button(page, 0)).toHaveCount(0) await expect(button(page, 1)).toHaveCount(0) expect(external).toEqual([]) }) test(`${core}: native Cast click reports media rejection once and permits retry`, async ({ page }, testInfo) => { const external = await setup(page, core, testInfo) await button(page, 1).click() await expect.poll(() => loads(page)).toBe(1) await page.evaluate(() => window.castStub.loads[0].reject(new Error('Controlled media load failure'))) await expect.poll(() => page.evaluate(() => window.castStub.errors)).toEqual([[], ['Error: Controlled media load failure']]) await expect(page.locator('#cast-player-1 .art-notice')).toContainText('Error casting media') expect(await page.evaluate(() => window.castStub.starts)).toEqual([0, 0]) await button(page, 1).click() await expect.poll(() => loads(page)).toBe(2) await page.evaluate(() => window.castStub.loads[1].resolve(undefined)) await expect.poll(() => page.evaluate(() => window.castStub.starts)).toEqual([0, 1]) await expect(page.locator('#cast-player-1 .art-notice')).toContainText('Casting started') expect(await page.evaluate(() => window.castStub.errors)).toEqual([[], ['Error: Controlled media load failure']]) expect(external).toEqual([]) }) }