refactor(dpip): [PKG-DPIP-03] own windows and DOM resources in strict TypeScript

This commit is contained in:
Harvey Zhao committed 2026-09-12 14:22:09 +08:00
1 parent 202351934b
commit 80d37d66f8
31 files changed
+2335 -597

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
import { hash } from '../../refactor/scripts/releases.mjs'
import { expect } from './fixtures.js'
export async function setupDpip(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 }))) }) })
}
+65
View File
@@ -0,0 +1,65 @@
import { dpipCandidate } from '../helpers/dpip.js'
import { setupDpip } from './dpip-helpers.js'
import { expect, test } from './fixtures.js'
const implementation = await dpipCandidate()
for (const core of ['published', 'candidate']) {
test(`Document PiP candidate/${core}: concurrent opens coalesce and a moved placeholder restores the native node`, async ({ page }, testInfo) => {
await setupDpip(page, core, implementation, testInfo)
await page.evaluate(() => {
window.firstDpipOpen = window.dpip.open()
window.secondDpipOpen = window.dpip.open()
})
expect(await page.evaluate(() => window.dpipEvidence.requests.length)).toBe(1)
await page.evaluate(async () => {
window.resolveDpip(0)
await Promise.all([window.firstDpipOpen, window.secondDpipOpen])
})
expect(await page.evaluate(() => ({ active: window.dpip.isActive, adopted: window.dpipPlayer.ownerDocument === window.dpipEvidence.windows[0].popup.document, sameVideo: window.art.video === window.dpipVideo }))).toEqual({ active: true, adopted: true, sameVideo: true })
await page.evaluate(async () => {
document.body.appendChild(document.querySelector('.artplayer-document-pip-placeholder'))
await window.dpip.close()
})
expect(await page.evaluate(() => ({ active: window.dpip.isActive, restored: window.dpipPlayer.parentNode === window.dpipParent && window.dpipPlayer.ownerDocument === document, closed: window.dpipEvidence.windows[0].closed, placeholder: document.querySelectorAll('.artplayer-document-pip-placeholder').length, styles: window.dpipEvidence.windows[0].popup.document.head.children.length, events: window.dpipEvidence.events }))).toEqual({ active: false, restored: true, closed: true, placeholder: 0, styles: 0, events: [true, false] })
await page.evaluate(() => window.art.destroy())
})
test(`Document PiP candidate/${core}: close and destroy revoke pending window requests before they resolve`, async ({ page }, testInfo) => {
await setupDpip(page, core, implementation, testInfo)
await page.evaluate(async () => {
const opening = window.dpip.open()
await window.dpip.close()
await opening
window.resolveDpip(0)
})
await expect.poll(() => page.evaluate(() => window.dpipEvidence.windows[0].closed)).toBe(true)
expect(await page.evaluate(() => ({ active: window.dpip.isActive, restored: window.dpipPlayer.parentNode === window.dpipParent, events: window.dpipEvidence.events }))).toEqual({ active: false, restored: true, events: [] })
await page.evaluate(async () => {
const opening = window.dpip.open()
window.art.destroy()
await opening
window.resolveDpip(1)
})
await expect.poll(() => page.evaluate(() => window.dpipEvidence.windows[1].closed)).toBe(true)
await page.evaluate(async () => {
await window.dpip.open()
window.dpip.toggle()
})
expect(await page.evaluate(() => ({ active: window.dpip.isActive, requests: window.dpipEvidence.requests.length, placeholders: document.querySelectorAll('.artplayer-document-pip-placeholder').length }))).toEqual({ active: false, requests: 2, placeholders: 0 })
})
test(`Document PiP candidate/${core}: interrupted native adoption rolls back without leaving a root or style nodes`, async ({ page }, testInfo) => {
await setupDpip(page, core, implementation, testInfo)
await page.evaluate(async () => {
const opening = window.dpip.open()
window.resolveDpip(0)
window.dpipEvidence.windows[0].popup.document.adoptNode = () => {
throw new DOMException('controlled adoption failure', 'NotSupportedError')
}
await opening
})
expect(await page.evaluate(() => ({ active: window.dpip.isActive, restored: window.dpipPlayer.parentNode === window.dpipParent, closed: window.dpipEvidence.windows[0].closed, placeholder: document.querySelectorAll('.artplayer-document-pip-placeholder').length, roots: window.dpipEvidence.windows[0].popup.document.body.children.length, styles: window.dpipEvidence.windows[0].popup.document.head.children.length }))).toEqual({ active: false, restored: true, closed: true, placeholder: 0, roots: 0, styles: 0 })
await page.evaluate(() => window.art.destroy())
})
}
+1 -40
View File
@@ -1,48 +1,9 @@
import { hash } from '../../refactor/scripts/releases.mjs'
import { dpipHistorical } from '../helpers/dpip.js'
import { setupDpip as setup } from './dpip-helpers.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) => {
+344
View File
@@ -0,0 +1,344 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Candidate fixes are compared to the immutable workspace via ARTPLAYER_DPIP_BASELINE.
import test from 'node:test'
import { dpipCandidate, dpipEnvironment } from './helpers/dpip.js'
const implementation = await dpipCandidate()
function deferred() {
let resolve
let reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
const setup = settings => dpipEnvironment(implementation, settings)
test('Document PiP candidate: public keys, snapshot/default options, controls and transition ordering remain stable', async () => {
const env = setup()
const options = { width: 720, placeholder: 'Active' }
const plugin = env.factory(options)
options.width = 1
const result = plugin(env.art)
assert.deepEqual(Object.keys(result), ['name', 'isSupported', 'isActive', 'open', 'close', 'toggle'])
assert.equal(Reflect.set(result, 'isActive', true), false)
assert.equal(result.name, 'artplayerPluginDocumentPip')
assert.equal(env.controls.get('document-pip').spec.index, 40)
assert.equal(await result.open(), undefined)
assert.deepEqual({ ...env.requests[0] }, { width: 720, height: 270 })
assert.deepEqual(env.emitted.map(event => [event.name, ...event.args]), [['document-pip', true], ['resize']])
assert.equal(env.parent.children[0].textContent, 'Active')
assert.equal(env.controls.get('document-pip').node.tooltip, 'Exit PIP Mode')
assert.equal(await result.close(), undefined)
assert.deepEqual(env.parent.children, [env.player, env.sibling])
assert.deepEqual(env.emitted.map(event => [event.name, ...event.args]), [['document-pip', true], ['resize'], ['document-pip', false], ['resize']])
assert.deepEqual(env.rebinds.map(item => item.document), [env.windows[0].document, env.document])
})
test('Document PiP candidate: normal rejection and unsupported fallback preserve error and return boundaries', async () => {
const error = new Error('denied')
const env = setup({ requestWindow: () => Promise.reject(error) })
const result = env.factory()(env.art)
assert.equal(await result.open(), undefined)
assert.equal(env.warnings[0][1], error)
assert.equal(result.isActive, false)
const fallback = setup({ supported: false })
const other = fallback.factory()(fallback.art)
await other.open()
assert.equal(fallback.art.pip, true)
assert.equal(other.isSupported, false)
Object.defineProperty(fallback.art, 'pip', { get() {
return false
}, set() {
throw error
} })
await assert.rejects(other.open(), caught => caught === error)
})
test('Document PiP candidate: overlapping opens issue one request and share one restored window', async () => {
const pending = deferred()
const env = setup({ requestWindow: () => pending.promise })
const result = env.factory()(env.art)
const first = result.open()
const second = result.open()
assert.equal(env.requests.length, 1)
pending.resolve(env.createWindow())
await Promise.all([first, second])
assert.equal(env.parent.children.length, 2)
await result.close()
assert.equal(env.windows[0].closed, true)
assert.deepEqual(env.parent.children, [env.player, env.sibling])
})
test('Document PiP candidate: close settles a pending open and closes the late window without adopting', async () => {
const pending = deferred()
const env = setup({ requestWindow: () => pending.promise })
const result = env.factory()(env.art)
let settled = false
const opening = result.open().then(() => {
settled = true
})
await result.close()
await env.flush()
assert.equal(settled, true)
const win = env.createWindow()
pending.resolve(win)
await env.flush()
await opening
assert.equal(win.closed, true)
assert.equal(result.isActive, false)
assert.deepEqual(env.parent.children, [env.player, env.sibling])
assert.deepEqual(env.emitted, [])
})
test('Document PiP candidate: pending toggle cancels and a fresh request survives the old resolution', async () => {
const requests = [deferred(), deferred()]
let index = 0
const env = setup({ requestWindow: () => requests[index++].promise })
const result = env.factory()(env.art)
const old = result.open()
assert.equal(result.toggle(), undefined)
const current = result.open()
const newWin = env.createWindow()
requests[1].resolve(newWin)
await current
const oldWin = env.createWindow()
requests[0].resolve(oldWin)
await old
await env.flush()
assert.equal(oldWin.closed, true)
assert.equal(newWin.closed, false)
assert.equal(env.player.ownerDocument, newWin.document)
await result.close()
})
test('Document PiP candidate: destroy cancels pending windows and makes escaped methods inert', async () => {
const pending = deferred()
const env = setup({ requestWindow: () => pending.promise })
const result = env.factory()(env.art)
const opening = result.open()
env.art.destroy()
const win = env.createWindow()
pending.resolve(win)
await opening
await env.flush()
assert.equal(win.closed, true)
await result.open()
result.toggle()
await result.close()
assert.equal(env.requests.length, 1)
assert.equal(result.isActive, false)
assert.equal(env.controls.size, 0)
assert.equal(env.subscriptions.get('destroy').size, 0)
assert.equal(env.subscriptions.get('document-pip').size, 0)
})
test('Document PiP candidate: stale rejection after destroy does not write notices', async () => {
const pending = deferred()
const env = setup({ requestWindow: () => pending.promise })
const result = env.factory()(env.art)
const opening = result.open()
env.art.destroy()
pending.reject(new Error('late rejection'))
await opening
await env.flush()
assert.equal(env.warnings.length, 0)
assert.equal(env.art.notice.show, '')
})
test('Document PiP candidate: destroy cancels owned transition timers and emits no delayed resize', async () => {
const delay = deferred()
const env = setup({ sleep: () => delay.promise })
const result = env.factory()(env.art)
const opening = result.open()
await env.flush()
env.art.destroy()
assert.equal(env.timers.size, 0)
const before = env.emitted.length
delay.resolve()
await opening
await env.flush()
assert.equal(env.emitted.length, before)
assert.equal(result.isActive, false)
assert.deepEqual(env.parent.children, [env.player, env.sibling])
})
test('Document PiP candidate: destroy from the active event releases listeners and prevents subsequent effects', async () => {
const env = setup()
const result = env.factory()(env.art)
env.art.on('document-pip', active => active && env.art.destroy())
await result.open()
assert.equal(result.isActive, false)
assert.equal(env.windows[0].closed, true)
assert.equal([...env.windows[0].listeners.values()].reduce((sum, value) => sum + value.size, 0), 0)
assert.equal(env.emitted.filter(event => event.name === 'resize').length, 0)
assert.equal(env.controls.size, 0)
})
test('Document PiP candidate: failed adoption rolls back all window and DOM resources immediately', async () => {
const env = setup({ requestWindow: (_, create) => {
const win = create()
win.document.adoptNode = () => {
throw new Error('denied adoption')
}
return Promise.resolve(win)
} })
const result = env.factory()(env.art)
await result.open()
assert.equal(result.isActive, false)
assert.equal(env.windows[0].closed, true)
assert.deepEqual(env.parent.children, [env.player, env.sibling])
assert.equal(env.windows[0].document.head.children.length, 0)
assert.equal(env.windows[0].document.body.children.length, 0)
assert.match(env.art.notice.show, /open failed/)
})
test('Document PiP candidate: moved or removed placeholders restore to the original parent and sibling', async () => {
for (const move of [true, false]) {
const env = setup()
const result = env.factory()(env.art)
await result.open()
const placeholder = env.parent.children[0]
if (move)
env.document.body.appendChild(placeholder)
else
placeholder.remove()
await result.close()
assert.deepEqual(env.parent.children, [env.player, env.sibling])
assert.equal(result.isActive, false)
assert.equal(placeholder.parentNode, null)
assert.equal(env.warnings.length, 0)
}
})
test('Document PiP candidate: cross-document styles follow the actual player owner and are released', async () => {
const env = setup()
const foreign = env.createWindow().document
foreign.body.appendChild(env.parent)
const style = foreign.createElement('style')
style.textContent = '.foreign { color:red }'
foreign.head.appendChild(style)
const result = env.factory()(env.art)
await result.open()
const target = env.windows[1].document
assert.equal(target.querySelectorAll('style').some(node => node.textContent === style.textContent), true)
await result.close()
assert.equal(env.player.ownerDocument, foreign)
assert.equal(target.head.children.length, 0)
})
test('Document PiP candidate: duplicate loading evaluation injects one style and removes loading listeners', () => {
const env = setup({ readyState: 'loading' })
env.evaluate()
env.document.dispatchEvent({ type: 'DOMContentLoaded' })
assert.equal(env.document.querySelectorAll('style').filter(node => node.id === 'artplayer-plugin-document-pip').length, 1)
assert.equal(env.document.listeners.get('DOMContentLoaded').size, 0)
})
test('Document PiP candidate: registration after destroy creates no controls or listeners', async () => {
const env = setup()
env.art.isDestroy = true
const result = env.factory()(env.art)
await result.open()
assert.equal(env.controls.size, 0)
assert.equal(env.requests.length, 0)
assert.equal(env.subscriptions.size, 0)
})
test('Document PiP candidate: partial control setup failure releases the click, control and destroy subscription', () => {
const env = setup()
const add = env.art.controls.add
env.art.controls.add = (spec) => {
add(spec)
throw new Error('control failure')
}
assert.throws(() => env.factory()(env.art), /control failure/)
assert.equal(env.controls.size, 0)
assert.equal(env.subscriptions.get('destroy').size, 0)
assert.equal(env.subscriptions.get('document-pip').size, 0)
})
test('Document PiP candidate: throwing window cleanup still restores DOM and releases remaining resources', async () => {
const env = setup()
const result = env.factory()(env.art)
await result.open()
const win = env.windows[0]
const remove = win.removeEventListener
win.removeEventListener = (...args) => {
remove(...args)
if (args[0] === 'resize')
throw new Error('listener release failed')
}
await result.close()
assert.equal(result.isActive, false)
assert.equal(win.closed, true)
assert.deepEqual(env.parent.children, [env.player, env.sibling])
assert.match(env.art.notice.show, /close failed/)
})
test('Document PiP candidate: a rejecting original parent leaves the player recoverable in its original document', async () => {
const env = setup()
const result = env.factory()(env.art)
await result.open()
env.parent.insertBefore = () => {
throw new Error('parent rejects restoration')
}
await result.close()
assert.equal(env.player.parentNode, env.document.body)
assert.equal(env.player.ownerDocument, env.document)
assert.equal(env.windows[0].closed, true)
assert.equal(result.isActive, false)
assert.match(env.art.notice.show, /close failed/)
})
test('Document PiP candidate: destruction during adoption restores the returned node without reactivating', async () => {
const env = setup({ requestWindow: (_, create) => {
const win = create()
const adopt = win.document.adoptNode
win.document.adoptNode = (node) => {
env.art.destroy()
return adopt(node)
}
return Promise.resolve(win)
} })
const result = env.factory()(env.art)
await result.open()
await env.flush()
assert.deepEqual(env.parent.children, [env.player, env.sibling])
assert.equal(env.player.ownerDocument, env.document)
assert.equal(env.windows[0].closed, true)
assert.equal(result.isActive, false)
assert.equal(env.controls.size, 0)
})
test('Document PiP candidate: destruction during style insertion releases nodes created by the interrupted mount', async () => {
const env = setup({ requestWindow: (_, create) => {
const win = create()
const append = win.document.head.appendChild
win.document.head.appendChild = function (node) {
env.art.destroy()
return append.call(this, node)
}
return Promise.resolve(win)
} })
const result = env.factory()(env.art)
await result.open()
await env.flush()
assert.equal(result.isActive, false)
assert.deepEqual(env.parent.children, [env.player, env.sibling])
assert.equal(env.windows[0].document.head.children.length, 0)
})
test('Document PiP candidate: a throwing public notice setter rejects open with the original error', async () => {
const env = setup({ requestWindow: () => Promise.reject(new Error('window rejected')) })
const noticeError = new Error('notice setter failed')
Object.defineProperty(env.art.notice, 'show', { get() {
return ''
}, set() {
throw noticeError
} })
const result = env.factory()(env.art)
await assert.rejects(result.open(), error => error === noticeError)
assert.equal(result.isActive, false)
})
+39 -2
View File
@@ -1,9 +1,32 @@
import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import vm from 'node:vm'
import { transform } from 'esbuild'
import { build, transform } from 'esbuild'
import less from 'less'
import { verifyDpipContract } from '../../refactor/scripts/dpip-contract.mjs'
import { readMember } from '../../refactor/scripts/releases.mjs'
import { getEntryFile } from '../../scripts/projects.js'
export async function dpipCandidate() {
if (process.env.ARTPLAYER_DPIP_BASELINE === '1')
return (await dpipHistorical()).find(item => item.name === 'frozen-workspace')
if (process.env.ARTPLAYER_DPIP_ARTIFACT)
return { name: 'candidate-artifact', code: await fs.readFile(process.env.ARTPLAYER_DPIP_ARTIFACT, 'utf8') }
const root = fileURLToPath(new URL('../../', import.meta.url))
const output = await build({ entryPoints: [getEntryFile(path.join(root, 'packages/artplayer-plugin-document-pip'))], bundle: true, write: false, format: 'cjs', target: 'es2020', plugins: [{
name: 'dpip-less-inline',
setup(build) {
build.onLoad({ filter: /\.less$/ }, async ({ path: filename }) => {
const source = await fs.readFile(filename.replace(/\?inline$/, ''), 'utf8')
return { contents: (await less.render(source)).css, loader: 'text' }
})
},
}] })
return { name: 'candidate-source', code: output.outputFiles[0].text }
}
export async function dpipHistorical() {
const contract = await verifyDpipContract()
@@ -116,6 +139,8 @@ export function dpipEnvironment(implementation, settings = {}) {
const controls = new Map()
const proxies = []
const rebinds = []
const timers = new Map()
let timerId = 0
const window = { ...eventTarget(), document }
document.defaultView = window
function createWindow() {
@@ -186,7 +211,18 @@ export function dpipEnvironment(implementation, settings = {}) {
art.emit('destroy')
},
}
const context = vm.createContext({ window, document, console: { warn: (...args) => warnings.push(args) }, module: { exports: {} } })
const context = vm.createContext({ window, document, console: { warn: (...args) => warnings.push(args) }, module: { exports: {} }, setTimeout(callback, ms) {
const id = timerId++
timers.set(id, callback)
sleeps.push(ms)
const delay = settings.sleep ? settings.sleep(ms) : Promise.resolve()
void delay.then(() => {
const run = timers.get(id)
timers.delete(id)
run?.()
})
return id
}, clearTimeout(id) { timers.delete(id) } })
context.exports = context.module.exports
const evaluate = () => vm.runInContext(`(() => { ${implementation.code}\n })()`, context, { timeout: 5000 })
evaluate()
@@ -208,6 +244,7 @@ export function dpipEnvironment(implementation, settings = {}) {
proxies,
subscriptions,
rebinds,
timers,
evaluate,
createWindow,
async flush() {