mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-14 16:36:18 -08:00
test(iframe): [PKG-IFRAME-05] verify native cached history and interrupted navigation
This commit is contained in:
1 parent
b35183c7af
commit
cb6c767731
19 files changed
+7908
-13
No files matched your search
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<title>Iframe media history child</title>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
.player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
<script src="/observe.js"></script>
|
||||
<script src="/core.js?core=__CORE__"></script>
|
||||
<script src="/tool.js"></script>
|
||||
<div class="player"></div>
|
||||
<script>
|
||||
window.art = new Artplayer({ container: '.player', url: '/pattern.mp4', muted: true, fullscreenWeb: true })
|
||||
ArtplayerToolIframe.inject()
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
|
||||
export const test = base.extend({
|
||||
diagnostics: [async ({ page, browser, request, browserName }, use, testInfo) => {
|
||||
const errors = []
|
||||
const misses = []
|
||||
let stopLoading = () => page.evaluate(() => window.stop())
|
||||
const failedRequests = []
|
||||
page.on('requestfailed', request => failedRequests.push({ url: request.url(), failure: request.failure() }))
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
if (browserName === 'chromium') {
|
||||
const session = await page.context().newCDPSession(page)
|
||||
await session.send('Page.enable')
|
||||
stopLoading = () => session.send('Page.stopLoading')
|
||||
session.on('Page.backForwardCacheNotUsed', event => misses.push(event))
|
||||
}
|
||||
await use({ misses, stopLoading })
|
||||
const manifest = await (await request.get('/manifest.json')).json()
|
||||
await testInfo.attach('history-environment', { contentType: 'application/json', body: JSON.stringify({ browser: browser.version(), stopMethod: browserName === 'chromium' ? 'CDP Page.stopLoading' : 'top window.stop()', manifest, errors, misses, failedRequests }) })
|
||||
expect(errors).toEqual([])
|
||||
}, { auto: true }],
|
||||
})
|
||||
|
||||
export async function initialize(page, core, relation, testInfo) {
|
||||
const id = hash(`${testInfo.title}/${testInfo.project.name}/${testInfo.workerIndex}`).slice(0, 16)
|
||||
await page.goto(`/parent.html?core=${core}&relation=${relation}&case=${id}&epoch=1`)
|
||||
await expect.poll(() => page.evaluate(() => window.tool.injected)).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.tool.commit(() => {
|
||||
return window.art.video.readyState >= 2
|
||||
}))).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
window.track('old', window.tool.commit((resolve) => {
|
||||
window.finishHeld = () => resolve(9)
|
||||
}))
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.tool.commit(() => {
|
||||
return typeof window.finishHeld
|
||||
}))).toBe('function')
|
||||
return id
|
||||
}
|
||||
|
||||
export { expect }
|
||||
@@ -0,0 +1,88 @@
|
||||
import { expect, initialize, test } from './fixtures.js'
|
||||
|
||||
for (const core of ['4.5.9', '5.4.0', 'candidate']) {
|
||||
for (const relation of ['same', 'cross']) {
|
||||
for (const mode of ['paused', 'playing']) {
|
||||
test(`Iframe cached history ${core}/${relation}/${mode}`, async ({ page, browserName }, testInfo) => {
|
||||
await initialize(page, core, relation, testInfo)
|
||||
await page.evaluate(() => window.tool.commit((resolve) => {
|
||||
const art = window.art
|
||||
art.currentTime = 1
|
||||
art.playbackRate = 1.25
|
||||
if (art.video.seeking)
|
||||
art.video.addEventListener('seeked', () => resolve(true), { once: true })
|
||||
else resolve(true)
|
||||
}))
|
||||
if (mode === 'playing') {
|
||||
await page.evaluate(() => window.tool.commit((resolve) => {
|
||||
window.art.play().then(() => resolve(true))
|
||||
}))
|
||||
await expect.poll(() => page.evaluate(() => window.tool.commit(() => {
|
||||
return window.art.currentTime
|
||||
}))).toBeGreaterThan(1.05)
|
||||
}
|
||||
const before = await page.evaluate(async () => ({
|
||||
parent: window.documentWitness,
|
||||
child: await window.tool.commit(() => {
|
||||
return { witness: window.documentWitness, version: window.Artplayer.version, currentTime: window.art.currentTime, rate: window.art.playbackRate }
|
||||
}),
|
||||
document: window.timeline.find(packet => packet.type === 'inject').__artplayerIframe.document,
|
||||
}))
|
||||
await page.goto('/away.html')
|
||||
await page.goBack({ waitUntil: 'commit' })
|
||||
await expect.poll(() => page.evaluate(() => window.lifecycle?.some(event => event.type === 'pageshow'))).toBe(true)
|
||||
const parent = await page.evaluate(() => ({ witness: window.documentWitness, lifecycle: window.lifecycle }))
|
||||
const restored = parent.lifecycle.some(event => event.type === 'pageshow' && event.persisted)
|
||||
if (browserName === 'chromium')
|
||||
expect(restored, 'The full Chromium channel must actually restore BFCache').toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.tool.injected)).toBe(true)
|
||||
const child = await page.evaluate(() => window.tool.commit(() => {
|
||||
return { witness: window.documentWitness, lifecycle: window.lifecycle, version: window.Artplayer.version, currentTime: window.art.currentTime, rate: window.art.playbackRate, held: typeof window.finishHeld, errors: window.fixtureErrors }
|
||||
}))
|
||||
expect(child.errors).toEqual([])
|
||||
if (restored) {
|
||||
expect(parent.witness).toBe(before.parent)
|
||||
expect(child.witness).toBe(before.child.witness)
|
||||
expect(child.lifecycle.some(event => event.type === 'pageshow' && event.persisted)).toBe(true)
|
||||
expect(child.rate).toBe(1.25)
|
||||
expect(child.currentTime).toBeGreaterThanOrEqual(1)
|
||||
expect(child.held).toBe('function')
|
||||
await expect.poll(() => page.evaluate(() => window.timeline.some(packet => packet.__artplayerIframe?.phase === 'resume'))).toBe(true)
|
||||
const continuity = await page.evaluate(() => ({ old: window.states.old, phases: window.timeline.filter(packet => packet.__artplayerIframe).map(packet => packet.__artplayerIframe.phase), ids: window.timeline.filter(packet => packet.__artplayerIframe).map(packet => packet.__artplayerIframe.document), injects: window.callbacks.filter(packet => packet.type === 'inject').length, privateCallbacks: window.callbacks.filter(packet => packet.type === 'artplayer-tool-iframe:session').length }))
|
||||
expect(continuity.ids.every(id => id === before.document)).toBe(true)
|
||||
expect(continuity.injects).toBe(1)
|
||||
expect(continuity.privateCallbacks).toBe(0)
|
||||
expect(continuity.old.status).toBe(continuity.phases.includes('leave') ? 'rejected' : 'pending')
|
||||
await page.evaluate(() => window.tool.commit(() => {
|
||||
window.finishHeld()
|
||||
}))
|
||||
await expect.poll(() => page.evaluate(() => window.states.old.status)).not.toBe('pending')
|
||||
if (continuity.old.status === 'pending')
|
||||
expect(await page.evaluate(() => window.states.old)).toEqual({ status: 'resolved', value: 9 })
|
||||
else
|
||||
expect(continuity.old).toEqual({ status: 'rejected', error: 'The iframe document has changed' })
|
||||
}
|
||||
else {
|
||||
expect(parent.witness).not.toBe(before.parent)
|
||||
expect(child.witness).not.toBe(before.child.witness)
|
||||
expect(child.lifecycle.some(event => event.persisted)).toBe(false)
|
||||
}
|
||||
const playback = await page.evaluate(() => window.tool.commit((resolve) => {
|
||||
window.art.play().then(() => {
|
||||
const result = { playing: !window.art.video.paused, width: window.art.video.videoWidth }
|
||||
resolve(result)
|
||||
})
|
||||
}))
|
||||
expect(playback.playing).toBe(true)
|
||||
expect(playback.width).toBeGreaterThan(0)
|
||||
await page.evaluate(() => window.tool.commit(() => {
|
||||
window.art.destroy()
|
||||
}))
|
||||
const final = await page.evaluate(() => ({ pending: Object.keys(window.tool.promises).length, errors: window.fixtureErrors }))
|
||||
expect(final).toEqual({ pending: 0, errors: [] })
|
||||
await testInfo.attach('iframe-history', { contentType: 'application/json', body: JSON.stringify({ outcome: restored ? 'actual-bfcache' : 'reload-control', core, relation, mode, before, parent, child, playback, final, scope: 'Real top-level history with embedded player. Reload controls do not satisfy BFCache acceptance. No simulated pageshow events or document recreation is counted as cached restoration.' }) })
|
||||
await page.evaluate(() => window.tool.destroy())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, initialize, test } from './fixtures.js'
|
||||
|
||||
for (const core of ['4.5.9', '5.4.0', 'candidate']) {
|
||||
for (const relation of ['same', 'cross']) {
|
||||
for (const mode of ['stop', '204', 'reset']) {
|
||||
test(`Iframe interrupted navigation ${core}/${relation}/${mode}`, async ({ page, request, diagnostics }, testInfo) => {
|
||||
const id = await initialize(page, core, relation, testInfo)
|
||||
const original = await page.evaluate(() => ({ source: window.targetFrame.src, document: window.timeline.find(packet => packet.type === 'inject').__artplayerIframe.document }))
|
||||
const witness = await page.evaluate(() => window.tool.commit(() => {
|
||||
return window.documentWitness
|
||||
}))
|
||||
const stall = new URL(`/stall?case=${id}`, original.source).href
|
||||
await page.evaluate((stall) => {
|
||||
window.targetFrame.src = stall
|
||||
window.track('queued', window.tool.commit(() => {
|
||||
window.executions = (window.executions || 0) + 1
|
||||
return new URL(location.href).searchParams.get('epoch')
|
||||
}))
|
||||
}, stall)
|
||||
await expect.poll(async () => (await (await request.get(`/status?case=${id}`)).json()).pending).toBe(true)
|
||||
expect(await page.evaluate(() => window.tool.injected)).toBe(false)
|
||||
expect(await page.evaluate(() => window.states.queued.status)).toBe('pending')
|
||||
if (mode === 'stop')
|
||||
await diagnostics.stopLoading()
|
||||
else
|
||||
expect((await request.get(`/release?case=${id}&mode=${mode}`)).ok()).toBe(true)
|
||||
await expect.poll(async () => (await (await request.get(`/status?case=${id}`)).json()).pending).toBe(false)
|
||||
if (mode === 'reset') {
|
||||
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')
|
||||
}
|
||||
else {
|
||||
await page.evaluate(() => window.targetFrame.contentWindow.postMessage({ type: 'history-fixture-finish' }, '*'))
|
||||
await expect.poll(() => page.evaluate(() => window.timeline.some(packet => packet.type === 'history-fixture-old-document'))).toBe(true)
|
||||
expect(await page.evaluate(() => window.timeline.find(packet => packet.type === 'history-fixture-old-document').data)).toEqual({ witness, executions: 0 })
|
||||
await expect.poll(() => page.evaluate(() => window.states.old.status)).toBe('resolved')
|
||||
expect(await page.evaluate(() => window.states.old.value)).toBe(9)
|
||||
}
|
||||
expect(await page.evaluate(() => window.states.queued.status)).toBe('pending')
|
||||
const finalUrl = new URL(original.source)
|
||||
finalUrl.searchParams.set('epoch', '2')
|
||||
await page.evaluate(url => window.targetFrame.src = url, finalUrl.href)
|
||||
await expect.poll(() => page.evaluate(() => window.states.queued.status)).toBe('resolved')
|
||||
expect(await page.evaluate(() => window.states.queued.value)).toBe('2')
|
||||
await expect.poll(() => page.evaluate(() => window.tool.commit(() => {
|
||||
return window.art.video.readyState >= 2
|
||||
}))).toBe(true)
|
||||
const recovered = await page.evaluate(() => ({ states: window.states, pending: Object.keys(window.tool.promises).length, document: window.timeline.filter(packet => packet.type === 'inject').at(-1).__artplayerIframe.document }))
|
||||
expect(recovered.document).not.toBe(original.document)
|
||||
expect(recovered.pending).toBe(0)
|
||||
await page.evaluate((stall) => {
|
||||
window.targetFrame.src = stall
|
||||
window.track('cleanup', window.tool.commit(() => {
|
||||
return 'must not execute'
|
||||
}))
|
||||
}, stall)
|
||||
await expect.poll(async () => (await (await request.get(`/status?case=${id}`)).json()).pending).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
window.tool.destroy()
|
||||
window.targetFrame.remove()
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.states.cleanup.status)).toBe('rejected')
|
||||
expect(await page.evaluate(() => window.states.cleanup.error)).toBe('The instance has been destroyed')
|
||||
await expect.poll(async () => (await (await request.get(`/status?case=${id}`)).json()).pending).toBe(false)
|
||||
await testInfo.attach('iframe-interruption', { contentType: 'application/json', body: JSON.stringify({ outcome: 'recovered-and-cleaned', core, relation, mode, original, recovered, scope: 'Native browser stop (Chromium Page.stopLoading; other engines top window.stop), HTTP 204 without document replacement, or truncated committed HTTP response. New requests wait for actual injection; applications resume through a later valid navigation or destroy. No implicit timeout or automatic failed-navigation recovery is claimed.' }) })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
window.documentWitness = Math.random().toString(36)
|
||||
window.lifecycle = []
|
||||
window.fixtureErrors = []
|
||||
for (const name of ['pageshow', 'pagehide']) {
|
||||
addEventListener(name, event => window.lifecycle.push({ type: event.type, persisted: event.persisted }))
|
||||
}
|
||||
addEventListener('error', event => window.fixtureErrors.push(event.message))
|
||||
addEventListener('unhandledrejection', event => window.fixtureErrors.push(String(event.reason)))
|
||||
addEventListener('message', (event) => {
|
||||
if (window === window.parent || event.source !== window.parent)
|
||||
return
|
||||
if (event.data?.type === 'history-fixture-finish') {
|
||||
window.parent.postMessage({ type: 'history-fixture-old-document', data: { witness: window.documentWitness, executions: window.executions || 0 } }, '*')
|
||||
window.finishHeld()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<title>Iframe document history</title>
|
||||
<script src="/observe.js"></script>
|
||||
<script src="/tool.js"></script>
|
||||
<iframe style="width: 640px; height: 360px" allowfullscreen></iframe>
|
||||
<script>
|
||||
window.timeline = []
|
||||
window.callbacks = []
|
||||
window.states = {}
|
||||
window.track = (name, promise) => {
|
||||
states[name] = { status: 'pending' }
|
||||
promise.then(
|
||||
(value) => (states[name] = { status: 'resolved', value }),
|
||||
(error) => (states[name] = { status: 'rejected', error: error.message }),
|
||||
)
|
||||
}
|
||||
const target = new URL('/child.html', location.href)
|
||||
target.search = location.search
|
||||
if (target.searchParams.get('relation') === 'cross') target.hostname = 'localhost'
|
||||
window.targetFrame = document.querySelector('iframe')
|
||||
addEventListener('message', (event) => {
|
||||
if (event.source === targetFrame.contentWindow) timeline.push(event.data)
|
||||
})
|
||||
window.tool = new ArtplayerToolIframe({ iframe: targetFrame, url: target.href })
|
||||
tool.message((packet) => callbacks.push(packet))
|
||||
</script>
|
||||
@@ -0,0 +1,127 @@
|
||||
/* eslint-disable antfu/no-top-level-await -- Assemble immutable test resources before listening. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import fs from 'node:fs'
|
||||
import http from 'node:http'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
|
||||
import { iframeCandidate } from '../helpers/iframe.js'
|
||||
import { compilePackage } from '../helpers/load.js'
|
||||
|
||||
const port = Number(process.env.ARTPLAYER_IFRAME_HISTORY_PORT || 8085)
|
||||
assert(Number.isInteger(port) && port > 0 && port < 65536)
|
||||
const tool = await iframeCandidate()
|
||||
const mapFile = process.env.ARTPLAYER_BROWSER_ARTIFACTS
|
||||
let coreBytes
|
||||
if (mapFile) {
|
||||
const map = JSON.parse(fs.readFileSync(mapFile))
|
||||
assert(typeof map.artplayer === 'string', 'Missing explicit core artifact')
|
||||
coreBytes = fs.readFileSync(path.resolve(path.dirname(mapFile), map.artplayer))
|
||||
}
|
||||
else {
|
||||
coreBytes = Buffer.from(await compilePackage('artplayer', 'umd'))
|
||||
}
|
||||
const cores = new Map([['candidate', coreBytes]])
|
||||
const releases = JSON.parse(fs.readFileSync('refactor/baselines/releases.json')).releases
|
||||
const oldCore = JSON.parse(fs.readFileSync('refactor/baselines/iframe-core.json')).release
|
||||
for (const release of [oldCore, releases.find(release => release.name === 'artplayer')]) {
|
||||
const member = `package/${release.manifest.main.replace(/^\.\//, '')}`
|
||||
const bytes = readMember(await ensureArchive(release), member)
|
||||
assert.equal(hash(bytes), release.files[member])
|
||||
cores.set(release.version, bytes)
|
||||
}
|
||||
const files = new Map(['parent.html', 'child.html', 'observe.js'].map(name => [name, fs.readFileSync(new URL(name, import.meta.url))]))
|
||||
files.set('tool.js', Buffer.from(tool.code))
|
||||
files.set('pattern.mp4', fs.readFileSync('test/browser/media/pattern.mp4'))
|
||||
const manifest = { tool: { name: tool.name, sha256: hash(tool.code) }, cores: Object.fromEntries([...cores].map(([name, bytes]) => [name, hash(bytes)])), files: Object.fromEntries([...files].map(([name, bytes]) => [name, hash(bytes)])) }
|
||||
const stalled = new Map()
|
||||
function send(req, res, bytes, type) {
|
||||
res.setHeader('Content-Type', type)
|
||||
res.setHeader('Cache-Control', 'public, max-age=60')
|
||||
if (req.headers.range) {
|
||||
const range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.range)
|
||||
const start = Number(range?.[1])
|
||||
const end = range?.[2] ? Math.min(Number(range[2]), bytes.length - 1) : bytes.length - 1
|
||||
if (!range || start > end || start >= bytes.length) {
|
||||
res.writeHead(416).end()
|
||||
return
|
||||
}
|
||||
res.statusCode = 206
|
||||
res.setHeader('Content-Range', `bytes ${start}-${end}/${bytes.length}`)
|
||||
bytes = bytes.subarray(start, end + 1)
|
||||
}
|
||||
res.setHeader('Content-Length', bytes.length)
|
||||
res.setHeader('Accept-Ranges', 'bytes')
|
||||
res.end(bytes)
|
||||
}
|
||||
const server = http.createServer((req, res) => {
|
||||
try {
|
||||
assert.equal(req.method, 'GET')
|
||||
const url = new URL(req.url, `http://127.0.0.1:${port}`)
|
||||
if (url.pathname === '/manifest.json') {
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.end(JSON.stringify(manifest))
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/away.html') {
|
||||
send(req, res, Buffer.from('<!doctype html><title>Away</title><p>Away from player</p>'), 'text/html')
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/favicon.ico') {
|
||||
res.writeHead(204).end()
|
||||
return
|
||||
}
|
||||
if (['/stall', '/release', '/status'].includes(url.pathname)) {
|
||||
const id = url.searchParams.get('case')
|
||||
assert(id && /^[a-f0-9]{16}$/.test(id))
|
||||
if (url.pathname === '/stall') {
|
||||
assert(!stalled.has(id) && stalled.size < 100, 'Duplicate or excessive test barrier')
|
||||
stalled.set(id, res)
|
||||
res.on('close', () => {
|
||||
if (stalled.get(id) === res)
|
||||
stalled.delete(id)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/release') {
|
||||
const pending = stalled.get(id)
|
||||
assert(pending, 'No stalled request')
|
||||
if (url.searchParams.get('mode') === 'reset') {
|
||||
pending.writeHead(200, { 'Content-Type': 'text/html', 'Content-Length': 1000 })
|
||||
pending.write('<!doctype html><title>Interrupted response</title>', () => pending.destroy())
|
||||
}
|
||||
else {
|
||||
pending.writeHead(204).end()
|
||||
}
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.end(JSON.stringify({ pending: stalled.has(id) }))
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/core.js') {
|
||||
const bytes = cores.get(url.searchParams.get('core'))
|
||||
assert(bytes, 'Unknown core')
|
||||
send(req, res, bytes, 'text/javascript')
|
||||
return
|
||||
}
|
||||
const name = url.pathname.slice(1)
|
||||
let bytes = files.get(name)
|
||||
assert(bytes, 'Unknown fixture resource')
|
||||
if (name === 'child.html') {
|
||||
const core = url.searchParams.get('core')
|
||||
assert(cores.has(core), 'Unknown child core')
|
||||
bytes = Buffer.from(bytes.toString().replace('__CORE__', core))
|
||||
}
|
||||
send(req, res, bytes, name.endsWith('.js') ? 'text/javascript' : name.endsWith('.mp4') ? 'video/mp4' : 'text/html')
|
||||
}
|
||||
catch (error) { res.writeHead(400, { 'Content-Type': 'text/plain' }).end(error.message) }
|
||||
})
|
||||
server.listen(port, '127.0.0.1', () => process.stdout.write(`Iframe history server: ${port}\n`))
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => {
|
||||
server.closeAllConnections()
|
||||
server.close()
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user