test(chromecast): [PKG-CAST-01] freeze published contracts and provenance

This commit is contained in:
Harvey Zhao committed 2026-09-13 16:35:26 +08:00
1 parent d5562b05b7
commit 30de007102
14 files changed
+833 -7

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Frozen Chromecast observations use the repository runner.
import test from 'node:test'
import { chromecastEnvironment, chromecastHistorical } from './helpers/chromecast.js'
for (const implementation of await chromecastHistorical()) {
if (implementation.name.startsWith('published-1.0.0-')) {
for (const script of [false, true]) {
test(`Chromecast ${implementation.name}: ${script ? 'global' : 'CJS'} eager SDK registration returns only name`, async () => {
const env = chromecastEnvironment(implementation, { script })
const player = env.player()
const register = env.factory({ icon: '<b>cast</b>' })
assert.equal(typeof env.context.__onGCastApiAvailable, 'function')
assert.equal(env.scripts.length, 0)
const pending = register(player.art)
assert.equal(player.controls.length, 0)
assert.equal(env.scripts.length, 1)
env.installSdk()
env.context.__onGCastApiAvailable(true)
env.scripts[0].onload({ type: 'load' })
const plugin = await pending
assert.deepEqual(Object.keys(plugin), ['name'])
assert.equal(plugin.name, 'artplayerPluginChromecast')
assert.equal(plugin.getCastState, undefined)
assert.equal(player.controls[0].name, 'chromecast')
assert.equal(player.controls[0].html, '<i class="art-icon art-icon-cast"><b>cast</b></i>')
assert.equal(player.controls[0].click(), undefined)
await env.flush()
assert.equal(env.mediaLoads[0].media.contentId, '/video.mp4')
assert.equal(env.mediaLoads[0].media.contentType, 'video/mp4')
assert.equal(env.sdkListeners.size, 0)
})
}
test(`Chromecast ${implementation.name}: existing SDK skips loading and live options select media without modern callbacks`, async () => {
const env = chromecastEnvironment(implementation)
env.installSdk()
const player = env.player('/movie.M3U8?token=one#position')
const events = []
const option = { onCastStart: () => events.push('start') }
await env.factory(option)(player.art)
assert.equal(env.scripts.length, 0)
player.controls[0].click()
await env.flush()
assert.equal(env.mediaLoads[0].media.contentType, 'application/x-mpegURL')
option.url = '/custom'
option.mimeType = 'application/custom'
player.controls[0].click()
await env.flush()
assert.equal(env.mediaLoads.at(-1).media.contentId, '/custom')
assert.equal(env.mediaLoads.at(-1).media.contentType, 'application/custom')
assert.deepEqual(events, [])
assert.equal(player.art.notice.show, '')
})
test(`Chromecast ${implementation.name}: script failure rejects registration without adding controls`, async () => {
const env = chromecastEnvironment(implementation)
const player = env.player()
const pending = env.factory({})(player.art)
const error = { type: 'error' }
env.scripts[0].onerror(error)
await assert.rejects(pending, value => value === error)
assert.equal(player.controls.length, 0)
})
continue
}
for (const script of [false, true]) {
test(`Chromecast ${implementation.name}: ${script ? 'global' : 'CJS'} lazy registration and public extraction`, async () => {
const env = chromecastEnvironment(implementation, { script })
const player = env.player()
const register = env.factory({ icon: '<b>cast</b>' })
assert.equal(env.scripts.length, 0)
assert.equal(player.controls.length, 0)
const pending = register(player.art)
assert.equal(typeof pending.then, 'function')
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>cast</b></i>')
assert.equal(env.scripts.length, 0)
})
}
test(`Chromecast ${implementation.name}: session callback order, casting request and MIME remain observable`, async () => {
const env = chromecastEnvironment(implementation)
const player = env.player('/movie.M3U8?token=one#position')
const events = []
const option = { sdk: '/local-sender.js', onStateChange: state => events.push(['state', state]), onCastAvailable: value => events.push(['available', value]), onCastStart: () => events.push(['start']) }
const plugin = await env.factory(option)(player.art)
const pending = player.controls[0].click()
assert.equal(env.scripts[0].src, '/local-sender.js')
const { session } = env.installSdk()
env.context.__onGCastApiAvailable(true)
env.sessionState('SESSION_STARTED', session)
await pending
await env.flush()
assert.equal(env.requests(), 0)
assert.equal(env.mediaLoads[0].media.contentId, '/movie.M3U8?token=one#position')
assert.equal(env.mediaLoads[0].media.contentType, 'application/x-mpegURL')
assert.equal(player.art.notice.show, 'Casting started')
assert.equal(plugin.getCastState(), 'SESSION_STARTED')
assert.equal(plugin.isCasting(), true)
assert.equal(player.icon.style.color, 'red')
assert.deepEqual(events, [['state', 'connected'], ['start']])
env.emit('caststatechanged', { castState: 'NO_DEVICES_AVAILABLE' })
env.emit('caststatechanged', { castState: 'NOT_CONNECTED' })
assert.deepEqual(events.slice(-2), [['available', false], ['available', true]])
option.url = 'https://media.test/custom'
option.mimeType = 'application/custom'
await player.controls[0].click()
await env.flush()
assert.equal(env.mediaLoads.at(-1).media.contentId, option.url)
assert.equal(env.mediaLoads.at(-1).media.contentType, option.mimeType)
assert.equal(env.scripts.length, 1)
})
test(`Chromecast ${implementation.name}: script failure preserves error identity and does not mark initialized`, async () => {
const env = chromecastEnvironment(implementation)
const player = env.player()
const failures = []
const plugin = await env.factory({ onError: error => failures.push(error) })(player.art)
const pending = player.controls[0].click()
const error = { type: 'error', target: env.scripts[0] }
env.scripts[0].onerror(error)
await assert.rejects(pending, value => value === error)
assert.equal(failures[0], error)
assert.equal(player.art.notice.show, 'Failed to initialize Cast API')
assert.equal(plugin.isCasting(), false)
})
}
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict'
import vm from 'node:vm'
import { transform } from 'esbuild'
import { verifyChromecastContract } from '../../refactor/scripts/chromecast-contract.mjs'
import { readMember } from '../../refactor/scripts/releases.mjs'
export async function chromecastHistorical() {
const { baseline, archives, sources } = await verifyChromecastContract()
const implementations = []
for (const release of [baseline.release, ...baseline.previous]) {
for (const field of ['main', 'legacy'])
implementations.push({ name: `published-${release.version}-${field}`, code: readMember(archives.get(release.version), `package/${release.manifest[field].replace(/^\.\//u, '')}`).toString() })
}
const prefix = 'packages/artplayer-plugin-chromecast'
const source = sources.get(`${prefix}/src/index.js`)
implementations.push({ name: 'frozen-source', code: (await transform(source, { format: 'cjs', target: 'es2020' })).code, scriptCode: (await transform(source, { format: 'iife', globalName: 'artplayerPluginChromecast', target: 'es2020' })).code })
for (const suffix of ['js', 'legacy.js'])
implementations.push({ name: `frozen-workspace-${suffix}`, code: sources.get(`${prefix}/dist/artplayer-plugin-chromecast.${suffix}`) })
return implementations
}
export function chromecastEnvironment(implementation, { script = false } = {}) {
const scripts = []
const icons = []
const controls = []
const logs = []
const sdkListeners = new Map()
const pendingMedia = []
const mediaLoads = []
const sdkOptions = []
let requests = 0
let sdk
const context = vm.createContext({
console: { log: (...args) => logs.push(args), warn: (...args) => logs.push(args), error: (...args) => logs.push(args) },
document: {
createElement: tag => ({ tag, remove() { scripts.splice(scripts.indexOf(this), 1) } }),
body: { appendChild(element) { scripts.push(element) } },
querySelector: selector => selector === '.art-icon-cast' ? icons[0] : null,
},
})
context.window = context
if (!script) {
context.module = { exports: {} }
context.exports = context.module.exports
}
vm.runInContext(script ? implementation.scriptCode || implementation.code : `(function () {\n${implementation.code}\n}).call(globalThis)`, context, { timeout: 1000 })
const exported = script ? context.artplayerPluginChromecast : context.module.exports
const factory = typeof exported === 'function' ? exported : exported.default
assert.equal(typeof factory, 'function')
function player(url = '/video.mp4') {
const listeners = new Map()
const localControls = []
const icon = { style: {} }
icons.push(icon)
const art = {
option: { url },
notice: { show: '' },
isDestroy: false,
template: { $player: { querySelector: () => icon } },
controls: {
add(option) {
controls.push(option)
localControls.push(option)
return { querySelector: () => icon }
},
remove(name) {
const index = localControls.findIndex(control => control.name === name)
if (index >= 0)
localControls.splice(index, 1)
},
},
on(name, callback) { listeners.set(name, [...listeners.get(name) || [], callback]) },
off(name, callback) { listeners.set(name, (listeners.get(name) || []).filter(value => value !== callback)) },
}
return { art, icon, controls: localControls, listeners, destroy() {
art.isDestroy = true
for (const callback of listeners.get('destroy') || []) callback()
} }
}
// Observe SDK-returned promise chains without changing the plugin's Promise globals.
function watched(promise) {
pendingMedia.push(promise)
promise.catch(() => {})
return { then: (...args) => watched(promise.then(...args)), catch: (...args) => watched(promise.catch(...args)) }
}
function installSdk({ requestResult, requestError, loadError } = {}) {
const session = { loadMedia(request) {
mediaLoads.push(request)
return watched(loadError ? Promise.reject(loadError) : Promise.resolve())
} }
const castContext = {
setOptions(value) { sdkOptions.push(value) },
addEventListener(name, listener) { sdkListeners.set(name, [...sdkListeners.get(name) || [], listener]) },
removeEventListener(name, listener) { sdkListeners.set(name, (sdkListeners.get(name) || []).filter(value => value !== listener)) },
getCurrentSession: () => session,
requestSession() {
requests++
return requestError ? Promise.reject(requestError) : Promise.resolve(requestResult)
},
}
context.chrome = { cast: { AutoJoinPolicy: { ORIGIN_SCOPED: 'origin_scoped' }, media: {
DEFAULT_MEDIA_RECEIVER_APP_ID: 'CC1AD845',
MediaInfo: class {
constructor(contentId, contentType) {
this.contentId = contentId
this.contentType = contentType
}
},
LoadRequest: class {
constructor(media) { this.media = media }
},
} } }
context.cast = { framework: {
CastContext: { getInstance: () => castContext },
CastContextEventType: { SESSION_STATE_CHANGED: 'sessionstatechanged', CAST_STATE_CHANGED: 'caststatechanged' },
SessionState: Object.fromEntries(['NO_SESSION', 'SESSION_STARTING', 'SESSION_STARTED', 'SESSION_ENDING', 'SESSION_ENDED', 'SESSION_RESUMED'].map(value => [value, value])),
CastState: Object.fromEntries(['NO_DEVICES_AVAILABLE', 'NOT_CONNECTED', 'CONNECTING', 'CONNECTED'].map(value => [value, value])),
} }
sdk = { session, castContext }
return sdk
}
function emit(name, value) {
for (const callback of sdkListeners.get(name) || []) callback(value)
}
function sessionState(state, session = sdk.session) {
emit('sessionstatechanged', { sessionState: state, session })
}
async function flush() {
for (let index = 0; index < 8; index++) await Promise.resolve()
}
return { context, exported, factory, scripts, icons, controls, logs, sdkListeners, mediaLoads, pendingMedia, sdkOptions, player, installSdk, emit, sessionState, flush, requests: () => requests }
}