test(dpip): [PKG-DPIP-02] reproduce window races and DOM rollback failures

This commit is contained in:
Harvey Zhao committed 2026-09-12 14:02:18 +08:00
1 parent efbbdb4782
commit 202351934b
13 files changed
+574 -13

No files matched your search

+2
View File
@@ -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).
+90
View File
@@ -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()
})
})
}
}
+227
View File
@@ -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)
})
}
+1 -1
View File
@@ -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,