Files
archy/docker/kithmoot/archipelago-video.test.mjs
T

385 lines
17 KiB
JavaScript

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()
})