594 lines
33 KiB
JavaScript
594 lines
33 KiB
JavaScript
#!/usr/bin/env node
|
|
// Source-only real-entry/real-adapter DOM test, not browser/native acceptance.
|
|
// Run with --experimental-vm-modules and a read-only node_modules path.
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const vm = require('node:vm')
|
|
const { createRequire } = require('node:module')
|
|
const { createHash } = require('node:crypto')
|
|
const deps = createRequire(path.join(path.resolve(process.argv[2] || 'neode-ui/node_modules'), '.consumer-test.cjs'))
|
|
const { JSDOM, ResourceLoader } = deps('jsdom')
|
|
const sourceDir = path.resolve(__dirname, '../docker/kithmoot')
|
|
const sources = Object.fromEntries(['video-entry.mjs', 'archipelago-video.mjs'].map(name => [name, fs.readFileSync(path.join(sourceDir, name), 'utf8')]))
|
|
for (const [name, bytes] of Object.entries(sources)) console.log('SHA256', name, createHash('sha256').update(bytes).digest('hex'))
|
|
console.log('JSDOM', deps('jsdom/package.json').version)
|
|
const tick = () => new Promise(resolve => setImmediate(resolve))
|
|
class NoNetwork extends ResourceLoader { fetch() { throw new Error('Network forbidden in consumer fixture') } }
|
|
async function fixture() {
|
|
const dom = new JSDOM('<main id="call"></main><aside id="unrelated">Keep me</aside>', {
|
|
url: 'https://call.invalid:8189/j/', runScripts: 'outside-only', resources: new NoNetwork(),
|
|
})
|
|
const win = dom.window, doc = win.document
|
|
const timers = new Set(), schedule = win.setTimeout.bind(win), cancelTimer = win.clearTimeout.bind(win)
|
|
win.setTimeout = (fn, delay, ...args) => {
|
|
const id = schedule(() => { timers.delete(id); fn(...args) }, delay)
|
|
timers.add(id); return id
|
|
}
|
|
win.clearTimeout = id => { timers.delete(id); cancelTimer(id) }
|
|
const messages = []
|
|
win.postMessage = (message, origin) => messages.push({ message, origin })
|
|
for (const key of ['fetch', 'WebSocket', 'XMLHttpRequest', 'EventSource']) win[key] = function () { throw new Error('Network forbidden') }
|
|
const listeners = []
|
|
const proto = win.EventTarget.prototype, add = proto.addEventListener, remove = proto.removeEventListener
|
|
proto.addEventListener = function (type, fn, options) {
|
|
const capture = typeof options === 'boolean' ? options : !!options?.capture
|
|
if (!listeners.some(x => x.target === this && x.type === type && x.fn === fn && x.capture === capture)) listeners.push({ target: this, type, fn, capture })
|
|
return add.call(this, type, fn, options)
|
|
}
|
|
proto.removeEventListener = function (type, fn, options) {
|
|
const capture = typeof options === 'boolean' ? options : !!options?.capture
|
|
const i = listeners.findIndex(x => x.target === this && x.type === type && x.fn === fn && x.capture === capture)
|
|
if (i >= 0) listeners.splice(i, 1)
|
|
return remove.call(this, type, fn, options)
|
|
}
|
|
const observers = [], Observer = win.MutationObserver
|
|
win.MutationObserver = class {
|
|
constructor(fn) { this.callback = fn; this.calls = 0; this.active = false; this.inner = new Observer(records => { this.calls++; fn(records, this) }); observers.push(this) }
|
|
observe(...args) { this.active = true; this.inner.observe(...args) }
|
|
disconnect() { this.active = false; this.inner.disconnect() }
|
|
}
|
|
let exits = 0, pauses = 0, entries = 0
|
|
doc.pictureInPictureEnabled = true
|
|
doc.pictureInPictureElement = null
|
|
doc.exitPictureInPicture = async () => { exits++; doc.pictureInPictureElement = null }
|
|
function video(id) {
|
|
const v = doc.createElement('video'); v.dataset.track = id; v.srcObject = { id }
|
|
Object.defineProperties(v, { videoWidth: { value: 640 }, videoHeight: { value: 360 } })
|
|
v.pause = () => { pauses++ }
|
|
v.play = async () => {}
|
|
v.requestPictureInPicture = async () => { entries++; doc.pictureInPictureElement = v }
|
|
doc.querySelector('#call').append(v)
|
|
return v
|
|
}
|
|
const a = video('a'), b = video('b')
|
|
const context = dom.getInternalVMContext()
|
|
const adapter = new vm.SourceTextModule(sources['archipelago-video.mjs'], { context, identifier: 'archipelago-video.mjs' })
|
|
const entry = new vm.SourceTextModule(sources['video-entry.mjs'], { context, identifier: 'video-entry.mjs' })
|
|
await entry.link(specifier => { assert.equal(specifier, './archipelago-video.mjs'); return adapter })
|
|
await entry.evaluate(); await tick()
|
|
const toolbar = doc.querySelector('aside[aria-label="Call picture-in-picture"]')
|
|
const select = toolbar.querySelector('select'), button = toolbar.querySelector('button')
|
|
const lifecycle = (type, persisted) => win.dispatchEvent(new win.PageTransitionEvent(type, { persisted }))
|
|
const choose = value => { select.value = String(value); select.dispatchEvent(new win.Event('change')) }
|
|
return { dom, win, doc, a, b, video, toolbar, select, button, observers, listeners, timers, messages, choose, lifecycle,
|
|
counts: () => ({ exits, pauses, entries }),
|
|
async close() {
|
|
try {
|
|
lifecycle('pagehide', false); await tick()
|
|
assert.equal(timers.size, 0, 'no pending adapter timers')
|
|
const owned = listeners.filter(x => [win, a, b, button, select].includes(x.target) || (x.target === doc && x.type === 'fullscreenchange'))
|
|
assert.equal(owned.length, 0, 'no owned media, document or consumer listeners after disposal')
|
|
assert.equal(observers.some(o => o.active), false)
|
|
assert.equal(toolbar.isConnected, false)
|
|
}
|
|
finally { dom.window.close() }
|
|
},
|
|
}
|
|
}
|
|
function presentation(f, mode) {
|
|
const field = mode === 'pip' ? 'pictureInPictureElement' : 'fullscreenElement'
|
|
const exit = mode === 'pip' ? 'exitPictureInPicture' : 'exitFullscreen'
|
|
const request = mode === 'pip' ? 'requestPictureInPicture' : 'requestFullscreen'
|
|
const event = mode === 'pip' ? 'leavepictureinpicture' : 'fullscreenchange'
|
|
const target = mode === 'pip' ? f.a : f.doc
|
|
const session = '11111111-1111-1111-1111-111111111111'
|
|
const host = state => f.win.dispatchEvent(new f.win.MessageEvent('message', { source: f.win, origin: 'https://call.invalid', data: {
|
|
type: 'archipelago:video-state', version: 1, state, native: true, session,
|
|
} }))
|
|
host('connected')
|
|
f.doc.pictureInPictureEnabled = mode === 'pip'
|
|
f.doc[field] = null
|
|
let entries = 0, exits = 0
|
|
for (const video of [f.a, f.b]) video[request] = async () => {
|
|
entries++; f.doc[field] = video
|
|
if (mode === 'fullscreen') f.doc.dispatchEvent(new f.win.Event(event))
|
|
}
|
|
f.doc[exit] = async () => { exits++; f.doc[field] = null }
|
|
return { field, exit, request, event, target, host, entries: () => entries, exits: () => exits,
|
|
fire: () => target.dispatchEvent(new f.win.Event(event)),
|
|
releases: () => f.messages.filter(x => x.message.action === 'release').length,
|
|
}
|
|
}
|
|
const statusOf = f => f.toolbar.querySelector('[role=status]').textContent
|
|
const mediaListeners = f => f.listeners.filter(x => [f.a, f.b].includes(x.target) || (x.target === f.doc && x.type === 'fullscreenchange'))
|
|
let passed = 0, failed = 0
|
|
async function check(name, run) {
|
|
let f, failure = null
|
|
try { f = await fixture(); await run(f) }
|
|
catch (error) { failure = error; console.error('FAIL', name, error.stack) }
|
|
finally {
|
|
try { if (f) await f.close() }
|
|
catch (error) { failure = error; console.error('FAIL cleanup', name, error.stack) }
|
|
}
|
|
if (failure) failed++
|
|
else { passed++; console.log('PASS', name) }
|
|
}
|
|
async function main() {
|
|
for (const mode of ['pip', 'fullscreen']) {
|
|
await check(`${mode} synchronous external departure during owned exit releases only once`, async f => {
|
|
const p = presentation(f, mode)
|
|
f.button.click(); await tick(); p.host('entered')
|
|
let exits = 0
|
|
f.doc[p.exit] = async () => { exits++; f.doc[p.field] = null; p.fire() }
|
|
f.a.dispatchEvent(new f.win.Event('ended')); await tick()
|
|
assert.equal(p.releases(), 1); assert.equal(exits, 1)
|
|
assert.equal(mediaListeners(f).length, 0); assert.equal(f.timers.size, 0)
|
|
})
|
|
for (const event of ['ended', 'error']) {
|
|
await check(`captured ${mode} ${event} callback cannot stop a reused video`, async f => {
|
|
const p = presentation(f, mode)
|
|
f.button.click(); await tick(); p.host('entered')
|
|
const old = mediaListeners(f).find(x => x.type === event).fn
|
|
f.lifecycle('pagehide', true); await tick(); f.lifecycle('pageshow', true)
|
|
f.button.click(); await tick(); p.host('entered')
|
|
old(new f.win.Event(event)); await tick()
|
|
assert.equal(f.doc[p.field], f.a); assert.match(statusOf(f), /is active/)
|
|
assert.equal(p.releases(), 1); assert.equal(p.exits(), 1)
|
|
})
|
|
}
|
|
}
|
|
for (const mode of ['pip', 'fullscreen']) {
|
|
for (const event of ['ended', 'error']) {
|
|
for (const phase of ['active', 'pending-entry', 'pending-exit', 'refused-exit']) {
|
|
await check(`${mode} ${event} during ${phase} preserves ownership, status and cleanup`, async f => {
|
|
const p = presentation(f, mode)
|
|
let stops = 0
|
|
const stream = { getTracks: () => [{ stop: () => { stops++ } }] }
|
|
f.a.srcObject = stream
|
|
let settle, reject, entryRequests = 0, exitRequests = 0
|
|
if (phase === 'pending-entry') f.a[p.request] = () => {
|
|
entryRequests++
|
|
return new Promise(resolve => { settle = () => { f.doc[p.field] = f.a; resolve() } })
|
|
}
|
|
if (phase === 'pending-exit' || phase === 'refused-exit') f.doc[p.exit] = () => {
|
|
exitRequests++
|
|
return new Promise((yes, no) => { settle = () => { f.doc[p.field] = null; yes() }; reject = no })
|
|
}
|
|
f.button.click(); await tick()
|
|
if (phase !== 'pending-entry') p.host('entered')
|
|
f.a.dispatchEvent(new f.win.Event(event)); await tick()
|
|
assert.equal(p.releases(), 1)
|
|
assert.equal(mediaListeners(f).length, 0); assert.equal(f.timers.size, 0)
|
|
f.a.dispatchEvent(new f.win.Event(event)); p.fire(); p.host('entered'); await tick()
|
|
assert.equal(p.releases(), 1, 'terminal signals cannot duplicate release')
|
|
assert.equal(f.a.srcObject, stream); assert.equal(stops, 0); assert.equal(f.counts().pauses, 0)
|
|
if (phase === 'active') {
|
|
assert.equal(p.exits(), 1); assert.equal(f.doc[p.field], null)
|
|
assert.match(statusOf(f), /Return to the call/)
|
|
} else if (phase === 'pending-entry') {
|
|
assert.match(statusOf(f), /Return to the call/)
|
|
f.button.click(); assert.equal(entryRequests, 1)
|
|
f.lifecycle('pagehide', false)
|
|
const status = statusOf(f)
|
|
settle(); await tick()
|
|
assert.equal(f.doc[p.field], null); assert.equal(p.exits(), 1)
|
|
assert.equal(statusOf(f), status)
|
|
assert.equal(f.toolbar.isConnected, false)
|
|
} else if (phase === 'pending-exit') {
|
|
assert.equal(exitRequests, 1); assert.equal(f.button.disabled, true)
|
|
assert.match(statusOf(f), /Leaving picture-in-picture/)
|
|
f.lifecycle('pagehide', true); f.lifecycle('pageshow', true)
|
|
assert.match(statusOf(f), /Leaving picture-in-picture/, 'consumer must not overwrite an unsettled exit')
|
|
f.button.click(); assert.equal(p.entries(), 1)
|
|
f.lifecycle('pagehide', false)
|
|
const status = statusOf(f)
|
|
settle(); await tick()
|
|
assert.equal(exitRequests, 1); assert.equal(statusOf(f), status)
|
|
assert.equal(f.toolbar.isConnected, false)
|
|
} else {
|
|
reject(new Error('Exit refused')); await tick()
|
|
assert.match(statusOf(f), /Could not exit/)
|
|
f.a.dispatchEvent(new f.win.Event(event)); p.fire(); await tick()
|
|
assert.match(statusOf(f), /Could not exit/)
|
|
assert.equal(exitRequests, 1)
|
|
f.doc[p.exit] = async () => { exitRequests++; f.doc[p.field] = null }
|
|
f.button.click(); await tick() // Exit-only gesture after refusal.
|
|
assert.equal(p.entries(), 1); assert.equal(exitRequests, 2)
|
|
assert.equal(f.doc[p.field], null)
|
|
}
|
|
assert.equal(mediaListeners(f).length, 0); assert.equal(f.timers.size, 0)
|
|
assert.equal(f.doc.querySelector('#unrelated').textContent, 'Keep me')
|
|
})
|
|
}
|
|
}
|
|
}
|
|
for (const mode of ['pip', 'fullscreen']) {
|
|
await check(`stale external ${mode} events cannot cancel a not-yet-presented attempt`, async f => {
|
|
const p = presentation(f, mode)
|
|
f.button.click(); await tick(); p.host('entered')
|
|
const old = mediaListeners(f).find(x => x.type === p.event).fn
|
|
f.lifecycle('pagehide', true); await tick(); f.lifecycle('pageshow', true)
|
|
let settle, requests = 0
|
|
f.a[p.request] = () => { requests++; return new Promise(resolve => { settle = () => { f.doc[p.field] = f.a; resolve() } }) }
|
|
f.button.click()
|
|
old(new f.win.Event(p.event)) // Removed registration captured before reuse.
|
|
p.fire() // A queued event reaches the new registration before ownership.
|
|
assert.equal(p.releases(), 1, 'no release of a new pending attempt')
|
|
assert.equal(f.button.disabled, true)
|
|
settle(); await tick(); p.host('entered')
|
|
assert.equal(requests, 1); assert.equal(f.doc[p.field], f.a)
|
|
assert.match(statusOf(f), /is active/)
|
|
assert.equal(p.exits(), 1)
|
|
// A different event kind must not cancel fullscreen ownership.
|
|
if (mode === 'fullscreen') f.a.dispatchEvent(new f.win.Event('leavepictureinpicture'))
|
|
assert.equal(f.doc[p.field], f.a)
|
|
assert.equal(p.releases(), 1)
|
|
})
|
|
await check(`${mode} resolved request without current ownership is not active`, async f => {
|
|
const p = presentation(f, mode)
|
|
f.a[p.request] = async () => {} // Surface vanished before notification/settlement.
|
|
f.button.click(); await tick(); p.host('entered')
|
|
assert.doesNotMatch(statusOf(f), /is active/)
|
|
assert.equal(f.messages.filter(x => x.message.action === 'enter').length, 0)
|
|
assert.equal(mediaListeners(f).length, 0); assert.equal(f.timers.size, 0)
|
|
assert.equal(p.releases(), 1); assert.equal(p.exits(), 0)
|
|
})
|
|
await check(`${mode} entry resolved after external loss never claims active or asks host to enter`, async f => {
|
|
const p = presentation(f, mode)
|
|
let settle
|
|
f.a[p.request] = () => new Promise(resolve => { settle = resolve })
|
|
f.button.click()
|
|
f.doc[p.field] = f.a
|
|
if (mode === 'fullscreen') p.fire()
|
|
else f.a.dispatchEvent(new f.win.Event('enterpictureinpicture'))
|
|
f.doc[p.field] = null; p.fire()
|
|
assert.equal(p.releases(), 1, 'observed entry then departure releases immediately, before request settlement')
|
|
assert.equal(mediaListeners(f).length, 0)
|
|
assert.equal(f.timers.size, 0)
|
|
settle(); await tick()
|
|
assert.doesNotMatch(statusOf(f), /is active/)
|
|
assert.equal(f.messages.filter(x => x.message.action === 'enter').length, 0)
|
|
assert.equal(p.releases(), 1); assert.equal(p.exits(), 0)
|
|
assert.equal(mediaListeners(f).length, 0); assert.equal(f.timers.size, 0)
|
|
})
|
|
}
|
|
for (const mode of ['pip', 'fullscreen']) {
|
|
for (const replacement of ['none', 'unrelated']) {
|
|
await check(`external ${mode} departure to ${replacement} releases listeners and permits persisted reuse`, async f => {
|
|
const p = presentation(f, mode), stream = f.a.srcObject
|
|
f.button.click(); await tick(); p.host('entered')
|
|
assert.match(statusOf(f), /is active/)
|
|
const old = mediaListeners(f).filter(x => x.type === p.event).map(x => x.fn)
|
|
const other = replacement === 'none' ? null : f.doc.createElement('video')
|
|
if (other) f.doc.body.append(other)
|
|
f.doc[p.field] = other; p.fire(); await tick()
|
|
assert.match(statusOf(f), /Return(?:ed)? to the call/)
|
|
assert.equal(p.releases(), 1, 'external departure releases the host once')
|
|
assert.equal(p.exits(), 0, 'external departure must not exit unrelated surface')
|
|
assert.equal(mediaListeners(f).length, 0)
|
|
assert.equal(f.timers.size, 0); assert.equal(f.button.disabled, false)
|
|
p.fire(); p.host('entered'); await tick()
|
|
assert.equal(p.releases(), 1); assert.doesNotMatch(statusOf(f), /is active/)
|
|
f.lifecycle('pagehide', true); f.lifecycle('pageshow', true); await tick()
|
|
assert.equal(f.doc[p.field], other)
|
|
f.button.click(); await tick(); p.host('entered')
|
|
for (const callback of old) callback(new f.win.Event(p.event))
|
|
p.fire() // Event while still owning the surface is not a departure.
|
|
assert.equal(f.doc[p.field], f.a); assert.match(statusOf(f), /is active/)
|
|
assert.equal(p.entries(), 2)
|
|
assert.equal(mediaListeners(f).filter(x => x.type === p.event).length, 1)
|
|
assert.equal(f.a.srcObject, stream); assert.equal(f.counts().pauses, 0)
|
|
f.lifecycle('pagehide', false); await tick()
|
|
const status = statusOf(f)
|
|
for (const callback of old) callback(new f.win.Event(p.event))
|
|
p.fire(); p.host('entered'); f.lifecycle('pageshow', true); await tick()
|
|
assert.equal(statusOf(f), status); assert.equal(mediaListeners(f).length, 0)
|
|
assert.equal(f.listeners.filter(x => [f.win, f.button, f.select].includes(x.target)).length, 0)
|
|
assert.equal(f.observers.some(o => o.active), false); assert.equal(f.timers.size, 0)
|
|
assert.equal(f.toolbar.isConnected, false)
|
|
assert.equal(f.doc.querySelector('#unrelated').textContent, 'Keep me')
|
|
})
|
|
}
|
|
}
|
|
for (const event of ['ended', 'error']) {
|
|
await check(`${event} after browser presentation vanished clears active status and releases once`, async f => {
|
|
const session = '11111111-1111-1111-1111-111111111111'
|
|
f.win.dispatchEvent(new f.win.MessageEvent('message', { source: f.win, origin: 'https://call.invalid', data: {
|
|
type: 'archipelago:video-state', version: 1, state: 'connected', session,
|
|
} }))
|
|
f.button.click(); await tick()
|
|
assert.match(f.toolbar.querySelector('[role=status]').textContent, /is active/)
|
|
const stream = f.a.srcObject
|
|
f.doc.pictureInPictureElement = null // Browser has already removed the surface.
|
|
f.a.dispatchEvent(new f.win.Event(event)); await tick()
|
|
assert.match(f.toolbar.querySelector('[role=status]').textContent, /Return to the call/)
|
|
assert.equal(f.listeners.filter(x => x.target === f.a).length, 0)
|
|
assert.equal(f.counts().exits, 0)
|
|
assert.equal(f.messages.filter(x => x.message.action === 'release').length, 1)
|
|
f.a.dispatchEvent(new f.win.Event(event)); await tick()
|
|
assert.equal(f.messages.filter(x => x.message.action === 'release').length, 1)
|
|
assert.equal(f.a.srcObject, stream); assert.equal(f.counts().pauses, 0)
|
|
assert.equal(f.timers.size, 0)
|
|
})
|
|
}
|
|
for (const mode of ['pip', 'fullscreen']) {
|
|
for (const cancel of ['selection', 'removal', 'persisted', 'same']) {
|
|
await check(`unsettled ${mode} exit blocks ${cancel} reentry until a fresh click`, async f => {
|
|
const field = mode === 'pip' ? 'pictureInPictureElement' : 'fullscreenElement'
|
|
const exit = mode === 'pip' ? 'exitPictureInPicture' : 'exitFullscreen'
|
|
const request = mode === 'pip' ? 'requestPictureInPicture' : 'requestFullscreen'
|
|
if (mode === 'fullscreen') {
|
|
f.doc.pictureInPictureEnabled = false
|
|
f.doc.fullscreenElement = null
|
|
f.win.dispatchEvent(new f.win.MessageEvent('message', { source: f.win, origin: 'https://call.invalid', data: {
|
|
type: 'archipelago:video-state', version: 1, state: 'connected', native: true, session: '11111111-1111-1111-1111-111111111111',
|
|
} }))
|
|
}
|
|
let entries = 0, exits = 0, settle
|
|
for (const video of [f.a, f.b]) video[request] = async () => { entries++; f.doc[field] = video }
|
|
f.doc[exit] = () => { exits++; return new Promise(resolve => { settle = () => { f.doc[field] = null; resolve() } }) }
|
|
f.button.click(); await tick()
|
|
assert.equal(f.doc[field], f.a)
|
|
if (cancel === 'selection') f.choose(1)
|
|
if (cancel === 'removal') f.a.remove()
|
|
if (cancel === 'persisted') { f.lifecycle('pagehide', true); f.lifecycle('pageshow', true) }
|
|
if (cancel === 'same') f.choose(0)
|
|
await tick()
|
|
assert.equal(exits, 1)
|
|
f.button.click(); await tick()
|
|
const duringExit = entries
|
|
settle(); await tick()
|
|
assert.equal(duringExit, 1, 'entry cannot overlap owned exit')
|
|
assert.equal(entries, 1, 'settlement must not auto-launch a presentation')
|
|
assert.equal(f.doc[field], null)
|
|
assert.doesNotMatch(f.toolbar.querySelector('[role=status]').textContent, /is active/)
|
|
f.button.click(); await tick()
|
|
assert.equal(entries, 2)
|
|
assert.equal(f.doc[field], ['selection', 'removal'].includes(cancel) ? f.b : f.a)
|
|
assert.equal(f.counts().pauses, 0)
|
|
f.doc[exit] = async () => { exits++; f.doc[field] = null }
|
|
})
|
|
}
|
|
}
|
|
for (const mode of ['pip', 'fullscreen']) {
|
|
for (const settlement of ['resolve', 'reject', 'unsettled']) {
|
|
await check(`${mode} ${settlement} exit after disposal preserves unrelated presentation and cleanup`, async f => {
|
|
const field = mode === 'pip' ? 'pictureInPictureElement' : 'fullscreenElement'
|
|
const exit = mode === 'pip' ? 'exitPictureInPicture' : 'exitFullscreen'
|
|
const request = mode === 'pip' ? 'requestPictureInPicture' : 'requestFullscreen'
|
|
if (mode === 'fullscreen') {
|
|
f.doc.pictureInPictureEnabled = false
|
|
f.doc.fullscreenElement = null
|
|
f.win.dispatchEvent(new f.win.MessageEvent('message', { source: f.win, origin: 'https://call.invalid', data: {
|
|
type: 'archipelago:video-state', version: 1, state: 'connected', native: true, session: '11111111-1111-1111-1111-111111111111',
|
|
} }))
|
|
}
|
|
let entries = 0, exits = 0, resolve, reject
|
|
for (const video of [f.a, f.b]) video[request] = async () => { entries++; f.doc[field] = video }
|
|
f.doc[exit] = () => { exits++; return new Promise((yes, no) => { resolve = yes; reject = no }) }
|
|
f.button.click(); await tick()
|
|
f.choose(1); f.button.click(); await tick()
|
|
assert.equal(entries, 1)
|
|
f.lifecycle('pagehide', false)
|
|
const status = f.toolbar.querySelector('[role=status]').textContent
|
|
const unrelated = f.doc.createElement('video'); f.doc.body.append(unrelated)
|
|
f.doc[field] = unrelated
|
|
if (settlement === 'resolve') resolve()
|
|
if (settlement === 'reject') reject(new Error('Exit denied'))
|
|
await tick()
|
|
f.a.dispatchEvent(new f.win.Event('leavepictureinpicture'))
|
|
f.lifecycle('pageshow', true); f.button.click(); await tick()
|
|
assert.equal(f.doc[field], unrelated)
|
|
assert.equal(exits, 1)
|
|
assert.equal(entries, 1)
|
|
assert.equal(f.toolbar.querySelector('[role=status]').textContent, status)
|
|
assert.equal(f.toolbar.isConnected, false)
|
|
assert.equal(f.observers.some(o => o.active), false)
|
|
assert.equal(f.listeners.filter(x => [f.win, f.a, f.b, f.button, f.select].includes(x.target)).length, 0)
|
|
assert.equal(f.timers.size, 0)
|
|
assert.equal(f.counts().pauses, 0)
|
|
})
|
|
}
|
|
await check(`${mode} rejected exit after selection change requires an exit-only retry`, async f => {
|
|
const field = mode === 'pip' ? 'pictureInPictureElement' : 'fullscreenElement'
|
|
const exit = mode === 'pip' ? 'exitPictureInPicture' : 'exitFullscreen'
|
|
const request = mode === 'pip' ? 'requestPictureInPicture' : 'requestFullscreen'
|
|
if (mode === 'fullscreen') {
|
|
f.doc.pictureInPictureEnabled = false
|
|
f.doc.fullscreenElement = null
|
|
f.win.dispatchEvent(new f.win.MessageEvent('message', { source: f.win, origin: 'https://call.invalid', data: {
|
|
type: 'archipelago:video-state', version: 1, state: 'connected', native: true, session: '11111111-1111-1111-1111-111111111111',
|
|
} }))
|
|
}
|
|
let entries = 0, exits = 0, reject
|
|
for (const video of [f.a, f.b]) video[request] = async () => { entries++; f.doc[field] = video }
|
|
f.doc[exit] = () => { exits++; return new Promise((_, no) => { reject = no }) }
|
|
f.button.click(); await tick(); f.choose(1)
|
|
reject(new Error('Exit refused')); await tick()
|
|
assert.match(f.toolbar.querySelector('[role=status]').textContent, /Could not exit/)
|
|
f.doc[exit] = async () => { exits++; f.doc[field] = null }
|
|
f.button.click(); await tick()
|
|
assert.equal(exits, 2); assert.equal(entries, 1)
|
|
f.button.click(); await tick()
|
|
assert.equal(f.doc[field], f.b); assert.equal(entries, 2)
|
|
assert.equal(f.counts().pauses, 0)
|
|
})
|
|
}
|
|
await check('selection change releases active original video without pausing call', async f => {
|
|
f.button.click(); await tick()
|
|
assert.equal(f.doc.pictureInPictureElement, f.a)
|
|
f.choose(1); await tick()
|
|
assert.equal(f.doc.pictureInPictureElement, null, 'old selection must leave PiP')
|
|
assert.equal(f.counts().pauses, 0)
|
|
assert.equal(f.doc.querySelectorAll('video').length, 2)
|
|
assert.equal(f.doc.querySelector('#unrelated').textContent, 'Keep me')
|
|
f.button.click(); await tick()
|
|
assert.equal(f.doc.pictureInPictureElement, f.b)
|
|
})
|
|
await check('permanent pagehide removes owned listeners and toolbar, disconnects queued observation', async f => {
|
|
f.button.click(); await tick()
|
|
f.video('queued') // Queue a real observer record before disposal.
|
|
f.lifecycle('pagehide', false)
|
|
f.lifecycle('pagehide', false)
|
|
await tick()
|
|
assert.equal(f.doc.pictureInPictureElement, null)
|
|
const owned = f.listeners.filter(x => x.target === f.win || x.target === f.button || x.target === f.select || x.target === f.a || x.target === f.b)
|
|
assert.deepEqual(owned.map(x => x.type), [], 'no owned listener remains')
|
|
assert.equal(f.toolbar.isConnected, false)
|
|
assert.equal(f.observers.some(o => o.active), false)
|
|
const calls = f.observers.map(o => o.calls)
|
|
const html = f.toolbar.innerHTML
|
|
f.video('after-disposal'); f.lifecycle('pageshow', true); f.button.click()
|
|
for (const o of f.observers) o.callback([]) // Already-dispatched/stale callback is harmless.
|
|
await tick()
|
|
assert.deepEqual(f.observers.map(o => o.calls), calls)
|
|
assert.equal(f.toolbar.innerHTML, html)
|
|
assert.equal(f.doc.pictureInPictureElement, null)
|
|
assert.equal(f.doc.querySelector('#unrelated').textContent, 'Keep me')
|
|
assert.equal(f.counts().pauses, 0)
|
|
})
|
|
await check('persisted pageshow refreshes eligibility and reuses the same adapter', async f => {
|
|
f.button.click(); await tick()
|
|
f.lifecycle('pagehide', true); await tick()
|
|
assert.equal(f.doc.pictureInPictureElement, null)
|
|
assert.equal(f.toolbar.isConnected, true)
|
|
f.a.srcObject = null // A property change is not a DOM mutation.
|
|
f.lifecycle('pageshow', true); f.lifecycle('pageshow', true); await tick()
|
|
assert.equal(f.select.options.length, 1, 'restoration refreshes eligible videos')
|
|
f.button.click(); await tick()
|
|
assert.equal(f.doc.pictureInPictureElement, f.b)
|
|
assert.equal(f.listeners.filter(x => x.target === f.button && x.type === 'click').length, 1)
|
|
assert.equal(f.listeners.filter(x => x.target === f.win && x.type === 'message').length, 1)
|
|
assert.equal(f.counts().pauses, 0)
|
|
})
|
|
for (const cancel of ['selection', 'removal', 'persisted', 'permanent']) {
|
|
await check(`pending entry cleans up after ${cancel} and cannot resurrect old selection`, async f => {
|
|
let settle
|
|
f.a.requestPictureInPicture = () => new Promise(resolve => { settle = () => { f.doc.pictureInPictureElement = f.a; resolve() } })
|
|
f.button.click()
|
|
assert.equal(f.button.disabled, true)
|
|
if (cancel === 'selection') f.choose(1)
|
|
if (cancel === 'removal') f.a.remove()
|
|
if (cancel === 'persisted') f.lifecycle('pagehide', true)
|
|
if (cancel === 'permanent') f.lifecycle('pagehide', false)
|
|
await tick()
|
|
assert.equal(f.listeners.filter(x => x.target === f.a).length, 0)
|
|
f.button.click() // Pending adapter must not overlap another browser entry.
|
|
assert.equal(f.doc.pictureInPictureElement, null)
|
|
settle(); await tick()
|
|
assert.equal(f.doc.pictureInPictureElement, null)
|
|
assert.equal(f.counts().pauses, 0)
|
|
if (cancel === 'permanent') {
|
|
f.lifecycle('pageshow', true); f.button.click(); await tick()
|
|
assert.equal(f.doc.pictureInPictureElement, null)
|
|
} else {
|
|
if (cancel === 'persisted') { f.lifecycle('pageshow', true); f.choose(1) }
|
|
f.button.click(); await tick()
|
|
assert.equal(f.doc.pictureInPictureElement, f.b)
|
|
}
|
|
})
|
|
}
|
|
await check('remote reorder preserves selected element and active presentation', async f => {
|
|
f.choose(1); f.button.click(); await tick()
|
|
f.doc.querySelector('#call').prepend(f.b); await tick()
|
|
assert.equal(f.select.value, '0')
|
|
assert.equal(f.doc.pictureInPictureElement, f.b)
|
|
assert.equal(f.counts().exits, 0)
|
|
f.a.remove(); await tick()
|
|
assert.equal(f.doc.pictureInPictureElement, f.b)
|
|
f.b.remove(); await tick()
|
|
assert.equal(f.doc.pictureInPictureElement, null)
|
|
assert.equal(f.toolbar.hidden, true)
|
|
assert.equal(f.listeners.filter(x => x.target === f.b).length, 0)
|
|
assert.equal(f.counts().pauses, 0)
|
|
})
|
|
await check('removal releases selected video and refreshes to surviving original', async f => {
|
|
f.button.click(); await tick(); f.a.remove(); await tick()
|
|
assert.equal(f.doc.pictureInPictureElement, null)
|
|
assert.equal(f.select.options.length, 1)
|
|
assert.equal(f.toolbar.hidden, false)
|
|
f.button.click(); await tick()
|
|
assert.equal(f.doc.pictureInPictureElement, f.b)
|
|
assert.equal(f.counts().pauses, 0)
|
|
})
|
|
await check('teardown preserves an unrelated active PiP element', async f => {
|
|
f.button.click(); await tick()
|
|
const unrelated = f.doc.createElement('video'); f.doc.body.append(unrelated)
|
|
f.doc.pictureInPictureElement = unrelated
|
|
f.choose(1); f.lifecycle('pagehide', false); await tick()
|
|
assert.equal(f.doc.pictureInPictureElement, unrelated)
|
|
assert.equal(unrelated.isConnected, true)
|
|
assert.equal(f.counts().exits, 0)
|
|
assert.equal(f.counts().pauses, 0)
|
|
})
|
|
await check('unmarked previews and empty streams never enter the remote picker', async f => {
|
|
const preview = f.doc.createElement('video'); preview.srcObject = {}; f.doc.body.append(preview)
|
|
const empty = f.video('empty'); empty.srcObject = null
|
|
await tick()
|
|
assert.equal(f.select.options.length, 2)
|
|
f.a.remove(); f.b.remove(); await tick()
|
|
assert.equal(f.toolbar.hidden, true)
|
|
f.button.click(); await tick()
|
|
assert.equal(f.doc.pictureInPictureElement, null)
|
|
assert.equal(preview.isConnected, true); assert.equal(empty.isConnected, true)
|
|
})
|
|
for (const persisted of [true, false]) {
|
|
await check(`synthetic host-awaiting entry clears timer and listeners on pagehide persisted=${persisted}`, async f => {
|
|
f.doc.pictureInPictureEnabled = false
|
|
f.doc.fullscreenElement = null
|
|
f.doc.exitFullscreen = async () => { f.doc.fullscreenElement = null }
|
|
for (const video of [f.a, f.b]) video.requestFullscreen = async () => { f.doc.fullscreenElement = video }
|
|
const session = '11111111-1111-1111-1111-111111111111'
|
|
const host = state => f.win.dispatchEvent(new f.win.MessageEvent('message', {
|
|
source: f.win, origin: 'https://call.invalid', data: { type: 'archipelago:video-state', version: 1, session, native: true, state },
|
|
}))
|
|
host('connected'); f.button.click(); await tick()
|
|
assert.equal(f.timers.size, 1)
|
|
assert.equal(f.doc.fullscreenElement, f.a)
|
|
assert.equal(f.messages.at(-1).message.action, 'enter')
|
|
f.lifecycle('pagehide', persisted); f.lifecycle('pagehide', persisted); await tick()
|
|
assert.equal(f.timers.size, 0)
|
|
assert.equal(f.doc.fullscreenElement, null)
|
|
assert.equal(f.listeners.filter(x => x.target === f.a).length, 0)
|
|
const status = f.toolbar.querySelector('[role=status]').textContent
|
|
host('entered'); await tick()
|
|
assert.equal(f.toolbar.querySelector('[role=status]').textContent, status)
|
|
assert.equal(f.counts().pauses, 0)
|
|
if (persisted) {
|
|
f.lifecycle('pageshow', true); f.choose(1); f.button.click(); await tick()
|
|
assert.equal(f.doc.fullscreenElement, f.b)
|
|
host('entered'); assert.equal(f.timers.size, 0)
|
|
} else {
|
|
assert.equal(f.listeners.filter(x => x.target === f.win).length, 0)
|
|
}
|
|
})
|
|
}
|
|
for (const stop of ['selection', 'removal', 'persisted']) {
|
|
await check(`status stops claiming active presentation after ${stop}`, async f => {
|
|
f.button.click(); await tick()
|
|
const status = f.toolbar.querySelector('[role=status]')
|
|
assert.match(status.textContent, /is active/)
|
|
if (stop === 'selection') f.choose(1)
|
|
if (stop === 'removal') f.a.remove()
|
|
if (stop === 'persisted') { f.lifecycle('pagehide', true); f.lifecycle('pageshow', true) }
|
|
await tick()
|
|
assert.equal(f.doc.pictureInPictureElement, null)
|
|
assert.doesNotMatch(status.textContent, /is active/, 'visible status must agree with released presentation')
|
|
assert.match(status.textContent, /call/)
|
|
})
|
|
}
|
|
console.log(JSON.stringify({ passed, failed })); process.exitCode = failed ? 1 : 0
|
|
}
|
|
main().catch(error => { console.error(error); process.exitCode = 1 })
|