refactor(core): [CORE-23] complete keyboard controls and focus lifecycle

This commit is contained in:
Harvey Zhao committed 2026-09-11 21:06:33 +08:00
1 parent faaa3e8c2d
commit 8ed0a5228c
190 files changed
+53981 -489

No files matched your search

@@ -0,0 +1,278 @@
import { expect, test } from './fixtures.js'
async function setup(page, options = {}) {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate((options) => {
window.menuCalls = []
window.menuEvents = []
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, fullscreenWeb: true, ...options })
window.art.on('contextmenu', value => window.menuEvents.push(value))
}, options)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.locator('.art-control-playAndPause').focus()
}
test('candidate: Shift F10 opens the default info action and closing info returns to the original control', async ({ page }) => {
await setup(page)
await page.keyboard.press('Shift+F10')
await expect(page.locator('.art-contextmenu-info')).toBeFocused()
expect(await page.evaluate(() => window.menuEvents)).toEqual([true])
await page.keyboard.press('Enter')
await expect(page.locator('.art-info-close')).toBeFocused()
await expect(page.locator('.art-contextmenus')).toBeHidden()
await page.keyboard.press('Escape')
await expect(page.locator('.art-control-playAndPause')).toBeFocused()
expect(await page.evaluate(() => window.art.playing)).toBe(false)
})
test('candidate: context menu Escape closes before fullscreen and Tab continues from its origin', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
window.art.fullscreenWeb = true
})
await page.keyboard.press('ContextMenu')
await expect(page.locator('.art-contextmenu-info')).toBeFocused()
await page.keyboard.press('Escape')
await expect(page.locator('.art-control-playAndPause')).toBeFocused()
expect(await page.evaluate(() => window.art.fullscreenWeb)).toBe(true)
await page.keyboard.press('Shift+F10')
await page.keyboard.press('Tab')
await expect(page.locator('.art-contextmenus')).toBeHidden()
await expect(page.locator('.art-icon-volumeClose')).toBeFocused()
})
test('candidate: keyboard choice navigation waits for activation and retains existing selection events', async ({ page }) => {
await setup(page, { playbackRate: true, aspectRatio: true, flip: true })
await page.keyboard.press('Shift+F10')
const choices = page.locator('.art-contextmenu-playbackRate [data-value]')
await expect(choices.first()).toBeFocused()
await page.keyboard.press('ArrowRight')
await expect(choices.nth(1)).toBeFocused()
expect(await page.evaluate(() => window.art.playbackRate)).toBe(1)
const value = Number(await choices.nth(1).getAttribute('data-value'))
await page.keyboard.press('Space')
await expect.poll(() => page.evaluate(() => window.art.playbackRate)).toBe(value)
await expect(page.locator('.art-control-playAndPause')).toBeFocused()
await page.keyboard.press('Shift+F10')
await expect(choices.nth(1)).toHaveAttribute('aria-pressed', 'true')
})
test('candidate: custom context actions retain callbacks, typeahead and focus across replacement', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
for (const name of ['Alpha', 'Beta']) {
window.art.contextmenu.add({ name, html: name, click(menu, event) {
window.menuCalls.push([name, this === window.art, menu === window.art.contextmenu, event.type, menu.show])
} })
}
})
await page.keyboard.press('Shift+F10')
await page.keyboard.press('b')
await expect(page.locator('.art-contextmenu-Beta')).toBeFocused()
await page.keyboard.press('Enter')
expect(await page.evaluate(() => window.menuCalls)).toEqual([['Beta', true, true, 'click', true]])
await expect(page.locator('.art-contextmenus')).toBeVisible()
expect(await page.evaluate(() => window.art.contextmenu.update({ name: 'Beta', html: 'Updated Beta' }) === window.art.contextmenu.Beta)).toBe(true)
await expect(page.locator('.art-contextmenu-Beta')).toBeFocused()
expect(await page.locator('.art-contextmenu-Beta').evaluate(element => element.nextElementSibling.className)).toBe('art-contextmenu art-contextmenu-info')
await page.evaluate(() => window.art.contextmenu.remove('Beta'))
await expect(page.locator('.art-contextmenu-info')).toBeFocused()
})
test('candidate: native context content retains input editing and button activation without nested roles', async ({ page }) => {
await setup(page)
await page.evaluate(() => window.art.contextmenu.add({ name: 'native', html: '<button id="menu-native">Native</button><input id="menu-input">', click(menu, event) {
if (event.target.id === 'menu-native')
window.menuCalls.push(['native'])
} }))
await page.keyboard.press('Shift+F10')
await page.locator('#menu-input').focus()
await page.keyboard.type('typed text')
await page.keyboard.press('ArrowLeft')
await expect(page.locator('#menu-input')).toHaveValue('typed text')
expect(await page.evaluate(() => window.art.currentTime)).toBe(0)
await page.locator('#menu-native').focus()
await expect(page.locator('.art-contextmenu-native')).not.toHaveAttribute('role', 'button')
await page.keyboard.press('Space')
expect(await page.evaluate(() => window.menuCalls)).toEqual([['native']])
})
test('candidate: disabled context menus do not claim the keyboard opening gesture', async ({ page }) => {
await setup(page)
expect(await page.evaluate(() => {
window.Artplayer.CONTEXTMENU = false
const event = new KeyboardEvent('keydown', { key: 'F10', shiftKey: true, bubbles: true, cancelable: true })
window.art.controls.playAndPause.dispatchEvent(event)
return { prevented: event.defaultPrevented, shown: window.art.contextmenu.show }
})).toEqual({ prevented: false, shown: false })
})
test('candidate: a hidden or disabled origin falls back to the player and outside focus is preserved', async ({ page }) => {
await setup(page)
for (const unavailable of ['hidden', 'disabled', 'removed']) {
await page.evaluate(() => window.art.controls.add({ name: 'origin', position: 'left', html: 'Origin', click() {} }))
await page.locator('.art-control-origin').focus()
await page.keyboard.press('Shift+F10')
await page.evaluate((unavailable) => {
const origin = window.art.controls.origin
if (unavailable === 'hidden')
origin.style.visibility = 'hidden'
else if (unavailable === 'disabled')
origin.setAttribute('aria-disabled', 'true')
else
origin.remove()
}, unavailable)
await page.keyboard.press('Escape')
await expect(page.locator('.art-video-player')).toBeFocused()
await page.evaluate(() => window.art.controls.remove('origin'))
}
await page.locator('.art-control-playAndPause').focus()
await page.keyboard.press('Shift+F10')
await page.locator('#play').focus()
await expect(page.locator('.art-contextmenus')).toBeHidden()
await expect(page.locator('#play')).toBeFocused()
})
test('candidate: menu reinitialization replaces listeners and a rejected duplicate initialization keeps them', async ({ page }) => {
await setup(page)
expect(await page.evaluate(() => {
const menu = window.art.contextmenu
try {
menu.init()
}
catch (error) {
return error.message.includes('existing [info]')
}
return false
})).toBe(true)
await page.keyboard.press('Shift+F10')
await expect(page.locator('.art-contextmenu-info')).toBeFocused()
await page.keyboard.press('Escape')
await page.evaluate(() => {
const menu = window.art.contextmenu
for (const name of [...menu.cache.keys()])
menu.remove(name)
menu.init()
window.menuEvents = []
})
await page.keyboard.press('Shift+F10')
await expect(page.locator('.art-contextmenu-info')).toBeFocused()
expect(await page.evaluate(() => window.menuEvents)).toEqual([true])
await page.keyboard.press('Escape')
expect(await page.evaluate(() => window.menuEvents)).toEqual([true, false])
await expect(page.locator('.art-control-playAndPause')).toBeFocused()
})
test('candidate: menu activation preserves callback focus transfers and destruction leaves detached controls inert', async ({ page }) => {
await setup(page)
await page.evaluate(() => window.art.contextmenu.add({ name: 'outside', html: 'Outside', click(menu) {
document.querySelector('#play').focus()
menu.show = false
} }))
await page.keyboard.press('Shift+F10')
await page.keyboard.press('Enter')
await expect(page.locator('#play')).toBeFocused()
await expect(page.locator('.art-contextmenus')).toBeHidden()
await page.evaluate(() => {
window.art.contextmenu.update({ name: 'outside', click() {
window.menuCalls.push('destroy')
this.destroy()
} })
window.detachedMenu = window.art.contextmenu.outside
})
await page.locator('.art-control-playAndPause').focus()
await page.keyboard.press('Shift+F10')
await page.keyboard.press('Space')
expect(await page.evaluate(() => {
window.detachedMenu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
window.detachedMenu.click()
return { calls: window.menuCalls, instances: window.Artplayer.instances.length, focused: document.activeElement.tagName }
})).toEqual({ calls: ['destroy'], instances: 0, focused: 'BODY' })
})
test('candidate: native editable context gestures remain native while pointer opening preserves external focus', async ({ page }) => {
await setup(page)
await page.evaluate(() => window.art.controls.add({ name: 'input', position: 'left', html: '<input id="context-input">' }))
await page.locator('#context-input').focus()
expect(await page.evaluate(() => {
const input = document.querySelector('#context-input')
const key = new KeyboardEvent('keydown', { key: 'F10', shiftKey: true, bubbles: true, cancelable: true })
const pointer = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
input.dispatchEvent(key)
input.dispatchEvent(pointer)
return { key: key.defaultPrevented, pointer: pointer.defaultPrevented, shown: window.art.contextmenu.show }
})).toEqual({ key: false, pointer: false, shown: false })
await page.locator('#play').focus()
expect(await page.evaluate(() => {
const event = new MouseEvent('contextmenu', { clientX: 200, clientY: 120, bubbles: true, cancelable: true })
window.art.template.$player.dispatchEvent(event)
return event.defaultPrevented
})).toBe(true)
await expect(page.locator('.art-contextmenus')).toBeVisible()
await expect(page.locator('#play')).toBeFocused()
await page.evaluate(() => {
window.art.contextmenu.show = false
})
await expect(page.locator('#play')).toBeFocused()
})
test('candidate: keyboard menu remains inside the player and ignores a canceled opening', async ({ page }, testInfo) => {
await setup(page, { playbackRate: true, aspectRatio: true, flip: true, lang: 'zh-cn' })
await page.evaluate(() => {
window.art.on('contextmenu', function cancel(show) {
if (show) {
window.art.off('contextmenu', cancel)
window.art.contextmenu.show = false
}
})
})
await page.keyboard.press('Shift+F10')
await expect(page.locator('.art-contextmenus')).toBeHidden()
await expect(page.locator('.art-control-playAndPause')).toBeFocused()
await page.keyboard.press('Shift+F10')
await expect(page.locator('.art-contextmenus')).toHaveAttribute('aria-label', '快捷菜单')
expect(await page.evaluate(() => {
const menu = window.art.template.$contextmenu.getBoundingClientRect()
const player = window.art.template.$player.getBoundingClientRect()
return menu.left >= player.left && menu.right <= player.right && menu.top >= player.top && menu.bottom <= player.bottom
})).toBe(true)
await page.locator('.art-video-player').screenshot({ path: `refactor/.cache/core23-contextmenu-keyboard-${testInfo.project.name}.png` })
})
test('candidate: custom menu handoff to mini returns to the control that opened the menu', async ({ page }) => {
await setup(page)
await page.evaluate(() => window.art.contextmenu.add({ name: 'mini', html: 'Mini', click(menu) {
this.mini = true
menu.show = false
} }))
await page.keyboard.press('Shift+F10')
await expect(page.locator('.art-contextmenu-mini')).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.locator('.art-mini-close')).toBeFocused()
await expect(page.locator('.art-contextmenus')).toBeHidden()
await page.keyboard.press('Escape')
await expect(page.locator('.art-mini-popup')).toBeHidden()
await expect(page.locator('.art-control-playAndPause')).toBeFocused()
})
test('candidate: context keyboard and focus ownership stay local to each player', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
const container = document.createElement('div')
container.id = 'second-player'
container.style.cssText = 'width:640px;height:360px'
document.body.append(container)
window.otherArt = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true })
})
await expect.poll(() => page.evaluate(() => window.otherArt.isReady)).toBe(true)
await page.keyboard.press('Shift+F10')
await expect(page.locator('.player .art-contextmenu-info')).toBeFocused()
await page.locator('#second-player .art-control-playAndPause').focus()
await expect(page.locator('.player .art-contextmenus')).toBeHidden()
await page.keyboard.press('Shift+F10')
await expect(page.locator('#second-player .art-contextmenu-info')).toBeFocused()
await page.evaluate(() => window.art.destroy())
await page.keyboard.press('Escape')
await expect(page.locator('#second-player .art-control-playAndPause')).toBeFocused()
expect(await page.evaluate(() => window.otherArt.playing)).toBe(false)
})
+168
View File
@@ -0,0 +1,168 @@
import { expect, test } from './fixtures.js'
async function setup(page) {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => {
window.focusCalls = []
window.focusOrder = []
const outside = document.createElement('button')
outside.id = 'outside-focus'
outside.textContent = 'Outside'
document.body.append(outside)
window.art = new window.Artplayer({
container: '.player',
url: '/test/pattern.mp4',
muted: true,
controls: ['first', 'second', 'third'].map((name, index) => ({
name,
index: index + 10,
position: 'right',
html: name,
click(component, event) {
window.focusCalls.push([name, this === window.art, component === window.art.controls, event.type])
},
})),
})
window.art.template.$player.addEventListener('focusin', event => window.focusOrder.push(event.target.className))
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
const control = (page, name) => page.locator(`.art-control-${name}`)
test('candidate: replacing a focused control restores its new entry without intermediate focus', async ({ page }) => {
await setup(page)
await control(page, 'first').focus()
const result = await page.evaluate(() => {
window.focusOrder.length = 0
return window.art.controls.update({ name: 'first', html: 'Updated' })
})
expect(result).toBeUndefined()
await expect(control(page, 'first')).toBeFocused()
expect(await page.evaluate(() => window.focusOrder)).toEqual(['art-control art-control-first'])
await page.keyboard.press('Space')
expect(await page.evaluate(() => window.focusCalls)).toEqual([['first', true, true, 'click']])
})
test('candidate: removing a focused control chooses a surviving neighbor and preserves external focus', async ({ page }) => {
await setup(page)
await control(page, 'first').focus()
await page.evaluate(() => window.art.controls.remove('first'))
await expect(control(page, 'second')).toBeFocused()
await control(page, 'third').focus()
await page.evaluate(() => window.art.controls.remove('third'))
await expect(control(page, 'second')).toBeFocused()
await page.locator('#outside-focus').focus()
await page.evaluate(() => window.art.controls.remove('second'))
await expect(page.locator('#outside-focus')).toBeFocused()
})
test('candidate: failed control replacement recovers focus but a veto keeps the original focused entry', async ({ page }) => {
await setup(page)
await control(page, 'first').focus()
expect(await page.evaluate(() => {
try {
window.art.controls.update({ name: 'first', mounted() {
throw new Error('mount failed')
} })
}
catch (error) { return error.message }
})).toBe('mount failed')
await expect(control(page, 'second')).toBeFocused()
expect(await page.evaluate(() => {
try {
window.art.controls.update({ name: 'second', beforeUnmount() {
throw new Error('veto')
} })
}
catch (error) { return error.message }
})).toBe('veto')
await expect(control(page, 'second')).toBeFocused()
})
test('candidate: callbacks can transfer focus outside without an intermediate or final control focus', async ({ page }) => {
await setup(page)
for (const phase of ['beforeUnmount', 'mounted']) {
await control(page, 'first').focus()
await page.evaluate((phase) => {
window.focusOrder.length = 0
window.art.controls.update({ name: 'first', beforeUnmount: undefined, mounted: undefined, [phase]() {
document.querySelector('#outside-focus').focus()
} })
}, phase)
await expect(page.locator('#outside-focus')).toBeFocused()
expect(await page.evaluate(() => window.focusOrder)).toEqual([])
}
})
test('candidate: replacing a focused selector option transfers focus to the new control', async ({ page }) => {
await setup(page)
await page.evaluate(() => window.art.controls.update({ name: 'first', click: undefined, selector: [{ html: 'Auto', default: true }, { html: 'HD' }], onSelect: item => item.html }))
await control(page, 'first').locator('.art-selector-value').focus()
await page.keyboard.press('Enter')
await expect(control(page, 'first').locator('.art-selector-item').first()).toBeFocused()
await page.evaluate(() => window.art.controls.update({ name: 'first', selector: undefined, html: 'Button', click() {} }))
await expect(control(page, 'first')).toBeFocused()
})
test('candidate: removing the last focusable control leaves focus on the player without an extra sequential Tab stop', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
for (const name of [...window.art.controls.cache.keys()]) {
if (name !== 'first')
window.art.controls.remove(name)
}
})
await control(page, 'first').focus()
await page.evaluate(() => window.art.controls.remove('first'))
await expect(page.locator('.art-video-player')).toBeFocused()
await expect(page.locator('.art-video-player')).toHaveAttribute('tabindex', '-1')
await page.evaluate(() => window.art.destroy(false))
await expect(page.locator('.art-video-player')).not.toHaveAttribute('tabindex')
})
test('candidate: focus recovery skips unavailable controls and uses a replacement native button', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
window.art.controls.second.style.visibility = 'hidden'
window.art.controls.third.setAttribute('aria-disabled', 'true')
})
await control(page, 'first').focus()
await page.evaluate(() => window.art.controls.update({ name: 'first', html: '<button>Native replacement</button>' }))
await expect(control(page, 'first').locator('button')).toBeFocused()
await expect(control(page, 'first')).not.toHaveAttribute('role', 'button')
await page.keyboard.press('Space')
expect(await page.evaluate(() => window.focusCalls)).toEqual([['first', true, true, 'click']])
await page.evaluate(() => window.art.controls.remove('first'))
await expect(page.locator('.art-icon-volumeClose')).toBeFocused()
})
test('candidate: an unrelated instance update cannot take focus from another player', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
const container = document.createElement('div')
container.style.cssText = 'width:640px;height:360px'
document.body.append(container)
window.secondArt = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true, controls: [{ name: 'custom', position: 'right', html: 'Other', click() {} }] })
})
await control(page, 'first').focus()
await page.evaluate(() => window.secondArt.controls.update({ name: 'custom', html: 'Changed' }))
await expect(control(page, 'first')).toBeFocused()
await control(page, 'custom').focus()
await page.evaluate(() => window.art.controls.remove('first'))
await expect(control(page, 'custom')).toBeFocused()
})
test('candidate: a disabled native replacement falls back and focus callbacks can destroy the player', async ({ page }) => {
await setup(page)
await control(page, 'first').focus()
await page.evaluate(() => window.art.controls.update({ name: 'first', html: '<fieldset disabled><button>Unavailable</button></fieldset>' }))
await expect(control(page, 'second')).toBeFocused()
await page.evaluate(() => window.art.controls.update({ name: 'second', mounted(element) {
window.retainedControl = element
element.addEventListener('focus', () => window.art.destroy(false), { once: true })
} }))
expect(await page.evaluate(() => window.art.isDestroy)).toBe(true)
await page.evaluate(() => window.retainedControl.click())
expect(await page.evaluate(() => window.focusCalls)).toEqual([])
})
+136
View File
@@ -0,0 +1,136 @@
import { expect, test } from './fixtures.js'
async function setup(page) {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => {
window.infoEvents = []
const outside = document.createElement('button')
outside.id = 'info-outside'
outside.textContent = 'Outside'
document.body.append(outside)
window.art = new window.Artplayer({
container: '.player',
url: '/test/pattern.mp4',
muted: true,
fullscreenWeb: true,
controls: [{ name: 'info-trigger', position: 'right', html: 'Info', click() { this.info.show = true } }],
})
window.art.on('info', value => window.infoEvents.push(value))
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
test('candidate: info keyboard entry, Space close and Escape preserve the opening control', async ({ page }) => {
await setup(page)
const trigger = page.locator('.art-control-info-trigger')
await trigger.focus()
await page.keyboard.press('Enter')
await expect(page.locator('.art-info-close')).toBeFocused()
await expect(page.locator('.art-info-close')).toHaveAttribute('aria-label', 'Close')
await expect(page.locator('.art-info')).toHaveAttribute('aria-label', 'Video Info')
await page.keyboard.press('Space')
await expect(page.locator('.art-info')).toBeHidden()
await expect(trigger).toBeFocused()
await page.evaluate(() => {
window.art.fullscreenWeb = true
})
await page.keyboard.press('Enter')
await expect(page.locator('.art-info-close')).toBeFocused()
await page.keyboard.press('Escape')
await expect(page.locator('.art-info')).toBeHidden()
await expect(trigger).toBeFocused()
expect(await page.evaluate(() => window.art.fullscreenWeb)).toBe(true)
await page.keyboard.press('Escape')
expect(await page.evaluate(() => window.art.fullscreenWeb)).toBe(false)
expect(await page.evaluate(() => window.infoEvents)).toEqual([true, false, true, false])
})
test('candidate: info pointer and programmatic visibility preserve unrelated focus', async ({ page }) => {
await setup(page)
await page.locator('.art-control-info-trigger').click()
await expect(page.locator('.art-info-close')).not.toBeFocused()
await page.locator('#info-outside').focus()
await page.evaluate(() => {
window.art.info.show = false
window.art.info.show = true
window.art.info.show = false
})
await expect(page.locator('#info-outside')).toBeFocused()
})
test('candidate: repeated info init replaces keyboard bindings and retains the current return target', async ({ page }) => {
await setup(page)
await page.locator('.art-control-info-trigger').focus()
await page.keyboard.press('Enter')
await page.evaluate(() => {
window.art.info.init()
window.art.info.init()
window.infoEvents.length = 0
})
await page.keyboard.press('Enter')
await expect(page.locator('.art-info')).toBeHidden()
await expect(page.locator('.art-control-info-trigger')).toBeFocused()
expect(await page.evaluate(() => window.infoEvents)).toEqual([false])
})
test('candidate: info with a removed origin returns to the player and destruction disables retained controls', async ({ page }) => {
await setup(page)
await page.locator('.art-control-info-trigger').focus()
await page.keyboard.press('Enter')
await page.evaluate(() => window.art.controls.remove('info-trigger'))
await page.keyboard.press('Escape')
await expect(page.locator('.art-video-player')).toBeFocused()
await page.evaluate(() => {
window.retainedInfo = window.art.template.$infoClose
window.art.destroy(false)
window.infoEvents.length = 0
window.retainedInfo.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
window.retainedInfo.click()
})
expect(await page.evaluate(() => window.infoEvents)).toEqual([])
})
for (const core of ['published', 'candidate']) {
test(`${core}: optional SSR info wrapper may be absent without preventing polling and close`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
expect(await page.evaluate(() => {
const container = document.querySelector('.player')
container.innerHTML = window.Artplayer.html
container.querySelector('.art-info').className = 'caller-info'
const close = container.querySelector('.art-info-close')
const panel = container.querySelector('.art-info-panel')
const art = new window.Artplayer({ container, url: '', useSSR: true })
art.info.show = true
close.click()
const result = { absent: art.template.$info === null, retained: art.template.$infoClose === close && art.template.$infoPanel === panel, closed: !art.info.show }
art.destroy()
return result
})).toEqual({ absent: true, retained: true, closed: true })
})
}
test('candidate: failed info name initialization releases its handlers and permits a clean retry', async ({ page }) => {
await setup(page)
expect(await page.evaluate(() => {
const get = window.art.i18n.get
const count = window.art.e.info.length
const failure = new Error('name failed')
window.art.i18n.get = () => {
throw failure
}
let same = false
try {
window.art.info.init()
}
catch (error) { same = error === failure }
window.art.i18n.get = get
const released = window.art.e.info.length === count - 1
window.art.info.init()
return { same, released }
})).toEqual({ same: true, released: true })
await page.locator('.art-control-info-trigger').focus()
await page.keyboard.press('Enter')
await expect(page.locator('.art-info-close')).toBeFocused()
await page.keyboard.press('Escape')
await expect(page.locator('.art-control-info-trigger')).toBeFocused()
})
@@ -0,0 +1,130 @@
import { expect, test } from './fixtures.js'
async function setup(page) {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => {
window.modeEvents = []
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, fullscreen: true, fullscreenWeb: true, pip: true })
window.art.on('fullscreen', value => window.modeEvents.push(['fullscreen', value]))
window.art.on('pip', value => window.modeEvents.push(['pip', value]))
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
test('candidate: native fullscreen keyboard activation retains focus through entry and exit', async ({ page }) => {
await setup(page)
const trigger = page.locator('.art-control-fullscreen')
await trigger.focus()
await page.keyboard.press('Enter')
await expect.poll(() => page.evaluate(() => window.art.fullscreen)).toBe(true)
await expect(trigger).toBeFocused()
await expect(trigger).toHaveAttribute('aria-label', 'Exit Fullscreen')
await page.keyboard.press('Space')
await expect.poll(() => page.evaluate(() => window.art.fullscreen)).toBe(false)
await expect(trigger).toBeFocused()
await expect(trigger).toHaveAttribute('aria-label', 'Fullscreen')
expect(await page.evaluate(() => ({ events: window.modeEvents, playing: window.art.playing }))).toEqual({ events: [['fullscreen', true], ['fullscreen', false]], playing: false })
})
test('candidate: native fullscreen external exit preserves the current in-player focus', async ({ page }) => {
await setup(page)
await page.locator('.art-control-fullscreen').focus()
await page.keyboard.press('Enter')
await expect.poll(() => page.evaluate(() => window.art.fullscreen)).toBe(true)
const play = page.locator('.art-control-playAndPause')
await play.focus()
await page.evaluate(async () => {
const exit = ['exitFullscreen', 'webkitExitFullscreen', 'webkitCancelFullScreen', 'mozCancelFullScreen', 'msExitFullscreen'].find(name => name in document)
await document[exit]()
})
await expect.poll(() => page.evaluate(() => window.art.fullscreen)).toBe(false)
await expect(play).toBeFocused()
await page.keyboard.press('Space')
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
})
test('candidate: PiP keyboard control follows real native capability without a second playback action', async ({ page }, testInfo) => {
await setup(page)
const capability = await page.evaluate(() => ({
standard: !!document.pictureInPictureEnabled && typeof window.art.video.requestPictureInPicture === 'function',
webkit: typeof window.art.video.webkitSetPresentationMode === 'function',
}))
await testInfo.attach('native-pip-capability', { contentType: 'application/json', body: JSON.stringify(capability) })
const trigger = page.locator('.art-control-pip')
await trigger.focus()
await page.keyboard.press('Enter')
if (capability.standard || capability.webkit) {
await expect.poll(() => page.evaluate(() => !!window.art.pip)).toBe(true)
await expect(trigger).toBeFocused()
await expect(trigger).toHaveAttribute('aria-label', 'Exit PIP Mode')
await page.keyboard.press('Space')
await expect.poll(() => page.evaluate(() => !!window.art.pip)).toBe(false)
await expect(trigger).toBeFocused()
await expect(trigger).toHaveAttribute('aria-label', 'PIP Mode')
expect(await page.evaluate(() => window.modeEvents)).toEqual([['pip', true], ['pip', false]])
}
else {
await expect(page.locator('.art-notice')).toBeVisible()
await expect(trigger).toBeFocused()
expect(await page.evaluate(() => !!window.art.pip)).toBe(false)
expect(await page.evaluate(() => window.modeEvents)).toEqual([])
}
expect(await page.evaluate(() => window.art.playing)).toBe(false)
})
test('candidate: SSR retains native nodes while keyboard controls toggle real subtitle cues and survive fullscreen', async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => {
const container = document.querySelector('.player')
container.innerHTML = window.Artplayer.html
window.ssrPlayer = container.querySelector('.art-video-player')
window.ssrVideo = container.querySelector('video')
window.ssrSubtitle = container.querySelector('.art-subtitle')
window.art = new window.Artplayer({
container,
useSSR: true,
url: '/test/pattern.mp4',
muted: true,
fullscreenWeb: true,
subtitle: { url: '/test/declaration-cues.vtt', name: 'English captions' },
controls: [{ name: 'captions', position: 'left', html: 'Captions', click() {
this.subtitle.show = !this.subtitle.show
} }],
})
})
await expect.poll(() => page.evaluate(() => window.art.isReady && window.art.subtitle.cues.length === 1)).toBe(true)
const play = page.locator('.art-control-playAndPause')
await play.focus()
await page.keyboard.press('Space')
await expect(page.locator('.art-subtitle-line')).toHaveText('Declaration cue')
await page.keyboard.press('Space')
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(false)
const captions = page.locator('.art-control-captions')
await captions.focus()
await page.keyboard.press('Enter')
await expect(page.locator('.art-subtitle')).toBeHidden()
await expect(captions).toBeFocused()
await page.keyboard.press('Space')
await expect(page.locator('.art-subtitle-line')).toBeVisible()
await expect(captions).toBeFocused()
const fullscreen = page.locator('.art-control-fullscreenWeb')
await fullscreen.focus()
await page.keyboard.press('Enter')
await expect.poll(() => page.evaluate(() => window.art.fullscreenWeb)).toBe(true)
await expect(fullscreen).toBeFocused()
await expect(page.locator('.art-subtitle-line')).toBeVisible()
await page.keyboard.press('Escape')
await expect.poll(() => page.evaluate(() => window.art.fullscreenWeb)).toBe(false)
await expect(fullscreen).toBeFocused()
expect(await page.evaluate(() => ({
player: window.ssrPlayer === window.art.template.$player,
video: window.ssrVideo === window.art.video,
subtitle: window.ssrSubtitle === window.art.template.$subtitle,
label: window.art.template.$track.label,
focusInSubtitle: !!window.ssrSubtitle.querySelector('[tabindex],button,input'),
live: window.ssrSubtitle.hasAttribute('aria-live'),
}))).toEqual({ player: true, video: true, subtitle: true, label: 'English captions', focusInSubtitle: false, live: false })
await page.locator('.art-video-player').screenshot({ path: `refactor/.cache/core23-ssr-subtitle-${testInfo.project.name}.png` })
await page.evaluate(() => window.art.destroy(false))
expect(await page.evaluate(() => window.ssrVideo.isConnected && window.ssrPlayer.classList.contains('art-destroy'))).toBe(true)
})
+127
View File
@@ -0,0 +1,127 @@
import { expect, test } from './fixtures.js'
async function setup(page) {
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android keyboard fixture' }))
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => {
const outside = document.createElement('button')
outside.id = 'lock-outside'
outside.textContent = 'Outside'
document.body.append(outside)
window.lockEvents = []
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, lock: true, hotkey: true })
window.art.on('lock', value => window.lockEvents.push([value, window.art.isLock, window.art.plugins.lock.state]))
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
test('candidate: lock uses one stable keyboard button, Escape unlocks and hidden controls leave Tab order', async ({ page }) => {
await setup(page)
const lock = page.locator('.art-layer-lock')
await lock.focus()
await expect(lock).toBeFocused()
await expect(lock).toHaveAttribute('aria-label', 'Lock')
await page.keyboard.press('Space')
await expect(lock).toHaveAttribute('aria-pressed', 'true')
await expect(lock).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.locator('#play')).toBeFocused()
await page.keyboard.press('Shift+Tab')
await expect(lock).toBeFocused()
await page.keyboard.press('Escape')
await expect(lock).toHaveAttribute('aria-pressed', 'false')
await expect(lock).toBeFocused()
expect(await page.evaluate(() => window.lockEvents)).toEqual([[true, true, true], [false, false, false]])
await page.keyboard.press('Tab')
await expect(page.locator('.art-control-progress')).toBeFocused()
})
test('candidate: programmatic lock moves hidden control focus to unlock and caller tabindex survives', async ({ page }) => {
await setup(page)
await page.locator('.art-control-progress').focus()
await page.evaluate(() => {
window.art.controls.add({ name: 'custom', position: 'right', html: '<button tabindex="3">Custom</button>' })
window.art.plugins.lock.state = true
})
await expect(page.locator('.art-layer-lock')).toBeFocused()
await expect(page.locator('.art-bottom')).toHaveAttribute('inert')
await expect(page.locator('.art-control-custom button')).toHaveAttribute('tabindex', '-1')
await page.keyboard.press('Enter')
await expect(page.locator('.art-bottom')).not.toHaveAttribute('inert')
await expect(page.locator('.art-control-custom button')).toHaveAttribute('tabindex', '3')
})
test('candidate: locked additions stay out of Tab order and removal or destruction releases focus attributes', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
window.art.plugins.lock.state = true
window.art.controls.add({ name: 'late', position: 'right', html: '<button>Late</button>' })
})
await expect(page.locator('.art-control-late button')).toHaveAttribute('tabindex', '-1')
await page.evaluate(() => {
window.retainedLock = window.art.layers.lock
window.art.layers.remove('lock')
})
await expect(page.locator('.art-bottom')).not.toHaveAttribute('inert')
await expect(page.locator('.art-control-late button')).not.toHaveAttribute('tabindex')
await page.evaluate(() => {
window.lockEvents.length = 0
window.retainedLock.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
window.art.destroy(false)
})
expect(await page.evaluate(() => window.lockEvents)).toEqual([])
})
test('candidate: lock Tab fallback works without native inert and preserves caller focus attributes', async ({ page }) => {
await setup(page)
await page.locator('.art-layer-lock').focus()
await page.keyboard.press('Enter')
await page.evaluate(() => window.art.template.$bottom.removeAttribute('inert'))
await page.keyboard.press('Tab')
await expect(page.locator('#play')).toBeFocused()
await page.keyboard.press('Shift+Tab')
await expect(page.locator('.art-layer-lock')).toBeFocused()
await page.keyboard.press('Escape')
await page.evaluate(() => {
window.art.template.$bottom.setAttribute('inert', 'caller-owned')
window.art.template.$bottom.setAttribute('aria-hidden', 'false')
window.art.plugins.lock.state = true
window.art.plugins.lock.state = false
})
await expect(page.locator('.art-bottom')).toHaveAttribute('inert', 'caller-owned')
await expect(page.locator('.art-bottom')).toHaveAttribute('aria-hidden', 'false')
})
test('candidate: moving a control outside a locked region restores its tabindex and cleanup preserves later caller edits', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
window.art.controls.add({ name: 'borrowed', position: 'right', html: '<button id="borrowed-focus" tabindex="2">Borrowed</button>' })
window.art.plugins.lock.state = true
})
await expect(page.locator('#borrowed-focus')).toHaveAttribute('tabindex', '-1')
await page.evaluate(() => document.body.append(document.querySelector('#borrowed-focus')))
await expect(page.locator('#borrowed-focus')).toHaveAttribute('tabindex', '2')
await page.evaluate(() => {
window.art.controls.progress.setAttribute('tabindex', '4')
window.art.plugins.lock.state = false
})
await expect(page.locator('.art-control-progress')).toHaveAttribute('tabindex', '4')
})
test('candidate: remounting an already locked layer reflects actual state and replaces its keyboard scope', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
window.art.plugins.lock.state = true
window.art.layers.update({ name: 'lock' })
window.lockEvents.length = 0
})
const lock = page.locator('.art-layer-lock')
await expect(lock).toHaveAttribute('aria-pressed', 'true')
await expect(lock.locator('.art-icon-lock')).toBeVisible()
expect(await lock.locator('.art-icon-lock').evaluate(element => element.style.display)).toBe('inline-flex')
await expect(lock.locator('.art-icon-unlock')).toHaveCSS('display', 'none')
await lock.focus()
await page.keyboard.press('Space')
await expect(page.locator('.art-bottom')).not.toHaveAttribute('inert')
expect(await page.evaluate(() => window.lockEvents)).toEqual([[false, false, false]])
})
+142
View File
@@ -0,0 +1,142 @@
import { expect, test } from './fixtures.js'
async function setup(page) {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => {
window.miniCalls = []
const outside = document.createElement('button')
outside.id = 'mini-outside'
outside.textContent = 'Outside'
document.body.append(outside)
window.art = new window.Artplayer({
container: '.player',
url: '/test/pattern.mp4',
muted: true,
hotkey: true,
controls: [{ name: 'mini-trigger', position: 'right', html: 'Mini', click() { this.mini = true } }],
})
window.art.on('mini', value => window.miniCalls.push(['mini', value]))
window.art.on('play', () => window.miniCalls.push(['play']))
window.art.on('pause', () => window.miniCalls.push(['pause']))
window.art.on('hotkey', event => window.miniCalls.push(['hotkey', event.code]))
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
test('candidate: mini keyboard entry reaches close and stable playback controls and Escape returns focus', async ({ page }, testInfo) => {
await setup(page)
const trigger = page.locator('.art-control-mini-trigger')
await trigger.focus()
await page.keyboard.press('Enter')
const popup = page.locator('.art-mini-popup')
await expect(popup.locator('.art-mini-close')).toBeFocused()
await expect(popup.locator('.art-mini-close')).toHaveAttribute('aria-label', 'Close')
await page.keyboard.press('Tab')
await expect(popup.locator('.art-mini-state')).toBeFocused()
await expect(popup.locator('.art-mini-state')).toHaveAttribute('aria-label', 'Play')
await page.keyboard.press('Space')
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
await expect(popup.locator('.art-mini-state')).toBeFocused()
await expect(popup.locator('.art-mini-state')).toHaveAttribute('aria-label', 'Pause')
await page.keyboard.press('Enter')
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(false)
await popup.screenshot({ path: testInfo.outputPath('mini-keyboard.png') })
await page.keyboard.press('Escape')
await expect(popup).toBeHidden()
await expect(trigger).toBeFocused()
expect(await page.evaluate(() => window.miniCalls)).toEqual([['mini', true], ['play'], ['pause'], ['mini', false]])
})
test('candidate: mini pointer entry preserves focus and Space closes after keyboard navigation', async ({ page }) => {
await setup(page)
await page.locator('#mini-outside').focus()
await page.locator('.art-control-mini-trigger').click()
await expect(page.locator('.art-mini-close')).not.toBeFocused()
await page.locator('.art-mini-close').focus()
await page.keyboard.press('Space')
await expect(page.locator('.art-mini-popup')).toBeHidden()
await expect(page.locator('.art-control-mini-trigger')).toBeFocused()
expect(await page.evaluate(() => window.miniCalls)).toEqual([['mini', true], ['mini', false]])
})
test('candidate: programmatic mini exit preserves external focus and removed return targets use the player', async ({ page }) => {
await setup(page)
await page.locator('.art-control-mini-trigger').focus()
await page.keyboard.press('Enter')
await page.locator('#mini-outside').focus()
await page.evaluate(() => {
window.art.mini = false
})
await expect(page.locator('#mini-outside')).toBeFocused()
await page.locator('.art-control-mini-trigger').focus()
await page.keyboard.press('Enter')
await page.evaluate(() => window.art.controls.remove('mini-trigger'))
await page.keyboard.press('Escape')
await expect(page.locator('.art-video-player')).toBeFocused()
await expect(page.locator('.art-mini-popup')).toBeHidden()
})
test('candidate: destroying from mini return focus cancels late notifications and retained controls', async ({ page }) => {
await setup(page)
await page.locator('.art-control-mini-trigger').focus()
await page.keyboard.press('Enter')
await page.evaluate(() => {
window.retainedMini = window.art.template.$mini
window.art.controls['mini-trigger'].addEventListener('focus', () => window.art.destroy(false), { once: true })
window.miniCalls.length = 0
})
await page.keyboard.press('Escape')
expect(await page.evaluate(() => window.art.isDestroy)).toBe(true)
await page.evaluate(() => {
window.retainedMini.querySelector('.art-mini-close').click()
window.retainedMini.querySelector('.art-icon-play').click()
})
expect(await page.evaluate(() => window.miniCalls)).toEqual([])
})
test('candidate: mini reuse keeps keyboard entry and never returns to a stale or hidden origin', async ({ page }) => {
await setup(page)
const trigger = page.locator('.art-control-mini-trigger')
await trigger.focus()
await page.keyboard.press('Enter')
await page.keyboard.press('Escape')
await page.keyboard.press('Enter')
await expect(page.locator('.art-mini-close')).toBeFocused()
await page.evaluate(() => {
window.art.controls['mini-trigger'].style.visibility = 'hidden'
})
await page.keyboard.press('Escape')
await expect(page.locator('.art-video-player')).toBeFocused()
await page.evaluate(() => {
window.art.controls['mini-trigger'].style.visibility = ''
})
await page.locator('#mini-outside').focus()
await page.evaluate(() => {
window.art.mini = true
})
await expect(page.locator('#mini-outside')).toBeFocused()
await page.locator('.art-mini-close').focus()
await page.keyboard.press('Escape')
await expect(page.locator('.art-video-player')).toBeFocused()
})
test('candidate: mini names use existing i18n keys and hidden controls cannot reactivate the mode', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
const get = window.art.i18n.get.bind(window.art.i18n)
const names = { 'Close': 'Localized close', 'Play': 'Localized play', 'Pause': 'Localized pause', 'Mini Player': 'Localized mini' }
window.art.i18n.get = key => names[key] || get(key)
})
await page.locator('.art-control-mini-trigger').focus()
await page.keyboard.press('Enter')
await expect(page.locator('.art-mini-popup')).toHaveAttribute('aria-label', 'Localized mini')
await expect(page.locator('.art-mini-close')).toHaveAttribute('aria-label', 'Localized close')
await expect(page.locator('.art-mini-state')).toHaveAttribute('aria-label', 'Localized play')
await page.keyboard.press('Escape')
await page.evaluate(() => {
window.miniCalls.length = 0
for (const selector of ['.art-mini-close', '.art-mini-state'])
document.querySelector(selector).dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
})
expect(await page.evaluate(() => window.miniCalls)).toEqual([])
})
+252
View File
@@ -0,0 +1,252 @@
import { expect, test } from './fixtures.js'
async function setup(page, mode = 'normal') {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate((mode) => {
window.selectorCalls = []
window.selectorClicks = []
window.selectorHotkeys = []
window.pendingSelections = {}
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, hotkey: true, fullscreenWeb: true })
window.art.on('hotkey', event => window.selectorHotkeys.push(event.code))
const selector = mode === 'empty'
? []
: mode === 'complex'
? [{ html: '<button id="nested-option">Native option</button>', value: 'native' }, { html: '<input id="nested-input" value="Editable">', value: 'input' }]
: ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel'].map((html, index) => ({ html, value: html, default: index === 1 }))
window.art.controls.add({ name: 'keyboard-selector', position: 'right', html: 'Bravo', tooltip: 'Quality', selector, click(component, event) {
window.selectorClicks.push([this === window.art, component === this.controls, event.type])
}, onSelect(item, element, event) {
window.selectorCalls.push([this === window.art, item.value, element === item.$control_item, event.type])
if (mode === 'async')
return new Promise(resolve => window.pendingSelections[item.value] = resolve)
if (mode === 'external-focus')
document.querySelector('#play').focus()
return item.html
} })
}, mode)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
const trigger = page => page.locator('.art-control-keyboard-selector .art-selector-value')
const list = page => page.locator('.art-control-keyboard-selector .art-selector-list')
const item = (page, index) => list(page).locator('.art-selector-item').nth(index)
test('candidate: selector Tab entry, focus navigation and typeahead do not commit before Enter', async ({ page }, testInfo) => {
await setup(page)
await page.locator('.art-control-playAndPause').focus()
for (let attempt = 0; attempt < 8 && !await trigger(page).evaluate(element => document.activeElement === element); attempt++)
await page.keyboard.press('Tab')
await expect(trigger(page)).toBeFocused()
await expect(trigger(page)).toHaveAttribute('aria-haspopup', 'listbox')
await expect(trigger(page)).toHaveAttribute('aria-expanded', 'false')
await expect(list(page)).toHaveAttribute('role', 'listbox')
await expect(list(page)).toHaveAttribute('aria-label', 'Quality')
await expect(list(page)).toBeHidden()
await page.keyboard.press('ArrowDown')
await expect(item(page, 1)).toBeFocused()
await expect(item(page, 1)).toHaveAttribute('aria-selected', 'true')
await page.keyboard.press('ArrowDown')
await expect(item(page, 2)).toBeFocused()
await expect(item(page, 2)).toHaveAttribute('aria-selected', 'false')
await page.keyboard.press('f')
await expect(item(page, 5)).toBeFocused()
await page.keyboard.press('Home')
await expect(item(page, 0)).toBeFocused()
await page.keyboard.press('End')
await expect(item(page, 7)).toBeFocused()
expect(await page.evaluate(() => window.selectorCalls)).toEqual([])
expect(await page.evaluate(() => window.art.currentTime)).toBe(0)
expect(await page.evaluate(() => window.art.volume)).toBe(0.7)
await page.locator('.player').screenshot({ path: testInfo.outputPath('selector-keyboard.png') })
await page.keyboard.press('Enter')
await expect(trigger(page)).toBeFocused()
await expect(trigger(page)).toHaveText('Hotel')
await expect(trigger(page)).toHaveAttribute('aria-expanded', 'false')
await expect(list(page)).toBeHidden()
await expect(item(page, 7)).toHaveAttribute('aria-selected', 'true')
await expect(item(page, 1)).toHaveAttribute('aria-selected', 'false')
expect(await page.evaluate(() => window.selectorCalls)).toEqual([[true, 'Hotel', true, 'click']])
expect(await page.evaluate(() => window.selectorHotkeys)).toEqual([])
expect(await page.locator('.art-control-keyboard-selector').getAttribute('role')).toBeNull()
})
test('candidate: selector Space commits once and Escape closes before web fullscreen exits', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
window.art.fullscreenWeb = true
})
await trigger(page).focus()
await page.keyboard.down('Space')
await page.keyboard.down('Space')
await expect(list(page)).toBeHidden()
await page.keyboard.up('Space')
await expect(item(page, 1)).toBeFocused()
await page.keyboard.press('ArrowDown')
await page.keyboard.down('Space')
await page.keyboard.down('Space')
expect(await page.evaluate(() => window.selectorCalls)).toEqual([])
await page.keyboard.up('Space')
await expect(trigger(page)).toBeFocused()
expect(await page.evaluate(() => window.selectorCalls)).toEqual([[true, 'Charlie', true, 'click']])
expect(await page.evaluate(() => window.selectorClicks)).toEqual([[true, true, 'click'], [true, true, 'click']])
await page.keyboard.press('ArrowDown')
await expect(item(page, 2)).toBeFocused()
await page.keyboard.press('Escape')
await expect(trigger(page)).toBeFocused()
await expect(list(page)).toBeHidden()
expect(await page.evaluate(() => window.art.fullscreenWeb)).toBe(true)
expect(await page.evaluate(() => window.selectorHotkeys)).toEqual([])
await page.keyboard.press('Tab')
await expect(trigger(page)).not.toBeFocused()
await expect(list(page)).toBeHidden()
})
test('candidate: keyboard selector retains asynchronous choice ownership and disposal', async ({ page }) => {
await setup(page, 'async')
await trigger(page).focus()
await page.keyboard.press('ArrowDown')
await page.keyboard.press('Home')
await page.keyboard.press('Enter')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('End')
await page.keyboard.press('Enter')
await page.evaluate(() => {
window.pendingSelections.Hotel('Latest')
window.pendingSelections.Alpha('Obsolete')
})
await expect(trigger(page)).toHaveText('Latest')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('Home')
await page.keyboard.press('Enter')
await page.evaluate(() => {
window.detachedValue = window.art.controls['keyboard-selector'].querySelector('.art-selector-value')
window.art.controls.remove('keyboard-selector')
window.pendingSelections.Alpha('After removal')
})
expect(await page.evaluate(() => window.detachedValue.textContent)).toBe('Alpha')
expect(await page.evaluate(() => {
const event = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })
window.detachedValue.dispatchEvent(event)
return event.defaultPrevented
})).toBe(false)
})
test('candidate: pointer hover and selector selection retain their existing click behavior', async ({ page }) => {
await setup(page)
await page.locator('#play').focus()
await trigger(page).hover()
await expect(list(page)).toBeVisible()
await expect(trigger(page)).toHaveAttribute('aria-expanded', 'true')
await item(page, 0).click()
await expect(trigger(page)).toHaveText('Alpha')
await expect(item(page, 0)).toHaveAttribute('aria-selected', 'true')
expect(await page.evaluate(() => window.selectorCalls)).toEqual([[true, 'Alpha', true, 'click']])
await trigger(page).focus()
await page.keyboard.press('ArrowDown')
await page.keyboard.press('Escape')
await expect(list(page)).toBeHidden()
await page.locator('#play').hover()
await trigger(page).hover()
await expect(list(page)).toBeVisible()
await expect(trigger(page)).toHaveAttribute('aria-expanded', 'true')
})
test('candidate: custom native controls retain their semantics and typing inside selector content', async ({ page }) => {
await setup(page, 'complex')
await trigger(page).focus()
await page.keyboard.press('ArrowDown')
await expect(page.locator('#nested-option')).toBeFocused()
await expect(list(page)).toHaveAttribute('role', 'group')
expect(await item(page, 0).getAttribute('role')).toBeNull()
await page.keyboard.press('Space')
expect(await page.evaluate(() => window.selectorCalls)).toEqual([[true, 'native', true, 'click']])
const currentButton = trigger(page).locator('#nested-option')
await expect(currentButton).toBeFocused()
expect(await trigger(page).getAttribute('role')).toBeNull()
expect(await currentButton.getAttribute('aria-haspopup')).toBeNull()
expect(await page.evaluate(() => window.art.video.paused)).toBe(true)
await page.keyboard.press('ArrowDown')
await expect(list(page).locator('#nested-option')).toBeFocused()
await page.locator('.art-selector-list #nested-input').focus()
await page.keyboard.press('End')
await page.keyboard.type(' text')
await expect(page.locator('.art-selector-list #nested-input')).toHaveValue('Editable text')
await page.keyboard.press('Escape')
await expect(list(page)).toBeHidden()
expect(await page.evaluate(() => window.selectorHotkeys)).toEqual([])
})
test('candidate: empty selectors are disabled and selection callbacks keep explicitly moved focus', async ({ page }) => {
await setup(page, 'empty')
await trigger(page).focus()
await expect(trigger(page)).toHaveAttribute('aria-disabled', 'true')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('Enter')
await expect(trigger(page)).toHaveAttribute('aria-expanded', 'false')
await expect(trigger(page)).toBeFocused()
expect(await page.evaluate(() => window.selectorClicks)).toEqual([])
await setup(page, 'external-focus')
await trigger(page).focus()
await page.keyboard.press('ArrowDown')
await page.keyboard.press('Enter')
await expect(page.locator('#play')).toBeFocused()
await expect(list(page)).toBeHidden()
})
test('candidate: typeahead accepts prefixes, resets after navigation and leaves modified input alone', async ({ page }) => {
await setup(page)
await trigger(page).focus()
await page.keyboard.press('ArrowDown')
await page.keyboard.type('ho')
await expect(item(page, 7)).toBeFocused()
await page.keyboard.press('Home')
await page.keyboard.press('f')
await expect(item(page, 5)).toBeFocused()
await page.keyboard.press('ArrowUp')
await page.keyboard.press('h')
await expect(item(page, 7)).toBeFocused()
await page.keyboard.press('Control+Home')
await expect(item(page, 7)).toBeFocused()
expect(await page.evaluate(() => window.selectorCalls)).toEqual([])
})
test('candidate: native trigger markup remains a single button and can open the existing popup', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
window.art.controls.update({ name: 'keyboard-selector', html: '<button id="custom-trigger">Choose quality</button>' })
})
const button = page.locator('#custom-trigger')
await expect(button).toHaveAttribute('aria-haspopup', 'listbox')
expect(await trigger(page).getAttribute('role')).toBeNull()
await button.focus()
await page.keyboard.press('Space')
await expect(item(page, 1)).toBeFocused()
expect(await page.evaluate(() => window.selectorClicks)).toEqual([[true, true, 'click']])
expect(await page.evaluate(() => window.art.video.paused)).toBe(true)
await page.keyboard.press('Escape')
await expect(button).toBeFocused()
await expect(list(page)).toBeHidden()
})
test('candidate: a dismissed hovered selector releases the next Escape to fullscreen', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
window.art.fullscreenWeb = true
})
await trigger(page).hover()
await trigger(page).focus()
await page.keyboard.press('ArrowDown')
await page.keyboard.press('Escape')
await expect(list(page)).toBeHidden()
expect(await page.evaluate(() => window.art.fullscreenWeb)).toBe(true)
await page.keyboard.press('Escape')
await expect.poll(() => page.evaluate(() => window.art.fullscreenWeb)).toBe(false)
})
test('candidate: hovering an empty selector does not announce an expanded popup', async ({ page }) => {
await setup(page, 'empty')
await trigger(page).hover()
await expect(trigger(page)).toHaveAttribute('aria-expanded', 'false')
await expect(trigger(page)).toHaveAttribute('aria-disabled', 'true')
})
+269
View File
@@ -0,0 +1,269 @@
import { expect, test } from './fixtures.js'
async function setup(page) {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => {
window.settingCalls = []
window.settingHotkeys = []
window.art = new window.Artplayer({
container: '.player',
url: '/test/pattern.mp4',
muted: true,
hotkey: true,
setting: true,
fullscreenWeb: true,
playbackRate: false,
aspectRatio: false,
flip: false,
subtitleOffset: false,
settings: [
{ name: 'quality', html: 'Quality', tooltip: 'Auto', selector: [
{ name: 'auto', html: 'Auto', default: true },
{ name: 'hd', html: 'HD' },
{ name: 'more', html: 'More', selector: [{ name: 'ultra', html: 'Ultra' }, { name: 'cinema', html: 'Cinema' }] },
], onSelect(item, element, event) {
window.settingCalls.push(['select', item.name, this === window.art, element === item.$item, event.type])
return item.html
} },
{ name: 'loop', html: 'Loop', switch: false, onSwitch(item, element, event) {
window.settingCalls.push(['switch', this === window.art, element === item.$item, event.type])
return !item.switch
} },
{ name: 'offset', html: 'Offset', range: [0, -10, 10, 1], onChange(item, element, event) {
window.settingCalls.push(['input', item.range[0], this === window.art, element === item.$item, event.type])
return `${item.range[0]}s`
}, onRange(item, element, event) {
window.settingCalls.push(['change', item.range[0], this === window.art, element === item.$item, event.type])
return `${item.range[0]}s`
} },
{ name: 'action', html: 'Action', onClick(item, element, event) {
window.settingCalls.push(['action', this === window.art, element === item.$item, event.type])
return 'Clicked'
} },
],
})
window.art.on('hotkey', event => window.settingHotkeys.push(event.code))
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.locator('.art-control-setting').focus()
}
const row = (page, name) => page.locator(`.art-setting-item[data-name="${name}"]`)
const button = page => page.locator('.art-control-setting')
for (const unavailable of ['removed', 'hidden', 'disabled']) {
test(`candidate: setting exit falls back to the player when its button is ${unavailable}`, async ({ page }) => {
await setup(page)
await page.keyboard.press('Enter')
await expect(row(page, 'quality')).toBeFocused()
await page.evaluate((unavailable) => {
if (unavailable === 'removed')
window.art.controls.remove('setting')
else if (unavailable === 'hidden')
window.art.controls.setting.style.visibility = 'hidden'
else
window.art.controls.setting.setAttribute('aria-disabled', 'true')
}, unavailable)
await page.keyboard.press('Escape')
await expect(page.locator('.art-settings')).toHaveCount(1)
await expect(page.locator('.art-settings')).toBeHidden()
await expect(page.locator('.art-video-player')).toBeFocused()
})
}
test('candidate: setting navigation ignores hidden rows and inert descendants', async ({ page }) => {
await setup(page)
await page.keyboard.press('Enter')
await page.evaluate(() => {
window.art.setting.find('loop').$item.style.visibility = 'hidden'
window.art.setting.find('offset').$item.setAttribute('inert', '')
})
await page.keyboard.press('ArrowDown')
await expect(row(page, 'action')).toBeFocused()
await page.keyboard.press('ArrowUp')
await expect(row(page, 'quality')).toBeFocused()
})
test('candidate: setting keyboard enters nested panels and returns to the originating item', async ({ page }, testInfo) => {
await setup(page)
await page.evaluate(() => {
window.art.fullscreenWeb = true
})
await expect(button(page)).toBeFocused()
await page.keyboard.press('Enter')
await expect(row(page, 'quality')).toBeFocused()
await expect(button(page)).toHaveAttribute('aria-expanded', 'true')
await expect(page.locator('.art-setting-panel.art-current')).toHaveAttribute('aria-label', 'Settings')
await page.keyboard.press('ArrowRight')
await expect(row(page, 'auto')).toBeFocused()
await expect(row(page, 'auto')).toHaveAttribute('aria-current', 'true')
await page.keyboard.press('ArrowDown')
await expect(row(page, 'hd')).toBeFocused()
expect(await page.evaluate(() => window.settingCalls)).toEqual([])
await page.keyboard.press('Enter')
await expect(row(page, 'quality')).toBeFocused()
expect(await page.evaluate(() => window.settingCalls)).toEqual([['select', 'hd', true, true, 'click']])
await expect(row(page, 'quality')).toContainText('HD')
await page.keyboard.press('ArrowRight')
await expect(row(page, 'hd')).toBeFocused()
await expect(row(page, 'hd')).toHaveAttribute('aria-current', 'true')
await page.keyboard.press('End')
await expect(row(page, 'more')).toBeFocused()
await page.keyboard.press('ArrowRight')
await expect(row(page, 'ultra')).toBeFocused()
await page.keyboard.press('ArrowLeft')
await expect(row(page, 'more')).toBeFocused()
await page.keyboard.press('Home')
await expect(page.locator('.art-setting-panel.art-current .art-setting-item-back')).toBeFocused()
await expect(page.locator('.art-setting-panel.art-current .art-setting-item-back')).toHaveAttribute('aria-label', 'Back: Quality')
await page.keyboard.press('Space')
await expect(row(page, 'quality')).toBeFocused()
await page.locator('.art-video-player').screenshot({ path: testInfo.outputPath('setting-keyboard.png') })
await page.keyboard.press('Escape')
await expect(button(page)).toBeFocused()
await expect(button(page)).toHaveAttribute('aria-expanded', 'false')
expect(await page.evaluate(() => window.art.fullscreenWeb)).toBe(true)
expect(await page.evaluate(() => window.settingHotkeys)).toEqual([])
await page.keyboard.press('Escape')
await expect.poll(() => page.evaluate(() => window.art.fullscreenWeb)).toBe(false)
})
test('candidate: setting switches and native ranges keep callbacks and avoid player shortcuts', async ({ page }) => {
await setup(page)
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await expect(row(page, 'loop')).toBeFocused()
await expect(row(page, 'loop')).toHaveAttribute('role', 'switch')
await expect(row(page, 'loop')).toHaveAttribute('aria-checked', 'false')
await page.keyboard.down('Space')
await page.keyboard.down('Space')
await page.keyboard.up('Space')
await expect(row(page, 'loop')).toHaveAttribute('aria-checked', 'true')
expect(await page.evaluate(() => window.settingCalls)).toEqual([['switch', true, true, 'click']])
await page.keyboard.press('Tab')
const range = row(page, 'offset').locator('input')
await expect(range).toBeFocused()
await expect(range).toHaveAttribute('aria-label', 'Offset')
await page.keyboard.press('ArrowRight')
await expect(range).toHaveValue('1')
await expect.poll(() => page.evaluate(() => window.settingCalls.filter(item => ['input', 'change'].includes(item[0])))).toEqual([
['input', 1, true, true, 'input'],
['change', 1, true, true, 'change'],
])
await page.evaluate(() => {
window.art.setting.find('offset').html = 'Subtitle delay'
})
await expect(range).toHaveAttribute('aria-label', 'Subtitle delay')
expect(await page.evaluate(() => [window.art.currentTime, window.art.volume, window.art.video.paused])).toEqual([0, 0.7, true])
expect(await page.evaluate(() => window.settingHotkeys)).toEqual([])
await page.keyboard.press('Escape')
await expect(button(page)).toBeFocused()
})
test('candidate: setting Tab leaves without trapping focus and pointer opening keeps its old focus path', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
window.settingFocusOrder = []
window.art.template.$setting.addEventListener('focusin', event => window.settingFocusOrder.push(event.target.dataset.name))
})
await page.keyboard.press('ArrowUp')
await expect(row(page, 'action')).toBeFocused()
expect(await page.evaluate(() => window.settingFocusOrder)).toEqual(['action'])
await page.keyboard.press('Tab')
await expect.poll(() => page.evaluate(() => window.art.setting.show)).toBe(false)
await expect(row(page, 'action')).not.toBeFocused()
await button(page).click()
await expect.poll(() => page.evaluate(() => window.art.setting.show)).toBe(true)
await expect(row(page, 'quality')).not.toBeFocused()
await row(page, 'action').click()
expect(await page.evaluate(() => window.settingCalls)).toEqual([['action', true, true, 'click']])
})
test('candidate: setting update, failed replacement and removal recover visible focus', async ({ page }) => {
await setup(page)
await page.keyboard.press('ArrowUp')
await expect(row(page, 'action')).toBeFocused()
await page.evaluate(() => window.art.setting.update({ name: 'action', html: 'Updated' }))
await expect(row(page, 'action')).toBeFocused()
await expect(row(page, 'action')).toHaveText('Updated')
await page.evaluate(() => {
const setting = window.art.setting
const create = setting.createItem
setting.createItem = function (...args) {
create.apply(this, args)
throw new Error('forced keyboard replacement failure')
}
try {
setting.update({ name: 'action', html: 'Failed' })
}
catch (error) {
window.settingUpdateFailure = error.message
}
finally {
setting.createItem = create
}
})
expect(await page.evaluate(() => window.settingUpdateFailure)).toBe('forced keyboard replacement failure')
await expect(row(page, 'action')).toBeFocused()
await expect(row(page, 'action')).toHaveText('Updated')
await page.keyboard.press('Enter')
expect(await page.evaluate(() => window.settingCalls)).toEqual([['action', true, true, 'click']])
await page.evaluate(() => window.art.setting.remove('action'))
await expect(row(page, 'quality')).toBeFocused()
})
test('candidate: suspended settings ignore keyboard callbacks and restored items resume', async ({ page }) => {
await setup(page)
await page.keyboard.press('ArrowUp')
await page.evaluate(() => {
const target = { name: 'action' }
Object.defineProperty(target, 'html', { enumerable: true, get() {
const item = window.art.setting.find('action').$item
item.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
item.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }))
throw new Error('resume old keyboard item')
} })
try {
window.art.setting.update(target)
}
catch {}
})
expect(await page.evaluate(() => window.settingCalls)).toEqual([])
expect(await page.evaluate(() => window.art.currentTime)).toBe(0)
await expect(row(page, 'action')).toBeFocused()
await page.keyboard.press('Enter')
expect(await page.evaluate(() => window.settingCalls)).toEqual([['action', true, true, 'click']])
})
test('candidate: native setting buttons retain their role, click path and keyboard navigation', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
window.art.setting.add({ name: 'native', html: '<button id="native-setting">Native action</button>', onClick(item, element, event) {
window.settingCalls.push(['native', this === window.art, element === item.$item, event.type])
return 'Done'
} })
})
await page.keyboard.press('ArrowUp')
const native = page.locator('#native-setting')
await expect(native).toBeFocused()
expect(await row(page, 'native').getAttribute('role')).toBeNull()
await page.keyboard.press('Space')
expect(await page.evaluate(() => window.settingCalls)).toEqual([['native', true, true, 'click']])
expect(await page.evaluate(() => window.art.video.paused)).toBe(true)
await page.keyboard.press('ArrowUp')
await expect(row(page, 'action')).toBeFocused()
expect(await page.evaluate(() => window.settingHotkeys)).toEqual([])
})
test('candidate: destroying during moved focus restoration cancels fullscreen notifications', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
window.modeEvents = []
window.art.on('fullscreenWeb', value => window.modeEvents.push(['fullscreenWeb', value]))
window.art.on('resize', () => window.modeEvents.push(['resize']))
window.art.controls.setting.addEventListener('focus', () => window.art.destroy(false), { once: true })
window.art.fullscreenWeb = true
})
expect(await page.evaluate(() => window.art.isDestroy)).toBe(true)
expect(await page.evaluate(() => window.modeEvents)).toEqual([])
})
+155
View File
@@ -0,0 +1,155 @@
import { expect, test } from './fixtures.js'
async function setup(page, options = {}) {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate((options) => {
const before = document.createElement('button')
before.id = 'before-player'
before.textContent = 'Before player'
document.body.prepend(before)
window.Artplayer.SEEK_STEP = 0.5
window.Artplayer.VOLUME_STEP = 0.05
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, hotkey: true, ...options })
window.sliderEvents = []
window.art.on('hotkey', event => window.sliderEvents.push(['hotkey', event.code]))
window.art.on('seek', (actual, value) => window.sliderEvents.push(['seek', value]))
window.art.on('setBar', (kind, ratio, event) => {
if (kind === 'played')
window.sliderEvents.push(['setBar', ratio, event?.type ?? null])
})
}, options)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.locator('#before-player').focus()
await page.keyboard.press('Tab')
if (await page.locator('.art-video').evaluate(element => element.ownerDocument.activeElement === element))
await page.keyboard.press('Tab')
await expect(page.locator('.art-control-progress')).toBeFocused()
}
test('candidate: progress is the first control, seeks with owned keys and follows actual media time', async ({ page }) => {
await setup(page)
const slider = page.locator('.art-control-progress')
await expect(slider).toHaveAttribute('role', 'slider')
await expect(slider).toHaveAttribute('aria-label', 'Progress')
await expect(slider).toHaveAttribute('aria-disabled', 'false')
await page.evaluate(() => {
window.art.currentTime = 1
})
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeCloseTo(1, 1)
await expect.poll(() => page.evaluate(() => window.art.video.seeking)).toBe(false)
await expect.poll(() => page.evaluate(() => Math.abs(Number(document.querySelector('.art-control-progress').getAttribute('aria-valuenow')) - window.art.currentTime))).toBeLessThan(0.001)
const start = await page.evaluate(() => window.art.currentTime)
await page.evaluate(() => {
window.sliderEvents.length = 0
})
await page.keyboard.press('ArrowRight')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeCloseTo(start + 0.5, 1)
await expect.poll(() => page.evaluate(() => Math.abs(Number(document.querySelector('.art-control-progress').getAttribute('aria-valuenow')) - window.art.currentTime))).toBeLessThan(0.001)
const events = await page.evaluate(() => window.sliderEvents)
expect(events.slice(0, 2)).toEqual([['setBar', (start + 0.5) / await page.evaluate(() => window.art.duration), null], ['seek', start + 0.5]])
await page.keyboard.press('ArrowUp')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeCloseTo(2, 1)
expect(await page.evaluate(() => window.art.volume)).toBe(0.7)
await page.keyboard.press('Home')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeCloseTo(0, 1)
await page.keyboard.press('End')
await expect.poll(() => page.evaluate(() => Math.abs(window.art.currentTime - window.art.duration))).toBeLessThan(0.1)
await page.keyboard.press('PageDown')
try {
await expect.poll(() => page.evaluate(() => Math.abs(window.art.currentTime - Math.max(0, window.art.duration - 5)))).toBeLessThan(0.1)
}
finally {
await test.info().attach('slider-after-pagedown', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({
events: window.sliderEvents,
focus: document.activeElement?.className,
currentTime: window.art.currentTime,
duration: window.art.duration,
seeking: window.art.video.seeking,
ended: window.art.video.ended,
paused: window.art.video.paused,
}))) })
}
expect(await page.evaluate(() => window.sliderEvents.filter(event => event[0] === 'hotkey'))).toEqual([])
await expect(slider).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.locator('.art-control-playAndPause')).toBeFocused()
})
test('candidate: volume panel opens from keyboard focus and the slider unmutes without seeking', async ({ page }) => {
await setup(page)
await page.keyboard.press('Tab')
await page.keyboard.press('Tab')
await expect(page.locator('.art-icon-volumeClose')).toBeFocused()
const slider = page.locator('.art-volume-slider')
await expect(slider).toBeVisible()
await expect.poll(() => page.locator('.art-volume-panel').evaluate(element => getComputedStyle(element).opacity)).toBe('1')
await page.keyboard.press('Tab')
await expect(slider).toBeFocused()
await expect(slider).toHaveAttribute('aria-label', 'Volume')
await expect(slider).toHaveAttribute('aria-orientation', 'vertical')
await expect(slider).toHaveAttribute('aria-valuenow', '0')
await page.keyboard.press('ArrowUp')
await expect.poll(() => page.evaluate(() => window.art.volume)).toBeCloseTo(0.05, 5)
expect(await page.evaluate(() => window.art.muted)).toBe(false)
await expect(slider).toHaveAttribute('aria-valuetext', '5%')
await page.keyboard.press('PageUp')
await expect.poll(() => page.evaluate(() => window.art.volume)).toBeCloseTo(0.55, 5)
await page.keyboard.press('End')
await expect.poll(() => page.evaluate(() => window.art.volume)).toBe(1)
await page.keyboard.press('Home')
await expect.poll(() => page.evaluate(() => window.art.volume)).toBe(0)
expect(await page.evaluate(() => window.art.currentTime)).toBe(0)
expect(await page.evaluate(() => window.sliderEvents.filter(event => ['seek', 'hotkey'].includes(event[0])))).toEqual([])
await expect(slider).toBeFocused()
await page.locator('#before-player').focus()
await expect.poll(() => page.locator('.art-volume-panel').evaluate(element => getComputedStyle(element).visibility)).toBe('hidden')
})
test('candidate: unknown duration disables progress until metadata returns and labels use i18n', async ({ page }) => {
await setup(page, { lang: 'zh-cn' })
const slider = page.locator('.art-control-progress')
await expect(slider).toHaveAttribute('aria-label', '播放进度')
await page.evaluate(() => {
Object.defineProperty(window.art.video, 'duration', { configurable: true, value: Infinity })
window.art.video.dispatchEvent(new Event('durationchange'))
window.sliderEvents.length = 0
})
await expect(slider).toHaveAttribute('aria-disabled', 'true')
await expect(slider).toHaveAttribute('aria-valuemax', '0')
await page.keyboard.press('End')
await page.keyboard.press('ArrowRight')
expect(await page.evaluate(() => window.sliderEvents)).toEqual([])
await page.evaluate(() => {
delete window.art.video.duration
window.art.video.dispatchEvent(new Event('durationchange'))
})
await expect(slider).toHaveAttribute('aria-disabled', 'false')
await page.keyboard.press('ArrowRight')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeCloseTo(0.5, 1)
})
test('candidate: progress removal and volume destruction inside callbacks cancel pending writes', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
const remove = (kind, ratio) => {
if (kind === 'played' && ratio > 0) {
window.art.off('setBar', remove)
window.art.controls.remove('progress')
}
}
window.art.on('setBar', remove)
window.sliderEvents.length = 0
})
await page.keyboard.press('ArrowRight')
expect(await page.evaluate(() => window.art.currentTime)).toBe(0)
expect(await page.evaluate(() => window.sliderEvents.filter(event => event[0] === 'seek'))).toEqual([])
await page.locator('.art-icon-volumeClose').focus()
await page.keyboard.press('Tab')
await expect(page.locator('.art-volume-slider')).toBeFocused()
await page.evaluate(() => {
window.savedVideo = window.art.video
window.art.once('muted', () => window.art.destroy(false))
})
await page.keyboard.press('ArrowUp')
expect(await page.evaluate(() => window.savedVideo.volume)).toBe(0.7)
})
+161
View File
@@ -0,0 +1,161 @@
import { expect, test } from './fixtures.js'
async function setup(page, core = 'candidate') {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
const before = document.createElement('button')
before.id = 'before-player'
before.textContent = 'Before player'
document.body.prepend(before)
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, hotkey: true, setting: true, fullscreenWeb: true, screenshot: true })
window.keyboardHits = []
window.art.on('hotkey', event => window.keyboardHits.push(event.code))
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.locator('#before-player').focus()
}
async function tabPastNativeVideo(page) {
await page.keyboard.press('Tab')
// Firefox includes the native video in its existing sequential focus order.
if (await page.locator('.art-video').evaluate(element => element.ownerDocument.activeElement === element))
await page.keyboard.press('Tab')
}
async function tabToPlay(page) {
await tabPastNativeVideo(page)
await expect(page.locator('.art-control-progress')).toBeFocused()
await page.keyboard.press('Tab')
}
test('published: Tab skips the non-interactive main controls', async ({ page }) => {
await setup(page, 'published')
await tabPastNativeVideo(page)
await expect(page.locator('#play')).toBeFocused()
expect(await page.locator('.art-controls [tabindex="0"]').count()).toBe(0)
})
test('candidate: keyboard reaches stable play control and toggles once with focus retained', async ({ page }) => {
await setup(page)
await tabToPlay(page)
const play = page.locator('.art-control-playAndPause')
await expect(play).toBeFocused()
await expect(play).toHaveAttribute('role', 'button')
await expect(play).toHaveAttribute('aria-label', 'Play')
await page.keyboard.press('Enter')
await expect.poll(() => page.evaluate(() => window.art.playing && window.art.currentTime > 0.1)).toBe(true)
await expect(play).toBeFocused()
await expect(play).toHaveAttribute('aria-label', 'Pause')
await page.keyboard.down('Space')
expect(await page.evaluate(() => window.art.playing)).toBe(true)
await page.keyboard.up('Space')
await expect.poll(() => page.evaluate(() => window.art.video.paused)).toBe(true)
await expect(play).toBeFocused()
expect(await page.evaluate(() => window.keyboardHits)).toEqual([])
await page.keyboard.press('Tab')
await expect(page.locator('.art-control-volume .art-icon-volumeClose')).toBeFocused()
})
test('candidate: mute state, focus visibility and automatic hiding follow keyboard focus', async ({ page }) => {
await setup(page)
await tabToPlay(page)
await page.keyboard.press('Enter')
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
await page.evaluate(() => {
window.Artplayer.CONTROL_HIDE_TIME = 0
})
await page.keyboard.press('Tab')
const muted = page.locator('.art-control-volume .art-icon-volumeClose')
const audible = page.locator('.art-control-volume .art-icon-volume')
await expect(muted).toBeFocused()
await expect(muted).toHaveAttribute('aria-label', 'Mute')
await expect(muted).toHaveAttribute('aria-pressed', 'true')
await page.keyboard.press('Space')
await expect(audible).toBeFocused()
await expect(audible).toHaveAttribute('aria-pressed', 'false')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.5)
expect(await page.evaluate(() => window.art.controls.show)).toBe(true)
expect(await audible.evaluate(element => getComputedStyle(element).outlineStyle)).not.toBe('none')
expect(await page.evaluate(() => window.keyboardHits)).toEqual([])
await page.locator('#before-player').focus()
await expect.poll(() => page.evaluate(() => window.art.controls.show)).toBe(false)
expect(await page.evaluate(() => window.art.isFocus)).toBe(false)
})
test('candidate: custom control keyboard clicks retain the callback and release on replacement', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
const art = window.art
window.customHits = []
art.controls.add({ name: 'keyboard-custom', position: 'right', html: 'Custom', click(component, event) {
window.customHits.push({ same: this === art, component: component === art.controls, type: event.type })
} })
})
const control = page.locator('.art-control-keyboard-custom')
await control.focus()
await page.keyboard.press('Enter')
await page.keyboard.down('Space')
await page.keyboard.down('Space')
await page.keyboard.up('Space')
expect(await page.evaluate(() => window.customHits)).toEqual([
{ same: true, component: true, type: 'click' },
{ same: true, component: true, type: 'click' },
])
await page.keyboard.down('Space')
await page.evaluate(() => {
const art = window.art
window.oldControl = art.controls['keyboard-custom']
art.controls.update({ name: 'keyboard-custom', html: 'Replaced' })
})
await page.keyboard.up('Space')
await page.evaluate(() => {
window.oldControl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
})
expect(await page.evaluate(() => window.customHits.length)).toBe(2)
await control.focus()
await page.keyboard.press('Enter')
expect(await page.evaluate(() => window.customHits.length)).toBe(3)
await page.evaluate(() => window.art.destroy(false))
expect(await page.evaluate(() => {
window.oldControl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
return window.customHits.length
})).toBe(3)
})
test('candidate: native buttons inside custom controls retain activation without player hotkeys', async ({ page }) => {
await setup(page)
await page.evaluate(() => {
const art = window.art
window.nativeButtonHits = 0
window.parentButtonHits = 0
const button = document.createElement('button')
button.textContent = 'Native action'
button.addEventListener('click', () => window.nativeButtonHits++)
art.controls.add({ name: 'native-action', position: 'right', html: button, click() {
window.parentButtonHits++
} })
art.hotkey.add('KeyK', () => window.keyboardHits.push('custom-k'))
})
const parent = page.locator('.art-control-native-action')
const button = parent.locator('button')
await expect(parent).not.toHaveAttribute('role', 'button')
await button.focus()
await page.keyboard.press('Space')
await page.keyboard.press('Enter')
expect(await page.evaluate(() => ({ native: window.nativeButtonHits, parent: window.parentButtonHits, paused: window.art.video.paused, hotkeys: window.keyboardHits }))).toEqual({ native: 2, parent: 2, paused: true, hotkeys: [] })
await page.keyboard.press('k')
expect(await page.evaluate(() => window.keyboardHits)).toEqual(['custom-k', 'KeyK'])
})
test('candidate: pointer focus does not prevent the existing automatic control hiding', async ({ page }) => {
await setup(page)
// Start a pointer interaction after setup's programmatic focus.
await page.locator('.art-control-playAndPause .art-icon-play').click()
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
await page.evaluate(() => {
window.Artplayer.CONTROL_HIDE_TIME = 0
})
await page.mouse.move(900, 650)
await expect.poll(() => page.evaluate(() => window.art.controls.show)).toBe(false)
await expect.poll(() => page.locator('.art-bottom').evaluate(element => getComputedStyle(element).opacity)).toBe('0')
})