Files
ArtPlayer/test/iframe-lifecycle.test.js

256 lines
11 KiB
JavaScript

import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Candidate assertions also run unchanged against the frozen workspace.
import test from 'node:test'
import { iframeCandidate, iframeEnvironment } from './helpers/iframe.js'
const implementation = await iframeCandidate()
const flush = () => new Promise(resolve => setImmediate(resolve))
function observe(promise) {
const state = { status: 'pending' }
promise.then(value => Object.assign(state, { status: 'resolved', value }), error => Object.assign(state, { status: 'rejected', error: error.message }))
return state
}
function setup(injected = true) {
const env = iframeEnvironment(implementation)
const instance = new env.Factory({ iframe: new env.Frame(), url: '/first' })
if (injected)
env.dispatch({ type: 'inject' })
return { ...env, instance }
}
function tick(env) {
for (const [id, timer] of [...env.timers]) {
assert.equal(timer.delay, 200)
env.timers.delete(id)
timer.callback()
}
}
test('Iframe candidate preserves instance shape, descriptors, binding and listener-before-src order', () => {
const env = setup(false)
const instance = env.instance
assert.deepEqual(Object.getOwnPropertyNames(env.Factory.prototype), ['constructor', 'onMessage', 'postMessage', 'commit', 'message', 'destroy'])
assert.deepEqual(Object.keys(instance), ['url', '$iframe', 'promises', 'injected', 'destroyed', 'messageCallback', 'onMessage'])
for (const key of Object.keys(instance)) {
const descriptor = Object.getOwnPropertyDescriptor(instance, key)
assert(descriptor.writable && descriptor.enumerable && descriptor.configurable)
}
assert.deepEqual(env.order, ['listen:message', 'src'])
assert.equal(instance.messageCallback(), null)
const receiver = instance.onMessage
receiver({ data: { type: 'inject' } })
assert.equal(instance.injected, true)
assert.equal(instance.destroy(), undefined)
})
test('Iframe candidate preserves numeric envelopes, resove spelling and generic response callback receiver', async () => {
const env = setup()
const registry = env.instance.promises
const seen = []
env.instance.message(function (packet) {
seen.push({ receiver: this === env.instance, ...packet })
})
const pending = env.instance.postMessage({ type: 'custom-query', data: 3, id: 900 })
assert.deepEqual(Object.keys(registry[1234]), ['resove', 'reject'])
assert.deepEqual(JSON.parse(JSON.stringify(env.sent)), [{ packet: { type: 'custom-query', data: 3, id: 1234 }, origin: '*' }])
env.dispatch({ type: 'custom-result', data: 4, id: 1234 })
assert.equal(await pending, 4)
assert.equal(registry, env.instance.promises)
assert.deepEqual(Object.keys(registry), [])
assert.deepEqual(seen, [{ receiver: true, type: 'custom-result', data: 4 }])
env.instance.destroy()
})
test('Iframe candidate matches simultaneous requests independently and out of order', async () => {
const env = setup()
const first = observe(env.instance.postMessage({ type: 'one' }))
const second = observe(env.instance.postMessage({ type: 'two' }))
assert.deepEqual(env.sent.map(item => item.packet.id), [1234, 1235])
env.dispatch({ type: 'response', data: 2, id: 1235 })
env.dispatch({ type: 'response', data: 1, id: 1234 })
await flush()
assert.deepEqual([first, second], [{ status: 'resolved', value: 1 }, { status: 'resolved', value: 2 }])
assert.equal(Object.keys(env.instance.promises).length, 0)
env.instance.destroy()
})
test('Iframe candidate never reuses a settled ID when the clock moves backwards', async () => {
const env = setup()
const first = env.instance.postMessage({ type: 'one' })
env.dispatch({ type: 'response', data: 1, id: 1234 })
assert.equal(await first, 1)
env.now(1000)
const second = observe(env.instance.postMessage({ type: 'two' }))
assert.equal(env.sent.at(-1).packet.id, 1235)
env.dispatch({ type: 'response', data: 'duplicate', id: 1234 })
await flush()
assert.equal(second.status, 'pending')
env.dispatch({ type: 'response', data: 2, id: 1235 })
await flush()
assert.deepEqual(second, { status: 'resolved', value: 2 })
env.instance.destroy()
})
test('Iframe candidate separates two instances sharing the same iframe window', async () => {
const env = setup()
const second = new env.Factory({ iframe: env.instance.$iframe, url: '/first' })
env.dispatch({ type: 'inject' })
const one = observe(env.instance.postMessage({ type: 'one' }))
const two = observe(second.postMessage({ type: 'two' }))
assert.deepEqual(env.sent.map(item => item.packet.id), [1234, 1235])
env.dispatch({ type: 'response', data: 1, id: 1234 })
env.dispatch({ type: 'response', data: 2, id: 1235 })
await flush()
assert.deepEqual([one, two], [{ status: 'resolved', value: 1 }, { status: 'resolved', value: 2 }])
env.instance.destroy()
second.destroy()
})
test('Iframe candidate destroys sent requests immediately and ignores saved receivers', async () => {
const env = setup()
const state = observe(env.instance.postMessage({ type: 'one' }))
const receiver = env.instance.onMessage
let calls = 0
env.instance.message(() => calls++)
env.instance.destroy()
receiver({ data: { type: 'response', data: 'late', id: 1234 } })
await flush()
assert.deepEqual(state, { status: 'rejected', error: 'The instance has been destroyed' })
assert.equal(Object.keys(env.instance.promises).length, 0)
assert.equal(calls, 0)
assert.equal(env.handlers.get('message').size, 0)
assert.equal(env.instance.destroy(), undefined)
})
test('Iframe candidate destroys waiting requests immediately and cancels every polling timer', async () => {
const env = setup(false)
const states = Array.from({ length: 3 }, () => observe(env.instance.postMessage({ type: 'wait' })))
const late = [...env.timers.values()].map(timer => timer.callback)
env.instance.destroy()
await flush()
assert.equal(env.timers.size, 0)
assert(states.every(state => state.status === 'rejected' && state.error === 'The instance has been destroyed'))
late.forEach(callback => callback())
assert.equal(env.timers.size, 0)
assert.equal(env.sent.length, 0)
})
test('Iframe candidate preserves 200ms injection waiting and allocates unique IDs after handshake', async () => {
const env = setup(false)
const one = observe(env.instance.postMessage({ type: 'one' }))
const two = observe(env.instance.postMessage({ type: 'two' }))
tick(env)
assert.equal(env.sent.length, 0)
assert.equal(env.timers.size, 2)
env.dispatch({ type: 'inject' })
assert.equal(env.sent.length, 0)
tick(env)
assert.equal(env.timers.size, 0)
assert.deepEqual(env.sent.map(item => item.packet.id), [1234, 1235])
env.dispatch({ type: 'response', data: 1, id: 1234 })
env.dispatch({ type: 'error', data: 'remote failure', id: 1235 })
await flush()
assert.deepEqual([one, two], [{ status: 'resolved', value: 1 }, { status: 'rejected', error: 'remote failure' }])
env.instance.destroy()
})
for (const deferred of [false, true]) {
test(`Iframe candidate clears ${deferred ? 'deferred' : 'immediate'} send failure and preserves the exact rejection`, async () => {
const env = setup(!deferred)
const failure = new Error('clone failure')
env.instance.$iframe.contentWindow.postMessage = () => {
throw failure
}
const promise = env.instance.postMessage({ type: 'query' })
const rejected = assert.rejects(promise, error => error === failure)
if (deferred) {
env.dispatch({ type: 'inject' })
tick(env)
}
await rejected
assert.equal(Object.keys(env.instance.promises).length, 0)
assert.equal(env.timers.size, 0)
env.instance.destroy()
})
}
test('Iframe candidate rejects after destroy without allocating a request or timer', async () => {
const env = setup(false)
env.instance.destroy()
await assert.rejects(env.instance.postMessage({ type: 'late' }), /The instance has been destroyed/)
assert.equal(env.timers.size, 0)
assert.equal(env.sent.length, 0)
assert.equal(Object.keys(env.instance.promises).length, 0)
})
test('Iframe candidate permits public resove/reject callbacks and releases their owned requests', async () => {
const env = setup()
const first = env.instance.postMessage({ type: 'one' })
env.instance.promises[1234].resove(5)
assert.equal(await first, 5)
assert.equal(Object.keys(env.instance.promises).length, 0)
const second = env.instance.postMessage({ type: 'two' })
const rejected = assert.rejects(second, /external rejection/)
env.instance.promises[env.sent.at(-1).packet.id].reject(new Error('external rejection'))
await rejected
assert.equal(Object.keys(env.instance.promises).length, 0)
env.instance.destroy()
})
test('Iframe candidate cancels owned promises even when public records were removed by a consumer', async () => {
const env = setup()
const state = observe(env.instance.postMessage({ type: 'one' }))
delete env.instance.promises[1234]
env.instance.destroy()
await flush()
assert.deepEqual(state, { status: 'rejected', error: 'The instance has been destroyed' })
})
test('Iframe candidate ignores inherited request keys without changing generic message callbacks', () => {
const env = setup()
const seen = []
env.instance.message(packet => seen.push(packet.type))
assert.doesNotThrow(() => env.dispatch({ type: 'custom', id: 'toString' }))
assert.deepEqual(seen, ['custom'])
env.instance.promises[7] = undefined
assert.doesNotThrow(() => env.dispatch({ type: 'empty', id: 7 }))
assert.deepEqual(seen, ['custom', 'empty'])
env.instance.destroy()
})
test('Iframe candidate preserves commit serialization, synchronous response and async resolver behavior', async () => {
const parent = setup()
const promise = parent.instance.commit(() => {
return 7
})
const packet = parent.sent[0].packet
assert.equal(packet.data.trim(), 'return 7')
const child = iframeEnvironment(implementation, true)
const handling = child.Factory.onMessage({ data: packet })
assert.equal(child.sent[0].packet.data, 7)
await handling
parent.dispatch(child.sent[0].packet)
assert.equal(await promise, 7)
const async = child.Factory.onMessage({ data: { type: 'commit', data: 'resolve(8)', id: 18 } })
assert.equal(child.sent.length, 1)
await async
assert.equal(child.sent[1].packet.data, 8)
parent.instance.destroy()
})
test('Iframe candidate preserves child errors, injection repetition and argument validation', async () => {
const parent = setup()
assert.throws(() => new parent.Factory({ iframe: {}, url: '/' }), /needs to be a HTMLIFrameElement/)
assert.throws(() => parent.instance.commit(null), /needs to be a function/)
assert.throws(() => parent.instance.message(null), /needs to be a function/)
assert.throws(() => parent.Factory.inject(), /can only be used in iframe/)
const child = iframeEnvironment(implementation, true)
child.Factory.inject()
child.Factory.inject()
assert.equal(child.handlers.get('message').size, 1)
assert.deepEqual(child.sent.map(item => item.packet.id), [0, 0])
await assert.rejects(child.Factory.onMessage({ data: { type: 'commit', data: 'throw new Error("child failure")', id: 19 } }), /child failure/)
assert.equal(child.sent.at(-1).packet.type, 'error')
assert.equal(child.sent.at(-1).packet.data, 'child failure')
parent.instance.destroy()
})