mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-09 20:26:16 -08:00
refactor(core): [CORE-20] type entry and converge initialization
This commit is contained in:
1 parent
ccf77c4ed2
commit
ee895a558f
100 files changed
+33854
-839
No files matched your search
@@ -0,0 +1,65 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: DOM utilities preserve native tree, style and event-path behavior`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(() => {
|
||||
const utils = window.Artplayer.utils
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const child = utils.append(root, '<span class="entry">one</span>')
|
||||
const textResult = utils.append(root, 'tail')
|
||||
utils.setStyles(child, { color: 'rgb(1, 2, 3)', width: '12.5px', display: 'inline-block' })
|
||||
const width = utils.getStyle(child, 'width')
|
||||
const selected = utils.query('.entry', root) === child && utils.queryAll('.entry', root).length === 1
|
||||
const replacement = document.createElement('b')
|
||||
const replaced = utils.replaceElement(replacement, child) === replacement
|
||||
let path = false
|
||||
root.addEventListener('click', event => path = utils.includeFromEvent(event, replacement))
|
||||
replacement.click()
|
||||
const result = { selected, textPrecedence: textResult === child, replaced, path, width, detached: child.parentNode === null, returnIdentity: utils.remove(replacement) === replacement }
|
||||
root.remove()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ selected: true, textPrecedence: true, replaced: true, path: true, width: 12.5, detached: true, returnIdentity: true })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: safe-area measurement releases the native probe after failure unlike the previous workspace implementation', async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const results = await page.evaluate(async () => {
|
||||
const { getSafeAreaInsets: previous } = await import('/test/legacy-safe-area.js')
|
||||
const results = []
|
||||
for (const measure of [previous, window.Artplayer.utils.getSafeAreaInsets]) {
|
||||
const before = document.body.childElementCount
|
||||
const success = measure()
|
||||
const successClean = document.body.childElementCount === before
|
||||
const original = window.getComputedStyle
|
||||
const error = new Error('controlled safe-area style failure')
|
||||
let sameError = false
|
||||
window.getComputedStyle = () => {
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
measure()
|
||||
}
|
||||
catch (value) {
|
||||
sameError = value === error
|
||||
}
|
||||
finally {
|
||||
window.getComputedStyle = original
|
||||
}
|
||||
const remaining = document.body.childElementCount - before
|
||||
while (document.body.childElementCount > before)
|
||||
document.body.lastElementChild.remove()
|
||||
results.push({ successClean, success, sameError, remaining })
|
||||
}
|
||||
return results
|
||||
})
|
||||
for (const result of results) {
|
||||
expect(result.successClean).toBe(true)
|
||||
expect(result.sameError).toBe(true)
|
||||
expect(Object.values(result.success).every(value => Number.isFinite(value) && value >= 0)).toBe(true)
|
||||
}
|
||||
expect(results.map(result => result.remaining)).toEqual([1, 0])
|
||||
})
|
||||
@@ -0,0 +1,272 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
for (const removeHtml of [true, false]) {
|
||||
test(`candidate: proxy-time destroy cleans the mounting template with removeHtml=${removeHtml}`, async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate((removeHtml) => {
|
||||
const { Artplayer } = window
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
let calls = 0
|
||||
let visibleTemplate
|
||||
let destroyedDOM
|
||||
const proxy = document.createElement('video')
|
||||
proxy.className = 'owned-by-caller'
|
||||
const art = new Artplayer({ container, url: '', proxy(owner) {
|
||||
visibleTemplate = Object.hasOwn(owner, 'template')
|
||||
owner.on('destroy', () => {
|
||||
calls++
|
||||
destroyedDOM = { nodes: container.childElementCount, retained: !!container.querySelector('.art-destroy') }
|
||||
})
|
||||
owner.destroy(removeHtml)
|
||||
return proxy
|
||||
} })
|
||||
art.destroy(!removeHtml)
|
||||
const result = {
|
||||
visibleTemplate,
|
||||
calls,
|
||||
destroyed: art.isDestroy,
|
||||
registered: Artplayer.instances.includes(art),
|
||||
hasEvents: Object.hasOwn(art, 'events'),
|
||||
nodes: container.childElementCount,
|
||||
retained: !!container.querySelector('.art-destroy'),
|
||||
destroyedDOM,
|
||||
proxyUntouched: proxy.parentNode === null && proxy.className === 'owned-by-caller',
|
||||
}
|
||||
const next = new Artplayer({ container, url: '' })
|
||||
result.reusable = Artplayer.instances.includes(next)
|
||||
next.destroy()
|
||||
return result
|
||||
}, removeHtml)
|
||||
const dom = { nodes: removeHtml ? 0 : 1, retained: !removeHtml }
|
||||
expect(result).toEqual({ visibleTemplate: false, calls: 1, destroyed: true, registered: false, hasEvents: false, ...dom, destroyedDOM: dom, proxyUntouched: true, reusable: true })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: player descriptor order and constructor/static surfaces match the published core', async ({ page }) => {
|
||||
const results = []
|
||||
for (const core of ['published', 'candidate']) {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
results.push(await page.evaluate(() => {
|
||||
const { Artplayer } = window
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const art = new Artplayer({ container, url: '', muted: true })
|
||||
const describe = (object, names) => names.map((name) => {
|
||||
const property = Object.getOwnPropertyDescriptor(object, name)
|
||||
return [name, property.enumerable, property.configurable, property.writable ?? null, typeof property.get, typeof property.set, typeof property.value]
|
||||
})
|
||||
const properties = Object.getOwnPropertyNames(art).filter((name) => {
|
||||
const property = Object.getOwnPropertyDescriptor(art, name)
|
||||
return !property.configurable && !property.enumerable
|
||||
})
|
||||
const result = {
|
||||
arity: Artplayer.length,
|
||||
playerArity: art.player.constructor.length,
|
||||
playerKeys: Object.keys(art.player),
|
||||
descriptors: describe(art, properties),
|
||||
prototype: describe(Artplayer.prototype, Object.getOwnPropertyNames(Artplayer.prototype).filter(name => name !== 'constructor')),
|
||||
statics: describe(Artplayer, Object.getOwnPropertyNames(Artplayer).filter(name => !['length', 'name', 'prototype'].includes(name))),
|
||||
state: [art.type, art.muted, art.volume, art.currentTime, art.played, art.loaded, art.playing, art.state],
|
||||
}
|
||||
art.destroy()
|
||||
return result
|
||||
}))
|
||||
}
|
||||
expect(results[1]).toEqual(results[0])
|
||||
expect(results[1].arity).toBe(2)
|
||||
expect(results[1].playerArity).toBe(1)
|
||||
expect(results[1].playerKeys).toEqual([])
|
||||
})
|
||||
|
||||
test('candidate: proxy-time destroy listener can mount a replacement without the old constructor overwriting it', async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
const result = await page.evaluate(() => {
|
||||
const { Artplayer } = window
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
let replacement
|
||||
const unused = document.createElement('video')
|
||||
const old = new Artplayer({ container, url: '', proxy(owner) {
|
||||
owner.on('destroy', () => {
|
||||
replacement = new Artplayer({ container, url: '' })
|
||||
})
|
||||
owner.destroy()
|
||||
return unused
|
||||
} })
|
||||
const result = {
|
||||
oldDestroyed: old.isDestroy,
|
||||
registry: Artplayer.instances.length === 1 && Artplayer.instances[0] === replacement,
|
||||
player: container.querySelector('.art-video-player') === replacement.template.$player,
|
||||
video: container.querySelector('video') === replacement.video,
|
||||
artId: container.dataset.artId === String(replacement.id),
|
||||
unusedDetached: unused.parentNode === null,
|
||||
}
|
||||
old.destroy()
|
||||
result.stillRegistered = Artplayer.instances.includes(replacement)
|
||||
replacement.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ oldDestroyed: true, registry: true, player: true, video: true, artId: true, unusedDetached: true, stillRegistered: true })
|
||||
})
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: subclasses retain receiver identity, initialization timing and static inheritance`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(() => {
|
||||
const { Artplayer } = window
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
let subclassDefaults = 0
|
||||
class Extended extends Artplayer {
|
||||
static get option() {
|
||||
subclassDefaults++
|
||||
return super.option
|
||||
}
|
||||
|
||||
constructor(option, ready) {
|
||||
super(option, ready)
|
||||
this.extension = 'after-super'
|
||||
}
|
||||
}
|
||||
Extended.NOTICE_TIME = 321
|
||||
let pluginShape
|
||||
let readyShape
|
||||
const art = new Extended({ container, url: '', plugins: [function (owner) {
|
||||
pluginShape = this === owner && owner instanceof Extended && !Object.hasOwn(owner, 'extension')
|
||||
return { name: 'subclass-plugin' }
|
||||
}] }, function (owner) {
|
||||
readyShape = this === owner && owner instanceof Extended && owner.extension === 'after-super'
|
||||
})
|
||||
const chained = art.emit('ready') === art
|
||||
const result = { pluginShape, readyShape, chained, subclassDefaults, registry: Artplayer.instances.includes(art), prototype: Object.getPrototypeOf(art) === Extended.prototype, constant: art.notice.art.constructor.NOTICE_TIME, inheritedInstances: Extended.instances === Artplayer.instances }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ pluginShape: true, readyShape: true, chained: true, subclassDefaults: 0, registry: true, prototype: true, constant: 321, inheritedInstances: true })
|
||||
})
|
||||
|
||||
test(`${core}: omitted unused SSR nodes stay nullable without an eager template rejection`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(() => {
|
||||
const { Artplayer } = window
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = Artplayer.html
|
||||
container.querySelector('.art-danmuku').remove()
|
||||
document.body.append(container)
|
||||
const art = new Artplayer({ container, url: '', useSSR: true })
|
||||
const result = { missing: art.template.$danmuku === null, query: art.query('.missing-node') === null, mounted: container.querySelector('.art-video-player') === art.template.$player }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ missing: true, query: true, mounted: true })
|
||||
})
|
||||
|
||||
test(`${core}: teardown during capability detection stops remaining player installers`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(() => {
|
||||
const { Artplayer } = window
|
||||
const key = 'WebKitPlaybackTargetAvailabilityEvent'
|
||||
const previous = Object.getOwnPropertyDescriptor(window, key)
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
let captured
|
||||
let reads = 0
|
||||
Object.defineProperty(window, key, { configurable: true, get() {
|
||||
reads++
|
||||
if (captured && !captured.isDestroy)
|
||||
captured.destroy()
|
||||
return undefined
|
||||
} })
|
||||
try {
|
||||
const art = new Artplayer({ container, url: '', proxy(owner) {
|
||||
captured = owner
|
||||
return document.createElement('video')
|
||||
} })
|
||||
return {
|
||||
reads,
|
||||
returned: captured === art,
|
||||
destroyed: art.isDestroy,
|
||||
registered: Artplayer.instances.includes(art),
|
||||
airplay: Object.hasOwn(art, 'airplay'),
|
||||
quality: Object.hasOwn(art, 'quality'),
|
||||
thumbnails: Object.hasOwn(art, 'thumbnails'),
|
||||
controls: Object.hasOwn(art, 'controls'),
|
||||
nodes: container.childElementCount,
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (previous)
|
||||
Object.defineProperty(window, key, previous)
|
||||
else
|
||||
delete window[key]
|
||||
}
|
||||
})
|
||||
expect(result).toEqual({ reads: 1, returned: true, destroyed: true, registered: core === 'published', airplay: core === 'published', quality: core === 'published', thumbnails: core === 'published', controls: core === 'published', nodes: 0 })
|
||||
})
|
||||
|
||||
test(`${core}: destroy during initial native volume assignment stops later construction`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const result = await page.evaluate(async () => {
|
||||
const { Artplayer } = window
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const calls = []
|
||||
let captured
|
||||
let thrown
|
||||
let returned
|
||||
try {
|
||||
returned = new Artplayer({
|
||||
container,
|
||||
url: '',
|
||||
proxy(art) {
|
||||
captured = art
|
||||
const video = document.createElement('video')
|
||||
const volume = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'volume')
|
||||
Object.defineProperty(video, 'volume', {
|
||||
get() { return volume.get.call(this) },
|
||||
set(value) {
|
||||
calls.push('volume')
|
||||
volume.set.call(this, value)
|
||||
if (!art.isDestroy) {
|
||||
art.destroy()
|
||||
calls.push('destroyed')
|
||||
}
|
||||
},
|
||||
})
|
||||
return video
|
||||
},
|
||||
layers: [{ name: 'later', html: 'later' }],
|
||||
plugins: [(art) => {
|
||||
calls.push(`plugin:${art.isDestroy}`)
|
||||
return { name: 'later' }
|
||||
}],
|
||||
})
|
||||
}
|
||||
catch (error) { thrown = error.message }
|
||||
if (!captured)
|
||||
throw new Error(`Initialization fixture did not reach proxy: ${thrown}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
return {
|
||||
calls,
|
||||
thrown: thrown ?? null,
|
||||
returned: returned === captured,
|
||||
destroyed: captured.isDestroy,
|
||||
registered: Artplayer.instances.includes(captured),
|
||||
hasLayers: Object.hasOwn(captured, 'layers'),
|
||||
hasPlugins: Object.hasOwn(captured, 'plugins'),
|
||||
nodes: container.childElementCount,
|
||||
listeners: captured.events.destroyEvents.size,
|
||||
}
|
||||
})
|
||||
if (core === 'candidate') {
|
||||
expect(result).toEqual({ calls: ['volume', 'destroyed'], thrown: null, returned: true, destroyed: true, registered: false, hasLayers: false, hasPlugins: false, nodes: 0, listeners: 0 })
|
||||
}
|
||||
else {
|
||||
expect(result.calls).toContain('plugin:true')
|
||||
expect(result.registered).toBe(true)
|
||||
expect(result.hasLayers).toBe(true)
|
||||
expect(result.hasPlugins).toBe(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -62,6 +62,7 @@ for (const extension of ['mp4', 'webm']) {
|
||||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.mp4': 'video/mp4', '.webm': 'video/webm', '.vtt': 'text/vtt', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg' }
|
||||
add('/test/declaration-cues.vtt', fs.readFileSync(path.join(workspace, 'test/browser/media/declaration-cues.vtt')), { kind: 'test-subtitles', file: 'test/browser/media/declaration-cues.vtt' })
|
||||
add('/test/thumbnail-grid.svg', fs.readFileSync(path.join(workspace, 'test/browser/media/thumbnail-grid.svg')), { kind: 'test-thumbnail-grid', file: 'test/browser/media/thumbnail-grid.svg' })
|
||||
add('/test/legacy-safe-area.js', fs.readFileSync(path.join(workspace, 'test/helpers/legacy-safe-area.js')), { kind: 'frozen-own-source', file: 'test/helpers/legacy-safe-area.js', commit: 'ccf77c4e' })
|
||||
function send(req, res, bytes, filename) {
|
||||
res.setHeader('Content-Type', mime[path.extname(filename)] || 'application/octet-stream')
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { getSafeAreaInsets as legacySafeArea } from './helpers/legacy-safe-area.js'
|
||||
import { loadModules, loadPublishedCore } from './helpers/load.js'
|
||||
|
||||
const names = ['append', 'remove', 'replaceElement', 'getComposedPath', 'includeFromEvent', 'getSafeAreaInsets', 'setStyle', 'setStyles', 'getStyle']
|
||||
const utils = await loadModules(Object.fromEntries(names.map(name => [name, { file: 'packages/artplayer/src/utils/dom', name }])))
|
||||
|
||||
function globalValue(t, name, value) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, name)
|
||||
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true })
|
||||
t.after(() => descriptor ? Object.defineProperty(globalThis, name, descriptor) : delete globalThis[name])
|
||||
}
|
||||
|
||||
test('event paths preserve native receiver/array identity and work without a browser window', async () => {
|
||||
const old = await loadPublishedCore()
|
||||
assert.throws(() => old.Artplayer.utils.getComposedPath({ target: null }), error => error.name === 'ReferenceError')
|
||||
assert.deepEqual(utils.getComposedPath({ target: null }), [])
|
||||
const parent = new EventTarget()
|
||||
const child = Object.assign(new EventTarget(), { parentNode: parent })
|
||||
assert.deepEqual(utils.getComposedPath({ target: child }), [child, parent])
|
||||
const path = [child, parent]
|
||||
const event = { target: child, composedPath() {
|
||||
assert.equal(this, event)
|
||||
return path
|
||||
} }
|
||||
assert.equal(utils.getComposedPath(event), path)
|
||||
assert.equal(utils.includeFromEvent(event, undefined), false)
|
||||
assert.equal(utils.includeFromEvent(event, parent), true)
|
||||
})
|
||||
|
||||
test('DOM append preserves string conversion and last-element precedence; detached removal still fails', (t) => {
|
||||
class Element {}
|
||||
globalValue(t, 'Element', Element)
|
||||
const existing = new Element()
|
||||
const text = { textContent: 'text' }
|
||||
const calls = []
|
||||
const parent = { lastElementChild: existing, lastChild: text, insertAdjacentHTML: (...args) => calls.push(args), appendChild: child => calls.push(child) }
|
||||
const data = { toString: () => '<b>converted</b>' }
|
||||
assert.equal(utils.append(parent, data), existing)
|
||||
assert.deepEqual(calls, [['beforeend', '<b>converted</b>']])
|
||||
assert.equal(utils.append(parent, existing), existing)
|
||||
assert.equal(calls[1], existing)
|
||||
assert.throws(() => utils.remove({ parentNode: null }), TypeError)
|
||||
const next = new Element()
|
||||
const child = { parentNode: { replaceChild: (...args) => calls.push(args) } }
|
||||
assert.equal(utils.replaceElement(next, child), next)
|
||||
assert.deepEqual(calls.at(-1), [next, child])
|
||||
})
|
||||
|
||||
test('style helpers preserve inherited keys, raw setter values and numeric/string reads', (t) => {
|
||||
const writes = []
|
||||
const element = { style: new Proxy({}, { set(target, key, value) {
|
||||
writes.push([key, value])
|
||||
return Reflect.set(target, key, value)
|
||||
} }) }
|
||||
const raw = { toString: () => '1px' }
|
||||
assert.equal(utils.setStyle(element, 'width', raw), element)
|
||||
assert.equal(writes[0][1], raw)
|
||||
assert.equal(utils.setStyles(element, Object.assign(Object.create({ inherited: 1 }), { own: null })), element)
|
||||
assert.deepEqual(writes.slice(1), [['own', null], ['inherited', 1]])
|
||||
globalValue(t, 'window', { getComputedStyle: () => ({ getPropertyValue: key => key === 'width' ? '12.5px' : 'auto' }) })
|
||||
assert.equal(utils.getStyle(element, 'width'), 12.5)
|
||||
assert.equal(utils.getStyle(element, 'width', false), '12.5px')
|
||||
assert(Number.isNaN(utils.getStyle(element, 'height')))
|
||||
})
|
||||
|
||||
test('safe-area measurement removes its owned probe after success and style/append failures', async (t) => {
|
||||
for (const boundary of ['success', 'style', 'append']) {
|
||||
const error = new Error(boundary)
|
||||
let attached = false
|
||||
let removed = 0
|
||||
const div = { style: {}, remove() {
|
||||
removed++
|
||||
attached = false
|
||||
} }
|
||||
const document = { createElement: () => div, body: { appendChild() {
|
||||
attached = true
|
||||
if (boundary === 'append')
|
||||
throw error
|
||||
} } }
|
||||
const getComputedStyle = () => {
|
||||
if (boundary === 'style')
|
||||
throw error
|
||||
return { top: '10px', right: 'auto', bottom: '2.5px', left: '0px' }
|
||||
}
|
||||
globalValue(t, 'document', document)
|
||||
globalValue(t, 'getComputedStyle', getComputedStyle)
|
||||
if (boundary === 'style') {
|
||||
const published = await loadPublishedCore()
|
||||
assert.equal(typeof published.Artplayer.utils.getSafeAreaInsets, 'undefined')
|
||||
assert.throws(() => legacySafeArea(), value => value === error)
|
||||
assert.equal(attached, true)
|
||||
assert.equal(removed, 0)
|
||||
attached = false
|
||||
}
|
||||
if (boundary === 'success')
|
||||
assert.deepEqual(utils.getSafeAreaInsets(), { top: 10, right: 0, bottom: 2.5, left: 0 })
|
||||
else
|
||||
assert.throws(() => utils.getSafeAreaInsets(), value => value === error)
|
||||
assert.equal(attached, false)
|
||||
assert.equal(removed, 1)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Verify the actual compiled entry in isolated server environments.
|
||||
import { test } from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
import { compilePackage, loadPublishedCore } from './helpers/load.js'
|
||||
|
||||
const source = await compilePackage('artplayer', 'umd')
|
||||
|
||||
test('the complete core imports without DOM or timers for ordinary and custom-UA server environments', async () => {
|
||||
for (const userAgent of [undefined, '', 'iPhone Safari', 'Macintosh Safari', 'Android Chrome']) {
|
||||
const module = { exports: {} }
|
||||
let timers = 0
|
||||
const globals = { module, exports: module.exports, setTimeout() {
|
||||
timers++
|
||||
} }
|
||||
if (userAgent !== undefined)
|
||||
globals.CUSTOM_USER_AGENT = userAgent
|
||||
vm.runInNewContext(source, globals, { timeout: 5000 })
|
||||
const Artplayer = module.exports
|
||||
assert.equal(typeof Artplayer, 'function')
|
||||
assert.equal(Artplayer.length, 2)
|
||||
assert.equal(Artplayer.utils.isBrowser, false)
|
||||
assert.equal(Artplayer.utils.userAgent, userAgent ?? '')
|
||||
assert.equal(Artplayer.instances.length, 0)
|
||||
assert.match(Artplayer.html, /art-video-player/)
|
||||
assert.equal(timers, 0)
|
||||
assert.equal(Object.hasOwn(globals, 'Artplayer'), false)
|
||||
assert.throws(() => new Artplayer({}), error => error.message === 'Artplayer can only be used in the browser environment')
|
||||
if (userAgent?.startsWith('iPhone') || userAgent?.startsWith('Macintosh'))
|
||||
await assert.rejects(loadPublishedCore({ CUSTOM_USER_AGENT: userAgent }), error => error.name === 'ReferenceError')
|
||||
}
|
||||
})
|
||||
|
||||
test('the full entry preserves lazy navigator access under a non-iOS custom UA', () => {
|
||||
const module = { exports: {} }
|
||||
const globals = { module, exports: module.exports, CUSTOM_USER_AGENT: 'Android Chrome' }
|
||||
Object.defineProperty(globals, 'navigator', { get() {
|
||||
throw new Error('native navigator must remain unread')
|
||||
} })
|
||||
vm.runInNewContext(source, globals, { timeout: 5000 })
|
||||
assert.equal(module.exports.utils.userAgent, 'Android Chrome')
|
||||
assert.equal(module.exports.utils.isBrowser, false)
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
import { build } from 'esbuild'
|
||||
import { loadPublishedCore, resolveSource } from './helpers/load.js'
|
||||
|
||||
const output = await build({ entryPoints: [resolveSource('packages/artplayer/src/utils/compatibility')], bundle: true, write: false, platform: 'node', format: 'cjs' })
|
||||
const code = output.outputFiles[0].text
|
||||
function evaluate(globals = {}) {
|
||||
const module = { exports: {} }
|
||||
const context = Object.defineProperties({ module, exports: module.exports }, Object.getOwnPropertyDescriptors(globals))
|
||||
vm.runInNewContext(code, context)
|
||||
return module.exports
|
||||
}
|
||||
|
||||
test('environment flags preserve custom UA priority, empty overrides and native touch detection', () => {
|
||||
const native = { userAgent: 'Macintosh Safari', maxTouchPoints: 1 }
|
||||
const globals = { navigator: native, window: {}, document: {} }
|
||||
const mac = evaluate(globals)
|
||||
assert.equal(mac.isSafari, true)
|
||||
assert.equal(mac.isIOS13, true)
|
||||
assert.equal(mac.isMobile, true)
|
||||
assert.equal(mac.isBrowser, true)
|
||||
const empty = evaluate({ ...globals, CUSTOM_USER_AGENT: '' })
|
||||
assert.equal(empty.userAgent, '')
|
||||
assert.equal(empty.isMobile, false)
|
||||
const phone = evaluate({ ...globals, CUSTOM_USER_AGENT: 'iPhone Safari' })
|
||||
assert.equal(phone.isIOS, true)
|
||||
const excluded = evaluate({ ...globals, CUSTOM_USER_AGENT: 'iPhone Safari', window: { MSStream: {} } })
|
||||
assert.equal(excluded.isIOS, false)
|
||||
})
|
||||
|
||||
test('SSR custom iOS and Macintosh UAs do not dereference absent browser globals', async () => {
|
||||
for (const ua of ['iPhone Safari', 'Macintosh Safari']) {
|
||||
await assert.rejects(loadPublishedCore({ CUSTOM_USER_AGENT: ua }), error => error.name === 'ReferenceError')
|
||||
const candidate = evaluate({ CUSTOM_USER_AGENT: ua })
|
||||
assert.equal(candidate.isBrowser, false)
|
||||
assert.equal(candidate.userAgent, ua)
|
||||
assert.equal(candidate.isMobile, ua.startsWith('iPhone'))
|
||||
}
|
||||
assert.equal(evaluate().userAgent, '')
|
||||
})
|
||||
|
||||
test('a custom user agent retains lazy access to the native navigator', () => {
|
||||
const globals = { CUSTOM_USER_AGENT: 'Android Chrome' }
|
||||
Object.defineProperty(globals, 'navigator', { get() {
|
||||
throw new Error('navigator must not be read')
|
||||
} })
|
||||
const result = evaluate(globals)
|
||||
assert.equal(result.userAgent, 'Android Chrome')
|
||||
assert.equal(result.isMobile, true)
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { attrMix, cssVarMix, typeMix, themeMix, posterMix } = await loadModules(Object.fromEntries(
|
||||
['attrMix', 'cssVarMix', 'typeMix', 'themeMix', 'posterMix'].map(name => [name, `packages/artplayer/src/player/${name}`]),
|
||||
))
|
||||
|
||||
test('attr retains dynamic keys, captured media, undefined reads and native receiver errors', () => {
|
||||
const symbol = Symbol('plugin')
|
||||
const video = { volume: 0.5, [symbol]: 1 }
|
||||
const error = new Error('native property')
|
||||
Object.defineProperty(video, 'throwing', {
|
||||
get() { return undefined },
|
||||
set() {
|
||||
throw error
|
||||
},
|
||||
})
|
||||
const art = { template: { $video: video } }
|
||||
attrMix(art)
|
||||
const attr = art.attr
|
||||
art.template.$video = { volume: 1 }
|
||||
assert.equal(attr('volume'), 0.5)
|
||||
assert.equal(attr('volume', undefined), 0.5)
|
||||
assert.equal(attr(symbol, 2), undefined)
|
||||
assert.equal(video[symbol], 2)
|
||||
assert.equal(attr('volume', null), undefined)
|
||||
assert.equal(video.volume, null)
|
||||
assert.throws(() => attr('throwing', 1), e => e === error)
|
||||
assert.deepEqual(Object.getOwnPropertyDescriptor(art, 'attr'), { value: attr, configurable: false, enumerable: false, writable: false })
|
||||
})
|
||||
|
||||
test('cssVar retains falsy reads and passes truthy values to the captured native style', (t) => {
|
||||
const saved = Object.getOwnPropertyDescriptor(globalThis, 'getComputedStyle')
|
||||
t.after(() => saved ? Object.defineProperty(globalThis, 'getComputedStyle', saved) : delete globalThis.getComputedStyle)
|
||||
const writes = []
|
||||
const player = { style: { setProperty: (...args) => {
|
||||
writes.push(args)
|
||||
} } }
|
||||
globalThis.getComputedStyle = (element) => {
|
||||
assert.equal(element, player)
|
||||
return { getPropertyValue: key => `value:${key}` }
|
||||
}
|
||||
const art = { template: { $player: player } }
|
||||
cssVarMix(art)
|
||||
const cssVar = art.cssVar
|
||||
art.template.$player = {}
|
||||
for (const value of [undefined, null, false, 0, '', Number.NaN])
|
||||
assert.equal(cssVar('--custom', value), 'value:--custom')
|
||||
const object = { toString: () => 'native coercion' }
|
||||
assert.equal(cssVar('--custom', object), undefined)
|
||||
assert.equal(writes[0][1], object)
|
||||
assert.equal(Object.getOwnPropertyDescriptor(art, 'cssVar').enumerable, false)
|
||||
})
|
||||
|
||||
test('type and theme preserve forwarding and immutable accessor descriptors', () => {
|
||||
const calls = []
|
||||
const option = { type: 'mp4' }
|
||||
const art = { option, cssVar(...args) {
|
||||
calls.push(args)
|
||||
return 'computed-theme'
|
||||
} }
|
||||
typeMix(art)
|
||||
themeMix(art)
|
||||
art.type = 'custom'
|
||||
assert.equal(option.type, 'custom')
|
||||
assert.equal(art.type, 'custom')
|
||||
assert.equal(art.theme, 'computed-theme')
|
||||
art.theme = ''
|
||||
assert.deepEqual(calls, [['--art-theme'], ['--art-theme', '']])
|
||||
for (const name of ['type', 'theme']) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(art, name)
|
||||
assert.equal(descriptor.enumerable, false)
|
||||
assert.equal(descriptor.configurable, false)
|
||||
assert.equal(typeof descriptor.get, 'function')
|
||||
assert.equal(typeof descriptor.set, 'function')
|
||||
}
|
||||
})
|
||||
|
||||
test('poster keeps historical quoted extraction, empty fallback and original target', () => {
|
||||
const poster = { style: { backgroundImage: 'url("first.png")' } }
|
||||
const art = { template: { $poster: poster } }
|
||||
posterMix(art)
|
||||
assert.equal(art.poster, 'first.png')
|
||||
poster.style.backgroundImage = 'url(unquoted.png)'
|
||||
assert.equal(art.poster, '')
|
||||
art.template.$poster = { style: {} }
|
||||
art.poster = 'new.png'
|
||||
assert.equal(poster.style.backgroundImage, 'url(new.png)')
|
||||
assert.equal(Object.getOwnPropertyDescriptor(art, 'poster').configurable, false)
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
// Frozen own-source fixture: ccf77c4e packages/artplayer/src/player/airplayMix.js
|
||||
// Original file SHA-256: b9ce49489e75045b6a1df4d8ed28f123c1b105ac4c04fefaabcffe4353ad004d
|
||||
// Only the relative utils import is relocated; the function body is unchanged.
|
||||
import { def } from '../../packages/artplayer/src/utils'
|
||||
|
||||
export default function airplayMix(art) {
|
||||
const {
|
||||
i18n,
|
||||
notice,
|
||||
proxy,
|
||||
template: { $video },
|
||||
} = art
|
||||
|
||||
let available = true
|
||||
|
||||
if (window.WebKitPlaybackTargetAvailabilityEvent && $video.webkitShowPlaybackTargetPicker) {
|
||||
proxy($video, 'webkitplaybacktargetavailabilitychanged', (event) => {
|
||||
switch (event.availability) {
|
||||
case 'available':
|
||||
available = true
|
||||
break
|
||||
case 'not-available':
|
||||
available = false
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
else {
|
||||
available = false
|
||||
}
|
||||
|
||||
def(art, 'airplay', {
|
||||
value() {
|
||||
if (available) {
|
||||
$video.webkitShowPlaybackTargetPicker()
|
||||
art.emit('airplay')
|
||||
}
|
||||
else {
|
||||
notice.show = i18n.get('AirPlay Not Available')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Frozen own-source fixture: ccf77c4e packages/artplayer/src/player/optionInit.js
|
||||
// Original file SHA-256: 9d1d9d276da2403a580498c62f947373e000d9cd6e4bef5831d272c3055b5592
|
||||
// Only the relative utils import is relocated; the function body is unchanged.
|
||||
import { clamp, setStyle } from '../../packages/artplayer/src/utils'
|
||||
|
||||
export default function optionInit(art) {
|
||||
const {
|
||||
option,
|
||||
storage,
|
||||
template: { $video, $poster },
|
||||
} = art
|
||||
|
||||
for (const key in option.moreVideoAttr) {
|
||||
art.attr(key, option.moreVideoAttr[key])
|
||||
}
|
||||
|
||||
if (option.muted) {
|
||||
art.muted = option.muted
|
||||
}
|
||||
|
||||
if (option.volume) {
|
||||
$video.volume = clamp(option.volume, 0, 1)
|
||||
}
|
||||
|
||||
const volumeStorage = storage.get('volume')
|
||||
if (typeof volumeStorage === 'number') {
|
||||
$video.volume = clamp(volumeStorage, 0, 1)
|
||||
}
|
||||
|
||||
if (option.poster) {
|
||||
setStyle($poster, 'backgroundImage', `url(${option.poster})`)
|
||||
}
|
||||
|
||||
if (option.autoplay) {
|
||||
$video.autoplay = option.autoplay
|
||||
}
|
||||
|
||||
if (option.playsInline) {
|
||||
$video.playsInline = true
|
||||
$video['webkit-playsinline'] = true
|
||||
}
|
||||
|
||||
if (option.theme) {
|
||||
option.cssVar['--art-theme'] = option.theme
|
||||
}
|
||||
|
||||
for (const key in option.cssVar) {
|
||||
art.cssVar(key, option.cssVar[key])
|
||||
}
|
||||
|
||||
art.url = option.url
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Frozen own-source regression fixture: ccf77c4e packages/artplayer/src/utils/dom.js
|
||||
// Function SHA-256: bbf533f90288190b5f058e5dfed1a25856ccfeaf3a3b6aba5d2c7f74ce947bda
|
||||
export function getSafeAreaInsets() {
|
||||
const div = document.createElement('div')
|
||||
div.style.cssText
|
||||
= 'position:fixed;top:env(safe-area-inset-top,0px);right:env(safe-area-inset-right,0px);bottom:env(safe-area-inset-bottom,0px);left:env(safe-area-inset-left,0px);pointer-events:none;visibility:hidden;'
|
||||
document.body.appendChild(div)
|
||||
const style = getComputedStyle(div)
|
||||
const insets = {
|
||||
top: Number.parseFloat(style.top) || 0,
|
||||
right: Number.parseFloat(style.right) || 0,
|
||||
bottom: Number.parseFloat(style.bottom) || 0,
|
||||
left: Number.parseFloat(style.left) || 0,
|
||||
}
|
||||
div.remove()
|
||||
return insets
|
||||
}
|
||||
@@ -29,14 +29,18 @@ export async function loadModules(exports, root = workspace) {
|
||||
return importCode(outputFiles[0].contents)
|
||||
}
|
||||
|
||||
export async function loadPackage(name, root = workspace) {
|
||||
export async function compilePackage(name, format = 'es', root = workspace) {
|
||||
const project = path.join(root, 'packages', name)
|
||||
const config = getViteBuildConfig({ entry: getEntryFile(project), name: getGlobalName(name), format: 'es', fileName: 'index.js', minify: false })
|
||||
const config = getViteBuildConfig({ entry: getEntryFile(project), name: getGlobalName(name), format, fileName: 'index.js', minify: false })
|
||||
config.build.write = false
|
||||
const result = await viteBuild({ root: project, ...config })
|
||||
const chunks = (Array.isArray(result) ? result : [result]).flatMap(item => item.output).filter(item => item.type === 'chunk')
|
||||
assert.equal(chunks.length, 1, 'Source fixture loader requires one self-contained JS chunk')
|
||||
return importCode(chunks[0].code)
|
||||
return chunks[0].code
|
||||
}
|
||||
|
||||
export async function loadPackage(name, root = workspace) {
|
||||
return importCode(await compilePackage(name, 'es', root))
|
||||
}
|
||||
|
||||
function commonJs(code, globals = {}) {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { optionInit, previousInit, airplayMix, previousAirplay, beginLifecycle, getScope } = await loadModules({
|
||||
optionInit: 'packages/artplayer/src/player/optionInit',
|
||||
previousInit: 'test/helpers/legacy-option-init',
|
||||
airplayMix: 'packages/artplayer/src/player/airplayMix',
|
||||
previousAirplay: 'test/helpers/legacy-airplay',
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
})
|
||||
|
||||
function initialHost(boundary) {
|
||||
const calls = []
|
||||
let art
|
||||
const record = (kind, key, value) => {
|
||||
calls.push([kind, key, value])
|
||||
if (`${kind}:${key}` === boundary)
|
||||
getScope(art).dispose()
|
||||
}
|
||||
const video = new Proxy({}, { set(target, key, value) {
|
||||
record('video', key, value)
|
||||
return Reflect.set(target, key, value)
|
||||
} })
|
||||
const poster = { style: new Proxy({}, { set(target, key, value) {
|
||||
record('poster', key, value)
|
||||
return Reflect.set(target, key, value)
|
||||
} }) }
|
||||
art = {
|
||||
option: { moreVideoAttr: { controls: false }, muted: true, volume: 2, poster: 'poster.png', autoplay: true, playsInline: true, theme: 'green', cssVar: { '--custom': '2' }, url: 'video.mp4' },
|
||||
storage: { get(key) {
|
||||
record('storage', key, undefined)
|
||||
return 0.4
|
||||
} },
|
||||
template: { $video: video, $poster: poster },
|
||||
attr: (key, value) => record('attr', key, value),
|
||||
cssVar: (key, value) => record('css', key, value),
|
||||
get muted() { return undefined },
|
||||
set muted(value) { record('player', 'muted', value) },
|
||||
get url() { return undefined },
|
||||
set url(value) { record('player', 'url', value) },
|
||||
}
|
||||
beginLifecycle(art)
|
||||
return { art, calls }
|
||||
}
|
||||
|
||||
test('option initialization retains native/DOM/write order, storage precedence and CSS mutation', () => {
|
||||
const old = initialHost()
|
||||
const current = initialHost()
|
||||
assert.equal(previousInit(old.art), undefined)
|
||||
assert.equal(optionInit(current.art), undefined)
|
||||
assert.deepEqual(current.calls, old.calls)
|
||||
assert.deepEqual(current.calls, [
|
||||
['attr', 'controls', false],
|
||||
['player', 'muted', true],
|
||||
['video', 'volume', 1],
|
||||
['storage', 'volume', undefined],
|
||||
['video', 'volume', 0.4],
|
||||
['poster', 'backgroundImage', 'url(poster.png)'],
|
||||
['video', 'autoplay', true],
|
||||
['video', 'playsInline', true],
|
||||
['video', 'webkit-playsinline', true],
|
||||
['css', '--custom', '2'],
|
||||
['css', '--art-theme', 'green'],
|
||||
['player', 'url', 'video.mp4'],
|
||||
])
|
||||
assert.deepEqual(current.art.option.cssVar, { '--custom': '2', '--art-theme': 'green' })
|
||||
})
|
||||
|
||||
test('option initialization stops after reentrant teardown from each external write boundary', () => {
|
||||
for (const boundary of ['attr:controls', 'player:muted', 'video:volume', 'storage:volume', 'poster:backgroundImage', 'video:autoplay', 'video:playsInline', 'css:--custom']) {
|
||||
const old = initialHost(boundary)
|
||||
const current = initialHost(boundary)
|
||||
previousInit(old.art)
|
||||
optionInit(current.art)
|
||||
assert(current.calls.length < old.calls.length, boundary)
|
||||
assert.deepEqual(current.calls.at(-1).slice(0, 2).join(':'), boundary)
|
||||
assert.equal(current.calls.some(call => call[0] === 'player' && call[1] === 'url'), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('initial option value getters and native conversion cannot write after teardown', () => {
|
||||
for (const field of ['moreVideoAttr', 'cssVar', 'url', 'volume']) {
|
||||
const { art, calls } = initialHost()
|
||||
if (field === 'moreVideoAttr' || field === 'cssVar') {
|
||||
const key = field === 'cssVar' ? '--custom' : 'controls'
|
||||
Object.defineProperty(art.option[field], key, { enumerable: true, get() {
|
||||
getScope(art).dispose()
|
||||
return 'stale'
|
||||
} })
|
||||
}
|
||||
else {
|
||||
Object.defineProperty(art.option, field, { get() {
|
||||
getScope(art).dispose()
|
||||
return field === 'url' ? 'stale.mp4' : 1
|
||||
} })
|
||||
}
|
||||
optionInit(art)
|
||||
assert.equal(calls.some(call => call[2] === 'stale' || call[2] === 'stale.mp4'), false)
|
||||
assert.equal(calls.some(call => call[0] === 'player' && call[1] === 'url'), false)
|
||||
if (field === 'volume')
|
||||
assert.equal(calls.some(call => call[0] === 'video' && call[1] === 'volume'), false)
|
||||
}
|
||||
})
|
||||
|
||||
function airplayHost(t, supported = true) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, value: supported ? { WebKitPlaybackTargetAvailabilityEvent() {} } : {} })
|
||||
t.after(() => descriptor ? Object.defineProperty(globalThis, 'window', descriptor) : delete globalThis.window)
|
||||
const calls = []
|
||||
let listener
|
||||
const video = Object.assign(new EventTarget(), { webkitShowPlaybackTargetPicker() {
|
||||
assert.equal(this, video)
|
||||
calls.push('picker')
|
||||
} })
|
||||
const art = { template: { $video: video }, proxy(target, name, callback) {
|
||||
assert.equal(target, video)
|
||||
assert.equal(name, 'webkitplaybacktargetavailabilitychanged')
|
||||
listener = callback
|
||||
}, i18n: { get: key => key }, notice: { show: '' }, emit: name => calls.push(name) }
|
||||
beginLifecycle(art)
|
||||
return { art, calls, availability: value => listener({ availability: value }) }
|
||||
}
|
||||
|
||||
test('AirPlay retains availability, captured native receiver, event order and unavailable notice', (t) => {
|
||||
const { art, calls, availability } = airplayHost(t)
|
||||
airplayMix(art)
|
||||
const airplay = art.airplay
|
||||
assert.equal(airplay(), undefined)
|
||||
assert.deepEqual(calls, ['picker', 'airplay'])
|
||||
availability('not-available')
|
||||
airplay()
|
||||
assert.equal(art.notice.show, 'AirPlay Not Available')
|
||||
assert.deepEqual(calls, ['picker', 'airplay'])
|
||||
availability('available')
|
||||
airplay()
|
||||
assert.deepEqual(calls, ['picker', 'airplay', 'picker', 'airplay'])
|
||||
assert.deepEqual(Object.getOwnPropertyDescriptor(art, 'airplay'), { value: airplay, configurable: false, enumerable: false, writable: false })
|
||||
})
|
||||
|
||||
test('AirPlay installation stops after capability getters or registration destroy its owner', (t) => {
|
||||
for (const install of [previousAirplay, airplayMix]) {
|
||||
for (const boundary of ['capability', 'picker', 'registration']) {
|
||||
const { art } = airplayHost(t)
|
||||
const reads = []
|
||||
Object.defineProperty(globalThis.window, 'WebKitPlaybackTargetAvailabilityEvent', { get() {
|
||||
reads.push('capability')
|
||||
if (boundary === 'capability')
|
||||
getScope(art).dispose()
|
||||
return true
|
||||
} })
|
||||
Object.defineProperty(art.template.$video, 'webkitShowPlaybackTargetPicker', { get() {
|
||||
reads.push('picker')
|
||||
if (boundary === 'picker')
|
||||
getScope(art).dispose()
|
||||
return () => {}
|
||||
} })
|
||||
art.proxy = () => {
|
||||
reads.push('registration')
|
||||
if (boundary === 'registration')
|
||||
getScope(art).dispose()
|
||||
}
|
||||
install(art)
|
||||
assert.equal(Object.hasOwn(art, 'airplay'), install === previousAirplay)
|
||||
const expected = ['capability', 'picker', 'registration']
|
||||
assert.deepEqual(reads, install === previousAirplay ? expected : expected.slice(0, expected.indexOf(boundary) + 1))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('AirPlay calls and picker completion cannot publish effects after destruction', (t) => {
|
||||
for (const install of [previousAirplay, airplayMix]) {
|
||||
const { art, calls, availability } = airplayHost(t)
|
||||
install(art)
|
||||
getScope(art).dispose()
|
||||
availability('available')
|
||||
art.airplay()
|
||||
assert.deepEqual(calls, install === previousAirplay ? ['picker', 'airplay'] : [])
|
||||
}
|
||||
const { art, calls } = airplayHost(t)
|
||||
art.template.$video.webkitShowPlaybackTargetPicker = () => {
|
||||
calls.push('picker')
|
||||
getScope(art).dispose()
|
||||
}
|
||||
airplayMix(art)
|
||||
art.airplay()
|
||||
assert.deepEqual(calls, ['picker'])
|
||||
})
|
||||
|
||||
test('AirPlay unavailable translation and picker getters respect teardown; native errors still throw', (t) => {
|
||||
const unavailable = airplayHost(t, false)
|
||||
unavailable.art.i18n.get = () => {
|
||||
getScope(unavailable.art).dispose()
|
||||
return 'stale notice'
|
||||
}
|
||||
airplayMix(unavailable.art)
|
||||
unavailable.art.airplay()
|
||||
assert.equal(unavailable.art.notice.show, '')
|
||||
const { art, calls } = airplayHost(t)
|
||||
airplayMix(art)
|
||||
const error = new Error('picker failed')
|
||||
art.template.$video.webkitShowPlaybackTargetPicker = () => {
|
||||
throw error
|
||||
}
|
||||
assert.throws(() => art.airplay(), value => value === error)
|
||||
Object.defineProperty(art.template.$video, 'webkitShowPlaybackTargetPicker', { get() {
|
||||
getScope(art).dispose()
|
||||
return () => calls.push('stale picker')
|
||||
} })
|
||||
art.airplay()
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
@@ -4,11 +4,53 @@ import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const file = 'packages/artplayer/src/lifecycle/instance'
|
||||
const { beginLifecycle, destroyInstance, finishLifecycle, getScope, urlMix } = await loadModules({
|
||||
...Object.fromEntries(['beginLifecycle', 'destroyInstance', 'finishLifecycle', 'getScope'].map(name => [name, { file, name }])),
|
||||
const { beginLifecycle, destroyInstance, duringTemplateMount, finishLifecycle, getScope, urlMix } = await loadModules({
|
||||
...Object.fromEntries(['beginLifecycle', 'destroyInstance', 'duringTemplateMount', 'finishLifecycle', 'getScope'].map(name => [name, { file, name }])),
|
||||
urlMix: 'packages/artplayer/src/player/urlMix',
|
||||
})
|
||||
|
||||
test('mounting template cleanup precedes destroy without exposing an early public property', () => {
|
||||
const calls = []
|
||||
const owner = { isDestroy: false, reset() {
|
||||
calls.push('reset')
|
||||
}, emit() {
|
||||
calls.push('destroy')
|
||||
} }
|
||||
beginLifecycle(owner)
|
||||
const template = { $video: {}, destroy(removeHtml) {
|
||||
calls.push(['template', removeHtml])
|
||||
} }
|
||||
duringTemplateMount(owner, template, () => {
|
||||
assert.equal(Object.hasOwn(owner, 'template'), false)
|
||||
destroyInstance(owner, [], false, true)
|
||||
})
|
||||
assert.deepEqual(calls, [['template', false], 'destroy'])
|
||||
assert(owner.isDestroy)
|
||||
assert.equal(Object.hasOwn(owner, 'template'), false)
|
||||
})
|
||||
|
||||
test('temporary mounting ownership is cleared after failures and completed mounting', () => {
|
||||
for (const fail of [false, true]) {
|
||||
let cleanups = 0
|
||||
const owner = { isDestroy: false, reset() {}, emit() {} }
|
||||
beginLifecycle(owner)
|
||||
const template = { destroy() {
|
||||
cleanups++
|
||||
} }
|
||||
const failure = new Error('mount failed')
|
||||
const mount = () => duringTemplateMount(owner, template, () => {
|
||||
if (fail)
|
||||
throw failure
|
||||
})
|
||||
if (fail)
|
||||
assert.throws(mount, error => error === failure)
|
||||
else
|
||||
mount()
|
||||
destroyInstance(owner, [], true, true)
|
||||
assert.equal(cleanups, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('instance teardown preserves order, public state and first-call removeHtml across reentry', () => {
|
||||
const calls = []
|
||||
const instances = []
|
||||
|
||||
@@ -20,15 +20,16 @@ test('Package checks reject missing files, missing wildcard exports and internal
|
||||
test('Actual isolated runtime rejects removed default exports and required files', async () => {
|
||||
const { dir } = await publishedConsumer()
|
||||
try {
|
||||
assert.equal(runtimeConsumer(dir).checks.length, 18 + Object.keys(emitterContracts).length)
|
||||
assert.equal(runtimeConsumer(dir, { baseline: true }).checks.length, 20 + Object.keys(emitterContracts).length)
|
||||
assert.throws(() => runtimeConsumer(dir), /Command failed/)
|
||||
const esm = path.join(dir, 'node_modules/artplayer-plugin-chapter/dist/artplayer-plugin-chapter.mjs')
|
||||
const original = fs.readFileSync(esm)
|
||||
fs.writeFileSync(esm, 'export const removedDefault = true\n')
|
||||
assert.throws(() => runtimeConsumer(dir), /Command failed/)
|
||||
assert.throws(() => runtimeConsumer(dir, { baseline: true }), /Command failed/)
|
||||
fs.writeFileSync(esm, original)
|
||||
const entry = path.join(dir, 'node_modules/artplayer-plugin-chapter/dist/artplayer-plugin-chapter.js')
|
||||
fs.unlinkSync(entry)
|
||||
assert.throws(() => runtimeConsumer(dir), /Command failed/)
|
||||
assert.throws(() => runtimeConsumer(dir, { baseline: true }), /Command failed/)
|
||||
}
|
||||
finally {
|
||||
removeConsumer(dir)
|
||||
|
||||
@@ -22,6 +22,12 @@ export restrictions and shared Emitter behavior. Public property descriptors and
|
||||
default configuration are compared against the fixed published core/chapter.
|
||||
This is a targeted compatibility check, not a complete behavioral API comparison.
|
||||
|
||||
Custom-UA SSR profiles run actual UMD/legacy bytes and direct ESM imports with no
|
||||
window/document/navigator shim. The frozen baseline's iOS/Macintosh ReferenceErrors
|
||||
are asserted only in explicit baseline mode; candidates must import successfully.
|
||||
The checks retain callback-free server imports, no UMD timers, and the browser-only
|
||||
constructor error. A negative fixture verifies the old package fails candidate mode.
|
||||
|
||||
Public, chapter-options, chapter-exports, language and legacy-plugin consumers run with
|
||||
TS 5.9.3 (Node10, NodeNext CJS/ESM and Bundler) and TS 4.3.5 (Node10). NodeNext CJS also
|
||||
checks import-equals/require and namespaced types. PKG-CHAPTER-04 resolved the initial
|
||||
|
||||
@@ -14,6 +14,7 @@ const vm = require('node:vm');
|
||||
checks.push(id)
|
||||
}
|
||||
const root = __dirname
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(root, 'expected.json')))
|
||||
const load = relative => fs.readFileSync(path.join(root, 'node_modules', relative), 'utf8')
|
||||
const core = require('artplayer')
|
||||
const chapter = require('artplayer-plugin-chapter')
|
||||
@@ -61,6 +62,66 @@ const vm = require('node:vm');
|
||||
}
|
||||
}
|
||||
check('SSR.constructor-browser-only', observations.constructorErrors.length === 6)
|
||||
observations.customUA = []
|
||||
const userAgents = ['', 'Android Chrome', 'iPhone Safari', 'Macintosh Safari']
|
||||
for (const filename of ['artplayer.js', 'artplayer.legacy.js']) {
|
||||
for (const userAgent of userAgents) {
|
||||
const module = { exports: {} }
|
||||
let timers = 0
|
||||
const context = { module, exports: module.exports, CUSTOM_USER_AGENT: userAgent, setTimeout() {
|
||||
timers++
|
||||
} }
|
||||
const historicalFailure = expected.baseline && /^(?:iPhone|Macintosh)/.test(userAgent)
|
||||
const evaluate = () => vm.runInNewContext(load(`artplayer/dist/${filename}`), context, { timeout: 2000 })
|
||||
if (historicalFailure) {
|
||||
assert.throws(evaluate, error => error.name === 'ReferenceError')
|
||||
}
|
||||
else {
|
||||
evaluate()
|
||||
assert.equal(module.exports.utils.userAgent, userAgent)
|
||||
assert.equal(module.exports.utils.isBrowser, false)
|
||||
assert.equal(module.exports.instances.length, 0)
|
||||
assert.throws(() => Reflect.construct(module.exports, [{}]), error => error.message === 'Artplayer can only be used in the browser environment')
|
||||
}
|
||||
assert.equal(timers, 0)
|
||||
observations.customUA.push({ filename, userAgent, historicalFailure: !!historicalFailure, timers })
|
||||
}
|
||||
}
|
||||
check('SSR.custom-ua-umd-legacy', observations.customUA.length === 8)
|
||||
const userAgentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'CUSTOM_USER_AGENT')
|
||||
const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
|
||||
try {
|
||||
// Node exposes navigator in newer versions; this profile intentionally checks
|
||||
// server imports without either browser globals or a navigator shim.
|
||||
assert(delete globalThis.navigator)
|
||||
for (const userAgent of userAgents) {
|
||||
globalThis.CUSTOM_USER_AGENT = userAgent
|
||||
const url = pathToFileURL(path.join(root, 'node_modules/artplayer/dist/artplayer.mjs'))
|
||||
url.searchParams.set('custom-user-agent', userAgent)
|
||||
const historicalFailure = expected.baseline && /^(?:iPhone|Macintosh)/.test(userAgent)
|
||||
if (historicalFailure) {
|
||||
await assert.rejects(import(url.href), error => error.name === 'ReferenceError')
|
||||
}
|
||||
else {
|
||||
const { default: Player } = await import(url.href)
|
||||
assert.equal(Player.utils.userAgent, userAgent)
|
||||
assert.equal(Player.utils.isBrowser, false)
|
||||
assert.equal(Player.instances.length, 0)
|
||||
}
|
||||
observations.customUA.push({ filename: 'artplayer.mjs', userAgent, historicalFailure: !!historicalFailure })
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (userAgentDescriptor)
|
||||
Object.defineProperty(globalThis, 'CUSTOM_USER_AGENT', userAgentDescriptor)
|
||||
else
|
||||
delete globalThis.CUSTOM_USER_AGENT
|
||||
if (navigatorDescriptor)
|
||||
Object.defineProperty(globalThis, 'navigator', navigatorDescriptor)
|
||||
else
|
||||
delete globalThis.navigator
|
||||
}
|
||||
check('SSR.custom-ua-esm', observations.customUA.length === 12)
|
||||
observations.languages = []
|
||||
const languages = fs.readdirSync(path.join(root, 'node_modules/artplayer/dist/i18n')).filter(file => file.endsWith('.mjs')).map(file => file.slice(0, -4)).sort()
|
||||
assert.deepEqual(languages, ['ar', 'cs', 'es', 'fa', 'fr', 'id', 'pl', 'ru', 'tr', 'vi', 'zh-tw'])
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { Storage } = await loadModules({ Storage: 'packages/artplayer/src/storage' })
|
||||
|
||||
function environment(t) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||
t.after(() => descriptor ? Object.defineProperty(globalThis, 'window', descriptor) : delete globalThis.window)
|
||||
const values = new Map()
|
||||
const native = {
|
||||
getItem: key => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}
|
||||
globalThis.window = { localStorage: native }
|
||||
return { values, native, storage: new Storage() }
|
||||
}
|
||||
|
||||
test('storage preserves envelope, mutable name/settings fields and synchronous returns', (t) => {
|
||||
const { storage, values } = environment(t)
|
||||
assert.deepEqual(Object.keys(storage), ['name', 'settings'])
|
||||
assert.equal(storage.name, 'artplayer_settings')
|
||||
assert.deepEqual(storage.get(), {})
|
||||
assert.equal(storage.set('volume', 0.5), undefined)
|
||||
assert.equal(values.get('artplayer_settings'), '{"volume":0.5}')
|
||||
storage.set('times', { example: 7 })
|
||||
assert.deepEqual(storage.get(), { volume: 0.5, times: { example: 7 } })
|
||||
assert.equal(storage.del('volume'), undefined)
|
||||
assert.deepEqual(storage.get(), { times: { example: 7 } })
|
||||
storage.name = 'custom'
|
||||
storage.set('plugin', true)
|
||||
assert.equal(values.get('custom'), '{"plugin":true}')
|
||||
assert.equal(storage.clear(), undefined)
|
||||
assert.equal(values.has('custom'), false)
|
||||
assert.equal(values.has('artplayer_settings'), true)
|
||||
})
|
||||
|
||||
test('storage retains truthy key selection and historical JSON payload shapes', (t) => {
|
||||
const { storage, values } = environment(t)
|
||||
for (const payload of [[], [1, 2], 'text', 4, true, null]) {
|
||||
values.set(storage.name, JSON.stringify(payload))
|
||||
const expected = payload || {}
|
||||
assert.deepEqual(storage.get(), expected)
|
||||
assert.deepEqual(storage.get(''), expected)
|
||||
assert.deepEqual(storage.get(0), expected)
|
||||
}
|
||||
storage.settings.fallback = 9
|
||||
values.set(storage.name, 'bad JSON')
|
||||
assert.equal(storage.get('fallback'), 9)
|
||||
assert.equal(storage.get(), storage.settings)
|
||||
})
|
||||
|
||||
test('storage falls back on denied native access and retains in-memory references', (t) => {
|
||||
const { storage } = environment(t)
|
||||
Object.defineProperty(window, 'localStorage', { get() {
|
||||
throw new Error('denied')
|
||||
} })
|
||||
const value = { plugin: true }
|
||||
assert.equal(storage.set('value', value), undefined)
|
||||
assert.equal(storage.get('value'), value)
|
||||
storage.del('value')
|
||||
assert.equal(storage.get('value'), undefined)
|
||||
const previous = storage.settings
|
||||
storage.clear()
|
||||
assert.notEqual(storage.settings, previous)
|
||||
assert.deepEqual(storage.settings, {})
|
||||
})
|
||||
|
||||
test('storage write failure does not merge fallback into successful native reads', (t) => {
|
||||
const { storage, native, values } = environment(t)
|
||||
values.set(storage.name, '{"volume":0.2}')
|
||||
native.setItem = () => {
|
||||
throw new Error('quota')
|
||||
}
|
||||
storage.set('volume', 0.7)
|
||||
assert.equal(storage.settings.volume, 0.7)
|
||||
assert.equal(storage.get('volume'), 0.2)
|
||||
native.getItem = () => {
|
||||
throw new Error('denied')
|
||||
}
|
||||
assert.equal(storage.get('volume'), 0.7)
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PipProperty } from '../../packages/artplayer/src/display/types'
|
||||
import type { DisplayStates } from '../../packages/artplayer/src/media/playback'
|
||||
import type { AirplayHost } from '../../packages/artplayer/src/player/airplayMix'
|
||||
import type { AttributeMethods } from '../../packages/artplayer/src/player/attrMix'
|
||||
import type { CssVariableMethods } from '../../packages/artplayer/src/player/cssVarMix'
|
||||
import type { InitialOptionHost } from '../../packages/artplayer/src/player/optionInit'
|
||||
import type { PlaybackStorage } from '../../packages/artplayer/src/plugins/auto-playback/records'
|
||||
import config from '../../packages/artplayer/src/config'
|
||||
import airplayMix from '../../packages/artplayer/src/player/airplayMix'
|
||||
import optionInit from '../../packages/artplayer/src/player/optionInit'
|
||||
import stateMix from '../../packages/artplayer/src/player/stateMix'
|
||||
import Storage from '../../packages/artplayer/src/storage'
|
||||
import { append, getStyle } from '../../packages/artplayer/src/utils/dom'
|
||||
|
||||
declare const element: HTMLElement
|
||||
declare const attrs: AttributeMethods
|
||||
declare const css: CssVariableMethods
|
||||
declare const initial: InitialOptionHost
|
||||
declare const airplay: AirplayHost
|
||||
const raw: unknown = attrs.attr(Symbol('extension'))
|
||||
const text: string = css.cssVar('--custom')
|
||||
const numeric: number = getStyle(element, 'width')
|
||||
const authored: string = getStyle(element, 'width', false)
|
||||
const node: Element | ChildNode | null = append(element, '')
|
||||
const storage = new Storage()
|
||||
const records: PlaybackStorage = storage
|
||||
declare const pip: PipProperty
|
||||
const display: DisplayStates = {
|
||||
mini: false,
|
||||
fullscreen: false,
|
||||
fullscreenWeb: false,
|
||||
get pip(): HTMLElement | null | boolean { return pip.pip },
|
||||
set pip(value: boolean) { pip.pip = value },
|
||||
}
|
||||
stateMix(display)
|
||||
const stored: unknown = storage.get('extension')
|
||||
config.events.push('custom-event')
|
||||
config.methods = ['customMethod']
|
||||
optionInit(initial)
|
||||
airplayMix(airplay)
|
||||
// @ts-expect-error DOM append can return a text node or null.
|
||||
const guaranteedElement: HTMLElement = append(element, '')
|
||||
// @ts-expect-error Raw storage reads are not validated records.
|
||||
const validated: Record<string, unknown> = storage.get()
|
||||
// @ts-expect-error String style reads are distinct from numeric reads.
|
||||
const wrong: number = getStyle(element, 'width', false)
|
||||
export { authored, display, guaranteedElement, node, numeric, raw, records, stored, text, validated, wrong }
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PlayerHost } from '../../packages/artplayer/src/player'
|
||||
import type { BuiltinHost } from '../../packages/artplayer/src/plugins/builtins'
|
||||
import Artplayer from '../../packages/artplayer/src'
|
||||
|
||||
const art = new Artplayer({ container: '#player', url: '' }, function (player) {
|
||||
const same: Artplayer = player
|
||||
this.option.plugins.push((core) => {
|
||||
core.currentTime = '1.5'
|
||||
return { name: 'source-extension', player: same }
|
||||
})
|
||||
})
|
||||
const playerHost: PlayerHost<Artplayer> = art
|
||||
const builtinHost: BuiltinHost = art
|
||||
const seeking: undefined = art.seek
|
||||
const quality: undefined = art.quality
|
||||
const fullscreen: boolean | undefined = art.fullscreen
|
||||
const noticeVisible: boolean = art.notice.show
|
||||
art.notice.show = new Error('test')
|
||||
art.seek = '1.5'
|
||||
art.quality = [{ html: document.createElement('span'), url: '/video.mp4' }]
|
||||
art.on('document:keydown', (event) => {
|
||||
const key: string = event.key
|
||||
return key
|
||||
})
|
||||
art.on('extension:event', (...values) => values)
|
||||
art.emit('video:timeupdate', new Event('timeupdate'))
|
||||
art.emit('extension:event', { custom: true })
|
||||
// @ts-expect-error The native media event payload must be an Event.
|
||||
art.emit('video:timeupdate', 'not an event')
|
||||
// @ts-expect-error Reconnect errors include the attempt count.
|
||||
art.emit('error', new Error('test'))
|
||||
// @ts-expect-error A setter-only seek is not a readable numeric value.
|
||||
const position: number = art.seek
|
||||
// @ts-expect-error Notice visibility is not the last notice message.
|
||||
const message: string = art.notice.show
|
||||
export { builtinHost, fullscreen, message, noticeVisible, playerHost, position, quality, seeking }
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { PluginFactory, PluginHost } from '../../packages/artplayer/src/plugins/types'
|
||||
import type Artplayer from '../../packages/artplayer/src'
|
||||
import type { PluginFactory } from '../../packages/artplayer/src/plugins/types'
|
||||
import Plugins from '../../packages/artplayer/src/plugins'
|
||||
|
||||
interface Host extends PluginHost<Host> {
|
||||
id: number
|
||||
}
|
||||
declare const host: Host
|
||||
declare const host: Artplayer
|
||||
const registry = new Plugins(host)
|
||||
const sync: typeof registry = registry.add(function (art) {
|
||||
const id: number = this.id + art.id
|
||||
@@ -12,7 +10,7 @@ const sync: typeof registry = registry.add(function (art) {
|
||||
})
|
||||
const asyncResult: Promise<typeof registry> = registry.add(async () => ({ name: 'async' }))
|
||||
const mixed: typeof registry | Promise<typeof registry> = registry.add(() => Math.random() ? Promise.resolve(1) : 1)
|
||||
const untyped: PluginFactory<Host> = () => null
|
||||
const untyped: PluginFactory<Artplayer> = () => null
|
||||
const unknown: typeof registry | Promise<typeof registry> = registry.add(untyped)
|
||||
// @ts-expect-error Synchronous plugins do not always return a Promise.
|
||||
const bad: Promise<typeof registry> = registry.add(() => ({ name: 'sync' }))
|
||||
|
||||
Reference in new issue
Block a user