mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-10 04:36:15 -08:00
test(iframe): [PKG-IFRAME-02] reproduce request and native window failures
This commit is contained in:
1 parent
a52f79a0cc
commit
b3f22652c2
10 files changed
+8259
-12
No files matched your search
@@ -0,0 +1,191 @@
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { iframeHistorical } from '../helpers/iframe.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const implementations = (await iframeHistorical()).filter(item => item.global !== 'ArtplayerHelperIframe')
|
||||
|
||||
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) => {
|
||||
await page.goto('/test/player.html?core=published')
|
||||
const parentOrigin = new URL(page.url()).origin
|
||||
const alternate = parentOrigin.replace('127.0.0.1', 'localhost')
|
||||
const childOrigin = relation === 'same-origin' ? parentOrigin : alternate
|
||||
const foreignOrigin = relation === 'same-origin' ? alternate : parentOrigin
|
||||
await page.route('**/iframe-baseline-lib.js', route => route.fulfill({ contentType: 'text/javascript', body: implementation.code }))
|
||||
await page.route('**/iframe-baseline-child?*', route => route.fulfill({ contentType: 'text/html', body: `<!doctype html><script src="/iframe-baseline-lib.js"></script><script>
|
||||
window.Tool = window.${implementation.global}; window.received = []; window.expectedErrors = [];
|
||||
addEventListener('unhandledrejection', event => {
|
||||
if (event.reason?.message === 'iframe baseline expected error') {
|
||||
expectedErrors.push(event.reason.message); event.preventDefault();
|
||||
}
|
||||
});
|
||||
addEventListener('message', event => {
|
||||
received.push({ type: event.data?.type, id: event.data?.id, fromParent: event.source === parent, origin: event.origin });
|
||||
if (event.data?.type === 'hold') {
|
||||
window.held = event.data;
|
||||
parent.postMessage({ type: 'fixture-held', data: held.id }, '*');
|
||||
}
|
||||
});
|
||||
if (new URL(location.href).searchParams.get('auto') !== '0') Tool.inject();
|
||||
parent.postMessage({ type: 'fixture-ready', data: location.search }, '*');
|
||||
</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 url = `${childOrigin}/iframe-baseline-child?auto=${manualInjection ? 0 : 1}&epoch=1`
|
||||
await page.evaluate((url) => {
|
||||
window.packets = []
|
||||
window.states = {}
|
||||
const frame = document.createElement('iframe')
|
||||
frame.id = 'target-frame'
|
||||
document.body.append(frame)
|
||||
window.targetFrame = frame
|
||||
window.addEventListener('message', event => window.packets.push({ type: event.data?.type, data: event.data?.data, id: event.data?.id, origin: event.origin, fromChild: event.source === frame.contentWindow }))
|
||||
window.tool = new window.IframeFactory({ iframe: frame, url })
|
||||
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)
|
||||
let result
|
||||
try {
|
||||
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.type === 'fixture-ready'))).toBe(true)
|
||||
if (!manualInjection)
|
||||
await expect.poll(() => page.evaluate(() => window.tool.injected)).toBe(true)
|
||||
if (scenario === 'round-trip') {
|
||||
result = await page.evaluate(async () => {
|
||||
const sync = await window.tool.commit(() => {
|
||||
return 7
|
||||
})
|
||||
const async = await window.tool.commit((resolve) => {
|
||||
setTimeout(() => resolve(8), 10)
|
||||
})
|
||||
let error
|
||||
try {
|
||||
await window.tool.commit(() => {
|
||||
throw new Error('iframe baseline expected error')
|
||||
})
|
||||
}
|
||||
catch (failure) { error = failure.message }
|
||||
const recovery = await window.tool.commit(() => {
|
||||
return 9
|
||||
})
|
||||
return { sync, async, error, recovery, pending: Object.keys(window.tool.promises).length }
|
||||
})
|
||||
expect(result).toEqual({ sync: 7, async: 8, error: 'iframe baseline expected error', recovery: 9, pending: 0 })
|
||||
}
|
||||
else if (scenario === 'same-tick') {
|
||||
await page.evaluate(() => {
|
||||
const now = Date.now
|
||||
Date.now = () => 1234
|
||||
try {
|
||||
window.track('first', window.tool.commit(() => {
|
||||
return 1
|
||||
}))
|
||||
window.track('second', window.tool.commit(() => {
|
||||
return 2
|
||||
}))
|
||||
}
|
||||
finally { Date.now = now }
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.packets.filter(packet => packet.type === 'response' && packet.id === 1234).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 })
|
||||
}
|
||||
else if (scenario === 'destroy-pending') {
|
||||
await page.evaluate(() => window.track('held', window.tool.postMessage({ type: 'hold' })))
|
||||
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.type === 'fixture-held'))).toBe(true)
|
||||
await page.evaluate(() => window.tool.destroy())
|
||||
const child = page.frames().find(frame => frame.url() === url)
|
||||
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 })
|
||||
}
|
||||
else if (scenario === 'foreign-message') {
|
||||
expect(await page.evaluate(() => window.tool.injected)).toBe(false)
|
||||
await page.evaluate((url) => {
|
||||
const frame = document.createElement('iframe')
|
||||
frame.id = 'foreign-frame'
|
||||
frame.src = url
|
||||
document.body.append(frame)
|
||||
}, `${foreignOrigin}/iframe-baseline-foreign`)
|
||||
await expect.poll(() => page.evaluate(() => window.tool.injected)).toBe(true)
|
||||
const id = await page.evaluate(() => {
|
||||
window.track('foreign', window.tool.postMessage({ type: 'hold' }))
|
||||
return Number(Object.keys(window.tool.promises)[0])
|
||||
})
|
||||
const foreign = page.frames().find(frame => frame.url().endsWith('/iframe-baseline-foreign'))
|
||||
await foreign.evaluate(id => parent.postMessage({ type: 'response', data: 'foreign response', id }, '*'), id)
|
||||
await expect.poll(() => page.evaluate(() => window.states.foreign.status)).toBe('resolved')
|
||||
const child = page.frames().find(frame => frame.url() === url)
|
||||
await child.evaluate(() => window.Tool.inject())
|
||||
await foreign.evaluate(() => parent.frames[0].postMessage({ type: 'commit', data: 'return "foreign command"', id: 17 }, '*'))
|
||||
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.type === 'response' && packet.data === 'foreign command'))).toBe(true)
|
||||
result = await page.evaluate(() => ({ state: window.states.foreign, foreignResponse: window.packets.find(packet => packet.data === 'foreign response') }))
|
||||
expect(result.state).toEqual({ status: 'resolved', value: 'foreign response' })
|
||||
expect(result.foreignResponse.fromChild).toBe(false)
|
||||
expect(result.foreignResponse.origin).toBe(foreignOrigin)
|
||||
expect(await child.evaluate(() => window.received.some(packet => packet.type === 'commit' && !packet.fromParent))).toBe(true)
|
||||
}
|
||||
else if (scenario === 'clone-failure') {
|
||||
await page.evaluate(() => {
|
||||
window.cloneErrors = []
|
||||
window.addEventListener('error', (event) => {
|
||||
if (event.error?.name === 'DataCloneError') {
|
||||
window.cloneErrors.push(event.error.name)
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
window.track('deferredClone', window.tool.postMessage({ type: 'query', data: () => 'not cloneable' }))
|
||||
})
|
||||
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)
|
||||
result = await page.evaluate(async () => {
|
||||
const deferredId = Object.keys(window.tool.promises)[0]
|
||||
const now = Date.now
|
||||
Date.now = () => Number(deferredId) + 1
|
||||
let failure
|
||||
try {
|
||||
await window.tool.postMessage({ type: 'query', data: () => 'also not cloneable' })
|
||||
}
|
||||
catch (error) { failure = error.name }
|
||||
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'] })
|
||||
}
|
||||
else {
|
||||
const nextUrl = `${childOrigin}/iframe-baseline-child?auto=0&epoch=2`
|
||||
await page.evaluate(url => window.targetFrame.src = url, nextUrl)
|
||||
await expect.poll(() => page.evaluate(() => window.packets.some(packet => packet.type === 'fixture-ready' && packet.data.includes('epoch=2')))).toBe(true)
|
||||
await page.evaluate(() => window.track('navigation', window.tool.commit(() => {
|
||||
return 9
|
||||
})))
|
||||
const child = page.frames().find(frame => frame.url() === nextUrl)
|
||||
await expect.poll(() => child.evaluate(() => window.received.some(packet => packet.type === 'commit'))).toBe(true)
|
||||
await child.evaluate(() => window.Tool.inject())
|
||||
const recovery = await page.evaluate(() => window.tool.commit(() => {
|
||||
return 10
|
||||
}))
|
||||
result = await page.evaluate(() => ({ injected: window.tool.injected, state: window.states.navigation, pending: Object.keys(window.tool.promises).length }))
|
||||
expect(result).toEqual({ injected: true, state: { status: 'pending' }, pending: 1 })
|
||||
expect(recovery).toBe(10)
|
||||
}
|
||||
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.' }) })
|
||||
}
|
||||
finally {
|
||||
await page.evaluate(() => {
|
||||
window.tool.destroy()
|
||||
document.querySelectorAll('iframe').forEach(frame => frame.remove())
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Repository Node runner.
|
||||
import test from 'node:test'
|
||||
import { iframeEnvironment, iframeHistorical } from './helpers/iframe.js'
|
||||
|
||||
const implementations = (await iframeHistorical()).filter(item => item.global !== 'ArtplayerHelperIframe')
|
||||
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(implementation, 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 }
|
||||
}
|
||||
|
||||
for (const implementation of implementations) {
|
||||
test(`Iframe ${implementation.name}: same-millisecond requests overwrite a pending callback`, async () => {
|
||||
const env = setup(implementation)
|
||||
const first = observe(env.instance.postMessage({ type: 'one', data: 1 }))
|
||||
const second = observe(env.instance.postMessage({ type: 'two', data: 2 }))
|
||||
assert.deepEqual(env.sent.map(item => item.packet.id), [1234, 1234])
|
||||
assert.equal(Object.keys(env.instance.promises).length, 1)
|
||||
env.dispatch({ type: 'response', data: 'first response', id: 1234 })
|
||||
await flush()
|
||||
assert.deepEqual(first, { status: 'pending' })
|
||||
assert.deepEqual(second, { status: 'resolved', value: 'first response' })
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test(`Iframe ${implementation.name}: destroy retains already-sent pending requests`, async () => {
|
||||
const env = setup(implementation)
|
||||
const state = observe(env.instance.postMessage({ type: 'one', data: 1 }))
|
||||
env.instance.destroy()
|
||||
await flush()
|
||||
assert.equal(state.status, 'pending')
|
||||
assert.deepEqual(Object.keys(env.instance.promises), ['1234'])
|
||||
assert.equal(env.handlers.get('message').size, 0)
|
||||
})
|
||||
|
||||
test(`Iframe ${implementation.name}: destroy leaves injection polling until the old timer runs`, async () => {
|
||||
const env = setup(implementation, false)
|
||||
const state = observe(env.instance.postMessage({ type: 'one', data: 1 }))
|
||||
assert.equal(env.timers.size, 1)
|
||||
env.instance.destroy()
|
||||
await flush()
|
||||
assert.equal(state.status, 'pending')
|
||||
const [id, timer] = [...env.timers][0]
|
||||
assert.equal(timer.delay, 200)
|
||||
env.timers.delete(id)
|
||||
timer.callback()
|
||||
await flush()
|
||||
assert.deepEqual(state, { status: 'rejected', error: 'The instance has been destroyed' })
|
||||
assert.equal(env.timers.size, 0)
|
||||
assert.equal(env.sent.length, 0)
|
||||
})
|
||||
|
||||
test(`Iframe ${implementation.name}: requests started after destroy reject without a timer`, async () => {
|
||||
const env = setup(implementation)
|
||||
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)
|
||||
})
|
||||
|
||||
test(`Iframe ${implementation.name}: distinct IDs settle out of order and errors retain their message`, async () => {
|
||||
const env = setup(implementation)
|
||||
const first = observe(env.instance.postMessage({ type: 'one' }))
|
||||
env.now(1235)
|
||||
const second = observe(env.instance.postMessage({ type: 'two' }))
|
||||
env.dispatch({ type: 'error', data: 'remote failure', id: 1235 })
|
||||
env.dispatch({ type: 'response', data: 7, id: 1234 })
|
||||
await flush()
|
||||
assert.deepEqual(first, { status: 'resolved', value: 7 })
|
||||
assert.deepEqual(second, { status: 'rejected', error: 'remote failure' })
|
||||
assert.equal(Object.keys(env.instance.promises).length, 0)
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test(`Iframe ${implementation.name}: custom non-error response types remain part of the generic protocol`, async () => {
|
||||
const env = setup(implementation)
|
||||
const result = env.instance.postMessage({ type: 'custom-query' })
|
||||
env.dispatch({ type: 'custom-result', data: { ok: true }, id: 1234 })
|
||||
assert.deepEqual(await result, { ok: true })
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test(`Iframe ${implementation.name}: malformed message data throws from the public receiver`, () => {
|
||||
const env = setup(implementation)
|
||||
assert.throws(() => env.dispatch(null), error => error.name === 'TypeError')
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test(`Iframe ${implementation.name}: immediate send failure rejects but leaves its public record`, async () => {
|
||||
const env = setup(implementation)
|
||||
env.instance.$iframe.contentWindow.postMessage = () => {
|
||||
throw new Error('clone failure')
|
||||
}
|
||||
await assert.rejects(env.instance.postMessage({ type: 'one' }), /clone failure/)
|
||||
assert.deepEqual(Object.keys(env.instance.promises), ['1234'])
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test(`Iframe ${implementation.name}: send failure after polling escapes while its promise stays pending`, async () => {
|
||||
const env = setup(implementation, false)
|
||||
const state = observe(env.instance.postMessage({ type: 'one' }))
|
||||
env.dispatch({ type: 'inject' })
|
||||
env.instance.$iframe.contentWindow.postMessage = () => {
|
||||
throw new Error('late clone failure')
|
||||
}
|
||||
const [id, timer] = [...env.timers][0]
|
||||
env.timers.delete(id)
|
||||
assert.throws(() => timer.callback(), /late clone failure/)
|
||||
await flush()
|
||||
assert.equal(state.status, 'pending')
|
||||
assert.deepEqual(Object.keys(env.instance.promises), ['1234'])
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test(`Iframe ${implementation.name}: saved message receiver can still settle and notify after destroy`, async () => {
|
||||
const env = setup(implementation)
|
||||
const state = observe(env.instance.postMessage({ type: 'one' }))
|
||||
const callback = env.instance.onMessage
|
||||
let calls = 0
|
||||
env.instance.message(() => calls++)
|
||||
env.instance.destroy()
|
||||
callback({ data: { type: 'response', data: 'late', id: 1234 } })
|
||||
await flush()
|
||||
assert.deepEqual(state, { status: 'resolved', value: 'late' })
|
||||
assert.equal(calls, 1)
|
||||
})
|
||||
|
||||
test(`Iframe ${implementation.name}: navigation retains the previous injected state`, () => {
|
||||
const env = setup(implementation)
|
||||
env.instance.$iframe.src = '/second'
|
||||
observe(env.instance.postMessage({ type: 'before-second-inject' }))
|
||||
assert.equal(env.instance.injected, true)
|
||||
assert.equal(env.sent.length, 1)
|
||||
assert.equal(env.timers.size, 0)
|
||||
assert.equal(env.instance.url, '/first')
|
||||
env.instance.destroy()
|
||||
})
|
||||
|
||||
test(`Iframe ${implementation.name}: repeated child inject repeats handshake but deduplicates its DOM listener`, () => {
|
||||
const env = iframeEnvironment(implementation, true)
|
||||
env.Factory.inject()
|
||||
env.Factory.inject()
|
||||
assert.deepEqual(env.sent.map(item => item.packet.type), ['inject', 'inject'])
|
||||
assert.equal(env.handlers.get('message').size, 1)
|
||||
})
|
||||
|
||||
test(`Iframe ${implementation.name}: child errors send a packet then reject and later commands still run`, async () => {
|
||||
const env = iframeEnvironment(implementation, true)
|
||||
await assert.rejects(env.Factory.onMessage({ data: { type: 'commit', data: 'throw new Error("child failure")', id: 1 } }), /child failure/)
|
||||
await env.Factory.onMessage({ data: { type: 'commit', data: 'return 9', id: 2 } })
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(env.sent)), [{ packet: { type: 'error', data: 'child failure', id: 1 }, origin: '*' }, { packet: { type: 'response', data: 9, id: 2 }, origin: '*' }])
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user