fix(iframe): [PKG-IFRAME-03] own pending requests and cancellation

This commit is contained in:
Harvey Zhao committed 2026-09-12 23:41:48 +08:00
1 parent b3f22652c2
commit b8d03e7366
21 files changed
+3826 -107

No files matched your search

+48 -12
View File
@@ -1,13 +1,20 @@
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { iframeHistorical } from '../helpers/iframe.js'
import { iframeCandidate, iframeHistorical } from '../helpers/iframe.js'
import { expect, test } from './fixtures.js'
const implementations = (await iframeHistorical()).filter(item => item.global !== 'ArtplayerHelperIframe')
const candidate = { ...await iframeCandidate(), lifecycle: true }
const implementations = process.env.ARTPLAYER_IFRAME_LIFECYCLE_ONLY === '1'
? [candidate]
: [...(await iframeHistorical()).filter(item => item.global !== 'ArtplayerHelperIframe'), candidate]
for (const implementation of implementations) {
for (const relation of ['same-origin', 'cross-origin']) {
for (const scenario of ['round-trip', 'same-tick', 'destroy-pending', 'foreign-message', 'navigation', 'clone-failure']) {
test(`Iframe ${implementation.name}/${relation}: ${scenario} historical behavior`, async ({ page }, testInfo) => {
const scenarios = implementation.lifecycle
? ['round-trip', 'same-tick', 'destroy-pending', 'destroy-wait', 'clone-failure']
: ['round-trip', 'same-tick', 'destroy-pending', 'foreign-message', 'navigation', 'clone-failure']
for (const scenario of scenarios) {
test(`Iframe ${implementation.name}/${relation}: ${scenario} ${implementation.lifecycle ? 'lifecycle acceptance' : 'historical behavior'}`, async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=published')
const parentOrigin = new URL(page.url()).origin
const alternate = parentOrigin.replace('127.0.0.1', 'localhost')
@@ -33,7 +40,7 @@ for (const implementation of implementations) {
</script>` }))
await page.route('**/iframe-baseline-foreign', route => route.fulfill({ contentType: 'text/html', body: '<!doctype html><script>parent.postMessage({type:"inject",data:"foreign"},"*")</script>' }))
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${implementation.code}; window.IframeFactory = module.exports.default || module.exports; })();` })
const manualInjection = scenario === 'foreign-message' || scenario === 'clone-failure'
const manualInjection = scenario === 'foreign-message' || scenario === 'clone-failure' || scenario === 'destroy-wait'
const url = `${childOrigin}/iframe-baseline-child?auto=${manualInjection ? 0 : 1}&epoch=1`
await page.evaluate((url) => {
window.packets = []
@@ -90,9 +97,11 @@ for (const implementation of implementations) {
}
finally { Date.now = now }
})
await expect.poll(() => page.evaluate(() => window.packets.filter(packet => packet.type === 'response' && packet.id === 1234).length)).toBe(2)
await expect.poll(() => page.evaluate(() => window.packets.filter(packet => packet.type === 'response').length)).toBe(2)
result = await page.evaluate(() => ({ states: window.states, pending: Object.keys(window.tool.promises).length }))
expect(result).toEqual({ states: { first: { status: 'pending' }, second: { status: 'resolved', value: 1 } }, pending: 0 })
expect(result).toEqual(implementation.lifecycle
? { states: { first: { status: 'resolved', value: 1 }, second: { status: 'resolved', value: 2 } }, pending: 0 }
: { states: { first: { status: 'pending' }, second: { status: 'resolved', value: 1 } }, pending: 0 })
}
else if (scenario === 'destroy-pending') {
await page.evaluate(() => window.track('held', window.tool.postMessage({ type: 'hold' })))
@@ -102,7 +111,22 @@ for (const implementation of implementations) {
await child.evaluate(() => window.Tool.postMessage({ type: 'response', data: 'late', id: window.held.id }))
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.type === 'response' && packet.data === 'late'))).toBe(true)
result = await page.evaluate(() => ({ destroyed: window.tool.destroyed, state: window.states.held, pending: Object.keys(window.tool.promises).length }))
expect(result).toEqual({ destroyed: true, state: { status: 'pending' }, pending: 1 })
expect(result).toEqual(implementation.lifecycle
? { destroyed: true, state: { status: 'rejected', error: 'The instance has been destroyed' }, pending: 0 }
: { destroyed: true, state: { status: 'pending' }, pending: 1 })
}
else if (scenario === 'destroy-wait') {
result = await page.evaluate(async () => {
window.track('waiting', window.tool.postMessage({ type: 'hold' }))
window.tool.destroy()
await Promise.resolve()
return { state: window.states.waiting, pending: Object.keys(window.tool.promises).length }
})
expect(result).toEqual({ state: { status: 'rejected', error: 'The instance has been destroyed' }, pending: 0 })
const child = page.frames().find(frame => frame.url() === url)
await child.evaluate(() => window.Tool.inject())
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.type === 'inject'))).toBe(true)
expect(await page.evaluate(() => window.tool.injected)).toBe(false)
}
else if (scenario === 'foreign-message') {
expect(await page.evaluate(() => window.tool.injected)).toBe(false)
@@ -143,9 +167,12 @@ for (const implementation of implementations) {
})
const child = page.frames().find(frame => frame.url() === url)
await child.evaluate(() => window.Tool.inject())
await expect.poll(() => page.evaluate(() => window.cloneErrors.length)).toBe(1)
if (implementation.lifecycle)
await expect.poll(() => page.evaluate(() => window.states.deferredClone.status)).toBe('rejected')
else
await expect.poll(() => page.evaluate(() => window.cloneErrors.length)).toBe(1)
result = await page.evaluate(async () => {
const deferredId = Object.keys(window.tool.promises)[0]
const deferredId = Object.keys(window.tool.promises)[0] || Date.now()
const now = Date.now
Date.now = () => Number(deferredId) + 1
let failure
@@ -156,7 +183,16 @@ for (const implementation of implementations) {
finally { Date.now = now }
return { failure, deferred: window.states.deferredClone, pending: Object.keys(window.tool.promises).length, uncaught: window.cloneErrors }
})
expect(result).toEqual({ failure: 'DataCloneError', deferred: { status: 'pending' }, pending: 2, uncaught: ['DataCloneError'] })
if (implementation.lifecycle) {
expect(result.failure).toBe('DataCloneError')
expect(result.deferred.status).toBe('rejected')
expect(result.deferred.error).toMatch(/clon/i)
expect(result.pending).toBe(0)
expect(result.uncaught).toEqual([])
}
else {
expect(result).toEqual({ failure: 'DataCloneError', deferred: { status: 'pending' }, pending: 2, uncaught: ['DataCloneError'] })
}
}
else {
const nextUrl = `${childOrigin}/iframe-baseline-child?auto=0&epoch=2`
@@ -177,7 +213,7 @@ for (const implementation of implementations) {
}
const packets = await page.evaluate(() => window.packets)
expect(packets.filter(packet => packet.fromChild).every(packet => packet.origin === childOrigin)).toBe(true)
await testInfo.attach('iframe-behavior', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), relation, scenario, parentOrigin, childOrigin, foreignOrigin, result, packets, scope: 'Actual browser iframe WindowProxy/source/origin and postMessage. Local route fulfills immutable library bytes. Date.now is controlled for collision and distinct clone-failure IDs. Only the deliberate child error/DataCloneError is intercepted; known historical defects are asserted, not fixed or waived.' }) })
await testInfo.attach('iframe-behavior', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), relation, scenario, parentOrigin, childOrigin, foreignOrigin, result, packets, lifecycleAcceptance: Boolean(implementation.lifecycle), scope: 'Actual browser iframe WindowProxy/source/origin and postMessage. Local route fulfills immutable library bytes. Date.now is controlled for collision and distinct clone-failure IDs. Only the deliberate child error/DataCloneError is intercepted. Historical defects are asserted for historical rows; lifecycle acceptance requires cleanup and absence of uncaught clone errors. Navigation and trust are not candidate acceptance yet.' }) })
}
finally {
await page.evaluate(() => {
+9
View File
@@ -1,6 +1,15 @@
import fs from 'node:fs'
import process from 'node:process'
import vm from 'node:vm'
import { verifyIframeContract } from '../../refactor/scripts/iframe-contract.mjs'
import { readMember } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from './load.js'
export async function iframeCandidate() {
if (process.env.ARTPLAYER_IFRAME_BASELINE === '1')
return (await iframeHistorical()).find(item => item.name === 'workspace.js')
return { name: process.env.ARTPLAYER_IFRAME_ARTIFACT ? 'candidate-artifact' : 'candidate-source', global: 'ArtplayerToolIframe', namespace: false, code: process.env.ARTPLAYER_IFRAME_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_IFRAME_ARTIFACT, 'utf8') : await compilePackage('artplayer-tool-iframe', 'umd') }
}
export async function iframeHistorical() {
const { baseline, archive, sources } = await verifyIframeContract()
+255
View File
@@ -0,0 +1,255 @@
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()
})