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: '', 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: '' }))
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)
})