#!/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('', {
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 })