refactor(core): CORE-18 type builtins and own prompt lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 12:31:01 +08:00
1 parent a842058136
commit 98a1800032
55 files changed
+26511 -650

No files matched your search

+319
View File
@@ -0,0 +1,319 @@
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 { autoPlayback, Emitter, beginLifecycle, getScope, beginSource, ownEntry, releaseEntry } = await loadModules({
autoPlayback: 'packages/artplayer/src/plugins/autoPlayback',
Emitter: 'packages/artplayer/src/utils/emitter',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
beginSource: { file: 'packages/artplayer/src/source/operation', name: 'beginSource' },
ownEntry: { file: 'packages/artplayer/src/component/resources', name: 'ownEntry' },
releaseEntry: { file: 'packages/artplayer/src/component/resources', name: 'releaseEntry' },
})
function fixture(t, stored = { first: 12 }) {
class Element {
style = {}
textContent = ''
children = new Map()
querySelector(name) {
if (!this.children.has(name))
this.children.set(name, new Element())
return this.children.get(name)
}
insertAdjacentHTML() {
this.lastElementChild = new Element()
}
}
const original = Object.getOwnPropertyDescriptor(globalThis, 'Element')
Object.defineProperty(globalThis, 'Element', { configurable: true, value: Element })
t.after(() => original ? Object.defineProperty(globalThis, 'Element', original) : Reflect.deleteProperty(globalThis, 'Element'))
let id = 0
const timers = new Map()
t.mock.method(globalThis, 'setTimeout', (callback, delay) => {
const key = ++id
timers.set(key, { callback, delay })
return key
})
t.mock.method(globalThis, 'clearTimeout', key => timers.delete(key))
const listeners = new Set()
const calls = []
const layer = new Element()
let records = stored
const storage = {
get(key) {
assert.equal(key, 'times')
return records
},
set(key, value) {
assert.equal(key, 'times')
records = value
calls.push(['save', value])
},
del(key) {
assert.equal(key, 'times')
records = undefined
},
}
const art = Object.assign(new Emitter(), {
constructor: { AUTO_PLAYBACK_MAX: 2, AUTO_PLAYBACK_MIN: 5, AUTO_PLAYBACK_TIMEOUT: 100 },
option: { id: 'first', url: 'first-url' },
template: { $player: new Element(), $poster: new Element() },
i18n: { get: key => key },
icons: { close: '<svg></svg>' },
storage,
currentTime: 3,
playing: false,
play() {
calls.push(['play'])
return Promise.resolve()
},
layers: { add(option) {
assert.equal(option.name, 'auto-playback')
ownEntry(art, layer)
return layer
} },
})
Object.defineProperty(art, 'seek', {
configurable: true,
get: () => art.currentTime,
set: value => calls.push(['seek', value]),
})
art.events = {
proxy(target, name, callback) {
const listener = { target, name, callback }
listeners.add(listener)
const cleanup = () => listeners.delete(listener)
getScope(art).add(() => {
cleanup()
})
return cleanup
},
remove: cleanup => cleanup(),
}
art.proxy = art.events.proxy
beginLifecycle(art)
beginSource(art)
const plugin = autoPlayback(art)
const jump = layer.querySelector('.art-auto-playback-jump')
const close = layer.querySelector('.art-auto-playback-close')
const last = layer.querySelector('.art-auto-playback-last')
const click = (target = jump) => {
for (const listener of [...listeners]) {
if (listener.target === target && listener.name === 'click')
listener.callback(new Event('click'))
}
}
const flush = () => {
const pending = [...timers.values()]
timers.clear()
for (const timer of pending)
timer.callback()
}
return { art, plugin, storage, layer, last, jump, close, click, flush, timers, calls, listeners, scope: getScope(art) }
}
test('autoPlayback retains result names, storage key, ID precedence, pruning and management methods', (t) => {
const f = fixture(t, { a: 1, b: 2, c: 3 })
assert.deepEqual(Object.keys(f.plugin), ['name', 'times', 'clear', 'delete'])
assert.equal(f.plugin.name, 'auto-playback')
f.art.playing = true
f.art.emit('video:timeupdate')
assert.deepEqual(f.plugin.times, { b: 2, c: 3, first: 3 })
f.art.option.id = ''
f.art.currentTime = 9
f.art.emit('video:timeupdate')
assert.deepEqual(f.plugin.times, { 'c': 3, 'first': 3, 'first-url': 9 })
assert.deepEqual(f.plugin.delete('first'), { 'c': 3, 'first-url': 9 })
assert.equal(f.plugin.clear(), undefined)
assert.deepEqual(f.plugin.times, {})
f.scope.dispose()
})
test('resume prompt preserves threshold, text, seek/play order and first-update timeout', (t) => {
const f = fixture(t, { first: 4 })
f.art.emit('ready')
assert.equal(f.layer.style.display, 'none')
f.storage.set('times', { first: 5 })
f.art.emit('restart')
assert.equal(f.layer.style.display, 'flex')
assert.equal(f.last.textContent, 'Last Seen 00:05')
assert.equal(f.jump.textContent, 'Jump Play')
assert.equal(f.timers.size, 0)
f.click()
assert.deepEqual(f.calls.slice(-2), [['seek', 5], ['play']])
assert.equal(f.art.template.$poster.style.display, 'none')
assert.equal(f.layer.style.display, 'none')
f.art.emit('video:timeupdate')
assert.equal(f.timers.size, 1)
assert.equal([...f.timers.values()][0].delay, 100)
f.flush()
assert.equal(f.layer.style.display, 'none')
f.scope.dispose()
})
test('restarts replace click targets and only the latest resume time is played', (t) => {
const f = fixture(t)
f.art.emit('ready')
f.storage.set('times', { first: 18 })
f.art.emit('restart')
f.storage.set('times', { first: 24 })
f.art.emit('restart')
f.click()
assert.deepEqual(f.calls.filter(call => call[0] === 'seek' || call[0] === 'play'), [['seek', 24], ['play']])
assert.equal(f.listeners.size, 2)
f.art.emit('video:timeupdate')
assert.equal(f.timers.size, 1)
f.scope.dispose()
assert.equal(f.timers.size, 0)
})
test('restarting below threshold cancels old clicks and pending first-update subscription', (t) => {
const f = fixture(t)
f.art.emit('ready')
f.storage.set('times', { first: 1 })
f.art.emit('restart')
f.click()
assert.equal(f.calls.some(call => call[0] === 'seek'), false)
f.art.emit('video:timeupdate')
assert.equal(f.timers.size, 0)
f.scope.dispose()
})
test('removing the layer releases prompt resources but keeps playing-time storage', (t) => {
const f = fixture(t)
f.art.emit('ready')
f.art.emit('video:timeupdate')
releaseEntry(f.layer)
f.click()
f.art.emit('restart')
assert.equal(f.calls.some(call => call[0] === 'seek'), false)
assert.equal(f.timers.size, 0)
assert.equal(f.listeners.size, 0)
f.art.playing = true
f.art.emit('video:timeupdate')
assert.deepEqual(f.plugin.times, { first: 3 })
f.scope.dispose()
})
test('source replacement hides stale prompts before restart and cancels their pending work', (t) => {
const f = fixture(t)
f.art.emit('ready')
beginSource(f.art)
assert.equal(f.layer.style.display, 'none')
f.click()
f.art.emit('video:timeupdate')
assert.equal(f.calls.some(call => call[0] === 'play'), false)
assert.equal(f.timers.size, 0)
f.scope.dispose()
})
test('destroy during seek stops play and subsequent DOM writes', (t) => {
const f = fixture(t)
f.art.emit('ready')
Object.defineProperty(f.art, 'seek', {
get: () => 0,
set() {
f.scope.dispose()
},
})
f.click()
assert.equal(f.calls.some(call => call[0] === 'play'), false)
assert.equal(f.art.template.$poster.style.display, undefined)
assert.equal(f.timers.size, 0)
})
test('destruction stops recording and first-update scheduling', (t) => {
const f = fixture(t)
f.art.emit('ready')
f.scope.dispose()
f.art.playing = true
f.art.emit('video:timeupdate')
f.art.emit('restart')
assert.equal(f.calls.length, 0)
assert.equal(f.timers.size, 0)
})
test('old timeout cannot hide a newer prompt and live timeout constants remain effective', (t) => {
const f = fixture(t)
f.art.emit('ready')
f.art.emit('video:timeupdate')
const stale = [...f.timers.values()][0].callback
f.storage.set('times', { first: 18 })
f.art.emit('restart')
stale()
assert.equal(f.layer.style.display, 'flex')
f.art.constructor.AUTO_PLAYBACK_TIMEOUT = 200
f.art.emit('video:timeupdate')
assert.equal(f.timers.size, 1)
assert.equal([...f.timers.values()][0].delay, 200)
f.scope.dispose()
})
test('a restart inside seek retains the new prompt and stops the old play continuation', (t) => {
const f = fixture(t)
f.art.emit('ready')
Object.defineProperty(f.art, 'seek', {
get: () => 0,
set() {
f.storage.set('times', { first: 25 })
f.art.emit('restart')
},
})
f.click()
assert.equal(f.calls.some(call => call[0] === 'play'), false)
assert.equal(f.last.textContent, 'Last Seen 00:25')
assert.equal(f.layer.style.display, 'flex')
assert.equal(f.listeners.size, 2)
f.scope.dispose()
})
test('destroying in localization callbacks prevents later DOM writes', (t) => {
const f = fixture(t)
f.art.i18n.get = () => {
f.scope.dispose()
return 'late'
}
f.art.emit('ready')
assert.equal(f.last.textContent, '')
assert.equal(f.jump.textContent, '')
assert.equal(f.listeners.size, 0)
})
test('destroying in storage callbacks prevents later record writes', (t) => {
const f = fixture(t)
f.storage.get = () => {
f.scope.dispose()
return { first: 12 }
}
f.art.playing = true
f.art.emit('video:timeupdate')
assert.equal(f.calls.length, 0)
})
test('invalid minimum retains the original comparison rather than showing a prompt', (t) => {
const f = fixture(t)
f.art.constructor.AUTO_PLAYBACK_MIN = Number.NaN
f.art.emit('ready')
assert.equal(f.layer.style.display, 'none')
assert.equal(f.listeners.size, 0)
f.scope.dispose()
})
test('resume close and internally rejected playback preserve the existing UI contract', async (t) => {
const f = fixture(t)
f.art.emit('ready')
f.click(f.close)
assert.equal(f.layer.style.display, 'none')
assert.equal(f.calls.length, 0)
f.art.emit('restart')
f.art.play = () => Promise.reject(new Error('play refused'))
f.click()
await Promise.resolve()
assert.equal(f.layer.style.display, 'none')
assert.equal(f.art.template.$poster.style.display, 'none')
f.scope.dispose()
})
+181
View File
@@ -0,0 +1,181 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, time = 1) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate((time) => {
window.Artplayer.AUTO_PLAYBACK_MIN = 0.5
window.Artplayer.AUTO_PLAYBACK_TIMEOUT = 24735
localStorage.setItem('artplayer_settings', JSON.stringify({ volume: 0.4, times: { example: time } }))
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', id: 'example', autoPlayback: true, muted: true })
window.resume = window.art.plugins['auto-playback']
window.promptLayer = window.art.layers['auto-playback']
}, time)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await expect.poll(() => page.evaluate(() => window.promptLayer.style.display)).toBe('flex')
}
for (const core of ['published', 'candidate']) {
test(`${core}: resume close and playback rejection preserve the UI contract`, async ({ page }, testInfo) => {
await setup(page, core)
await page.locator('.art-auto-playback-close').click()
expect(await page.evaluate(() => window.promptLayer.style.display)).toBe('none')
await page.evaluate((published) => {
window.art.emit('restart', window.art.url)
const failure = new Error('controlled play rejection')
window.resumeRejections = []
if (published) {
window.addEventListener('unhandledrejection', (event) => {
if (event.reason === failure) {
event.preventDefault()
window.resumeRejections.push({ name: event.reason.name, message: event.reason.message, same: true })
}
})
}
window.art.template.$video.play = () => Promise.reject(failure)
}, core === 'published')
await page.locator('.art-auto-playback-jump').click()
expect(await page.evaluate(() => ({ layer: window.promptLayer.style.display, poster: window.art.template.$poster.style.display }))).toEqual({ layer: 'none', poster: 'none' })
if (core === 'published')
await expect.poll(() => page.evaluate(() => window.resumeRejections.length)).toBe(2)
const rejections = await page.evaluate(() => window.resumeRejections)
expect(rejections).toEqual(core === 'published' ? Array.from({ length: 2 }, () => ({ name: 'Error', message: 'controlled play rejection', same: true })) : [])
await testInfo.attach('known-resume-rejections', { contentType: 'application/json', body: JSON.stringify({ core, rejections }) })
await page.evaluate(() => window.art.destroy())
})
test(`${core}: resume API retains storage envelope, ID lookup and deletion`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const plugin = window.resume
const before = plugin.times
const removed = plugin.delete('example')
const cleared = plugin.clear()
const envelope = JSON.parse(localStorage.getItem('artplayer_settings'))
window.art.destroy()
return { keys: Object.keys(plugin), name: plugin.name, before, removed, cleared, times: plugin.times, volume: envelope.volume, removedTimes: !Object.hasOwn(envelope, 'times') }
})).toEqual({ keys: ['name', 'times', 'clear', 'delete'], name: 'auto-playback', before: { example: 1 }, removed: {}, cleared: undefined, times: {}, volume: 0.4, removedTimes: true })
})
test(`${core}: resume button seeks and plays actual video`, async ({ page }) => {
await setup(page, core)
await page.locator('.art-auto-playback-jump').click()
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(1)
expect(await page.evaluate(() => ({ prompt: window.promptLayer.style.display, poster: window.art.template.$poster.style.display }))).toEqual({ prompt: 'none', poster: 'none' })
await expect.poll(() => page.evaluate(() => window.resume.times.example)).toBeGreaterThan(1)
await page.evaluate(() => window.art.destroy())
})
test(`${core}: repeated restart leaves one effective click for the current resume target`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(() => {
const art = window.art
const seeks = []
let plays = 0
art.on('seek', (_time, requested) => seeks.push(requested))
art.template.$video.play = () => {
plays++
return Promise.resolve()
}
art.storage.set('times', { example: 1.5 })
art.emit('restart', art.url)
art.storage.set('times', { example: 2 })
art.emit('restart', art.url)
window.promptLayer.querySelector('.art-auto-playback-jump').click()
const label = window.promptLayer.querySelector('.art-auto-playback-last').textContent
art.destroy()
return { seeks, plays, label }
})
expect(result.seeks).toEqual(core === 'candidate' ? [2] : [1, 1.5, 2])
expect(result.plays).toBe(core === 'candidate' ? 1 : 3)
expect(result.label).toBe('Last Seen 00:02')
})
test(`${core}: restart below threshold invalidates the old prompt click`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
let plays = 0
art.template.$video.play = () => {
plays++
return Promise.resolve()
}
art.storage.set('times', { example: 0.1 })
art.emit('restart', art.url)
window.promptLayer.querySelector('.art-auto-playback-jump').click()
const result = { hidden: window.promptLayer.style.display, plays }
art.destroy()
return result
})).toEqual({ hidden: 'none', plays: core === 'candidate' ? 0 : 1 })
})
}
for (const action of ['restart', 'source', 'remove', 'destroy']) {
test(`candidate: ${action} cancels obsolete resume timers and subscriptions`, async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate((action) => {
const art = window.art
const layer = window.promptLayer
const set = window.setTimeout
const clear = window.clearTimeout
const pending = new Set()
const callbacks = []
let plays = 0
art.template.$video.play = () => {
plays++
return Promise.resolve()
}
window.setTimeout = function (callback, delay, ...args) {
const timer = set.call(this, callback, delay, ...args)
if (delay === 24735) {
callbacks.push(callback)
pending.add(timer)
}
return timer
}
window.clearTimeout = function (timer) {
pending.delete(timer)
return clear.call(this, timer)
}
art.emit('video:timeupdate', new Event('timeupdate'))
const scheduled = pending.size
if (action === 'restart') {
art.storage.set('times', { example: 2 })
art.emit('restart', art.url)
}
else if (action === 'source') {
art.url = '/test/pattern.mp4?resume=2'
}
else if (action === 'remove') {
art.layers.remove('auto-playback')
}
else {
art.destroy(false)
}
for (const callback of callbacks)
callback()
if (action !== 'restart')
layer.querySelector('.art-auto-playback-jump').click()
const result = { scheduled, remaining: pending.size, display: layer.style.display, plays }
window.setTimeout = set
window.clearTimeout = clear
art.destroy()
return result
}, action)).toEqual({ scheduled: 1, remaining: 0, display: action === 'restart' ? 'flex' : 'none', plays: 0 })
})
}
test('candidate: destruction inside seek prevents resume playback continuation', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
let plays = 0
art.template.$video.play = () => {
plays++
return Promise.resolve()
}
art.on('seek', () => art.destroy(false))
window.promptLayer.querySelector('.art-auto-playback-jump').click()
return { destroyed: art.isDestroy, plays }
})).toEqual({ destroyed: true, plays: 0 })
})
+99
View File
@@ -0,0 +1,99 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, mobile = true, live = false) {
await page.addInitScript(mobile => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: mobile ? 'ArtPlayer Android builtin fixture' : 'ArtPlayer Desktop builtin fixture' }), mobile)
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate((live) => {
window.art = new window.Artplayer({
container: '.player',
url: '/test/pattern.mp4',
muted: true,
isLive: live,
miniProgressBar: true,
lock: true,
autoPlayback: true,
autoOrientation: true,
fastForward: true,
})
}, live)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
for (const mobile of [true, false]) {
for (const live of [true, false]) {
test(`${core}: builtin availability keeps mobile=${mobile} live=${live} conditions`, async ({ page }) => {
await setup(page, core, mobile, live)
expect(await page.evaluate(() => {
const names = ['mini-progress-bar', 'lock', 'auto-playback', 'autoOrientation', 'fastForward']
const result = names.map(name => Boolean(window.art.plugins[name]))
window.art.destroy()
return result
})).toEqual([!live, mobile, !live, mobile, mobile && !live])
})
}
}
test(`${core}: lock keeps public state, repeated events, DOM classes and icon switching`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const plugin = art.plugins.lock
const events = []
art.on('lock', state => events.push([state, art.isLock, plugin.state]))
plugin.state = true
plugin.state = true
art.layers.lock.click()
const result = {
keys: Object.keys(plugin),
events,
lock: art.layers.lock.querySelector('.art-icon-lock').style.display,
unlock: art.layers.lock.querySelector('.art-icon-unlock').style.display,
}
art.destroy()
return result
})).toEqual({ keys: ['name', 'state'], events: [[true, true, true], [true, true, true], [false, false, false]], lock: 'none', unlock: 'inline-flex' })
})
test(`${core}: removed lock icons stop observing the still-usable plugin state API`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const icon = art.layers.lock.querySelector('.art-icon-lock')
art.layers.remove('lock')
art.plugins.lock.state = true
const result = { flag: art.isLock, state: art.plugins.lock.state, icon: icon.style.display }
art.destroy()
return result
})).toEqual({ flag: true, state: true, icon: core === 'candidate' ? 'none' : 'inline-flex' })
})
test(`${core}: miniature progress follows the control event without adding public state`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const player = art.template.$player
art.emit('control', false)
const hiddenControls = player.classList.contains('art-mini-progress-bar')
art.emit('control', true)
const shownControls = player.classList.contains('art-mini-progress-bar')
const keys = Object.keys(art.plugins['mini-progress-bar'])
art.destroy()
return { hiddenControls, shownControls, keys }
})).toEqual({ hiddenControls: true, shownControls: false, keys: ['name'] })
})
}
test('candidate: retained builtin setters and events do not mutate a destroyed player', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
art.emit('control', true)
art.destroy(false)
let events = 0
art.on('lock', () => events++)
art.plugins.lock.state = true
art.emit('control', false)
return { lock: art.isLock, state: art.plugins.lock.state, mini: art.template.$player.classList.contains('art-mini-progress-bar'), events }
})).toEqual({ lock: false, state: false, mini: false, events: 0 })
})
+132
View File
@@ -0,0 +1,132 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android long-press fixture' }))
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
window.Artplayer.FAST_FORWARD_TIME = 20
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, fastForward: true })
window.touch = (type, count = 1) => {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'touches', { value: Array.from({ length: count }, (_, identifier) => ({ identifier, pageX: 50, pageY: 50, clientX: 50, clientY: 50 })) })
window.art.template.$video.dispatchEvent(event)
}
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.evaluate(() => window.art.play())
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.evaluate(() => {
window.art.playbackRate = 1.5
})
}
for (const core of ['published', 'candidate']) {
test(`${core}: native source reset retains its default playback rate behavior`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => {
window.art.url = '/test/pattern.mp4?reset=1'
})
await expect.poll(() => page.evaluate(() => window.art.playbackRate)).toBe(1)
expect(await page.evaluate(() => window.art.template.$video.defaultPlaybackRate)).toBe(1)
await page.evaluate(() => window.art.destroy())
})
test(`${core}: long press accelerates real playback and restores its prior rate`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => Object.keys(window.art.plugins.fastForward))).toEqual(['name', 'state'])
await page.evaluate(() => window.touch('touchstart'))
await expect.poll(() => page.evaluate(() => window.art.plugins.fastForward.state)).toBe(true)
expect(await page.evaluate(() => window.art.playbackRate)).toBe(3)
await page.evaluate(() => window.touch('touchend', 0))
expect(await page.evaluate(() => ({ state: window.art.plugins.fastForward.state, rate: window.art.playbackRate }))).toEqual({ state: false, rate: 1.5 })
await page.evaluate(() => window.art.destroy())
})
test(`${core}: touchcancel releases an active long press`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => window.touch('touchstart'))
await expect.poll(() => page.evaluate(() => window.art.plugins.fastForward.state)).toBe(true)
await page.evaluate(() => window.touch('touchcancel', 0))
expect(await page.evaluate(() => ({ state: window.art.plugins.fastForward.state, rate: window.art.playbackRate }))).toEqual({ state: core === 'published', rate: core === 'published' ? 3 : 1.5 })
await page.evaluate(() => {
window.touch('touchend', 0)
window.art.destroy()
})
})
}
for (const action of ['lock', 'pause', 'source', 'destroy']) {
test(`candidate: ${action} restores the active long-press rate`, async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => window.touch('touchstart'))
await expect.poll(() => page.evaluate(() => window.art.plugins.fastForward.state)).toBe(true)
await page.evaluate((action) => {
const art = window.art
if (action === 'lock') {
art.isLock = true
art.emit('lock', true)
}
else if (action === 'pause') {
art.pause()
}
else if (action === 'source') {
const video = art.template.$video
const rate = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'playbackRate')
window.restoredRates = []
Object.defineProperty(video, 'playbackRate', {
configurable: true,
get() {
return rate.get.call(this)
},
set(value) {
window.restoredRates.push(value)
rate.set.call(this, value)
},
})
art.url = '/test/pattern.mp4?replacement=1'
}
else {
art.destroy(false)
}
}, action)
await expect.poll(() => page.evaluate(() => ({ state: window.art.plugins.fastForward.state, rate: window.art.playbackRate }))).toEqual({ state: false, rate: action === 'source' ? 1 : 1.5 })
if (action === 'source')
expect(await page.evaluate(() => window.restoredRates)).toEqual([1.5])
await page.evaluate(() => window.art.destroy())
})
}
test('candidate: repeated pending touches own a single timer which destruction cancels', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
art.constructor.FAST_FORWARD_TIME = 24734
const set = window.setTimeout
const clear = window.clearTimeout
const pending = new Set()
const callbacks = []
window.setTimeout = function (callback, delay, ...args) {
const timer = set.call(this, callback, delay, ...args)
if (delay === 24734) {
pending.add(timer)
callbacks.push(callback)
}
return timer
}
window.clearTimeout = function (timer) {
pending.delete(timer)
return clear.call(this, timer)
}
window.touch('touchstart')
window.touch('touchstart')
const scheduled = pending.size
art.destroy(false)
const resetRate = art.playbackRate
for (const callback of callbacks)
callback()
const result = { scheduled, remaining: pending.size, state: art.plugins.fastForward.state, resetRate, rate: art.playbackRate }
window.setTimeout = set
window.clearTimeout = clear
return result
})).toEqual({ scheduled: 1, remaining: 0, state: false, resetRate: 1, rate: 1 })
})
+168
View File
@@ -0,0 +1,168 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.evaluate(() => {
window.Artplayer.NOTICE_TIME = 24733
window.art.notice.destroy()
})
}
for (const core of ['published', 'candidate']) {
test(`${core}: notice updates do not call a consumer override of destroy`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const notice = window.art.notice
const original = notice.destroy
let calls = 0
notice.destroy = function () {
calls++
return original.call(this)
}
notice.show = 'first'
notice.show = 'second'
const implicit = calls
notice.destroy()
const explicit = calls
window.art.destroy()
return { implicit, explicit }
})).toEqual({ implicit: 0, explicit: 1 })
})
test(`${core}: notice preserves public shape, Error text and false hide semantics`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const notice = art.notice
notice.show = new Error(' trimmed ')
const visible = notice.show
const text = art.template.$noticeInner.textContent
const timer = notice.timer
notice.show = false
const result = {
fields: Object.keys(notice),
prototype: Object.getOwnPropertyNames(Object.getPrototypeOf(notice)),
visible,
text,
hidden: !notice.show,
retained: art.template.$noticeInner.textContent,
sameTimer: timer === notice.timer,
}
notice.destroy()
art.destroy()
return result
})).toEqual({ fields: ['art', 'timer'], prototype: ['constructor', 'destroy', 'show'], visible: true, text: 'trimmed', hidden: true, retained: 'trimmed', sameTimer: true })
})
test(`${core}: notice expires naturally during real playback`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => window.art.play())
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.evaluate(() => {
window.Artplayer.NOTICE_TIME = 20
window.art.notice.show = 'transient'
})
await expect.poll(() => page.evaluate(() => ({ visible: window.art.notice.show, text: window.art.template.$noticeInner.textContent }))).toEqual({ visible: false, text: '' })
expect(await page.evaluate(() => window.art.playing)).toBe(true)
await page.evaluate(() => window.art.destroy())
})
test(`${core}: manual notice destroy permits reuse and does not hide its DOM`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const notice = window.art.notice
notice.show = 'visible'
notice.destroy()
const visible = notice.show
const timer = notice.timer
notice.show = 'reused'
const reused = window.art.template.$noticeInner.textContent
notice.destroy()
window.art.destroy()
return { visible, timer, reused }
})).toEqual({ visible: true, timer: null, reused: 'reused' })
})
test(`${core}: destruction during notice text assignment cannot schedule a late timer`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const notice = art.notice
const inner = art.template.$noticeInner
const descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent')
Object.defineProperty(inner, 'textContent', {
configurable: true,
get() {
return descriptor.get.call(this)
},
set(value) {
descriptor.set.call(this, value)
art.destroy(false)
},
})
notice.show = 'destroy during write'
const result = { destroyed: art.isDestroy, timer: notice.timer !== null, visible: notice.show }
notice.destroy()
return result
})).toEqual({ destroyed: true, timer: core === 'published', visible: core === 'published' })
})
test(`${core}: expiry does not hide a notice installed during the clearing write`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const inner = art.template.$noticeInner
const descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent')
const original = window.setTimeout
let expire
window.setTimeout = function (callback, delay, ...args) {
if (delay === 24733)
expire = callback
return original.call(this, callback, delay, ...args)
}
art.notice.show = 'old'
Object.defineProperty(inner, 'textContent', {
configurable: true,
get() {
return descriptor.get.call(this)
},
set(value) {
descriptor.set.call(this, value)
if (value === '')
art.notice.show = 'nested'
},
})
expire()
const result = { text: inner.textContent, visible: art.notice.show }
window.setTimeout = original
art.notice.destroy()
art.destroy()
return result
})).toEqual({ text: 'nested', visible: core === 'candidate' })
})
}
test('candidate: retained callbacks and assignments cannot revive destroyed notice resources', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const original = window.setTimeout
const callbacks = []
window.setTimeout = function (callback, delay, ...args) {
if (delay === 24733)
callbacks.push(callback)
return original.call(this, callback, delay, ...args)
}
art.notice.show = 'old'
art.notice.show = 'new'
callbacks[0]()
const before = art.template.$noticeInner.textContent
art.destroy(false)
callbacks[1]()
art.notice.show = 'ignored'
window.setTimeout = original
return { before, after: art.template.$noticeInner.textContent, timer: art.notice.timer, calls: callbacks.length }
})).toEqual({ before: 'new', after: 'new', timer: null, calls: 2 })
})
+193
View File
@@ -0,0 +1,193 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, mobile = false) {
await page.addInitScript(mobile => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: mobile ? 'ArtPlayer Android info fixture' : 'ArtPlayer Desktop info fixture' }), mobile)
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: mask preserves observations before and after its destroy handler`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
expect(await page.evaluate(() => {
const seen = []
const art = new window.Artplayer({ container: '.player', url: '', layers: [{
name: 'observer',
html: '',
mounted() {
this.on('destroy', () => seen.push(['before', this.template.$state.querySelector('.art-icon-error').style.display]))
},
}] })
art.on('destroy', () => seen.push(['after', art.template.$state.querySelector('.art-icon-error').style.display]))
art.destroy(false)
return seen
})).toEqual([['before', 'none'], ['after', '']])
})
test(`${core}: info preserves fields, native media text and close visibility`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.template.$video.volume = 0.4
art.info.init()
const volume = art.template.$infoPanel.querySelector('[data-video="volume"]').textContent
const source = art.template.$infoPanel.querySelector('[data-video="currentSrc"]').textContent === art.template.$video.currentSrc
art.info.show = true
art.template.$infoClose.click()
const result = { fields: Object.keys(art.info), prototype: Object.getOwnPropertyNames(Object.getPrototypeOf(art.info)), volume, source, visible: art.info.show }
art.destroy()
return result
})).toEqual({ fields: ['id', 'art', 'cache', 'add', 'remove', 'update', 'name'], prototype: ['constructor', 'init'], volume: '0.40', source: true, visible: false })
})
test(`${core}: info retains native nullable string conversion for custom media fields`, async ({ page }) => {
await setup(page, core, true)
expect(await page.evaluate(() => {
const art = window.art
const item = document.createElement('span')
item.dataset.video = 'fixtureValue'
art.template.$infoPanel.append(item)
const values = [null, undefined, true, 1.234, Number.NaN, Infinity, { toString: () => 'custom' }]
const result = values.map((value) => {
art.template.$video.fixtureValue = value
art.info.init()
return item.textContent
})
art.template.$video.fixtureValue = Symbol('not text')
let failed
try {
art.info.init()
}
catch (error) {
failed = error.name
}
art.destroy()
return { result, failed }
})).toEqual({ result: ['', '', 'true', '1.23', 'NaN', 'Infinity', 'custom'], failed: 'TypeError' })
})
test(`${core}: repeated info init owns only one new timer and close handler`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const set = window.setTimeout
const clear = window.clearTimeout
const pending = new Set()
window.setTimeout = function (callback, delay, ...args) {
const timer = set.call(this, callback, delay, ...args)
if (delay === 24736)
pending.add(timer)
return timer
}
window.clearTimeout = function (timer) {
pending.delete(timer)
return clear.call(this, timer)
}
art.constructor.INFO_LOOP_TIME = 24736
art.info.init()
art.info.init()
let closed = 0
art.on('info', state => !state && closed++)
art.info.show = true
art.template.$infoClose.click()
const timers = pending.size
art.destroy()
const result = { timers, closed, remaining: pending.size }
window.setTimeout = set
window.clearTimeout = clear
return result
})).toEqual({ timers: core === 'candidate' ? 1 : 2, closed: core === 'candidate' ? 1 : 3, remaining: 0 })
})
test(`${core}: info mobile construction waits for an explicit init`, async ({ page }) => {
await setup(page, core, true)
expect(await page.evaluate(() => {
const art = window.art
const node = art.template.$infoPanel.querySelector('[data-video="volume"]')
const before = node.textContent
art.template.$video.volume = 0.4
art.info.init()
const after = node.textContent
art.destroy()
return { before, after }
})).toEqual({ before: '', after: '0.40' })
})
test(`${core}: mask click plays native video and destruction displays the terminal icon`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => window.art.template.$state.click())
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
expect(await page.evaluate(() => {
const art = window.art
const state = art.template.$state.querySelector('.art-icon-state')
const error = art.template.$state.querySelector('.art-icon-error')
art.loading.show = true
const loading = art.loading.show
const icon = Boolean(art.template.$loading.querySelector('.art-icon-loading'))
art.destroy(false)
return { loading, icon, state: state.style.display, error: error.style.display }
})).toEqual({ loading: true, icon: true, state: 'none', error: '' })
})
}
test('candidate: info destruction during a media getter prevents text writes and late init', async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(() => {
const art = window.art
const node = art.template.$infoPanel.querySelector('[data-video="volume"]')
const before = node.textContent
Object.defineProperty(art.template.$video, 'volume', { configurable: true, get() {
art.destroy(false)
return 0.8
} })
art.info.init()
const after = node.textContent
art.info.init()
return { before, after, destroyed: art.isDestroy }
})
expect(result.after).toBe(result.before)
expect(result.destroyed).toBe(true)
})
test('candidate: reentrant info text conversion cannot write after player destruction', async ({ page }) => {
await setup(page, 'candidate', true)
expect(await page.evaluate(() => {
const art = window.art
const item = document.createElement('span')
item.dataset.video = 'fixtureValue'
item.textContent = 'before'
art.template.$infoPanel.append(item)
art.template.$video.fixtureValue = { toString() {
art.destroy(false)
return 'late'
} }
art.info.init()
return { text: item.textContent, destroyed: art.isDestroy }
})).toEqual({ text: 'before', destroyed: true })
})
test('candidate: mask finalization follows an earlier throwing observer without replacing its error', async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
expect(await page.evaluate(() => {
const failure = new Error('early observer failed')
const art = new window.Artplayer({ container: '.player', url: '', layers: [{
name: 'observer',
html: '',
mounted() {
this.on('destroy', () => {
throw failure
})
},
}] })
let original = false
try {
art.destroy(false)
}
catch (error) {
original = error === failure
}
return { original, destroyed: art.isDestroy, state: art.template.$state.querySelector('.art-icon-state').style.display, error: art.template.$state.querySelector('.art-icon-error').style.display }
})).toEqual({ original: true, destroyed: true, state: 'none', error: '' })
})
+112
View File
@@ -0,0 +1,112 @@
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 { lock, miniProgressBar, Emitter, beginLifecycle, getScope, ownEntry, releaseEntry } = await loadModules({
lock: 'packages/artplayer/src/plugins/lock',
miniProgressBar: 'packages/artplayer/src/plugins/miniProgressBar',
Emitter: 'packages/artplayer/src/utils/emitter',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
ownEntry: { file: 'packages/artplayer/src/component/resources', name: 'ownEntry' },
releaseEntry: { file: 'packages/artplayer/src/component/resources', name: 'releaseEntry' },
})
function fixture(t) {
class Element {
style = {}
children = []
appendChild(child) {
this.children.push(child)
this.lastElementChild = child
}
}
const original = Object.getOwnPropertyDescriptor(globalThis, 'Element')
Object.defineProperty(globalThis, 'Element', { configurable: true, value: Element })
t.after(() => original ? Object.defineProperty(globalThis, 'Element', original) : Reflect.deleteProperty(globalThis, 'Element'))
const classes = new Set()
const classList = { add: value => classes.add(value), remove: value => classes.delete(value), contains: value => classes.has(value) }
const element = new Element()
let option
const art = Object.assign(new Emitter(), {
template: { $player: { classList } },
isLock: false,
icons: { lock: new Element(), unlock: new Element() },
layers: { add(value) {
option = value
ownEntry(art, element)
option.mounted(element)
return element
} },
})
beginLifecycle(art)
const plugin = lock(art)
return { art, plugin, classes, classList, element, click: () => option.click(), scope: getScope(art) }
}
test('lock preserves state/class/flag ordering, repeated notifications and icon visibility', (t) => {
const f = fixture(t)
assert.deepEqual(Object.keys(f.plugin), ['name', 'state'])
const seen = []
f.art.on('lock', state => seen.push([state, f.plugin.state, f.art.isLock]))
f.plugin.state = true
f.plugin.state = true
assert.equal(f.art.icons.lock.style.display, 'inline-flex')
assert.equal(f.art.icons.unlock.style.display, 'none')
f.click()
assert.equal(f.art.icons.lock.style.display, 'none')
assert.equal(f.art.icons.unlock.style.display, 'inline-flex')
assert.deepEqual(seen, [[true, true, true], [true, true, true], [false, false, false]])
f.scope.dispose()
})
test('lock icon subscriptions are removed with the layer but the live state API remains usable', (t) => {
const f = fixture(t)
releaseEntry(f.element)
f.plugin.state = true
assert.equal(f.plugin.state, true)
assert.equal(f.art.isLock, true)
assert.equal(f.art.icons.lock.style.display, 'none')
assert.equal(f.art.e.lock, undefined)
f.scope.dispose()
})
test('destroyed lock does not emit or mutate state through retained setters and clicks', (t) => {
const f = fixture(t)
f.scope.dispose()
let events = 0
f.art.on('lock', () => events++)
f.plugin.state = true
f.click()
assert.equal(f.plugin.state, false)
assert.equal(f.art.isLock, false)
assert.equal(events, 0)
})
test('destruction during lock class mutation stops the subsequent flag and event', (t) => {
const f = fixture(t)
const original = f.classList.add
f.classList.add = (name) => {
original(name)
f.scope.dispose()
}
let events = 0
f.art.on('lock', () => events++)
f.plugin.state = true
assert.equal(f.art.isLock, false)
assert.equal(events, 0)
})
test('miniProgressBar preserves its control-state class and releases the subscription', (t) => {
const f = fixture(t)
assert.deepEqual(miniProgressBar(f.art), { name: 'mini-progress-bar' })
f.art.emit('control', false)
assert.equal(f.classes.has('art-mini-progress-bar'), true)
f.art.emit('control', true)
assert.equal(f.classes.has('art-mini-progress-bar'), false)
f.scope.dispose()
f.art.emit('control', false)
assert.equal(f.classes.has('art-mini-progress-bar'), false)
assert.equal(f.art.e.control, undefined)
})
+229
View File
@@ -0,0 +1,229 @@
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 { fastForward, Emitter, beginLifecycle, getScope, beginSource } = await loadModules({
fastForward: 'packages/artplayer/src/plugins/fastForward',
Emitter: 'packages/artplayer/src/utils/emitter',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
beginSource: { file: 'packages/artplayer/src/source/operation', name: 'beginSource' },
})
function fixture(t) {
let id = 0
const timers = new Map()
t.mock.method(globalThis, 'setTimeout', (callback, delay) => {
const key = ++id
timers.set(key, { callback, delay })
return key
})
t.mock.method(globalThis, 'clearTimeout', key => timers.delete(key))
const classes = new Set()
const video = new EventTarget()
const art = Object.assign(new Emitter(), {
template: { $video: video, $player: { classList: {
add: value => classes.add(value),
remove: value => classes.delete(value),
contains: value => classes.has(value),
} } },
constructor: { FAST_FORWARD_TIME: 100, FAST_FORWARD_VALUE: 3 },
playing: true,
isLock: false,
playbackRate: 1.5,
proxy(target, name, callback) {
target.addEventListener(name, callback)
const remove = () => target.removeEventListener(name, callback)
getScope(art).add(() => {
remove()
})
return remove
},
})
beginLifecycle(art)
beginSource(art)
const plugin = fastForward(art)
const start = (count = 1) => {
const event = new Event('touchstart')
Object.defineProperty(event, 'touches', { value: Array.from({ length: count }, (_, identifier) => ({ identifier })) })
video.dispatchEvent(event)
}
const flush = () => {
const callbacks = [...timers.values()]
timers.clear()
for (const { callback } of callbacks)
callback()
}
return { art, plugin, start, flush, timers, video, scope: getScope(art) }
}
test('fastForward preserves name, state, live constants and previous playback rate', (t) => {
const f = fixture(t)
assert.deepEqual(Object.keys(f.plugin), ['name', 'state'])
assert.equal(f.plugin.name, 'fastForward')
assert.equal(f.plugin.state, false)
f.art.constructor.FAST_FORWARD_TIME = 200
f.start()
assert.equal([...f.timers.values()][0].delay, 200)
f.art.constructor.FAST_FORWARD_VALUE = 4
f.flush()
assert.equal(f.art.playbackRate, 4)
assert.equal(f.plugin.state, true)
f.art.emit('document:touchend', new Event('touchend'))
assert.equal(f.art.playbackRate, 1.5)
assert.equal(f.plugin.state, false)
f.scope.dispose()
})
test('fastForward only schedules a single playing unlocked touch', (t) => {
const f = fixture(t)
f.start(2)
f.art.playing = false
f.start()
f.art.playing = true
f.art.isLock = true
f.start()
assert.equal(f.timers.size, 0)
f.scope.dispose()
})
test('repeated touch starts do not leave timers or overwrite the original rate', (t) => {
const f = fixture(t)
f.start()
f.start()
assert.equal(f.timers.size, 1)
f.flush()
f.start(2)
assert.equal(f.art.playbackRate, 1.5)
assert.equal(f.plugin.state, false)
f.scope.dispose()
})
for (const event of ['document:touchmove', 'document:touchend', 'document:touchcancel', 'video:pause', 'destroy']) {
test(`fastForward cancels pending and active presses on ${event}`, (t) => {
const f = fixture(t)
f.start()
const stale = [...f.timers.values()][0].callback
f.art.emit(event, new Event(event))
assert.equal(f.timers.size, 0)
stale()
assert.equal(f.art.playbackRate, 1.5)
f.start()
f.flush()
assert.equal(f.plugin.state, true)
f.art.emit(event, new Event(event))
assert.equal(f.art.playbackRate, 1.5)
assert.equal(f.plugin.state, false)
f.scope.dispose()
})
}
test('native touchcancel, lock, source replacement and destruction release the press', (t) => {
const f = fixture(t)
f.start()
f.video.dispatchEvent(new Event('touchcancel'))
assert.equal(f.timers.size, 0)
f.start()
f.flush()
f.art.isLock = true
f.art.emit('lock', true)
assert.equal(f.art.playbackRate, 1.5)
f.art.isLock = false
f.start()
f.flush()
beginSource(f.art)
assert.equal(f.art.playbackRate, 1.5)
assert.equal(f.plugin.state, false)
f.start()
f.flush()
f.scope.dispose()
assert.equal(f.art.playbackRate, 1.5)
assert.equal(f.plugin.state, false)
f.start()
f.art.emit('lock', false)
assert.equal(f.timers.size, 0)
})
test('destruction inside the playback rate setter cannot leave an active class', (t) => {
const f = fixture(t)
let rate = 1.5
Object.defineProperty(f.art, 'playbackRate', {
get: () => rate,
set(value) {
rate = value
if (value === 3)
f.scope.dispose()
},
})
f.start()
f.flush()
assert.equal(rate, 1.5)
assert.equal(f.plugin.state, false)
assert.equal(f.timers.size, 0)
})
test('failed rate assignments release press state and preserve the thrown value', (t) => {
const f = fixture(t)
const failure = new Error('rate refused')
Object.defineProperty(f.art, 'playbackRate', {
get: () => 1.5,
set(value) {
if (value === 3)
throw failure
},
})
f.start()
assert.throws(f.flush, error => error === failure)
assert.equal(f.plugin.state, false)
assert.equal(f.timers.size, 0)
f.scope.dispose()
})
test('source replacement cancels pending work and callback-time flags are rechecked', (t) => {
const f = fixture(t)
f.start()
const stale = [...f.timers.values()][0].callback
beginSource(f.art)
assert.equal(f.timers.size, 0)
stale()
assert.equal(f.art.playbackRate, 1.5)
f.start()
f.art.playing = false
f.flush()
assert.equal(f.plugin.state, false)
f.art.playing = true
f.start()
f.art.isLock = true
f.flush()
assert.equal(f.art.playbackRate, 1.5)
assert.equal(f.plugin.state, false)
f.scope.dispose()
})
test('a new press started during rate restoration supersedes the outer restart', (t) => {
const f = fixture(t)
f.start()
f.flush()
let rate = 3
let reentered = false
Object.defineProperty(f.art, 'playbackRate', {
get: () => rate,
set(value) {
rate = value
if (value === 1.5 && !reentered) {
reentered = true
f.start()
f.flush()
}
},
})
f.start()
assert.equal(f.timers.size, 0)
assert.equal(f.plugin.state, true)
assert.equal(rate, 3)
f.art.emit('document:touchend', new Event('touchend'))
assert.equal(rate, 1.5)
assert.equal(f.plugin.state, false)
f.scope.dispose()
})
+163
View File
@@ -0,0 +1,163 @@
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 { Notice, Emitter, beginLifecycle, getScope } = await loadModules({
Notice: 'packages/artplayer/src/notice',
Emitter: 'packages/artplayer/src/utils/emitter',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
})
function fixture(t) {
const timers = new Map()
let id = 0
t.mock.method(globalThis, 'setTimeout', (callback, delay) => {
const key = ++id
timers.set(key, { callback, delay })
return key
})
t.mock.method(globalThis, 'clearTimeout', key => timers.delete(key))
const classes = new Set()
const classList = {
add: value => classes.add(value),
remove: value => classes.delete(value),
contains: value => classes.has(value),
}
const inner = { textContent: '' }
const art = Object.assign(new Emitter(), {
template: { $player: { classList }, $noticeInner: inner },
constructor: { NOTICE_TIME: 100 },
})
beginLifecycle(art)
const scope = getScope(art)
const notice = new Notice(art)
return { art, scope, notice, inner, classList, timers }
}
test('notice preserves fields, descriptors, text, live delay and hide semantics', (t) => {
const f = fixture(t)
assert.deepEqual(Object.keys(f.notice), ['art', 'timer'])
assert.deepEqual(Object.getOwnPropertyNames(Object.getPrototypeOf(f.notice)), ['constructor', 'destroy', 'show'])
assert.equal(f.notice.show, false)
assert.equal(f.notice.timer, null)
f.notice.show = new Error(' example ')
assert.equal(f.inner.textContent, 'example')
assert.equal(f.notice.show, true)
assert.equal(f.timers.get(f.notice.timer).delay, 100)
f.art.constructor.NOTICE_TIME = 200
f.notice.show = ' next '
assert.equal(f.inner.textContent, ' next ')
assert.equal(f.timers.size, 1)
assert.equal(f.timers.get(f.notice.timer).delay, 200)
f.notice.show = ''
assert.equal(f.notice.show, false)
assert.equal(f.inner.textContent, ' next ')
assert.equal(f.timers.size, 1)
f.timers.get(f.notice.timer).callback()
assert.equal(f.inner.textContent, '')
})
test('manual notice destroy cancels its timer and permits reuse while alive', (t) => {
const f = fixture(t)
f.notice.show = 'visible'
assert.equal(f.notice.destroy(), undefined)
assert.equal(f.notice.timer, null)
assert.equal(f.timers.size, 0)
assert.equal(f.notice.show, true)
assert.equal(f.inner.textContent, 'visible')
f.notice.destroy()
f.notice.show = 'reused'
assert.equal(f.timers.size, 1)
f.art.emit('destroy')
assert.equal(f.timers.size, 0)
f.notice.show = 'again'
assert.equal(f.timers.size, 1)
f.scope.dispose()
f.notice.show = 'ignored'
assert.equal(f.timers.size, 0)
assert.equal(f.inner.textContent, 'again')
})
test('notice does not allocate a timer after destruction during DOM text writes', (t) => {
const f = fixture(t)
Object.defineProperty(f.inner, 'textContent', {
get: () => '',
set() {
f.scope.dispose()
},
})
f.notice.show = 'destroy during setter'
assert.equal(f.notice.timer, null)
assert.equal(f.timers.size, 0)
assert.equal(f.notice.show, false)
})
test('obsolete notice timer callbacks cannot erase a newer message', (t) => {
const f = fixture(t)
f.notice.show = 'old'
const stale = f.timers.get(f.notice.timer).callback
f.notice.show = 'new'
stale()
assert.equal(f.inner.textContent, 'new')
assert.equal(f.notice.show, true)
f.scope.dispose()
stale()
assert.equal(f.inner.textContent, 'new')
})
test('notice expiry does not hide a message installed by a nested DOM write', (t) => {
const f = fixture(t)
f.notice.show = 'old'
const expire = f.timers.get(f.notice.timer).callback
let value = 'old'
Object.defineProperty(f.inner, 'textContent', {
get: () => value,
set(next) {
value = next
if (next === '')
f.notice.show = 'nested'
},
})
expire()
assert.equal(f.inner.textContent, 'nested')
assert.equal(f.notice.show, true)
assert.equal(f.timers.size, 1)
})
test('nested notice writes keep only the newest timer', (t) => {
const f = fixture(t)
let value = ''
Object.defineProperty(f.inner, 'textContent', {
get: () => value,
set(next) {
value = next
if (next === 'outer')
f.notice.show = 'inner'
},
})
f.notice.show = 'outer'
assert.equal(f.inner.textContent, 'inner')
assert.equal(f.notice.show, true)
assert.equal(f.timers.size, 1)
f.scope.dispose()
assert.equal(f.timers.size, 0)
})
test('notice message updates do not invoke a consumer override of the public destroy method', (t) => {
const f = fixture(t)
let calls = 0
const original = f.notice.destroy
f.notice.destroy = function () {
calls++
return original.call(this)
}
f.notice.show = 'first'
f.notice.show = 'second'
assert.equal(calls, 0)
assert.equal(f.inner.textContent, 'second')
f.notice.destroy()
assert.equal(calls, 1)
f.scope.dispose()
})
+284
View File
@@ -0,0 +1,284 @@
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 { Info, Loading, Mask, Emitter, beginLifecycle, getScope, destroyInstance } = await loadModules({
Info: 'packages/artplayer/src/info',
Loading: 'packages/artplayer/src/loading',
Mask: 'packages/artplayer/src/mask',
Emitter: 'packages/artplayer/src/utils/emitter',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
destroyInstance: { file: 'packages/artplayer/src/lifecycle/instance', name: 'destroyInstance' },
})
function fixture(t) {
class Element {
style = {}
children = []
textContent = ''
dataset = {}
appendChild(child) {
this.children.push(child)
this.lastElementChild = child
}
querySelectorAll() {
return this.children
}
}
const original = Object.getOwnPropertyDescriptor(globalThis, 'Element')
Object.defineProperty(globalThis, 'Element', { configurable: true, value: Element })
t.after(() => original ? Object.defineProperty(globalThis, 'Element', original) : Reflect.deleteProperty(globalThis, 'Element'))
let id = 0
const timers = new Map()
t.mock.method(globalThis, 'setTimeout', (callback, delay) => {
const key = ++id
timers.set(key, { callback, delay })
return key
})
t.mock.method(globalThis, 'clearTimeout', key => timers.delete(key))
const classes = new Set()
const player = new Element()
player.classList = { add: value => classes.add(value), remove: value => classes.delete(value), contains: value => classes.has(value) }
const panel = new Element()
const time = new Element()
time.dataset.video = 'currentTime'
const source = new Element()
source.dataset.video = 'src'
panel.children.push(time, source)
const listeners = new Set()
const art = Object.assign(new Emitter(), {
constructor: { INFO_LOOP_TIME: 100 },
template: { $player: player, $infoPanel: panel, $infoClose: new Element(), $video: { currentTime: 1.234, src: 'first' }, $loading: new Element(), $state: new Element() },
icons: { loading: new Element(), state: new Element(), error: new Element() },
play: () => Promise.resolve(),
})
art.events = {
proxy(target, name, callback) {
const listener = { target, name, callback }
listeners.add(listener)
const cleanup = () => listeners.delete(listener)
getScope(art).add(() => {
cleanup()
})
return cleanup
},
remove: cleanup => cleanup(),
}
art.proxy = art.events.proxy
beginLifecycle(art)
const click = (target) => {
for (const listener of [...listeners]) {
if (listener.target === target)
listener.callback(new Event('click'))
}
}
const flush = () => {
const pending = [...timers.values()]
timers.clear()
for (const timer of pending)
timer.callback()
}
return { art, time, source, timers, listeners, click, flush, scope: getScope(art), Element }
}
test('info retains component fields, immediate numeric formatting, live delay and close behavior', (t) => {
const f = fixture(t)
const info = new Info(f.art)
assert.deepEqual(Object.keys(info), ['id', 'art', 'cache', 'add', 'remove', 'update', 'name'])
assert.deepEqual(Object.getOwnPropertyNames(Object.getPrototypeOf(info)), ['constructor', 'init'])
assert.equal(f.time.textContent, '1.23')
assert.equal(f.source.textContent, 'first')
assert.equal(f.timers.size, 1)
f.art.template.$video.currentTime = 3
f.art.constructor.INFO_LOOP_TIME = 200
f.flush()
assert.equal(f.time.textContent, '3.00')
assert.equal([...f.timers.values()][0].delay, 200)
info.show = true
f.click(f.art.template.$infoClose)
assert.equal(info.show, false)
f.scope.dispose()
assert.equal(f.timers.size, 0)
})
test('info reinitialization replaces its poll and listener rather than multiplying work', (t) => {
const f = fixture(t)
const info = new Info(f.art)
const stale = [...f.timers.values()][0].callback
info.init()
info.init()
assert.equal(f.timers.size, 1)
assert.equal(f.listeners.size, 1)
f.art.template.$video.currentTime = 5
stale()
assert.equal(f.time.textContent, '1.23')
f.flush()
assert.equal(f.time.textContent, '5.00')
f.scope.dispose()
})
test('closed info init cannot attach listeners or write media values', (t) => {
const f = fixture(t)
const info = new Info(f.art)
f.scope.dispose()
f.art.template.$video.currentTime = 9
info.init()
assert.equal(f.time.textContent, '1.23')
assert.equal(f.listeners.size, 0)
assert.equal(f.timers.size, 0)
})
test('destruction during info media reads stops all subsequent DOM writes and scheduling', (t) => {
const f = fixture(t)
const info = new Info(f.art)
Object.defineProperty(f.art.template.$video, 'currentTime', {
get() {
f.scope.dispose()
return 20
},
})
info.init()
assert.equal(f.time.textContent, '1.23')
assert.equal(f.timers.size, 0)
})
test('manual destroy emission stops info work, while a live manual init may restart it', (t) => {
const f = fixture(t)
const info = new Info(f.art)
f.art.emit('destroy')
assert.equal(f.timers.size, 0)
info.init()
assert.equal(f.timers.size, 1)
assert.equal(f.listeners.size, 1)
f.scope.dispose()
})
test('failed info reads propagate the original error and release the failed initialization', (t) => {
const f = fixture(t)
const info = new Info(f.art)
const failure = new Error('media getter failed')
Object.defineProperty(f.art.template.$video, 'currentTime', {
get() {
throw failure
},
})
assert.throws(() => info.init(), error => error === failure)
assert.equal(f.timers.size, 0)
assert.equal(f.listeners.size, 0)
f.scope.dispose()
})
test('loading and mask preserve component visibility and actual icon identity', async (t) => {
const f = fixture(t)
const loading = new Loading(f.art)
const mask = new Mask(f.art)
assert.equal(f.art.template.$loading.children[0], f.art.icons.loading)
assert.deepEqual(f.art.template.$state.children, [f.art.icons.state, f.art.icons.error])
assert.equal(f.art.icons.error.style.display, 'none')
loading.show = true
mask.show = true
assert.equal(loading.show, true)
assert.equal(mask.show, true)
let plays = 0
f.art.play = () => {
plays++
return Promise.reject(new Error('play refused'))
}
f.click(f.art.template.$state)
await Promise.resolve()
assert.equal(plays, 1)
f.art.emit('destroy')
assert.equal(f.art.icons.state.style.display, 'none')
assert.equal(f.art.icons.error.style.display, null)
f.scope.dispose()
})
test('mask root cleanup shows its terminal icon even when no destroy event is emitted', (t) => {
const f = fixture(t)
const mask = new Mask(f.art)
assert.equal(mask.name, 'mask')
const retained = [...f.listeners][0].callback
let plays = 0
f.art.play = () => {
plays++
}
f.scope.dispose()
retained(new Event('click'))
assert.equal(plays, 0)
assert.equal(f.art.icons.state.style.display, 'none')
assert.equal(f.art.icons.error.style.display, null)
assert.equal(f.art.e.destroy, undefined)
})
test('info reinitialization captures replacement panel and close nodes without retaining the old ones', (t) => {
const f = fixture(t)
const info = new Info(f.art)
const oldClose = f.art.template.$infoClose
const panel = new f.Element()
const item = new f.Element()
item.dataset.video = 'src'
panel.children.push(item)
f.art.template.$infoPanel = panel
f.art.template.$infoClose = new f.Element()
info.init()
assert.equal(item.textContent, 'first')
info.show = true
f.click(oldClose)
assert.equal(info.show, true)
f.click(f.art.template.$infoClose)
assert.equal(info.show, false)
assert.equal(f.listeners.size, 1)
f.scope.dispose()
})
test('info getter reentry leaves only the newer poll alive', (t) => {
const f = fixture(t)
const info = new Info(f.art)
let nested = false
Object.defineProperty(f.art.template.$video, 'currentTime', {
get() {
if (!nested) {
nested = true
info.init()
return 20
}
return 30
},
})
info.init()
assert.equal(f.time.textContent, '30.00')
assert.equal(f.timers.size, 1)
assert.equal(f.listeners.size, 1)
f.scope.dispose()
})
test('mask retains destroy observer order while still finalizing after an earlier observer throws', (t) => {
const f = fixture(t)
f.art.reset = () => {}
f.art.template.destroy = () => {}
const seen = []
f.art.on('destroy', () => seen.push(['before', f.art.icons.error.style.display]))
const mask = new Mask(f.art)
assert.equal(mask.name, 'mask')
f.art.on('destroy', () => seen.push(['after', f.art.icons.error.style.display]))
destroyInstance(f.art, [f.art], false, false)
assert.deepEqual(seen, [['before', 'none'], ['after', null]])
})
test('mask final cleanup survives a throwing earlier destroy observer', (t) => {
const f = fixture(t)
f.art.reset = () => {}
f.art.template.destroy = () => {}
const failure = new Error('early destroy observer')
f.art.on('destroy', () => {
throw failure
})
const mask = new Mask(f.art)
assert.equal(mask.name, 'mask')
assert.throws(() => destroyInstance(f.art, [f.art], false, false), error => error === failure)
assert.equal(f.art.icons.error.style.display, null)
assert.equal(f.art.e.destroy.length, 1)
})
+15
View File
@@ -0,0 +1,15 @@
import type { AutoPlaybackHost } from '../../packages/artplayer/src/plugins/autoPlayback'
import autoPlayback from '../../packages/artplayer/src/plugins/autoPlayback'
declare const host: AutoPlaybackHost
const plugin = autoPlayback(host)
const times: Record<string, number> = plugin.times
const remaining: Record<string, number> = plugin.delete('example')
const cleared: void = plugin.clear()
// @ts-expect-error Record deletion takes the persisted string key.
plugin.delete({ id: 'example' })
// @ts-expect-error Playback times contain numbers, not text.
const invalid: Record<string, string> = plugin.times
// @ts-expect-error The plugin needs both recording and prompt resources.
autoPlayback({ storage: host.storage })
export { cleared, invalid, remaining, times }
+15
View File
@@ -0,0 +1,15 @@
import type { LockHost } from '../../packages/artplayer/src/plugins/lock'
import type { MiniProgressHost } from '../../packages/artplayer/src/plugins/miniProgressBar'
import lock from '../../packages/artplayer/src/plugins/lock'
import miniProgressBar from '../../packages/artplayer/src/plugins/miniProgressBar'
declare const host: LockHost & MiniProgressHost
const plugin = lock(host)
plugin.state = true
const state: boolean = plugin.state
const mini: { name: string } = miniProgressBar(host)
// @ts-expect-error Lock state takes a boolean.
plugin.state = 'locked'
// @ts-expect-error The miniature progress plugin has no public state accessor.
miniProgressBar(host).state = true
export { mini, state }
+12
View File
@@ -0,0 +1,12 @@
import type { FastForwardHost } from '../../packages/artplayer/src/plugins/fastForward'
import fastForward from '../../packages/artplayer/src/plugins/fastForward'
declare const host: FastForwardHost
const plugin = fastForward(host)
const visible: boolean = plugin.state
const name: string = plugin.name
// @ts-expect-error Fast-forward state is read-only.
plugin.state = true
// @ts-expect-error A playing flag does not provide the plugin's media/event host.
fastForward({ playing: true })
export { name, visible }
+22
View File
@@ -0,0 +1,22 @@
import type Notice from '../../packages/artplayer/src/notice'
import type Artplayer from '../../packages/artplayer/types/artplayer'
declare const notice: Notice
declare const art: Artplayer
const visible: boolean = notice.show
notice.show = 'message'
notice.show = new Error('message')
notice.show = false
notice.show = ''
const timer: number | null = notice.timer
const destroyed: void = notice.destroy()
// @ts-expect-error The runtime getter returns visibility, not message text.
const text: string = notice.show
// @ts-expect-error True is not a declared message input.
notice.show = true
// @ts-expect-error Arbitrary objects are not declared message inputs.
notice.show = {}
// Preserve old legal consumers until CORE-21 coordinates an explicit precise view.
const legacy: string | Error | false = art.notice.show
art.notice.show = false
export { destroyed, legacy, text, timer, visible }
+20
View File
@@ -0,0 +1,20 @@
import type { InfoHost } from '../../packages/artplayer/src/info/poll'
import type { LoadingHost } from '../../packages/artplayer/src/loading'
import type { MaskHost } from '../../packages/artplayer/src/mask'
import Info from '../../packages/artplayer/src/info'
import Loading from '../../packages/artplayer/src/loading'
import Mask from '../../packages/artplayer/src/mask'
declare const host: InfoHost & LoadingHost & MaskHost
const info = new Info(host)
const initialized: void = info.init()
const loading = new Loading(host)
const mask = new Mask(host)
loading.show = true
mask.show = false
const visible: boolean = info.show
// @ts-expect-error A polling host needs its media and panel resources.
const invalid = new Info({ constructor: { INFO_LOOP_TIME: 1 } })
// @ts-expect-error Component visibility is boolean.
loading.show = 'visible'
export { initialized, invalid, visible }