import { test } from 'node:test' import assert from 'node:assert/strict' import { attachVideoPip } from './archipelago-video.mjs' function fixture({ native = false, browser = true } = {}) { const sent = [] const win = new EventTarget() win.parent = { postMessage: (...args) => sent.push(args) } const doc = Object.assign(new EventTarget(), { pictureInPictureEnabled: browser, fullscreenElement: null, pictureInPictureElement: null }) let requests = 0, exits = 0 const video = Object.assign(new EventTarget(), { videoWidth: 640, videoHeight: 480, ended: false, paused: false, requestPictureInPicture: async () => { requests++; doc.pictureInPictureElement = video }, requestFullscreen: async () => { requests++; doc.fullscreenElement = video }, pause() { this.paused = true }, play: async () => {}, }) doc.exitPictureInPicture = async () => { exits++; doc.pictureInPictureElement = null } doc.exitFullscreen = async () => { exits++; doc.fullscreenElement = null } const button = Object.assign(new EventTarget(), { disabled: false }) const status = { textContent: '' } const adapter = attachVideoPip({ button, status, getVideo: () => video, dashboardOrigin: 'https://node.example', win, doc }) function message(data, origin = 'https://node.example', source = win.parent) { const event = new Event('message') Object.assign(event, { origin, source, data: { type: 'archipelago:video-state', version: 1, acquisition: sent.filter(([item]) => item.action === 'reserve').at(-1)?.[0].acquisition ?? null, ...data } }) win.dispatchEvent(event) } const session = '12345678-1234-4234-8234-123456789abc' if (native) message({ state: 'connected', session, native: true }) // Existing lifecycle vectors start at the admitted user click. Model the // paired host reservation and second click here, without replacing the real // adapter or changing its pending presentation/cleanup promises. const enter = () => { const before = sent.length const fullscreenBefore = doc.fullscreenElement const pending = adapter.enter() const reserve = sent.slice(before).find(([item]) => item.action === 'reserve')?.[0] if (!reserve) return pending assert.equal(doc.fullscreenElement, fullscreenBefore) message({ state: 'reserved', session, acquisition: reserve.acquisition }) return adapter.enter() } return { adapter: { ...adapter, enter }, doc, video, button, status, sent, message, session, win, requests: () => requests, exits: () => exits } } for (const state of ['restored', 'closed', 'released', 'error']) test(`168 ${state}: acknowledgement waits for owned exit and retains original identity`, async () => { const f = fixture({ native: true, browser: false }); await f.adapter.enter() const owner = f.sent.find(([data]) => data.action === 'reserve')[0] const pending = deferred() f.doc.exitFullscreen = async () => { await pending.promise; f.doc.fullscreenElement = null } f.message({ state, session: f.session }) const releases = () => f.sent.filter(([data]) => data.action === 'release') assert.equal(releases().length, 0) pending.resolve(); await new Promise(resolve => setImmediate(resolve)) assert.equal(releases().length, 1) assert.equal(releases()[0][0].session, owner.session) assert.equal(releases()[0][0].acquisition, owner.acquisition) assert.equal(releases()[0][0].presentationSettled, true) f.adapter.dispose() }) for (const outcome of ['unchanged', 'rejected', 'rejected-cleared']) test(`168 restored: ${outcome} exit cannot acknowledge settlement`, async () => { const f = fixture({ native: true, browser: false }); await f.adapter.enter() f.doc.exitFullscreen = async () => { if (outcome === 'rejected-cleared') f.doc.fullscreenElement = null if (outcome.startsWith('rejected')) throw Error('exit refused') } f.message({ state: 'restored', session: f.session }) await new Promise(resolve => setImmediate(resolve)) assert.equal(f.sent.filter(([data]) => data.action === 'release').length, 0) f.adapter.dispose() }) function deferred() { let resolve, reject const promise = new Promise((yes, no) => { resolve = yes; reject = no }) return { promise, resolve, reject } } test('late browser entry after disposal releases PiP without stopping the call', async () => { const f = fixture() const pending = deferred() f.video.requestPictureInPicture = async () => { await pending.promise f.doc.pictureInPictureElement = f.video } const entering = f.adapter.enter() f.adapter.dispose() const status = f.status.textContent pending.resolve() await entering assert.equal(f.doc.pictureInPictureElement, null) assert.equal(f.exits(), 1) assert.equal(f.video.paused, false) assert.equal(f.status.textContent, status) assert.equal(f.button.disabled, false) }) test('browser PiP uses the same element and returns without stopping the call', async () => { const f = fixture(); await f.adapter.enter() assert.equal(f.doc.pictureInPictureElement, f.video) assert.equal(f.requests(), 1); assert.equal(f.video.paused, false) await f.adapter.enter() assert.equal(f.exits(), 1); assert.equal(f.video.paused, false) f.adapter.dispose() }) test('late fullscreen after stop is exited without sending native enter', async () => { const f = fixture({ native: true, browser: false }) const pending = deferred() f.video.requestFullscreen = async () => { await pending.promise f.doc.fullscreenElement = f.video } const entering = f.adapter.enter() f.adapter.stop() pending.resolve() await entering assert.equal(f.doc.fullscreenElement, null) assert.equal(f.exits(), 1) assert.equal(f.sent.some(([data]) => data.action === 'enter'), false) assert.equal(f.video.paused, false) f.adapter.dispose() }) test('stopped pending entry cannot overlap a retry on the same video', async () => { const f = fixture() const pending = deferred() let requests = 0 f.video.requestPictureInPicture = async () => { requests++ await pending.promise f.doc.pictureInPictureElement = f.video } const entering = f.adapter.enter() f.adapter.stop() const retry = f.adapter.enter() pending.resolve() await Promise.all([entering, retry]) assert.equal(requests, 1) assert.equal(f.doc.pictureInPictureElement, null) await f.adapter.enter() assert.equal(requests, 2) assert.equal(f.doc.pictureInPictureElement, f.video) f.adapter.dispose() }) test('disposed adapter cannot be reentered through its public method', async () => { const f = fixture() f.adapter.dispose() await f.adapter.enter() assert.equal(f.requests(), 0) assert.equal(f.doc.pictureInPictureElement, null) }) test('late rejected entry does not overwrite status after stop or disposal', async () => { for (const teardown of ['stop', 'dispose']) { const f = fixture() const pending = deferred() f.video.requestPictureInPicture = () => pending.promise const entering = f.adapter.enter() f.adapter[teardown]() f.status.textContent = 'New lifecycle status' pending.reject(new Error('Old request denied')) await entering assert.equal(f.status.textContent, 'New lifecycle status') assert.equal(f.button.disabled, false) f.adapter.dispose() } }) test('host entry acknowledgement after stop cannot resurrect active status', async () => { const f = fixture({ native: true, browser: false }) await f.adapter.enter() f.adapter.stop() f.status.textContent = 'Returned to call' f.message({ state: 'entered', session: f.session }) assert.equal(f.status.textContent, 'Returned to call') assert.equal(f.doc.fullscreenElement, null) f.adapter.dispose() }) test('pagehide cancels pending entry and pageshow retains a usable adapter', async () => { const f = fixture() const pending = deferred() f.video.requestPictureInPicture = async () => { await pending.promise f.doc.pictureInPictureElement = f.video } const entering = f.adapter.enter() f.win.dispatchEvent(new Event('pagehide')) pending.resolve() await entering assert.equal(f.doc.pictureInPictureElement, null) f.win.dispatchEvent(new Event('pageshow')) assert.equal(f.sent.at(-1)[0].type, 'archipelago:video-ready') await f.adapter.enter() assert.equal(f.doc.pictureInPictureElement, f.video) f.adapter.dispose() const count = f.sent.length for (const name of ['play', 'pause', 'ended', 'error', 'leavepictureinpicture']) { f.video.dispatchEvent(new Event(name)) } f.win.dispatchEvent(new Event('pageshow')) f.button.dispatchEvent(new Event('click')) assert.equal(f.sent.length, count) assert.equal(f.doc.pictureInPictureElement, null) }) test('late completion never exits a different element owned by another viewer', async () => { for (const browser of [true, false]) { const f = fixture({ native: !browser, browser }) const pending = deferred() const field = browser ? 'pictureInPictureElement' : 'fullscreenElement' const method = browser ? 'requestPictureInPicture' : 'requestFullscreen' f.video[method] = () => pending.promise const entering = f.adapter.enter() f.adapter.stop() const other = new EventTarget() f.doc[field] = other pending.resolve() await entering assert.equal(f.doc[field], other) assert.equal(f.exits(), 0) f.adapter.dispose() } }) test('native entry fullscreens first and sends no source or credentials', async () => { const f = fixture({ native: true, browser: false }); await f.adapter.enter() assert.equal(f.doc.fullscreenElement, f.video) assert.deepEqual(f.sent.at(-1), [{ type: 'archipelago:video-request', version: 1, session: f.session, acquisition: f.sent.find(([item]) => item.action === 'reserve')[0].acquisition, action: 'enter', width: 640, height: 480, playing: true }, 'https://node.example']) f.message({ state: 'entered', session: f.session }) assert.equal(f.button.disabled, false) f.message({ state: 'restored', session: f.session }) assert.equal(f.exits(), 1); assert.equal(f.video.paused, false) f.adapter.dispose() }) test('foreign origins, sibling windows and stale sessions cannot control playback', async () => { const f = fixture({ native: true, browser: false }); await f.adapter.enter() f.message({ state: 'command', session: f.session, command: 'pause' }, 'https://evil.example') f.message({ state: 'command', session: f.session, command: 'pause' }, 'https://node.example', {}) f.message({ state: 'command', session: 'wrong', command: 'pause' }) assert.equal(f.video.paused, false) f.message({ state: 'command', session: f.session, command: 'pause' }) assert.equal(f.video.paused, true) f.adapter.dispose() }) test('unsupported browser and zero-dimension video show truthful failures', async () => { const f = fixture({ browser: false }); await f.adapter.enter() assert.match(f.status.textContent, /unavailable/); assert.equal(f.requests(), 0) f.video.videoWidth = 0; await f.adapter.enter() assert.match(f.status.textContent, /Wait for/) f.adapter.dispose() }) test('stale leave callback cannot clear a new attempt on the same video', async () => { const f = fixture() const callbacks = [] const add = f.video.addEventListener.bind(f.video) f.video.addEventListener = (name, fn) => { if (name === 'leavepictureinpicture') callbacks.push(fn); add(name, fn) } try { await f.adapter.enter() f.adapter.stop(); await tick() const pending = deferred() f.video.requestPictureInPicture = async () => { await pending.promise; f.doc.pictureInPictureElement = f.video } const entering = f.adapter.enter() callbacks[0]({ target: f.video }) // A callback already captured before listener removal. pending.resolve(); await entering assert.equal(f.doc.pictureInPictureElement, f.video) assert.match(f.status.textContent, /is active/) callbacks[0]({ target: f.video }) assert.equal(f.doc.pictureInPictureElement, f.video) assert.match(f.status.textContent, /is active/) } finally { f.adapter.dispose() } }) test('host restoration does not claim completed browser exit while its promise is pending', async () => { const f = fixture({ native: true, browser: false }) try { await f.adapter.enter() const pending = deferred() f.doc.exitFullscreen = async () => { await pending.promise; f.doc.fullscreenElement = null } f.message({ state: 'restored', session: f.session }) const during = f.status.textContent await f.adapter.enter() assert.equal(f.requests(), 1) pending.resolve(); await tick() assert.doesNotMatch(during, /Returned to|is active/) assert.equal(f.doc.fullscreenElement, null) } finally { f.adapter.dispose() } }) test('all owned exits must settle even when another owned exit rejects', async () => { const f = fixture() const pip = deferred(), full = deferred() try { await f.adapter.enter() f.doc.fullscreenElement = f.video // Synthetic dual-surface fault, not a browser support claim. f.doc.exitPictureInPicture = () => pip.promise f.doc.exitFullscreen = () => full.promise f.adapter.stop() pip.reject(new Error('PiP denied')); await tick() await f.adapter.enter() assert.equal(f.requests(), 1) assert.equal(f.button.disabled, true) f.doc.pictureInPictureElement = null; f.doc.fullscreenElement = null full.resolve(); await tick() await f.adapter.enter() assert.equal(f.requests(), 2) } finally { f.adapter.dispose() } }) for (const browser of [true, false]) { test(`cancelled entry cleanup exit stays single-flight after rejection browser=${browser}`, async () => { const f = fixture({ browser, native: !browser }) const field = browser ? 'pictureInPictureElement' : 'fullscreenElement' const request = browser ? 'requestPictureInPicture' : 'requestFullscreen' const exit = browser ? 'exitPictureInPicture' : 'exitFullscreen' const entry = deferred(), exiting = deferred() let requests = 0, exits = 0 f.video[request] = async () => { requests++; await entry.promise; f.doc[field] = f.video } f.doc[exit] = () => { exits++; return exiting.promise } try { const entering = f.adapter.enter() f.adapter.stop(); entry.resolve(); await tick() await f.adapter.enter() assert.equal(requests, 1); assert.equal(exits, 1) exiting.reject(new Error('Exit denied')); await entering assert.match(f.status.textContent, /Could not exit/) const unrelated = new EventTarget() f.doc[field] = unrelated f.adapter.stop(); await tick() assert.equal(f.doc[field], unrelated) assert.equal(exits, 1, 'no blanket exit after rejection or ownership change') f.doc[exit] = async () => { exits++; f.doc[field] = null } await f.adapter.enter() assert.equal(requests, 2) } finally { f.adapter.dispose() } }) } const tick = () => new Promise(resolve => setImmediate(resolve)) for (const browser of [true, false]) { const mode = browser ? 'PiP' : 'fullscreen' const field = browser ? 'pictureInPictureElement' : 'fullscreenElement' const exit = browser ? 'exitPictureInPicture' : 'exitFullscreen' for (const refusal of ['reject', 'throw', 'unchanged']) { test(`${mode} ${refusal} exit retains ownership until explicit retry releases it`, async () => { const f = fixture({ browser, native: !browser }) try { await f.adapter.enter() const pending = deferred() let exits = 0 f.doc[exit] = () => { exits++ if (refusal === 'throw') throw new Error('Exit denied') return pending.promise } f.adapter.stop() f.adapter.stop() await f.adapter.enter() assert.equal(f.requests(), 1) assert.equal(exits, 1) if (refusal === 'reject') pending.reject(new Error('Exit denied')) else pending.resolve() await tick() assert.match(f.status.textContent, /could not exit/i) assert.doesNotMatch(f.status.textContent, /is active|Returned to/) assert.equal(f.button.disabled, false) f.doc[exit] = async () => { exits++; f.doc[field] = null } await f.adapter.enter() // Explicit retry is exit-only, not another entry. await tick() assert.equal(exits, 2) assert.equal(f.requests(), 1) assert.equal(f.doc[field], null) await f.adapter.enter() assert.equal(f.requests(), 2) assert.equal(f.doc[field], f.video) } finally { f.adapter.dispose() } }) } } test('permission denial clears busy state without claiming PiP entry', async () => { const f = fixture(); f.video.requestPictureInPicture = async () => { throw new Error('Permission denied') } await f.adapter.enter() assert.equal(f.button.disabled, false); assert.equal(f.status.textContent, 'Permission denied') assert.equal(f.doc.pictureInPictureElement, null) f.adapter.dispose() })