mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-23 13:51:20 -08:00
test(dpip): [PKG-DPIP-02] reproduce window races and DOM rollback failures
This commit is contained in:
1 parent
efbbdb4782
commit
202351934b
13 files changed
+574
-13
No files matched your search
@@ -54,3 +54,5 @@ These tests are separate from the fast Node suite and run in the Browser playbac
|
||||
formats with a controlled Audio object: timing intent, offsets, source updates, rejection,
|
||||
volume/rate, independent instances and cleanup. Published-only tests retain defect evidence.
|
||||
See [audio validation](../refactor/audio-validation.md) for actual media tests and limitations.
|
||||
|
||||
Document PiP: `yarn test:dpip` runs 48 historical window/DOM/lifecycle cases. Native DOM iframe checks use `yarn test:browser test/browser/dpip.spec.js`; controlled window APIs do not establish native Document PiP support. See [validation notes](../refactor/dpip-validation.md).
|
||||
@@ -0,0 +1,90 @@
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { dpipHistorical } from '../helpers/dpip.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const implementations = await dpipHistorical()
|
||||
|
||||
async function setup(page, core, implementation, testInfo) {
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
await page.evaluate(() => {
|
||||
window.dpipEvidence = { nativeCapability: typeof window.documentPictureInPicture?.requestWindow === 'function', scope: 'real DOM iframe with controlled requestWindow; not native Document PiP', windows: [], requests: [], events: [] }
|
||||
Object.defineProperty(window, 'documentPictureInPicture', { configurable: true, value: {
|
||||
requestWindow(option) {
|
||||
return new Promise((resolve) => {
|
||||
window.dpipEvidence.requests.push({ option, resolve })
|
||||
})
|
||||
},
|
||||
} })
|
||||
window.resolveDpip = (index) => {
|
||||
const frame = document.createElement('iframe')
|
||||
frame.width = '640'
|
||||
frame.height = '360'
|
||||
frame.style.cssText = 'display:block;border:0;width:640px;height:360px'
|
||||
document.body.appendChild(frame)
|
||||
const popup = frame.contentWindow
|
||||
const record = { frame, popup, closed: false }
|
||||
Object.defineProperty(popup, 'close', { configurable: true, value: () => {
|
||||
record.closed = true
|
||||
} })
|
||||
window.dpipEvidence.windows.push(record)
|
||||
window.dpipEvidence.requests[index].resolve(popup)
|
||||
}
|
||||
})
|
||||
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${implementation.code}; window.dpipFactory = module.exports.default || module.exports; })();` })
|
||||
await page.evaluate(() => {
|
||||
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, plugins: [window.dpipFactory()] })
|
||||
window.dpip = window.art.plugins.artplayerPluginDocumentPip
|
||||
window.dpipPlayer = window.art.template.$player
|
||||
window.dpipVideo = window.art.video
|
||||
window.dpipParent = window.dpipPlayer.parentNode
|
||||
window.art.on('document-pip', active => window.dpipEvidence.events.push(active))
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.readyState)).toBeGreaterThanOrEqual(2)
|
||||
await testInfo.attach('dpip-input', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), core, ...(await page.evaluate(() => ({ capability: window.dpipEvidence.nativeCapability, scope: window.dpipEvidence.scope }))) }) })
|
||||
}
|
||||
|
||||
for (const implementation of implementations) {
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`Document PiP ${implementation.name}/${core}: real DOM migration and pagehide restore preserve media and controls`, async ({ page }, testInfo) => {
|
||||
await setup(page, core, implementation, testInfo)
|
||||
await page.evaluate(() => {
|
||||
window.dpipOpening = window.dpip.open()
|
||||
window.resolveDpip(0)
|
||||
})
|
||||
await page.evaluate(() => window.dpipOpening)
|
||||
await expect.poll(() => page.evaluate(() => window.dpipVideo.videoWidth)).toBe(testInfo.project.name === 'webkit' ? 640 : 320)
|
||||
expect(await page.evaluate(() => ({ active: window.dpip.isActive, adopted: window.dpipPlayer.ownerDocument === window.dpipEvidence.windows[0].popup.document, videoIdentity: window.dpipVideo === window.art.video, placeholder: window.dpipParent.querySelectorAll('.artplayer-document-pip-placeholder').length, width: window.dpipVideo.videoWidth }))).toEqual({ active: true, adopted: true, videoIdentity: true, placeholder: 1, width: testInfo.project.name === 'webkit' ? 640 : 320 })
|
||||
await page.evaluate(() => {
|
||||
const popup = window.dpipEvidence.windows[0].popup
|
||||
popup.dispatchEvent(new popup.Event('pagehide'))
|
||||
popup.dispatchEvent(new popup.Event('unload'))
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.dpip.isActive)).toBe(false)
|
||||
expect(await page.evaluate(() => ({ restored: window.dpipPlayer.parentNode === window.dpipParent && window.dpipPlayer.ownerDocument === document, videoIdentity: window.art.video === window.dpipVideo, placeholders: document.querySelectorAll('.artplayer-document-pip-placeholder').length, closed: window.dpipEvidence.windows[0].closed, events: window.dpipEvidence.events }))).toEqual({ restored: true, videoIdentity: true, placeholders: 0, closed: true, events: [true, false] })
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test(`Document PiP ${implementation.name}/${core}: historical pending close and moved placeholder fail in real DOM`, async ({ page }, testInfo) => {
|
||||
await setup(page, core, implementation, testInfo)
|
||||
await page.evaluate(async () => {
|
||||
window.dpipOpening = window.dpip.open()
|
||||
await window.dpip.close()
|
||||
window.resolveDpip(0)
|
||||
await window.dpipOpening
|
||||
})
|
||||
expect(await page.evaluate(() => window.dpip.isActive)).toBe(true)
|
||||
await page.evaluate(async () => {
|
||||
document.body.appendChild(document.querySelector('.artplayer-document-pip-placeholder'))
|
||||
const closing = window.dpip.close()
|
||||
window.dpipFailureSnapshot = { active: window.dpip.isActive, detached: window.dpipPlayer.parentNode === null, closed: window.dpipEvidence.windows[0].closed, notice: window.art.template.$noticeInner.textContent }
|
||||
await closing
|
||||
})
|
||||
expect(await page.evaluate(() => window.dpipFailureSnapshot)).toEqual({ active: true, detached: true, closed: false, notice: 'Document Picture-in-Picture close failed' })
|
||||
await page.evaluate(async () => {
|
||||
window.dpipParent.appendChild(document.querySelector('.artplayer-document-pip-placeholder'))
|
||||
await window.dpip.close()
|
||||
window.art.destroy()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Frozen historical failures remain separate from candidate regression tests.
|
||||
import test from 'node:test'
|
||||
import { dpipEnvironment, dpipHistorical } from './helpers/dpip.js'
|
||||
|
||||
function deferred() {
|
||||
let resolve
|
||||
let reject
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes
|
||||
reject = no
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
for (const implementation of await dpipHistorical()) {
|
||||
const label = implementation.name
|
||||
|
||||
test(`Document PiP ${label}: rejection retains notice and node identity; retry and pagehide restore once`, async () => {
|
||||
let reject = true
|
||||
const error = new Error('request denied')
|
||||
const env = dpipEnvironment(implementation, { requestWindow: (_, create) => reject ? Promise.reject(error) : Promise.resolve(create()) })
|
||||
const result = env.factory()(env.art)
|
||||
assert.equal(await result.open(), undefined)
|
||||
assert.equal(result.isActive, false)
|
||||
assert.equal(env.player.parentNode, env.parent)
|
||||
assert.equal(env.warnings[0][1], error)
|
||||
assert.match(env.art.notice.show, /open failed/)
|
||||
reject = false
|
||||
await result.open()
|
||||
const win = env.windows[0]
|
||||
win.dispatchEvent({ type: 'pagehide' })
|
||||
win.dispatchEvent({ type: 'unload' })
|
||||
await env.flush()
|
||||
assert.equal(result.isActive, false)
|
||||
assert.equal(win.closed, true)
|
||||
assert.deepEqual(env.parent.children, [env.player, env.sibling])
|
||||
assert.deepEqual(env.emitted.filter(event => event.name === 'document-pip').map(event => event.args[0]), [true, false])
|
||||
assert.equal([...win.listeners.values()].reduce((sum, set) => sum + set.size, 0), 0)
|
||||
})
|
||||
|
||||
test(`Document PiP ${label}: disabled fallback reports an open failure and throwing video fallback rejects unchanged`, async () => {
|
||||
const env = dpipEnvironment(implementation, { supported: false })
|
||||
const result = env.factory({ fallbackToVideoPiP: false })(env.art)
|
||||
await result.open()
|
||||
assert.equal(env.art.pip, false)
|
||||
assert.match(env.art.notice.show, /open failed/)
|
||||
assert.equal(env.requests.length, 0)
|
||||
const fallback = dpipEnvironment(implementation, { supported: false })
|
||||
const error = new Error('video PiP denied')
|
||||
Object.defineProperty(fallback.art, 'pip', { get() {
|
||||
return false
|
||||
}, set() {
|
||||
throw error
|
||||
} })
|
||||
await assert.rejects(fallback.factory()(fallback.art).open(), value => value === error)
|
||||
assert.equal(fallback.warnings.length, 0)
|
||||
})
|
||||
|
||||
test(`Document PiP ${label}: close cannot cancel a pending request and late resolution reopens`, async () => {
|
||||
const pending = deferred()
|
||||
const env = dpipEnvironment(implementation, { requestWindow: () => pending.promise })
|
||||
const result = env.factory()(env.art)
|
||||
const opening = result.open()
|
||||
await result.close()
|
||||
assert.equal(result.isActive, false)
|
||||
pending.resolve(env.createWindow())
|
||||
await opening
|
||||
assert.equal(result.isActive, true, 'Historical defect: close lost to pending open')
|
||||
assert.notEqual(env.player.ownerDocument, env.document)
|
||||
await result.close()
|
||||
})
|
||||
|
||||
test(`Document PiP ${label}: destroy does not revoke pending requests or escaped open methods`, async () => {
|
||||
const pending = deferred()
|
||||
const env = dpipEnvironment(implementation, { requestWindow: () => pending.promise })
|
||||
const result = env.factory()(env.art)
|
||||
const opening = result.open()
|
||||
env.art.destroy()
|
||||
pending.resolve(env.createWindow())
|
||||
await opening
|
||||
assert.equal(env.art.isDestroy, true)
|
||||
assert.equal(result.isActive, true, 'Historical defect: late window takes a destroyed player')
|
||||
await result.close()
|
||||
await result.open()
|
||||
assert.equal(env.requests.length, 2, 'Historical escaped method starts another request')
|
||||
assert.equal(result.isActive, true)
|
||||
await result.close()
|
||||
})
|
||||
|
||||
test(`Document PiP ${label}: concurrent opens leak the first window and restore into that window`, async () => {
|
||||
const first = deferred()
|
||||
const second = deferred()
|
||||
let request = 0
|
||||
const env = dpipEnvironment(implementation, { requestWindow: () => request++ === 0 ? first.promise : second.promise })
|
||||
const result = env.factory()(env.art)
|
||||
const a = result.open()
|
||||
const b = result.open()
|
||||
const firstWindow = env.createWindow()
|
||||
const secondWindow = env.createWindow()
|
||||
first.resolve(firstWindow)
|
||||
await a
|
||||
second.resolve(secondWindow)
|
||||
await b
|
||||
assert.equal(env.requests.length, 2)
|
||||
await result.close()
|
||||
assert.equal(result.isActive, false)
|
||||
assert.equal(firstWindow.closed, false)
|
||||
assert.equal(secondWindow.closed, true)
|
||||
assert.equal(env.player.ownerDocument, firstWindow.document, 'Historical restore target was overwritten by the second open')
|
||||
assert.equal(firstWindow.listeners.get('pagehide').size, 1)
|
||||
assert.equal(env.parent.children[0].className, 'artplayer-document-pip-placeholder')
|
||||
})
|
||||
|
||||
test(`Document PiP ${label}: delayed open and close resize effects survive terminal destroy`, async () => {
|
||||
const delays = []
|
||||
const env = dpipEnvironment(implementation, { sleep: () => {
|
||||
const pending = deferred()
|
||||
delays.push(pending)
|
||||
return pending.promise
|
||||
} })
|
||||
const result = env.factory()(env.art)
|
||||
const opening = result.open()
|
||||
await env.flush()
|
||||
assert.equal(delays.length, 1)
|
||||
env.art.destroy()
|
||||
assert.equal(delays.length, 2)
|
||||
const before = env.emitted.length
|
||||
for (const delay of delays) delay.resolve()
|
||||
await opening
|
||||
await env.flush()
|
||||
assert.deepEqual(env.emitted.slice(before).map(event => event.name), ['resize', 'resize'])
|
||||
assert.equal(result.isActive, false)
|
||||
})
|
||||
|
||||
test(`Document PiP ${label}: destroy during activation leaves a later resize and retained plugin subscriptions`, async () => {
|
||||
const env = dpipEnvironment(implementation)
|
||||
const result = env.factory()(env.art)
|
||||
env.art.on('document-pip', (active) => {
|
||||
if (active)
|
||||
env.art.destroy()
|
||||
})
|
||||
await result.open()
|
||||
await env.flush()
|
||||
assert.equal(result.isActive, false)
|
||||
assert.equal(env.controls.has('document-pip'), true)
|
||||
assert.equal(env.subscriptions.get('destroy').size, 1)
|
||||
assert.equal(env.controls.get('document-pip').node.listeners.get('click').size, 1)
|
||||
assert.equal(env.emitted.filter(event => event.name === 'resize').length, 2)
|
||||
})
|
||||
|
||||
test(`Document PiP ${label}: failed adoption retains active window and placeholder until explicit close`, async () => {
|
||||
const env = dpipEnvironment(implementation, { requestWindow: (_, create) => {
|
||||
const win = create()
|
||||
win.document.adoptNode = () => {
|
||||
throw new Error('adoption refused')
|
||||
}
|
||||
return Promise.resolve(win)
|
||||
} })
|
||||
const result = env.factory()(env.art)
|
||||
await result.open()
|
||||
assert.equal(result.isActive, true)
|
||||
assert.equal(env.windows[0].closed, false)
|
||||
assert.equal(env.parent.children.length, 3)
|
||||
assert.match(env.art.notice.show, /open failed/)
|
||||
await result.close()
|
||||
assert.deepEqual(env.parent.children, [env.player, env.sibling])
|
||||
})
|
||||
|
||||
test(`Document PiP ${label}: moving the placeholder makes restoration detach the player and retain the window`, async () => {
|
||||
const env = dpipEnvironment(implementation)
|
||||
const result = env.factory()(env.art)
|
||||
await result.open()
|
||||
const placeholder = env.parent.children[0]
|
||||
const alternate = env.document.createElement('section')
|
||||
env.document.body.appendChild(alternate)
|
||||
alternate.appendChild(placeholder)
|
||||
await result.close()
|
||||
assert.match(env.art.notice.show, /close failed/)
|
||||
assert.equal(result.isActive, true)
|
||||
assert.equal(env.windows[0].closed, false)
|
||||
assert.equal(env.player.parentNode, null)
|
||||
assert.equal(env.windows[0].listeners.get('pagehide').size, 0)
|
||||
})
|
||||
|
||||
test(`Document PiP ${label}: foreign ownerDocument styles are omitted while node restoration still works`, async () => {
|
||||
const env = dpipEnvironment(implementation)
|
||||
const foreign = env.createWindow().document
|
||||
const foreignStyle = foreign.createElement('style')
|
||||
foreignStyle.textContent = '.foreign { color: red }'
|
||||
foreign.head.appendChild(foreignStyle)
|
||||
foreign.body.appendChild(env.parent)
|
||||
const result = env.factory()(env.art)
|
||||
await result.open()
|
||||
const win = env.windows[1]
|
||||
assert.equal(win.document.querySelectorAll('style').some(style => style.textContent === foreignStyle.textContent), false)
|
||||
await result.close()
|
||||
assert.equal(env.player.ownerDocument, foreign)
|
||||
assert.deepEqual(env.parent.children, [env.player, env.sibling])
|
||||
})
|
||||
|
||||
test(`Document PiP ${label}: source replacement keeps player identity and popup resize calls the current method`, async () => {
|
||||
const env = dpipEnvironment(implementation)
|
||||
const video = env.document.createElement('video')
|
||||
env.player.appendChild(video)
|
||||
const result = env.factory()(env.art)
|
||||
await result.open()
|
||||
let resized = 0
|
||||
env.art.resize = () => resized++
|
||||
video.src = 'second.mp4'
|
||||
env.art.emit('restart', video.src)
|
||||
env.windows[0].dispatchEvent({ type: 'resize' })
|
||||
assert.equal(resized, 1)
|
||||
await result.close()
|
||||
assert.equal(env.player.children[0], video)
|
||||
assert.equal(video.src, 'second.mp4')
|
||||
assert.equal(video.ownerDocument, env.document)
|
||||
})
|
||||
|
||||
test(`Document PiP ${label}: duplicate evaluation preserves Parcel caching versus newer duplicate style injection`, () => {
|
||||
const env = dpipEnvironment(implementation, { readyState: 'loading' })
|
||||
env.evaluate()
|
||||
env.document.dispatchEvent({ type: 'DOMContentLoaded' })
|
||||
const parcelCached = ['published-1.0.1', 'published-1.0.2'].includes(label)
|
||||
assert.equal(env.document.querySelectorAll('style').filter(style => style.id === 'artplayer-plugin-document-pip').length, parcelCached ? 1 : 2)
|
||||
})
|
||||
}
|
||||
@@ -188,7 +188,7 @@ export function dpipEnvironment(implementation, settings = {}) {
|
||||
}
|
||||
const context = vm.createContext({ window, document, console: { warn: (...args) => warnings.push(args) }, module: { exports: {} } })
|
||||
context.exports = context.module.exports
|
||||
const evaluate = () => vm.runInContext(implementation.code, context, { timeout: 5000 })
|
||||
const evaluate = () => vm.runInContext(`(() => { ${implementation.code}\n })()`, context, { timeout: 5000 })
|
||||
evaluate()
|
||||
return {
|
||||
factory: context.module.exports.default || context.module.exports,
|
||||
|
||||
Reference in new issue
Block a user