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: 'custom' })
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, 'custom')
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()
})