mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-22 05:11:20 -08:00
refactor(core): [CORE-23] complete keyboard controls and focus lifecycle
This commit is contained in:
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)
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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]])
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
Reference in new issue
Block a user