refactor(iframe): [PKG-IFRAME-03] scope requests to active documents

This commit is contained in:
Harvey Zhao committed 2026-09-13 01:07:37 +08:00
1 parent b8cdafedd1
commit 1342de94ef
27 files changed
+10860 -69

No files matched your search

+1 -1
View File
@@ -116,7 +116,7 @@ for (const item of cases) {
for (const payload of [null, false, 3, 'packet', {}, { type: 3 }])
window.targetFrame.contentWindow.postMessage(payload, '*')
})
await expect.poll(() => child.evaluate(() => window.received.length)).toBe(6)
await expect.poll(() => child.evaluate(() => window.received.filter(packet => !packet.internal).length)).toBe(6)
const value = await page.evaluate(() => window.tool.commit(() => {
return 12
}))
+12 -1
View File
@@ -5,11 +5,22 @@
window.Tool = window[parameters.get('global')];
window.received = [];
window.executions = 0;
window.documentWitness = Math.random().toString(36);
addEventListener('pagehide', event => parent.postMessage({ type: 'fixture-pagehide', data: { epoch: parameters.get('epoch'), persisted: event.persisted } }, '*'));
addEventListener('pageshow', event => parent.postMessage({ type: 'fixture-pageshow', data: { epoch: parameters.get('epoch'), persisted: event.persisted, witness: window.documentWitness } }, '*'));
addEventListener('message', event => {
received.push({ type: event.data?.type, id: event.data?.id, fromParent: event.source === parent, origin: event.origin });
received.push({ type: event.data?.type, id: event.data?.id, fromParent: event.source === parent, origin: event.origin, internal: event.data?.type === 'artplayer-tool-iframe:session' && event.data?.__artplayerIframe?.version === 1 });
if (event.data?.type === 'hold') window.held = event.data;
parent.postMessage({ type: 'fixture-observed', data: { count: received.length, type: event.data?.type, id: event.data?.id } }, '*');
});
if (parameters.get('auto') !== '0') Tool.inject();
parent.postMessage({ type: 'fixture-ready' }, '*');
</script>
<body>
<script>
if (parameters.get('barrier') === '1') {
const image = document.createElement('img');
image.src = '/iframe-load-barrier.svg';
document.body.append(image);
}
</script>
+291
View File
@@ -0,0 +1,291 @@
import fs from 'node:fs'
import { hash } from '../../refactor/scripts/releases.mjs'
import { iframeCandidate, iframeHistorical } from '../helpers/iframe.js'
import { expect, test } from './fixtures.js'
const implementation = await iframeCandidate()
const scenarios = ['inject-before-load', 'duplicate-inject', 'attribute-navigation', 'child-navigation', 'rapid-navigation', 'same-source-reload', 'fragment-navigation', 'attribute-fragment', 'stale-packets', 'history-back', 'srcdoc-navigation']
const childHtml = fs.readFileSync(new URL('./iframe-boundary-child.html', import.meta.url), 'utf8')
for (const relation of ['same-origin', 'cross-origin', 'opaque-origin']) {
for (const scenario of scenarios) {
test(`Iframe document ${relation}/${scenario}/${implementation.name}`, async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=published')
const parentOrigin = new URL(page.url()).origin
const childOrigin = relation === 'cross-origin' ? parentOrigin.replace('127.0.0.1', 'localhost') : parentOrigin
const urlFor = (epoch, auto = true) => `${childOrigin}/test/iframe-boundary-child.html?global=${implementation.global}&auto=${auto ? 1 : 0}&epoch=${epoch}`
const url = urlFor(1) + (scenario === 'inject-before-load' ? '&barrier=1' : '')
let releaseBarrier
await page.route('**/iframe-load-barrier.svg', async (route) => {
await new Promise(resolve => releaseBarrier = resolve)
await route.fulfill({ contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>' })
})
await page.route('**/iframe-boundary-lib.js', route => route.fulfill({ contentType: 'text/javascript', body: implementation.code }))
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${implementation.code}; window.IframeFactory = module.exports.default || module.exports; })();` })
await page.evaluate(({ url, opaque }) => {
window.timeline = []
window.states = {}
window.callbacks = []
const frame = document.createElement('iframe')
if (opaque)
frame.sandbox = 'allow-scripts'
document.body.append(frame)
window.targetFrame = frame
frame.addEventListener('load', () => window.timeline.push({ kind: 'load' }))
addEventListener('message', event => window.timeline.push({ kind: 'message', packet: event.data, fromChild: event.source === frame.contentWindow, origin: event.origin }))
window.tool = new window.IframeFactory({ iframe: frame, url })
window.tool.message(packet => window.callbacks.push(packet))
window.track = (key, promise) => {
window.states[key] = { status: 'pending' }
promise.then(value => window.states[key] = { status: 'resolved', value }, error => window.states[key] = { status: 'rejected', error: error.message })
}
}, { url: scenario === 'attribute-fragment' && relation === 'same-origin' ? url.replace(parentOrigin, '') : url, opaque: relation === 'opaque-origin' })
let result
try {
await expect.poll(() => page.evaluate(() => window.tool.injected)).toBe(true)
let child = page.frames().find(frame => frame.url() === url)
const first = await page.evaluate(() => window.timeline.find(item => item.packet?.type === 'inject').packet.__artplayerIframe?.document || 'unmarked')
if (first !== 'unmarked')
await expect.poll(() => child.evaluate(() => window.received.some(packet => packet.internal))).toBe(true)
const witness = await child.evaluate(() => window.documentWitness)
await page.evaluate(() => window.track('old', window.tool.postMessage({ type: 'hold' })))
await expect.poll(() => child.evaluate(() => Boolean(window.held))).toBe(true)
const heldId = await child.evaluate(() => window.held.id)
if (scenario === 'inject-before-load') {
await expect.poll(() => Boolean(releaseBarrier)).toBe(true)
expect(await page.evaluate(() => window.timeline.filter(item => item.kind === 'load').length)).toBe(0)
releaseBarrier()
await expect.poll(() => page.evaluate(() => window.timeline.some(item => item.kind === 'load'))).toBe(true)
expect(await page.evaluate(() => window.states.old.status)).toBe('pending')
expect(await page.evaluate(() => window.tool.injected)).toBe(true)
await child.evaluate(() => window.Tool.postMessage({ type: 'response', id: window.held.id, data: 'after load' }))
await expect.poll(() => page.evaluate(() => window.states.old.status)).toBe('resolved')
result = await page.evaluate(() => ({ state: window.states.old, order: window.timeline.filter(item => item.kind === 'load' || item.packet?.type === 'inject').map(item => item.kind === 'load' ? 'load' : 'inject') }))
expect(result.order).toEqual(['inject', 'load'])
}
else if (scenario === 'duplicate-inject') {
await child.evaluate(() => {
window.Tool.inject()
window.Tool.inject()
})
await expect.poll(() => page.evaluate(() => window.timeline.filter(item => item.packet?.type === 'inject').length)).toBe(3)
expect(await page.evaluate(() => window.states.old.status)).toBe('pending')
await child.evaluate(() => window.Tool.postMessage({ type: 'response', id: window.held.id, data: 12 }))
await expect.poll(() => page.evaluate(() => window.states.old.status)).toBe('resolved')
result = await page.evaluate(() => ({ state: window.states.old, documentIds: window.timeline.filter(item => item.packet?.type === 'inject').map(item => item.packet.__artplayerIframe?.document || 'unmarked') }))
expect(result.documentIds.every(value => value === first)).toBe(true)
}
else if (scenario === 'fragment-navigation') {
await child.evaluate(() => location.hash = 'chapter')
await expect.poll(() => child.evaluate(() => location.hash)).toBe('#chapter')
expect(await child.evaluate(() => window.documentWitness)).toBe(witness)
await child.evaluate(() => window.Tool.postMessage({ type: 'response', id: window.held.id, data: 'same document' }))
await expect.poll(() => page.evaluate(() => window.states.old.status)).toBe('resolved')
result = await page.evaluate(() => ({ state: window.states.old, injected: window.tool.injected }))
expect(result.injected).toBe(true)
}
else if (scenario === 'attribute-fragment') {
await child.evaluate(() => location.hash = 'child')
await page.evaluate(() => window.targetFrame.src = `${window.targetFrame.src.split('#')[0]}#parent`)
await expect.poll(() => child.evaluate(() => location.hash)).toBe('#parent')
expect(await child.evaluate(() => window.documentWitness)).toBe(witness)
expect(await page.evaluate(() => window.states.old.status)).toBe('pending')
await child.evaluate(() => location.hash = 'child-again')
await page.evaluate(() => {
const attribute = window.targetFrame.src
window.targetFrame.src = attribute
})
await expect.poll(() => child.evaluate(() => location.hash)).toBe('#parent')
expect(await child.evaluate(() => window.documentWitness)).toBe(witness)
expect(await page.evaluate(() => window.states.old.status)).toBe('pending')
await child.evaluate(() => window.Tool.postMessage({ type: 'response', id: window.held.id, data: 'attribute fragment' }))
await expect.poll(() => page.evaluate(() => window.states.old.status)).toBe('resolved')
result = await page.evaluate(() => ({ state: window.states.old, injected: window.tool.injected }))
expect(result.injected).toBe(true)
}
else if (scenario === 'same-source-reload') {
await page.evaluate(() => {
const currentSource = window.targetFrame.src
window.targetFrame.src = currentSource
})
await expect.poll(() => page.evaluate(() => window.timeline.filter(item => item.packet?.type === 'inject').length)).toBe(2)
child = page.frames().find(frame => frame.url() === url)
expect(await child.evaluate(() => window.documentWitness)).not.toBe(witness)
await expect.poll(() => page.evaluate(() => window.states.old.status)).toBe('rejected')
result = await page.evaluate(() => ({ state: window.states.old, documentIds: window.timeline.filter(item => item.packet?.type === 'inject').map(item => item.packet.__artplayerIframe.document) }))
expect(result.documentIds[0]).not.toBe(result.documentIds[1])
}
else if (scenario === 'srcdoc-navigation') {
const html = childHtml.replace('src="/iframe-boundary-lib.js"', `src="${parentOrigin}/iframe-boundary-lib.js"`).replace('new URL(location.href).searchParams', `new URLSearchParams('global=${implementation.global}&auto=0&epoch=srcdoc')`)
await page.evaluate((html) => {
window.targetFrame.srcdoc = html
window.track('srcdoc', window.tool.commit(() => {
return document.URL
}))
}, html)
await expect.poll(() => page.frames().some(frame => frame.url() === 'about:srcdoc')).toBe(true)
child = page.frames().find(frame => frame.url() === 'about:srcdoc')
await expect.poll(() => child.evaluate(() => Boolean(window.Tool))).toBe(true)
await expect.poll(() => page.evaluate(() => window.states.old.status)).toBe('rejected')
expect(await page.evaluate(() => window.tool.injected)).toBe(false)
await child.evaluate(() => window.Tool.inject())
await expect.poll(() => page.evaluate(() => window.states.srcdoc.status)).toBe('resolved')
expect(await page.evaluate(() => window.states.srcdoc.value)).toBe('about:srcdoc')
await page.evaluate((url) => {
window.track('srcdocHeld', window.tool.postMessage({ type: 'hold' }))
window.targetFrame.src = url
}, urlFor(2))
await expect.poll(() => child.evaluate(() => Boolean(window.held))).toBe(true)
expect(await page.evaluate(() => window.states.srcdocHeld.status)).toBe('pending')
await page.evaluate(() => window.targetFrame.removeAttribute('srcdoc'))
await expect.poll(() => page.frames().some(frame => frame.url() === urlFor(2))).toBe(true)
await expect.poll(() => page.evaluate(() => window.states.srcdocHeld.status)).toBe('rejected')
const value = await page.evaluate(() => window.tool.commit(() => {
return new URL(location.href).searchParams.get('epoch')
}))
expect(value).toBe('2')
result = await page.evaluate(() => ({ states: window.states, injected: window.tool.injected, pending: Object.keys(window.tool.promises).length }))
expect(result.pending).toBe(0)
}
else {
const next = urlFor(scenario === 'rapid-navigation' ? 3 : 2, false)
if (scenario === 'child-navigation' || scenario === 'history-back') {
await child.evaluate(url => location.href = url, next)
}
else {
await page.evaluate(({ next, intermediate }) => {
if (intermediate) {
window.targetFrame.src = intermediate
window.track('intermediate', window.tool.commit(() => {
return 2
}))
}
window.targetFrame.src = next
window.track('new', window.tool.commit(() => {
return new URL(location.href).searchParams.get('epoch')
}))
}, { next, intermediate: scenario === 'rapid-navigation' ? urlFor(2, false) : null })
}
await expect.poll(() => page.frames().some(frame => frame.url() === next)).toBe(true)
child = page.frames().find(frame => frame.url() === next)
await expect.poll(() => child.evaluate(() => Boolean(window.Tool))).toBe(true)
await expect.poll(() => page.evaluate(() => window.states.old.status)).toBe('rejected')
expect(await page.evaluate(() => window.states.old.error)).toBe('The iframe document has changed')
expect(await page.evaluate(() => window.tool.injected)).toBe(false)
if (scenario === 'rapid-navigation')
expect(await page.evaluate(() => window.states.intermediate.status)).toBe('pending')
await child.evaluate(() => window.Tool.inject())
await expect.poll(() => page.evaluate(() => window.tool.injected)).toBe(true)
const current = await page.evaluate(() => window.timeline.filter(item => item.packet?.type === 'inject').at(-1).packet.__artplayerIframe.document)
expect(current).not.toBe(first)
if (scenario === 'stale-packets') {
await child.evaluate(({ document, id }) => parent.postMessage({ type: 'old-notification', data: 'stale', id, __artplayerIframe: { version: 1, document, phase: 'message' } }, '*'), { document: first, id: heldId })
await expect.poll(() => page.evaluate(() => window.timeline.some(item => item.packet?.type === 'old-notification'))).toBe(true)
expect(await page.evaluate(() => window.callbacks.some(packet => packet.type === 'old-notification'))).toBe(false)
await page.evaluate(document => window.targetFrame.contentWindow.postMessage({ type: 'commit', data: 'window.executions++; return 999', id: 999, __artplayerIframe: { version: 1, document, phase: 'message' } }, '*'), first)
await expect.poll(() => child.evaluate(() => window.received.some(packet => packet.id === 999))).toBe(true)
expect(await child.evaluate(() => window.executions)).toBe(0)
}
if (scenario === 'history-back') {
const beforeBack = await page.evaluate(() => window.timeline.filter(item => item.packet?.type === 'fixture-pageshow').length)
await child.evaluate(() => history.back())
await expect.poll(() => page.evaluate(count => window.timeline.filter(item => item.packet?.type === 'fixture-pageshow').length > count, beforeBack)).toBe(true)
child = page.frames().find(frame => frame.url() === url)
await expect.poll(() => page.evaluate(() => window.tool.injected)).toBe(true)
const restored = await child.evaluate(() => ({ witness: window.documentWitness, epoch: new URL(location.href).searchParams.get('epoch') }))
const pageshow = await page.evaluate(() => window.timeline.filter(item => item.packet?.type === 'fixture-pageshow').at(-1).packet.data)
expect(restored.epoch).toBe('1')
if (pageshow.persisted)
expect(restored.witness).toBe(witness)
const value = await page.evaluate(() => window.tool.commit(() => {
return new URL(location.href).searchParams.get('epoch')
}))
expect(value).toBe('1')
result = { pageshow, restored, actualBfcache: pageshow.persisted }
}
else {
const value = await page.evaluate(() => window.tool.commit(() => {
return new URL(location.href).searchParams.get('epoch')
}))
expect(value).toBe(scenario === 'rapid-navigation' ? '3' : '2')
if (scenario === 'rapid-navigation') {
await expect.poll(() => page.evaluate(() => window.states.intermediate.status)).toBe('resolved')
expect(await page.evaluate(() => window.states.intermediate.value)).toBe(2)
}
if (scenario !== 'child-navigation') {
await expect.poll(() => page.evaluate(() => window.states.new.status)).toBe('resolved')
expect(await page.evaluate(() => window.states.new.value)).toBe(value)
}
result = await page.evaluate(() => ({ states: window.states, pending: Object.keys(window.tool.promises).length }))
expect(result.pending).toBe(0)
}
}
const timeline = await page.evaluate(() => window.timeline)
expect(await page.evaluate(() => window.callbacks.some(packet => packet.type === 'artplayer-tool-iframe:session'))).toBe(false)
await testInfo.attach('iframe-navigation', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), relation, scenario, parentOrigin, childOrigin, result, timeline, scope: 'Actual native frame navigation and postMessage; history-back records actual pageshow.persisted rather than assuming bfcache. Delayed-load barrier forces injection before load. Stale-packet tests replay old metadata from the controlled current frame to isolate document filtering. No media/player integration.' }) })
}
finally {
releaseBarrier?.()
await page.evaluate(() => {
window.tool.destroy()
document.querySelectorAll('iframe').forEach(frame => frame.remove())
})
}
})
}
}
for (const historical of (await iframeHistorical()).filter(item => ['published-artplayer-plugin-iframe.js', 'workspace.js'].includes(item.name))) {
for (const relation of ['same-origin', 'cross-origin', 'opaque-origin']) {
test(`Iframe legacy child navigation ${relation}/${historical.name}/${implementation.name}`, async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=published')
const parentOrigin = new URL(page.url()).origin
const childOrigin = relation === 'cross-origin' ? parentOrigin.replace('127.0.0.1', 'localhost') : parentOrigin
const first = `${childOrigin}/test/iframe-boundary-child.html?global=${historical.global}&auto=1&epoch=1`
const second = first.replace('auto=1&epoch=1', 'auto=0&epoch=2')
await page.route('**/iframe-boundary-lib.js', route => route.fulfill({ contentType: 'text/javascript', body: historical.code }))
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${implementation.code}; window.IframeFactory = module.exports.default || module.exports; })();` })
await page.evaluate(({ first, opaque }) => {
const frame = document.createElement('iframe')
if (opaque)
frame.sandbox = 'allow-scripts'
document.body.append(frame)
window.targetFrame = frame
window.tool = new window.IframeFactory({ iframe: frame, url: first })
window.states = {}
window.track = (key, promise) => {
window.states[key] = { status: 'pending' }
promise.then(value => window.states[key] = { status: 'resolved', value }, error => window.states[key] = { status: 'rejected', error: error.message })
}
}, { first, opaque: relation === 'opaque-origin' })
try {
await expect.poll(() => page.evaluate(() => window.tool.injected)).toBe(true)
await page.evaluate((second) => {
window.track('old', window.tool.postMessage({ type: 'hold' }))
window.targetFrame.src = second
window.track('new', window.tool.commit(() => {
return new URL(location.href).searchParams.get('epoch')
}))
}, second)
await expect.poll(() => page.frames().some(frame => frame.url() === second)).toBe(true)
const child = page.frames().find(frame => frame.url() === second)
await expect.poll(() => child.evaluate(() => Boolean(window.Tool))).toBe(true)
await expect.poll(() => page.evaluate(() => window.states.old.status)).toBe('rejected')
expect(await page.evaluate(() => window.tool.injected)).toBe(false)
await child.evaluate(() => window.Tool.inject())
await expect.poll(() => page.evaluate(() => window.states.new.status)).toBe('resolved')
const result = await page.evaluate(() => ({ states: window.states, pending: Object.keys(window.tool.promises).length }))
expect(result.states.new.value).toBe('2')
expect(result.states.old.error).toBe('The iframe document has changed')
expect(result.pending).toBe(0)
await testInfo.attach('iframe-navigation', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), relation, scenario: 'legacy-child-source-change', child: { name: historical.name, sha256: hash(historical.code) }, parentOrigin, childOrigin, result, scope: 'Actual source attribute navigation with an immutable historical child. No document metadata or internal child navigation protection is claimed for an unchanged legacy peer.' }) })
}
finally {
await page.evaluate(() => {
window.tool.destroy()
window.targetFrame.remove()
})
}
})
}
}
+341
View File
@@ -0,0 +1,341 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Controlled document markers and owned MutationObserver callbacks.
import test from 'node:test'
import { iframeCandidate, iframeEnvironment } from './helpers/iframe.js'
const implementation = await iframeCandidate()
const flush = () => new Promise(resolve => setImmediate(resolve))
const control = 'artplayer-tool-iframe:session'
function marked(type, document, phase, data, id = 0) {
return { type, data, id, __artplayerIframe: { version: 1, document, phase } }
}
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() {
const env = iframeEnvironment(implementation)
const observers = []
env.box.MutationObserver = class {
records = []
disconnected = false
constructor(callback) {
this.callback = callback
observers.push(this)
}
observe(target, options) {
this.target = target
this.options = options
}
takeRecords() { return this.records.splice(0) }
disconnect() {
this.disconnected = true
this.records = []
}
}
const frame = new env.Frame()
frame.hasAttribute = () => false
frame.getAttribute = name => name === 'src' ? frame.src : null
const instance = new env.Factory({ iframe: frame, url: '/first' })
const inject = (document = 'first') => env.dispatch(marked('inject', document, 'inject'), frame.contentWindow)
const navigate = (value) => {
const oldValue = frame.src
frame.src = value
assert(observers[0], 'a navigation observer must own native attribute changes')
observers[0].records.push({ attributeName: 'src', oldValue })
}
const tick = () => {
for (const [id, timer] of [...env.timers]) {
env.timers.delete(id)
timer.callback()
}
}
return { ...env, instance, frame, observers, inject, navigate, tick }
}
test('Iframe document handshake keeps public callback data while acknowledging the peer privately', () => {
const env = setup()
const seen = []
env.instance.message(packet => seen.push({ ...packet }))
env.inject()
assert.equal(env.instance.injected, true)
assert.deepEqual(seen, [{ type: 'inject', data: undefined }])
assert.equal(env.sent[0].packet.type, control)
assert.deepEqual(JSON.parse(JSON.stringify(env.sent[0].packet.__artplayerIframe)), { version: 1, document: 'first', phase: 'ack' })
assert.deepEqual(Object.keys(env.instance), ['url', '$iframe', 'promises', 'injected', 'destroyed', 'messageCallback', 'onMessage'])
env.instance.destroy()
})
test('Iframe requests and replies remain associated with their active document', async () => {
const env = setup()
env.inject()
const state = observe(env.instance.postMessage({ type: 'query' }))
const request = env.sent.at(-1).packet
assert.equal(request.__artplayerIframe.document, 'first')
env.dispatch(marked('response', 'wrong', 'message', 'wrong reply', request.id), env.frame.contentWindow)
await flush()
assert.equal(state.status, 'pending')
env.dispatch(marked('custom', 'first', 'message', 7, request.id), env.frame.contentWindow)
await flush()
assert.deepEqual(state, { status: 'resolved', value: 7 })
env.instance.destroy()
})
test('Iframe a different document handshake cancels old requests and drops old notifications', async () => {
const env = setup()
env.inject()
const state = observe(env.instance.postMessage({ type: 'query' }))
const id = env.sent.at(-1).packet.id
const messages = []
env.instance.message(packet => messages.push(packet.type))
env.inject('second')
env.dispatch(marked('response', 'first', 'message', 'late', id), env.frame.contentWindow)
env.dispatch(marked('custom-old', 'first', 'message', 'late'), env.frame.contentWindow)
await flush()
assert.deepEqual(state, { status: 'rejected', error: 'The iframe document has changed' })
assert.deepEqual(messages, ['inject'])
assert.equal(Object.keys(env.instance.promises).length, 0)
env.instance.destroy()
})
test('Iframe repeated injection from the same document preserves its pending requests', async () => {
const env = setup()
env.inject()
const state = observe(env.instance.postMessage({ type: 'query' }))
const id = env.sent.at(-1).packet.id
env.inject()
env.dispatch(marked('response', 'first', 'message', 8, id), env.frame.contentWindow)
await flush()
assert.deepEqual(state, { status: 'resolved', value: 8 })
env.instance.destroy()
})
test('Iframe synchronous post after src change cancels old work and queues for the new injection', async () => {
const env = setup()
env.inject()
const first = observe(env.instance.postMessage({ type: 'query' }))
env.navigate('/second')
const second = observe(env.instance.postMessage({ type: 'query' }))
env.dispatch(marked(control, 'first', 'leave'), env.frame.contentWindow)
await flush()
assert.deepEqual(first, { status: 'rejected', error: 'The iframe document has changed' })
assert.equal(second.status, 'pending')
assert.equal(env.instance.injected, false)
assert.equal(env.observers[0].records.length, 0)
assert.equal(env.sent.filter(item => item.packet.type === 'query').length, 1)
env.inject('second')
env.tick()
const request = env.sent.at(-1).packet
assert.equal(request.__artplayerIframe.document, 'second')
env.dispatch(marked('response', 'second', 'message', 9, request.id), env.frame.contentWindow)
await flush()
assert.deepEqual(second, { status: 'resolved', value: 9 })
assert.equal(env.instance.url, '/first')
env.instance.destroy()
})
test('Iframe old pagehide and repeated old inject cannot cancel or activate a newly queued navigation', async () => {
const env = setup()
env.inject()
env.navigate('/second')
const state = observe(env.instance.postMessage({ type: 'query' }))
env.inject('first')
env.dispatch(marked(control, 'first', 'leave'), env.frame.contentWindow)
env.dispatch(marked(control, 'first', 'leave'), env.frame.contentWindow)
await flush()
assert.equal(state.status, 'pending')
assert.equal(env.instance.injected, false)
env.inject('second')
env.tick()
const request = env.sent.at(-1).packet
env.dispatch(marked('response', 'second', 'message', 10, request.id), env.frame.contentWindow)
await flush()
assert.deepEqual(state, { status: 'resolved', value: 10 })
env.instance.destroy()
})
test('Iframe native attribute observation suspends sends and confirmed departure cancels old work', async () => {
const env = setup()
env.inject()
const state = observe(env.instance.postMessage({ type: 'query' }))
env.navigate('/second')
const observer = env.observers[0]
observer.callback(observer.takeRecords())
await flush()
assert.equal(state.status, 'pending')
assert.equal(env.instance.injected, false)
env.dispatch(marked(control, 'first', 'leave'), env.frame.contentWindow)
await flush()
assert.deepEqual(state, { status: 'rejected', error: 'The iframe document has changed' })
assert.equal(env.instance.injected, false)
env.instance.destroy()
assert.equal(observer.disconnected, true)
observer.callback([{ attributeName: 'src', oldValue: '/second' }])
assert.equal(env.timers.size, 0)
})
test('Iframe consecutive source mutations preserve requests queued after navigation started', async () => {
for (const departed of [false, true]) {
const env = setup()
env.inject()
const old = observe(env.instance.postMessage({ type: 'query' }))
env.navigate('/second')
const queued = observe(env.instance.postMessage({ type: 'query' }))
if (departed)
env.dispatch(marked(control, 'first', 'leave'), env.frame.contentWindow)
env.navigate('/third')
env.observers[0].callback(env.observers[0].takeRecords())
env.dispatch(marked(control, 'first', 'leave'), env.frame.contentWindow)
await flush()
assert.equal(old.error, 'The iframe document has changed')
assert.equal(queued.status, 'pending')
env.inject('third')
env.tick()
const request = env.sent.at(-1).packet
env.dispatch(marked('response', 'third', 'message', 13, request.id), env.frame.contentWindow)
await flush()
assert.deepEqual(queued, { status: 'resolved', value: 13 })
env.instance.destroy()
}
})
test('Iframe confirmed fragment navigation preserves requests; a same-source document departure cancels them', async () => {
const env = setup()
env.inject()
const state = observe(env.instance.postMessage({ type: 'query' }))
env.navigate('/first#chapter')
const observer = env.observers[0]
observer.callback(observer.takeRecords())
env.dispatch(marked(control, 'first', 'fragment', '/first#chapter'), env.frame.contentWindow)
await flush()
assert.equal(state.status, 'pending')
assert.equal(env.instance.injected, true)
env.navigate('/first#chapter')
observer.callback(observer.takeRecords())
env.dispatch(marked(control, 'first', 'leave'), env.frame.contentWindow)
await flush()
assert.deepEqual(state, { status: 'rejected', error: 'The iframe document has changed' })
env.instance.destroy()
})
test('Iframe pagehide cancels current work; a restored document can resume after another document', async () => {
const env = setup()
env.inject()
const first = observe(env.instance.postMessage({ type: 'query' }))
env.dispatch(marked(control, 'first', 'leave'), env.frame.contentWindow)
await flush()
assert.equal(first.error, 'The iframe document has changed')
env.inject('second')
const second = observe(env.instance.postMessage({ type: 'query' }))
env.dispatch(marked(control, 'first', 'resume'), env.frame.contentWindow)
await flush()
assert.equal(second.error, 'The iframe document has changed')
const restored = observe(env.instance.postMessage({ type: 'query' }))
assert.equal(env.sent.at(-1).packet.__artplayerIframe.document, 'first')
env.instance.destroy()
await flush()
assert.equal(restored.error, 'The instance has been destroyed')
})
test('Iframe legacy peer remains unmarked and source replacement still waits for its ordinary injection', async () => {
const env = setup()
env.dispatch({ type: 'inject' }, env.frame.contentWindow)
const first = observe(env.instance.postMessage({ type: 'query' }))
assert.deepEqual(Object.keys(env.sent.at(-1).packet), ['type', 'data', 'id'])
env.navigate('/second')
const second = observe(env.instance.postMessage({ type: 'query' }))
env.dispatch({ type: 'inject' }, env.frame.contentWindow)
env.tick()
const request = env.sent.at(-1).packet
assert.deepEqual(Object.keys(request), ['type', 'data', 'id'])
env.dispatch({ type: 'response', data: 11, id: request.id }, env.frame.contentWindow)
await flush()
assert.equal(first.error, 'The iframe document has changed')
assert.deepEqual(second, { status: 'resolved', value: 11 })
env.instance.destroy()
})
test('Iframe child advertises a document without changing ordinary messages to an unmodified parent', () => {
const env = iframeEnvironment(implementation, true)
env.Factory.inject()
const injection = env.sent[0].packet
assert.equal(injection.type, 'inject')
assert.equal(injection.id, 0)
assert.equal(injection.data, undefined)
assert.equal(injection.__artplayerIframe.version, 1)
assert.equal(typeof injection.__artplayerIframe.document, 'string')
env.Factory.postMessage({ type: 'custom', data: 1 })
assert.deepEqual(Object.keys(env.sent.at(-1).packet), ['type', 'data', 'id'])
for (const callback of env.handlers.get('pagehide') || []) callback({})
assert.equal(env.sent.length, 2)
})
test('Iframe child tags acknowledged responses, ignores stale-document commands and hides lifecycle from legacy parents', async () => {
const env = iframeEnvironment(implementation, true)
env.Factory.inject()
const document = env.sent[0].packet.__artplayerIframe.document
await env.Factory.onMessage({ data: marked(control, document, 'ack'), source: env.box.parent })
await env.Factory.onMessage({ data: marked('commit', 'old-document', 'message', 'return 7', 7), source: env.box.parent })
assert.equal(env.sent.length, 1)
await env.Factory.onMessage({ data: marked('commit', document, 'message', 'return 8', 8), source: env.box.parent })
assert.equal(env.sent.at(-1).packet.data, 8)
assert.equal(env.sent.at(-1).packet.__artplayerIframe.document, document)
env.Factory.inject()
assert.equal(env.handlers.get('pagehide').size, 1)
assert.equal(env.handlers.get('pageshow').size, 1)
for (const callback of env.handlers.get('pagehide')) callback({})
assert.equal(env.sent.at(-1).packet.__artplayerIframe.phase, 'leave')
for (const callback of env.handlers.get('pageshow')) callback({ persisted: true })
assert.equal(env.sent.at(-1).packet.__artplayerIframe.phase, 'resume')
})
test('Iframe failed child listener acquisition releases partial registrations and can be retried', () => {
const env = iframeEnvironment(implementation, true)
const add = env.box.addEventListener
const failure = new Error('pageshow listener failure')
env.box.addEventListener = (name, callback) => {
add(name, callback)
if (name === 'pageshow')
throw failure
}
assert.throws(() => env.Factory.inject(), error => error === failure)
for (const name of ['message', 'pagehide', 'pageshow']) assert.equal(env.handlers.get(name).size, 0)
env.box.addEventListener = add
env.Factory.inject()
for (const name of ['message', 'pagehide', 'pageshow']) assert.equal(env.handlers.get(name).size, 1)
})
test('Iframe internal cancellation retains ownership when a consumer replaces public reject', async () => {
const env = setup()
env.inject()
const state = observe(env.instance.postMessage({ type: 'query' }))
const id = env.sent.at(-1).packet.id
let calls = 0
env.instance.promises[id].reject = () => calls++
env.dispatch(marked('error', 'first', 'message', 'remote error', id), env.frame.contentWindow)
await flush()
assert.equal(calls, 1)
assert.equal(state.status, 'pending')
env.instance.destroy()
await flush()
assert.deepEqual(state, { status: 'rejected', error: 'The instance has been destroyed' })
assert.equal(calls, 1)
assert.equal(Object.keys(env.instance.promises).length, 0)
})
test('Iframe a changed src getter resolution without an attribute mutation does not invent a navigation', async () => {
const env = setup()
env.inject()
env.frame.getAttribute = name => name === 'src' ? '/first' : null
Object.defineProperty(env.frame, 'src', { value: 'https://new-base.example/first' })
const state = observe(env.instance.postMessage({ type: 'query' }))
assert.equal(env.instance.injected, true)
assert.equal(env.sent.at(-1).packet.type, 'query')
env.dispatch(marked('response', 'first', 'message', 13, env.sent.at(-1).packet.id), env.frame.contentWindow)
await flush()
assert.deepEqual(state, { status: 'resolved', value: 13 })
env.instance.destroy()
})