refactor(core): CORE-17 type input events and own interaction lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 11:24:15 +08:00
1 parent 88aef830df
commit a842058136
72 files changed
+31156 -1219

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, raf = false) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate((raf) => {
window.Artplayer.SCROLL_TIME = 24731
window.Artplayer.RESIZE_TIME = 0
window.Artplayer.USE_RAF = raf
const orientation = new EventTarget()
orientation.onchange = null
Object.defineProperty(window.screen, 'orientation', { configurable: true, value: orientation })
window.createPlayer('/test/pattern.mp4')
}, raf)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: actual animation frames follow playback and stop after destruction inside raf`, async ({ page }) => {
await setup(page, core, true)
await page.evaluate(() => {
window.frameCount = 0
window.art.on('raf', () => window.frameCount++)
return window.art.play()
})
await expect.poll(() => page.evaluate(() => window.frameCount)).toBeGreaterThan(2)
await page.evaluate(() => window.art.pause())
const paused = await page.evaluate(() => window.frameCount)
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
expect(await page.evaluate(() => window.frameCount)).toBe(paused)
await page.evaluate(() => {
window.art.once('raf', () => window.art.destroy())
return window.art.play()
})
await expect.poll(() => page.evaluate(() => window.art.isDestroy)).toBe(true)
const destroyed = await page.evaluate(() => window.frameCount)
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
expect(await page.evaluate(() => window.frameCount)).toBe(destroyed)
})
test(`${core}: Events keeps its public fields, prototype and detached native methods`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const events = art.events
const target = document.createElement('div')
let hits = 0
const { proxy, hover } = events
const dispose = proxy(target, 'example', () => hits++)
hover(target, () => hits++, () => hits++)
target.dispatchEvent(new Event('example'))
target.dispatchEvent(new Event('mouseenter'))
target.dispatchEvent(new Event('mouseleave'))
events.remove(dispose)
target.dispatchEvent(new Event('example'))
const result = { fields: Object.keys(events).sort(), prototype: Object.getOwnPropertyNames(Object.getPrototypeOf(events)).sort(), hits }
art.destroy()
return result
})).toEqual({ fields: ['bindGlobalEvents', 'destroyEvents', 'hover', 'proxy'], prototype: ['constructor', 'destroy', 'hover', 'proxy', 'remove'], hits: 3 })
})
test(`${core}: screen orientation dispatch works when onchange has no assigned handler`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(async () => {
const art = window.art
let resized = 0
art.on('resize', () => resized++)
window.screen.orientation.dispatchEvent(new Event('change'))
await new Promise(resolve => setTimeout(resolve, 30))
art.destroy()
return resized
})).toBe(core === 'candidate' ? 1 : 0)
})
test(`${core}: destruction releases the scroll throttle reset timer`, 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 === 24731)
pending.add(timer)
return timer
}
window.clearTimeout = function (timer) {
pending.delete(timer)
return clear.call(this, timer)
}
try {
art.emit('window:scroll', new Event('scroll'))
const before = pending.size
art.destroy()
return { before, after: pending.size }
}
finally {
for (const timer of pending)
clear.call(window, timer)
window.setTimeout = set
window.clearTimeout = clear
}
})).toEqual({ before: 1, after: core === 'candidate' ? 0 : 1 })
})
test(`${core}: view visibility uses the container's current iframe viewport`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const frame = document.createElement('iframe')
frame.style.cssText = 'width:200px;height:100px'
document.body.append(frame)
const container = art.template.$container
container.style.cssText = 'position:absolute;top:250px;left:0;width:200px;height:100px'
frame.contentDocument.body.append(container)
art.events.bindGlobalEvents()
const seen = []
art.on('view', visible => seen.push(visible))
frame.contentWindow.dispatchEvent(new Event('scroll'))
art.destroy()
frame.remove()
return seen
})).toEqual([core === 'published'])
})
}
+150
View File
@@ -0,0 +1,150 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, primeMedia = true) {
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android gesture fixture' }))
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
window.events = []
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, gesture: true })
window.touch = (target, type, points) => {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'touches', { value: points.map(([pageX, pageY, identifier = 1]) => ({ pageX, pageY, clientX: pageX, clientY: pageY, identifier })) })
target.dispatchEvent(event)
return event
}
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
if (!primeMedia)
return
await page.evaluate(() => window.art.play())
await expect.poll(() => page.evaluate(() => window.art.currentTime > 0.1)).toBe(true)
await page.evaluate(() => window.art.pause())
await expect.poll(() => page.evaluate(() => window.art.template.$video.paused && !window.art.template.$video.seeking)).toBe(true)
await page.evaluate(() => {
window.art.currentTime = 2
})
await expect.poll(() => page.evaluate(() => !window.art.template.$video.seeking && Math.abs(window.art.currentTime - 2) < 0.1)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: a gesture before first play still issues the requested seek`, async ({ page }) => {
await setup(page, core, false)
expect(await page.evaluate(() => {
const art = window.art
const start = art.currentTime
const expected = start + art.duration * 0.1 * art.constructor.TOUCH_MOVE_RATIO
const requests = []
art.on('seek', (time, requested) => requests.push(requested))
window.touch(art.template.$video, 'touchstart', [[100, 100]])
window.touch(art.template.$video, 'touchmove', [[100 + art.width * 0.1, 100]])
art.destroy()
return { count: requests.length, valid: Math.abs(requests[0] - expected) < 0.000001 }
})).toEqual({ count: 1, valid: true })
})
test(`${core}: progress touch keeps absolute position and bar-before-seek ordering`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(() => {
const art = window.art
const progress = art.template.$progress
const rect = progress.getBoundingClientRect()
const calls = []
art.on('setBar', (kind, value, event) => {
if (event?.type === 'touchstart')
calls.push(['bar', value, event])
})
art.on('seek', (time, requested) => calls.push(['seek', requested]))
const event = window.touch(progress, 'touchstart', [[rect.left + progress.clientWidth / 2, rect.top]])
window.touch(document, 'touchend', [])
return { order: calls.map(item => item[0]), percentage: calls[0][1], identity: calls[0][2] === event, requested: calls[1][1], expected: art.duration / 2 }
})
expect(result.order).toEqual(['bar', 'seek'])
expect(result.percentage).toBeCloseTo(0.5, 6)
expect(result.identity).toBe(true)
expect(result.requested).toBeCloseTo(result.expected, 6)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeCloseTo(result.expected, 1)
await page.evaluate(() => window.art.destroy())
})
test(`${core}: destroy during absolute progress touch stops the following seek`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const progress = art.template.$progress
const rect = progress.getBoundingClientRect()
let seeks = 0
art.on('seek', () => seeks++)
art.on('setBar', (kind, value, event) => {
if (event?.type === 'touchstart')
art.destroy()
})
window.touch(progress, 'touchstart', [[rect.left + progress.clientWidth / 2, rect.top]])
return { seeks, destroyed: art.isDestroy }
})).toEqual({ seeks: core === 'candidate' ? 0 : 1, destroyed: true })
})
test(`${core}: normal touch drag keeps seek/bar ordering and original touch identity`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(() => {
const art = window.art
const video = art.template.$video
const calls = []
const start = art.currentTime
const expected = start + art.duration * 0.2 * art.constructor.TOUCH_MOVE_RATIO
art.on('seek', (time, requested) => calls.push(['seek', requested]))
art.on('setBar', (kind, percentage, event) => {
if (event?.type === 'touchmove')
calls.push(['bar', kind, percentage, event])
})
window.touch(video, 'touchstart', [[100, 100]])
const event = window.touch(video, 'touchmove', [[100 + art.width * 0.2, 100]])
window.touch(document, 'touchend', [])
return { expected, order: calls.map(item => item[0]), requested: calls[0][1], percentage: calls[1][2], identity: calls[1][3] === event }
})
expect(result.order).toEqual(['seek', 'bar'])
expect(result.identity).toBe(true)
expect(result.requested).toBeCloseTo(result.expected, 6)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeCloseTo(result.expected, 1)
await page.evaluate(() => window.art.destroy())
})
for (const cause of ['cancel', 'lock', 'multitouch', 'identifier']) {
test(`${core}: ${cause} invalidates an in-progress gesture`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate((cause) => {
const art = window.art
const video = art.template.$video
const seeks = []
art.on('seek', (time, requested) => seeks.push(requested))
window.touch(video, 'touchstart', [[100, 100]])
window.touch(video, 'touchmove', [[100 + art.width * 0.1, 100]])
if (cause === 'cancel')
window.touch(video, 'touchcancel', [])
if (cause === 'lock')
art.isLock = true
if (cause === 'multitouch')
window.touch(video, 'touchmove', [[150, 100], [200, 100, 2]])
window.touch(video, 'touchmove', [[100 + art.width * 0.3, 100, cause === 'identifier' ? 2 : 1]])
art.destroy()
return seeks.length
}, cause)).toBe(core === 'candidate' ? 1 : 2)
})
}
test(`${core}: destroy from a touch seek callback prevents the later progress notification`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const video = art.template.$video
let bars = 0
art.on('seek', () => art.destroy())
art.on('setBar', (kind, value, event) => {
if (event?.type === 'touchmove')
bars++
})
window.touch(video, 'touchstart', [[100, 100]])
window.touch(video, 'touchmove', [[100 + art.width * 0.1, 100]])
return { bars, destroyed: art.isDestroy }
})).toEqual({ bars: core === 'candidate' ? 0 : 1, destroyed: true })
})
}
+99
View File
@@ -0,0 +1,99 @@
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)
}
for (const action of ['rebind', 'destroy']) {
test(`candidate: ${action} during native global registration cancels the interrupted binding`, async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate((action) => {
const art = window.art
const frames = [document.createElement('iframe'), document.createElement('iframe')]
frames.forEach(frame => document.body.append(frame))
const [interrupted, newest] = frames
const target = interrupted.contentDocument
const original = target.addEventListener
target.addEventListener = function (name, ...args) {
if (name === 'click') {
if (action === 'destroy')
art.destroy()
else
art.events.bindGlobalEvents({ document: newest.contentDocument, window: newest.contentWindow })
}
return original.call(this, name, ...args)
}
const received = []
art.on('document:mouseup', event => received.push(event.target === newest.contentDocument ? 'newest' : 'stale'))
art.events.bindGlobalEvents({ document: target, window: interrupted.contentWindow })
document.dispatchEvent(new Event('mouseup'))
for (const frame of frames)
frame.contentDocument.dispatchEvent(new frame.contentWindow.Event('mouseup'))
if (!art.isDestroy)
art.destroy()
target.addEventListener = original
for (const frame of frames)
frame.remove()
return { received, remaining: art.events.destroyEvents.size }
}, action)).toEqual({ received: action === 'rebind' ? ['newest'] : [], remaining: 0 })
})
}
for (const core of ['published', 'candidate']) {
test(`${core}: global rebinding forwards original events once and releases the previous document`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const frame = document.createElement('iframe')
document.body.append(frame)
const next = frame.contentDocument
const events = []
art.on('document:mouseup', event => events.push(event))
const first = new Event('mouseup')
document.dispatchEvent(first)
const returned = art.events.bindGlobalEvents({ document: next, window: frame.contentWindow })
art.events.bindGlobalEvents({ document: next, window: frame.contentWindow })
document.dispatchEvent(new Event('mouseup'))
const second = new frame.contentWindow.Event('mouseup')
next.dispatchEvent(second)
art.destroy()
next.dispatchEvent(new frame.contentWindow.Event('mouseup'))
frame.remove()
return { count: events.length, identity: events[0] === first && events[1] === second, returned: typeof returned }
})).toEqual({ count: 2, identity: true, returned: 'undefined' })
})
test(`${core}: failed global rebinding keeps the previous document and removes partial new listeners`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const frame = document.createElement('iframe')
document.body.append(frame)
const next = frame.contentDocument
const original = next.addEventListener
const failure = new Error('document listener denied')
next.addEventListener = function (name, ...args) {
if (name === 'keydown')
throw failure
return original.call(this, name, ...args)
}
const received = []
art.on('document:mouseup', event => received.push(event.target === document ? 'old' : 'new'))
let sameError = false
try {
art.events.bindGlobalEvents({ document: next, window: frame.contentWindow })
}
catch (error) {
sameError = error === failure
}
next.addEventListener = original
document.dispatchEvent(new Event('mouseup'))
next.dispatchEvent(new frame.contentWindow.Event('mouseup'))
art.destroy()
frame.remove()
return { received, sameError }
})).toEqual({ received: core === 'candidate' ? ['old'] : ['new'], sameError: true })
})
}
+214
View File
@@ -0,0 +1,214 @@
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)
}
for (const core of ['published', 'candidate']) {
test(`${core}: mobile keeps keyboard initialization opt-in through the existing init method`, async ({ page }) => {
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android keyboard fixture' }))
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const initial = Object.keys(art.hotkey.keys)
let hits = 0
art.hotkey.add('KeyK', () => hits++)
art.isFocus = true
document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyK' }))
const before = hits
art.hotkey.init()
document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyK' }))
const result = { initial, before, after: hits }
art.destroy()
return result
})).toEqual({ initial: [], before: 0, after: 1 })
})
test(`${core}: prototype-named hotkeys do not crash or replace the keys prototype`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const prototype = Object.getPrototypeOf(art.hotkey.keys)
const errors = []
let hits = 0
art.isFocus = true
for (const key of ['__proto__', 'constructor', 'toString']) {
try {
const callback = () => hits++
art.hotkey.add(key, callback)
document.dispatchEvent(new KeyboardEvent('keydown', { code: key, cancelable: true }))
art.hotkey.remove(key, callback)
}
catch {
errors.push(key)
}
}
const stable = Object.getPrototypeOf(art.hotkey.keys) === prototype
art.destroy()
return { errors, hits, stable }
})).toEqual({ errors: core === 'candidate' ? [] : ['__proto__', 'constructor', 'toString'], hits: core === 'candidate' ? 3 : 0, stable: true })
})
}
test('candidate: native keyboard play/pause, seek, volume and web-fullscreen Escape retain their behavior', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
window.art.isFocus = true
window.nativeHotkeys = []
window.art.on('hotkey', event => window.nativeHotkeys.push(event.code))
})
await page.keyboard.press('Space')
await expect.poll(() => page.evaluate(() => window.art.playing && window.art.currentTime > 0.1)).toBe(true)
await page.keyboard.press('Space')
await expect.poll(() => page.evaluate(() => window.art.template.$video.paused && !window.art.template.$video.seeking)).toBe(true)
await page.evaluate(() => {
window.art.currentTime = 1
})
await expect.poll(() => page.evaluate(() => !window.art.template.$video.seeking && Math.abs(window.art.currentTime - 1) < 0.1)).toBe(true)
await page.keyboard.press('ArrowRight')
expect(await page.evaluate(() => window.nativeHotkeys.at(-1))).toBe('ArrowRight')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(5)
await expect.poll(() => page.evaluate(() => window.art.template.$video.seeking)).toBe(false)
await page.keyboard.press('ArrowLeft')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeLessThan(2)
const volume = await page.evaluate(() => {
window.art.volume = 0.5
return window.art.volume
})
await page.keyboard.press('ArrowUp')
expect(await page.evaluate(() => window.art.volume)).toBeGreaterThan(volume)
await page.evaluate(() => {
window.art.fullscreenWeb = true
})
await page.keyboard.press('Escape')
expect(await page.evaluate(() => window.art.fullscreenWeb)).toBe(false)
await page.evaluate(() => window.art.destroy())
})
for (const kind of ['inherited', 'plaintext', 'select']) {
test(`candidate: ${kind} editing and IME composition do not activate player shortcuts`, async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate((kind) => {
const art = window.art
const root = document.createElement('div')
root.innerHTML = kind === 'select' ? '<select><option>one</option></select>' : `<div contenteditable="${kind === 'plaintext' ? 'plaintext-only' : 'true'}"><span tabindex="0">edit</span></div>`
art.template.$player.append(root)
const target = root.querySelector('span,select')
target.focus()
art.isFocus = true
let hits = 0
const generic = []
art.hotkey.add('KeyK', () => hits++)
art.on('keydown', value => generic.push(value.code))
const key = new KeyboardEvent('keydown', { code: 'KeyK', cancelable: true, bubbles: true })
target.dispatchEvent(key)
target.blur()
const composition = new KeyboardEvent('keydown', { code: 'KeyK', cancelable: true, bubbles: true, isComposing: true })
document.dispatchEvent(composition)
art.destroy()
return { hits, generic, prevented: [key.defaultPrevented, composition.defaultPrevented] }
}, kind)).toEqual({ hits: 0, generic: ['KeyK', 'KeyK'], prevented: [false, false] })
})
}
test('candidate: destroying during a key callback stops remaining keys and notifications', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const calls = []
art.isFocus = true
art.hotkey.add('KeyK', () => {
calls.push('first')
art.destroy(false)
})
art.hotkey.add('KeyK', () => calls.push('second'))
art.on('hotkey', () => calls.push('hotkey'))
art.on('keydown', () => calls.push('keydown'))
document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyK' }))
art.hotkey.init()
art.emit('document:keydown', new KeyboardEvent('keydown', { code: 'KeyK' }))
return calls
})).toEqual(['first'])
})
for (const core of ['published', 'candidate']) {
test(`${core}: hotkey registration retains chain, array identity, receiver and event order`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.isFocus = true
const calls = []
const event = new KeyboardEvent('keydown', { code: 'KeyK', bubbles: true, cancelable: true })
function callback(value) {
calls.push(['callback', this === art, value === event])
}
const add = art.hotkey.add('KeyK', callback)
const list = art.hotkey.keys.KeyK
art.hotkey.add('KeyK', callback)
art.on('hotkey', value => calls.push(['hotkey', value === event]))
art.on('keydown', value => calls.push(['keydown', value === event]))
document.dispatchEvent(event)
const count = list.length
const remove = art.hotkey.remove('KeyK', callback)
const result = { chain: add === art.hotkey && remove === art.hotkey, count, retainedArray: list.length === 0, missing: !Object.hasOwn(art.hotkey.keys, 'KeyK'), keysPrototype: Object.getPrototypeOf(art.hotkey.keys) === Object.prototype, prevented: event.defaultPrevented, calls }
art.destroy()
return result
})).toEqual({ chain: true, count: 1, retainedArray: true, missing: true, keysPrototype: true, prevented: true, calls: [['callback', true, true], ['hotkey', true], ['keydown', true]] })
})
test(`${core}: rebound document input retains typing instead of triggering a hotkey`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => {
const frame = document.createElement('iframe')
frame.id = 'keyboard-frame'
document.body.append(frame)
const doc = frame.contentDocument
doc.body.innerHTML = '<input id="editor">'
window.art.events.bindGlobalEvents({ document: doc, window: frame.contentWindow })
window.hits = 0
window.art.hotkey.add('KeyK', () => window.hits++)
window.art.isFocus = true
})
const editor = page.frameLocator('#keyboard-frame').locator('#editor')
await editor.focus()
await editor.press('k')
expect(await page.evaluate(() => window.hits)).toBe(core === 'candidate' ? 0 : 1)
await expect(editor).toHaveValue(core === 'candidate' ? 'k' : '')
await page.evaluate(() => window.art.destroy())
})
test(`${core}: shadow-root input retains typing instead of triggering a hotkey`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => {
const host = document.createElement('div')
host.id = 'shadow-editor'
window.art.template.$player.append(host)
host.attachShadow({ mode: 'open' }).innerHTML = '<input id="editor">'
window.hits = 0
window.art.hotkey.add('KeyK', () => window.hits++)
window.art.isFocus = true
})
const editor = page.locator('#shadow-editor input')
await editor.focus()
await editor.press('k')
expect(await page.evaluate(() => window.hits)).toBe(core === 'candidate' ? 0 : 1)
await expect(editor).toHaveValue(core === 'candidate' ? 'k' : '')
await page.evaluate(() => window.art.destroy())
})
test(`${core}: repeated hotkey init does not multiply the keyboard subscription`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.isFocus = true
let hits = 0
art.hotkey.add('KeyK', () => hits++)
art.hotkey.init()
document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyK', bubbles: true, cancelable: true }))
art.destroy()
return hits
})).toBe(core === 'candidate' ? 1 : 2)
})
}
+175
View File
@@ -0,0 +1,175 @@
import { expect, test } from './fixtures.js'
async function setup(page, mobile = false) {
if (mobile)
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android closeout fixture' }))
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => {
window.Artplayer.SCROLL_TIME = 0
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, gesture: true })
window.touch = (target, name, x, y) => {
const event = new Event(name, { bubbles: true })
Object.defineProperty(event, 'touches', { value: [{ identifier: 1, pageX: x, pageY: y, clientX: x, clientY: y }] })
target.dispatchEvent(event)
}
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
test('candidate closeout: restoring a removed default hotkey keeps identity and real playback', async ({ page }) => {
await setup(page)
expect(await page.evaluate(() => {
const art = window.art
const original = art.hotkey.keys.Space[0]
art.hotkey.remove('Space', original)
const absent = !art.hotkey.keys.Space
art.hotkey.init()
art.hotkey.init()
art.isFocus = true
return { absent, count: art.hotkey.keys.Space.length, identity: art.hotkey.keys.Space[0] === original }
})).toEqual({ absent: true, count: 1, identity: true })
await page.keyboard.press('Space')
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
await page.evaluate(() => window.art.destroy())
})
test('candidate closeout: a failed native removal remains retryable and releases its record', async ({ page }) => {
await setup(page)
expect(await page.evaluate(() => {
const art = window.art
const target = document.createElement('div')
const remove = target.removeEventListener
const warning = console.warn
const error = new Error('temporary removal failure')
let deny = true
let warnings = 0
let hits = 0
console.warn = (message, actual) => {
if (actual === error)
warnings++
}
target.removeEventListener = function (...args) {
if (deny)
throw error
return remove.apply(this, args)
}
try {
const dispose = art.events.proxy(target, 'example', () => hits++)
art.events.remove(dispose)
const retained = art.events.destroyEvents.has(dispose)
target.dispatchEvent(new Event('example'))
deny = false
art.events.remove(dispose)
target.dispatchEvent(new Event('example'))
const released = !art.events.destroyEvents.has(dispose)
art.destroy()
return { retained, released, warnings, hits }
}
finally {
console.warn = warning
target.removeEventListener = remove
}
})).toEqual({ retained: true, released: true, warnings: 1, hits: 1 })
})
test('candidate closeout: destruction before native add returns cannot leave a late listener', async ({ page }) => {
await setup(page)
expect(await page.evaluate(() => {
const art = window.art
const target = document.createElement('div')
const add = target.addEventListener
let hits = 0
target.addEventListener = function (...args) {
art.destroy()
return add.apply(this, args)
}
art.events.proxy(target, 'example', () => hits++)
target.dispatchEvent(new Event('example'))
return { hits, remaining: art.events.destroyEvents.size, destroyed: art.isDestroy }
})).toEqual({ hits: 0, remaining: 0, destroyed: true })
})
test('candidate closeout: window registration failure restores the previous global binding', async ({ page }) => {
await setup(page)
expect(await page.evaluate(() => {
const art = window.art
const frame = document.createElement('iframe')
document.body.append(frame)
const next = frame.contentWindow
const add = next.addEventListener
const failure = new Error('window registration failed after add')
next.addEventListener = function (name, ...args) {
add.call(this, name, ...args)
if (name === 'scroll')
throw failure
}
const received = []
art.on('document:mouseup', event => received.push(event.target === document ? 'old-document' : 'new-document'))
art.on('window:scroll', event => received.push(event.target === window ? 'old-window' : 'new-window'))
let sameError = false
try {
art.events.bindGlobalEvents({ document: next.document, window: next })
}
catch (error) {
sameError = error === failure
}
next.addEventListener = add
next.document.dispatchEvent(new next.Event('mouseup'))
next.dispatchEvent(new next.Event('scroll'))
document.dispatchEvent(new Event('mouseup'))
window.dispatchEvent(new Event('scroll'))
art.destroy()
frame.remove()
return { sameError, received }
})).toEqual({ sameError: true, received: ['old-document', 'old-window'] })
})
for (const cause of ['source', 'rotation', 'geometry']) {
test(`candidate closeout: ${cause} changes cancel the old gesture in the installed player`, async ({ page }) => {
await setup(page, true)
expect(await page.evaluate((cause) => {
const art = window.art
const video = art.template.$video
let seeks = 0
art.on('seek', () => seeks++)
window.touch(video, 'touchstart', 100, 100)
if (cause === 'source')
art.url = '/test/pattern.mp4?closeout=source'
if (cause === 'rotation')
art.isRotate = true
if (cause === 'geometry') {
art.template.$player.style.setProperty('width', '0px', 'important')
art.template.$player.style.setProperty('min-width', '0px', 'important')
}
const before = seeks
const width = art.width
window.touch(video, 'touchmove', cause === 'rotation' ? 100 : 140, cause === 'rotation' ? 140 : 100)
art.destroy()
return { newSeeks: seeks - before, zeroWidth: cause !== 'geometry' || width === 0 }
}, cause)).toEqual({ newSeeks: 0, zeroWidth: true })
})
}
test('candidate closeout: adopted viewport retains exact gap boundaries', async ({ page }) => {
await setup(page)
expect(await page.evaluate(async () => {
const art = window.art
const frame = document.createElement('iframe')
frame.style.cssText = 'height:100px;width:200px'
document.body.append(frame)
const container = art.template.$container
container.style.cssText = 'position:absolute;top:150px;left:0;height:20px;width:20px'
frame.contentDocument.body.append(container)
art.events.bindGlobalEvents()
art.constructor.SCROLL_GAP = 50
const states = []
art.on('view', value => states.push(value))
frame.contentWindow.dispatchEvent(new Event('scroll'))
await new Promise(resolve => setTimeout(resolve, 0))
container.style.top = '151px'
frame.contentWindow.dispatchEvent(new Event('scroll'))
art.destroy()
frame.remove()
return states
})).toEqual([true, false])
})
+151
View File
@@ -0,0 +1,151 @@
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)
}
for (const core of ['published', 'candidate']) {
test(`${core}: null options preserve the native default and callbacks can be removed directly`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const target = document.createElement('div')
let hits = 0
let failed = false
const callback = () => hits++
try {
const remove = art.events.proxy(target, 'example', callback, null)
target.dispatchEvent(new Event('example'))
remove()
target.dispatchEvent(new Event('example'))
}
catch {
failed = true
}
art.destroy()
return { hits, failed }
})).toEqual({ hits: 1, failed: false })
})
test(`${core}: once and abort use native event identity and permit subsequent registration`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const target = document.createElement('div')
const seen = []
const event = new Event('example')
const callback = function (received) {
seen.push(this === target && received === event)
}
const once = art.events.proxy(target, 'example', callback, { once: true })
target.dispatchEvent(event)
target.dispatchEvent(event)
once()
const controller = new AbortController()
const aborted = art.events.proxy(target, 'example', callback, { signal: controller.signal })
target.dispatchEvent(event)
controller.abort()
target.dispatchEvent(event)
aborted()
const skipped = art.events.proxy(target, 'example', callback, { signal: controller.signal })
target.dispatchEvent(event)
skipped()
art.events.proxy(target, 'example', callback)
target.dispatchEvent(event)
art.destroy()
target.dispatchEvent(event)
return seen
})).toEqual([true, true, true])
})
test(`${core}: direct event disposer preserves native callback identity and releases bookkeeping`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const target = document.createElement('div')
let hits = 0
const callback = () => hits++
const before = art.events.destroyEvents.size
const remove = art.events.proxy(target, 'example', callback)
target.removeEventListener('example', callback)
target.dispatchEvent(new Event('example'))
const returned = remove()
const result = { hits, returned: typeof returned, remaining: art.events.destroyEvents.size - before }
art.destroy()
return result
})).toEqual({ hits: 0, returned: 'undefined', remaining: core === 'candidate' ? 0 : 1 })
})
test(`${core}: changing capture options after registration cannot prevent cleanup`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const target = document.createElement('div')
let hits = 0
const options = { capture: true }
const remove = art.events.proxy(target, 'example', () => hits++, options)
options.capture = false
remove()
target.dispatchEvent(new Event('example'))
art.destroy()
return hits
})).toBe(core === 'candidate' ? 0 : 1)
})
test(`${core}: partially failing array registration rolls back the listeners it created`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const target = document.createElement('div')
const original = target.addEventListener
const failure = new Error('second registration denied')
target.addEventListener = function (name, ...args) {
if (name === 'second')
throw failure
return original.call(this, name, ...args)
}
let hits = 0
let sameError = false
const before = art.events.destroyEvents.size
try {
art.events.proxy(target, ['first', 'second'], () => hits++)
}
catch (error) {
sameError = error === failure
}
target.addEventListener = original
target.dispatchEvent(new Event('first'))
const result = { hits, sameError, remaining: art.events.destroyEvents.size - before }
art.destroy()
return result
})).toEqual({ hits: core === 'candidate' ? 0 : 1, sameError: true, remaining: core === 'candidate' ? 0 : 1 })
})
test(`${core}: registration throwing after native add does not leave an untracked listener`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const target = document.createElement('div')
const original = target.addEventListener
const failure = new Error('after native add')
target.addEventListener = function (...args) {
original.apply(this, args)
throw failure
}
let hits = 0
let sameError = false
try {
art.events.proxy(target, 'example', () => hits++)
}
catch (error) {
sameError = error === failure
}
target.addEventListener = original
target.dispatchEvent(new Event('example'))
art.destroy()
return { hits, sameError }
})).toEqual({ hits: core === 'candidate' ? 0 : 1, sameError: true })
})
}
+112
View File
@@ -0,0 +1,112 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, mobile = false) {
if (mobile)
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android pointer fixture' }))
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}: real mouse input focuses the player, hovers and toggles actual playback`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => {
window.art.constructor.DBCLICK_FULLSCREEN = false
window.art.constructor.DBCLICK_TIME = 0
window.pointerTrusted = []
window.art.on('click', event => window.pointerTrusted.push(event.isTrusted))
})
const video = page.locator('.art-video')
await video.click({ position: { x: 100, y: 100 } })
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
expect(await page.evaluate(() => ({ focus: window.art.isFocus, hover: window.art.template.$player.classList.contains('art-hover') }))).toEqual({ focus: true, hover: true })
await video.click({ position: { x: 100, y: 100 } })
await expect.poll(() => page.evaluate(() => window.art.template.$video.paused)).toBe(true)
expect(await page.evaluate(() => window.pointerTrusted)).toEqual([true, true])
await page.mouse.move(1, 1)
await expect.poll(() => page.evaluate(() => window.art.template.$player.classList.contains('art-hover'))).toBe(false)
await page.evaluate(() => window.art.destroy())
})
test(`${core}: click pairs preserve callback identity and playback ordering`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.constructor.DBCLICK_TIME = 60000
art.constructor.DBCLICK_FULLSCREEN = false
const calls = []
const events = [new MouseEvent('click'), new MouseEvent('click'), new MouseEvent('click')]
let current
art.template.$video.play = () => {
calls.push('play')
return Promise.resolve()
}
for (const name of ['click', 'dblclick']) {
art.on(name, function (event) {
calls.push(`${name}:${event === current}:${this === art}`)
}, art)
}
for (const event of events) {
current = event
art.template.$video.dispatchEvent(event)
}
art.destroy()
return calls
})).toEqual(['click:true:true', 'play', 'dblclick:true:true', 'click:true:true', 'play'])
})
for (const name of ['click', 'dblclick']) {
test(`${core}: destruction in ${name} stops its subsequent default action`, async ({ page }) => {
await setup(page, core, name === 'dblclick')
expect(await page.evaluate((name) => {
const art = window.art
art.constructor.DBCLICK_TIME = 60000
let plays = 0
art.template.$video.play = () => {
plays++
return Promise.resolve()
}
art.on(name, () => art.destroy())
const video = art.template.$video
video.dispatchEvent(new MouseEvent('click'))
if (name === 'dblclick')
video.dispatchEvent(new MouseEvent('click'))
return { plays, destroyed: art.isDestroy }
}, name)).toEqual({ plays: core === 'published' ? 1 : 0, destroyed: true })
})
}
test(`${core}: focus, hover and move preserve flags, classes and original events`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const { $player } = art.template
const input = document.createElement('input')
$player.append(input)
const calls = []
let expected
for (const name of ['focus', 'blur', 'mousemove'])
art.on(name, event => calls.push([name, event === expected, art.isFocus, art.isInput]))
art.on('hover', (state, event) => calls.push(['hover', state, event === expected, $player.classList.contains('art-hover')]))
expected = new MouseEvent('click', { bubbles: true })
input.dispatchEvent(expected)
expected = new MouseEvent('contextmenu', { bubbles: true })
document.body.dispatchEvent(expected)
expected = new MouseEvent('mouseenter')
$player.dispatchEvent(expected)
expected = new MouseEvent('mousemove')
$player.dispatchEvent(expected)
expected = new MouseEvent('mouseleave')
$player.dispatchEvent(expected)
art.destroy()
return calls
})).toEqual([
['focus', true, true, true],
['blur', true, false, false],
['hover', true, true, true],
['mousemove', true, false, false],
['hover', false, true, false],
])
})
}