#!/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 })