refactor(chromecast): [PKG-CAST-03] isolate SDK and player lifecycles

This commit is contained in:
Harvey Zhao committed 2026-09-13 17:13:35 +08:00
1 parent 87cfcf5a6c
commit b0cfbfe3a0
26 files changed
+2503 -551

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
import fs from 'node:fs'
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
let candidate
test.beforeAll(async () => {
candidate = process.env.ARTPLAYER_CHROMECAST_ARTIFACT
? fs.readFileSync(process.env.ARTPLAYER_CHROMECAST_ARTIFACT, 'utf8')
: await compilePackage('artplayer-plugin-chromecast', 'umd')
})
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, artifact: process.env.ARTPLAYER_CHROMECAST_ARTIFACT || 'candidate source', 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([])
})
}
+629
View File
@@ -0,0 +1,629 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
import fs from 'node:fs'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Candidate SDK behavior uses the repository runner.
import test from 'node:test'
import { setImmediate as nextTurn } from 'node:timers/promises'
import { pathToFileURL } from 'node:url'
import { chromecastEnvironment } from './helpers/chromecast.js'
import { compilePackage } from './helpers/load.js'
const artifact = process.env.ARTPLAYER_CHROMECAST_ARTIFACT
const implementation = { code: artifact ? fs.readFileSync(artifact, 'utf8') : await compilePackage('artplayer-plugin-chromecast', 'umd') }
function environment(options) {
const env = chromecastEnvironment(implementation, options)
const timers = new Map()
let next = 0
env.context.setTimeout = (callback, delay) => {
const id = ++next
timers.set(id, { callback, delay })
return id
}
env.context.clearTimeout = id => timers.delete(id)
return { ...env, timers, player(...args) {
const player = env.player(...args)
const add = player.art.controls.add
player.art.controls.add = (option) => {
const element = add(option)
option.mounted?.call(player.art, element)
// The actual Control.add override has no return, unlike Component.add.
return undefined
}
return player
} }
}
function deferred() {
let resolve
let reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
for (const script of [false, true]) {
test(`Chromecast candidate ${script ? 'global' : 'CJS'}: lazy async registration and custom DOM contract`, async () => {
const env = environment({ script })
assert.equal(env.factory.default, env.factory)
const player = env.player()
const register = env.factory({ icon: '<b>custom</b>' })
assert.equal(player.controls.length, 0)
const pending = register(player.art)
assert.equal(typeof pending.then, 'function')
assert.equal(player.controls.length, 1)
const plugin = await pending
assert.deepEqual(Object.keys(plugin), ['name', 'getCastState', 'isCasting'])
assert.equal(plugin.name, 'artplayerPluginChromecast')
assert.equal(plugin.getCastState(), null)
assert.equal(plugin.isCasting(), false)
assert.equal(player.controls[0].name, 'chromecast')
assert.equal(player.controls[0].position, 'right')
assert.equal(player.controls[0].tooltip, 'Chromecast')
assert.equal(player.controls[0].html, '<i class="art-icon art-icon-cast"><b>custom</b></i>')
assert.equal(env.scripts.length, 0)
assert.equal(env.timers.size, 0)
player.destroy()
})
}
test('Chromecast candidate: native ESM default and historical factory default alias retain identity', async () => {
const esmArtifact = process.env.ARTPLAYER_CHROMECAST_ESM_ARTIFACT
const namespace = esmArtifact
? await import(pathToFileURL(esmArtifact))
: await import(`data:text/javascript;base64,${Buffer.from(await compilePackage('artplayer-plugin-chromecast', 'es')).toString('base64')}`)
assert.equal(typeof namespace.default, 'function')
assert.equal(namespace.default.default, namespace.default)
assert.equal(Object.getOwnPropertyDescriptor(namespace.default, 'default').writable, true)
assert.equal(typeof namespace.default({}), 'function')
})
test('Chromecast candidate: existing complete SDK immediately installs listeners without loading another script', async () => {
const env = environment()
env.installSdk()
const player = env.player()
const plugin = await env.factory({})(player.art)
const pending = player.controls[0].click()
env.sessionState('SESSION_STARTED')
assert.equal(plugin.getCastState(), 'SESSION_STARTED')
assert.equal(player.icon.style.color, 'red')
await pending
assert.equal(env.scripts.length, 0)
assert.equal(env.sdkOptions.length, 1)
assert.equal(env.requests(), 0)
assert.equal(env.mediaLoads.length, 1)
assert.equal(player.art.notice.show, 'Casting started')
player.destroy()
})
test('Chromecast candidate: availability synchronously installs listeners and preserves live callbacks and callback this', async () => {
const env = environment()
const player = env.player('/movie.M3U8?token=one#position')
const events = []
const option = { sdk: '/sender.js', onStateChange(state) {
assert.equal(this, option)
events.push([state, player.icon.style.color])
}, onCastStart() { assert.equal(this, option) } }
const plugin = await env.factory(option)(player.art)
const pending = player.controls[0].click()
assert.equal(env.scripts[0].src, '/sender.js')
env.installSdk()
env.context.__onGCastApiAvailable(true)
env.sessionState('SESSION_STARTED')
assert.equal(plugin.getCastState(), 'SESSION_STARTED')
assert.deepEqual(events, [['connected', undefined]])
assert.equal(player.icon.style.color, 'red')
await pending
assert.equal(env.mediaLoads[0].media.contentType, 'application/x-mpegURL')
assert.equal(env.timers.size, 0)
player.art.option.url = '/changed.webm'
option.onCastStart = function () {
assert.equal(this, option)
events.push(['live'])
}
await player.controls[0].click()
assert.equal(env.mediaLoads[1].media.contentId, '/changed.webm')
option.url = '/override'
option.mimeType = 'application/custom'
await player.controls[0].click()
assert.equal(env.mediaLoads[2].media.contentId, '/override')
assert.equal(env.mediaLoads[2].media.contentType, 'application/custom')
assert.deepEqual(events.slice(-2), [['live'], ['live']])
player.destroy()
})
test('Chromecast candidate: concurrent loading deduplicates scripts, preserves prior handler, and isolates uninitialized factory instances', async () => {
const env = environment()
const calls = []
function prior(...args) {
calls.push(args)
}
env.context.__onGCastApiAvailable = prior
const register = env.factory({ sdk: '/first.js' })
const first = env.player()
const second = env.player()
const idle = env.player()
const firstPlugin = await register(first.art)
const secondPlugin = await register(second.art)
const idlePlugin = await register(idle.art)
const one = first.controls[0].click()
const two = second.controls[0].click()
assert.equal(env.scripts.length, 1)
env.installSdk()
env.context.__onGCastApiAvailable(true, 'ready')
env.sessionState('SESSION_STARTED')
await Promise.all([one, two])
assert.deepEqual(calls, [[true, 'ready']])
assert.equal(env.context.__onGCastApiAvailable, prior)
assert.equal(env.sdkOptions.length, 1)
assert.equal(firstPlugin.isCasting(), true)
assert.equal(secondPlugin.isCasting(), true)
assert.equal(first.icon.style.color, 'red')
assert.equal(second.icon.style.color, 'red')
assert.equal(idle.icon.style.color, undefined)
assert.equal(idlePlugin.isCasting(), false)
assert.equal(idlePlugin.getCastState(), null)
first.destroy()
assert.equal(env.sdkListeners.get('sessionstatechanged').length, 1)
env.sessionState('SESSION_ENDED', null)
assert.equal(second.icon.style.color, 'white')
assert.equal(first.icon.style.color, 'red')
second.destroy()
idle.destroy()
})
test('Chromecast candidate: successful undefined requestSession completion resolves current session and concurrent clicks share one operation', async () => {
const env = environment()
const { castContext, session } = env.installSdk()
const requested = deferred()
let current = null
let requests = 0
castContext.getCurrentSession = () => current
castContext.requestSession = () => {
requests++
return requested.promise
}
const player = env.player()
await env.factory({})(player.art)
const one = player.controls[0].click()
const two = player.controls[0].click()
assert.equal(one, two)
await env.flush()
assert.equal(requests, 1)
current = session
requested.resolve(undefined)
await one
assert.equal(env.mediaLoads.length, 1)
player.destroy()
})
for (const mode of ['missing-session', 'request-reject', 'media-reject', 'configure-throw']) {
test(`Chromecast candidate: ${mode} settles click with one original error callback and stage notice`, async () => {
const env = environment()
const error = new Error(mode)
const { castContext } = env.installSdk(mode === 'media-reject' ? { loadError: error } : {})
if (mode === 'configure-throw')
castContext.setOptions = () => { throw error }
if (mode === 'missing-session' || mode === 'request-reject') {
castContext.getCurrentSession = () => null
if (mode === 'request-reject')
castContext.requestSession = () => Promise.reject(error)
}
const errors = []
const option = { onError(value) {
assert.equal(this, option)
errors.push(value)
} }
const player = env.player()
await env.factory(option)(player.art)
await assert.rejects(player.controls[0].click(), value => mode === 'missing-session' ? value.message === 'No active Cast session' : value === error)
assert.equal(errors.length, 1)
assert.equal(player.art.notice.show, mode === 'media-reject' ? 'Error casting media' : mode === 'configure-throw' ? 'Failed to initialize Cast API' : 'Error connecting to cast session')
if (mode !== 'media-reject')
assert.equal(env.mediaLoads.length, 0)
player.destroy()
})
}
test('Chromecast candidate: configure errors inside availability do not escape global callback and permit retry', async () => {
const env = environment()
const player = env.player()
await env.factory({})(player.art)
const pending = player.controls[0].click()
const { castContext } = env.installSdk()
const original = castContext.setOptions
const error = new Error('configuration')
castContext.setOptions = () => {
throw error
}
assert.doesNotThrow(() => env.context.__onGCastApiAvailable(true))
await assert.rejects(pending, value => value === error)
castContext.setOptions = original
await player.controls[0].click()
assert.equal(env.mediaLoads.length, 1)
assert.equal(env.sdkListeners.get('sessionstatechanged').length, 1)
player.destroy()
})
for (const mode of ['script-error', 'unavailable', 'timeout']) {
test(`Chromecast candidate: ${mode} restores external callback, removes failed script and permits retry`, async () => {
const env = environment()
function prior() {}
env.context.__onGCastApiAvailable = prior
const player = env.player()
const errors = []
await env.factory({ onError: value => errors.push(value) })(player.art)
const pending = player.controls[0].click()
const script = env.scripts[0]
const original = { type: 'error' }
if (mode === 'script-error') {
script.onerror(original)
}
else if (mode === 'unavailable') {
env.context.__onGCastApiAvailable(false)
}
else {
script.onload()
const timer = [...env.timers.values()][0]
assert.equal(timer.delay, 30000)
timer.callback()
}
await assert.rejects(pending, value => mode === 'script-error' ? value === original : value.message.includes(mode === 'timeout' ? 'Timed out' : 'not available'))
assert.equal(errors.length, 1)
assert.equal(env.context.__onGCastApiAvailable, prior)
assert.equal(env.scripts.length, 0)
assert.equal(env.timers.size, 0)
const retry = player.controls[0].click()
assert.equal(env.scripts.length, 1)
env.installSdk()
env.context.__onGCastApiAvailable(true)
await retry
assert.equal(env.mediaLoads.length, 1)
player.destroy()
})
}
test('Chromecast candidate: asynchronous framework can become ready after script load and the last owner cancels unfinished loading', async () => {
const env = environment()
const first = env.player()
const second = env.player()
await env.factory({})(first.art)
await env.factory({ sdk: '/different.js' })(second.art)
const one = first.controls[0].click()
const two = second.controls[0].click()
assert.equal(env.scripts.length, 1)
assert.match(env.scripts[0].src, /gstatic/)
env.scripts[0].onload()
assert.equal(env.timers.size, 1)
first.destroy()
await one
assert.equal(env.scripts.length, 1)
second.destroy()
await two
assert.equal(env.scripts.length, 0)
assert.equal(env.timers.size, 0)
assert.equal(env.context.__onGCastApiAvailable, undefined)
assert.equal(env.mediaLoads.length, 0)
})
test('Chromecast candidate: one destroyed loader subscriber does not strand remaining subscriber', async () => {
const env = environment()
const first = env.player()
const second = env.player()
await env.factory({})(first.art)
await env.factory({})(second.art)
const one = first.controls[0].click()
const two = second.controls[0].click()
first.destroy()
await one
env.installSdk()
env.context.__onGCastApiAvailable(true)
await two
assert.equal(env.mediaLoads.length, 1)
assert.equal(env.sdkListeners.get('sessionstatechanged').length, 1)
assert.equal(first.art.notice.show, '')
second.destroy()
})
test('Chromecast candidate: an external callback exception preserves external semantics without stranding plugin initialization', async () => {
const env = environment()
const error = new Error('external')
env.context.__onGCastApiAvailable = () => {
throw error
}
const player = env.player()
await env.factory({})(player.art)
const pending = player.controls[0].click()
env.installSdk()
assert.throws(() => env.context.__onGCastApiAvailable(true), value => value === error)
await pending
assert.equal(env.mediaLoads.length, 1)
player.destroy()
})
test('Chromecast candidate: external handler replacement during loading is not overwritten on cleanup', async () => {
const env = environment()
const player = env.player()
await env.factory({})(player.art)
const pending = player.controls[0].click()
const own = env.context.__onGCastApiAvailable
function replacement() {}
env.context.__onGCastApiAvailable = replacement
env.installSdk()
own(true)
await pending
assert.equal(env.context.__onGCastApiAvailable, replacement)
player.destroy()
})
for (const terminal of ['NO_SESSION', 'SESSION_ENDING', 'SESSION_ENDED', 'SESSION_START_FAILED']) {
test(`Chromecast candidate: ${terminal} cancels an in-flight session request and cannot revive casting`, async () => {
const env = environment()
const { castContext, session } = env.installSdk()
env.context.cast.framework.SessionState.SESSION_START_FAILED = 'SESSION_START_FAILED'
let current = null
const request = deferred()
castContext.getCurrentSession = () => current
castContext.requestSession = () => request.promise
const player = env.player()
const calls = []
const plugin = await env.factory({ onStateChange: state => calls.push(state), onCastStart: () => calls.push('start') })(player.art)
const pending = player.controls[0].click()
await env.flush()
env.sessionState(terminal, null)
await pending
current = session
request.resolve(undefined)
await env.flush()
assert.equal(env.mediaLoads.length, 0)
assert.equal(plugin.getCastState(), terminal)
assert.equal(plugin.isCasting(), false)
assert.deepEqual(calls, [terminal === 'SESSION_ENDING' ? 'disconnecting' : 'disconnected'])
assert.equal(player.icon.style.color, terminal === 'SESSION_ENDING' ? 'orange' : 'white')
player.destroy()
})
}
for (const action of ['destroy', 'ended', 'replacement']) {
test(`Chromecast candidate: ${action} during media load suppresses obsolete success and consumes late rejection`, async () => {
const env = environment()
const { session, castContext } = env.installSdk()
const media = deferred()
session.loadMedia = () => media.promise
const calls = []
const player = env.player()
const plugin = await env.factory({ onCastStart: () => calls.push('start'), onError: () => calls.push('error') })(player.art)
const pending = player.controls[0].click()
await env.flush()
assert.equal(plugin.isCasting(), true)
if (action === 'destroy') {
player.destroy()
}
else if (action === 'ended') {
env.sessionState('SESSION_ENDED', null)
}
else {
const other = { loadMedia: () => Promise.resolve() }
castContext.getCurrentSession = () => other
env.sessionState('SESSION_RESUMED', other)
}
await pending
media.reject(new Error('obsolete'))
await env.flush()
assert.deepEqual(calls, [])
assert.equal(player.art.notice.show, '')
if (action === 'destroy') {
assert.equal(env.sdkListeners.get('sessionstatechanged').length, 0)
assert.equal(env.sdkListeners.get('caststatechanged').length, 0)
assert.equal(player.listeners.get('destroy').length, 0)
}
player.destroy()
})
}
test('Chromecast candidate: reentrant destruction while reading current session never invokes media load', async () => {
const env = environment()
const { castContext, session } = env.installSdk()
const player = env.player()
await env.factory({})(player.art)
castContext.getCurrentSession = () => {
player.destroy()
return session
}
await player.controls[0].click()
assert.equal(env.mediaLoads.length, 0)
})
test('Chromecast candidate: availability and every normalized state retain raw session getters and owned callback ordering', async () => {
const env = environment()
env.installSdk()
env.context.cast.framework.SessionState.SESSION_START_FAILED = 'SESSION_START_FAILED'
const player = env.player()
const states = []
const availability = []
const option = { onStateChange(state) {
assert.equal(this, option)
states.push(state)
}, onCastAvailable(value) {
assert.equal(this, option)
availability.push(value)
} }
const plugin = await env.factory(option)(player.art)
await player.controls[0].click()
for (const state of ['SESSION_STARTING', 'SESSION_STARTED', 'SESSION_RESUMED', 'SESSION_ENDING', 'SESSION_ENDED', 'SESSION_START_FAILED', 'NO_SESSION']) {
env.sessionState(state)
assert.equal(plugin.getCastState(), state)
}
assert.deepEqual(states, ['connecting', 'connected', 'connected', 'disconnecting', 'disconnected', 'disconnected', 'disconnected'])
assert.equal(plugin.isCasting(), false)
for (const castState of ['NO_DEVICES_AVAILABLE', 'NOT_CONNECTED', 'CONNECTING', 'CONNECTED', 'unknown'])
env.emit('caststatechanged', { castState })
assert.deepEqual(availability, [false, true, true, true])
player.destroy()
})
test('Chromecast candidate: synchronous initial no-session replay preserves casting and the availability subscription', async () => {
const env = environment()
const { castContext } = env.installSdk()
const add = castContext.addEventListener
castContext.addEventListener = (name, listener) => {
add(name, listener)
if (name === 'sessionstatechanged')
listener({ sessionState: 'NO_SESSION', session: null })
}
const values = []
const player = env.player()
await env.factory({ onCastAvailable: value => values.push(value) })(player.art)
await player.controls[0].click()
assert.equal(env.mediaLoads.length, 1)
await player.controls[0].click()
env.emit('caststatechanged', { castState: 'NOT_CONNECTED' })
assert.deepEqual(values, [true])
assert.equal(env.sdkListeners.get('sessionstatechanged').length, 1)
assert.equal(env.sdkListeners.get('caststatechanged').length, 1)
player.destroy()
})
test('Chromecast candidate: initial NO_SESSION immediately after availability still requests and loads a new session', async () => {
const env = environment()
const player = env.player()
const plugin = await env.factory({})(player.art)
const pending = player.controls[0].click()
const { castContext, session } = env.installSdk()
let current = null
let requests = 0
castContext.getCurrentSession = () => current
castContext.requestSession = () => {
requests++
current = session
return Promise.resolve(undefined)
}
env.context.__onGCastApiAvailable(true)
env.sessionState('NO_SESSION', null)
await pending
assert.equal(requests, 1)
assert.equal(env.mediaLoads.length, 1)
assert.equal(plugin.getCastState(), 'NO_SESSION', 'Getter remains the raw last SDK event')
assert.equal(plugin.isCasting(), true)
player.destroy()
})
test('Chromecast candidate: cleanup attempts all owned listeners and the player subscription after an SDK removal failure', async () => {
const env = environment()
const { castContext } = env.installSdk()
const player = env.player()
const plugin = await env.factory({})(player.art)
await player.controls[0].click()
const remove = castContext.removeEventListener
const error = new Error('remove session listener')
const names = []
castContext.removeEventListener = (name, listener) => {
names.push(name)
if (name === 'sessionstatechanged')
throw error
remove(name, listener)
}
assert.throws(() => player.destroy(), value => value === error)
assert.deepEqual(names, ['sessionstatechanged', 'caststatechanged'])
assert.equal(env.sdkListeners.get('caststatechanged').length, 0)
assert.equal(player.listeners.get('destroy').length, 0)
assert.equal(plugin.isCasting(), false)
const state = plugin.getCastState()
env.sessionState('SESSION_STARTED')
assert.equal(plugin.getCastState(), state)
assert.doesNotThrow(() => player.destroy())
})
test('Chromecast candidate: partial subscription failure preserves the setup error and retries complete initialization', async () => {
const env = environment()
const { castContext } = env.installSdk()
const add = castContext.addEventListener
const remove = castContext.removeEventListener
const setupError = new Error('add availability')
const cleanupError = new Error('remove after add failed')
castContext.addEventListener = (name, listener) => {
if (name === 'caststatechanged')
throw setupError
add(name, listener)
}
castContext.removeEventListener = (name, listener) => {
remove(name, listener)
if (name === 'sessionstatechanged')
throw cleanupError
}
const errors = []
const player = env.player()
await env.factory({ onError: error => errors.push(error) })(player.art)
await assert.rejects(player.controls[0].click(), value => value === setupError)
assert.deepEqual(errors, [setupError])
assert.equal(env.sdkListeners.get('sessionstatechanged').length, 0)
castContext.addEventListener = add
castContext.removeEventListener = remove
await player.controls[0].click()
assert.equal(env.sdkListeners.get('sessionstatechanged').length, 1)
assert.equal(env.sdkListeners.get('caststatechanged').length, 1)
player.destroy()
})
test('Chromecast candidate: ignored DOM-dispatch click rejection is observed internally without duplicate reporting', async () => {
const env = environment()
const error = new Error('receiver load failed')
env.installSdk({ loadError: error })
const player = env.player()
const errors = []
await env.factory({ onError: value => errors.push(value) })(player.art)
// The core DOM dispatcher calls click and ignores its return value.
player.controls[0].click()
await nextTurn()
assert.deepEqual(errors, [error])
assert.equal(player.art.notice.show, 'Error casting media')
await assert.rejects(player.controls[0].click(), value => value === error)
assert.deepEqual(errors, [error, error])
player.destroy()
})
test('Chromecast candidate: synchronous user callback reentry shares the guarded click operation', async () => {
const env = environment()
const { castContext } = env.installSdk()
const add = castContext.addEventListener
castContext.addEventListener = (name, listener) => {
add(name, listener)
if (name === 'sessionstatechanged')
listener({ sessionState: 'NO_SESSION', session: null })
}
let reentered
const player = env.player()
await env.factory({ onStateChange() {
if (!reentered)
reentered = player.controls[0].click()
} })(player.art)
const pending = player.controls[0].click()
assert.equal(reentered, pending)
await pending
assert.equal(env.mediaLoads.length, 1)
assert.equal(env.sdkListeners.get('sessionstatechanged').length, 1)
player.destroy()
})
test('Chromecast candidate: mounted captures the owned icon when Control.add returns undefined', async () => {
const env = environment()
env.installSdk()
const first = env.player()
const second = env.player()
await env.factory({})(first.art)
await env.factory({})(second.art)
assert.equal(typeof second.controls[0].mounted, 'function')
await second.controls[0].click()
env.sessionState('SESSION_STARTED')
assert.equal(second.icon.style.color, 'red')
assert.equal(first.icon.style.color, undefined)
env.sessionState('SESSION_ENDING')
assert.equal(second.icon.style.color, 'orange')
env.sessionState('SESSION_ENDED', null)
assert.equal(second.icon.style.color, 'white')
first.destroy()
second.destroy()
})