385 lines
17 KiB
JavaScript
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()
|
|
})
|