test(iframe): [PKG-IFRAME-05] verify native cached history and interrupted navigation

This commit is contained in:
Harvey Zhao committed 2026-09-13 02:55:57 +08:00
1 parent b35183c7af
commit cb6c767731
19 files changed
+7908 -13

No files matched your search

+3
View File
@@ -87,6 +87,8 @@ jobs:
node --input-type=module -e 'import fs from "node:fs"; const { output } = JSON.parse(fs.readFileSync("refactor/.cache/packages/latest.json")); fs.appendFileSync(process.env.GITHUB_ENV, `ARTPLAYER_BROWSER_ARTIFACTS=${output}/browser-artifacts.json\n`);'
- name: Run all three engines
run: yarn test:browser
- name: Verify iframe cached history and interrupted navigation
run: yarn test:iframe-history
- name: Compare installed performance and verify resource cleanup
run: yarn test:performance
- name: Upload browser evidence
@@ -96,6 +98,7 @@ jobs:
name: browser-${{ github.run_id }}-${{ github.run_attempt }}
path: |
refactor/.cache/browser/
refactor/.cache/iframe-history/
refactor/.cache/performance/
refactor/.cache/packages/latest.json
refactor/.cache/packages/run-*/*.json
+1
View File
@@ -55,6 +55,7 @@
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
"test:iframe-history": "playwright test --config playwright.iframe-history.config.js",
"test:performance": "playwright test --config playwright.performance.config.js",
"test:browser:install": "playwright install chromium firefox webkit",
"test:browser:report": "playwright show-report refactor/.cache/browser/html",
+23 -3
View File
@@ -181,6 +181,26 @@ its initial commit handles cancellation during cleanup. This is an editor
convention, not automatic DOM-removal behavior or a new player API. Standalone
integrations remain responsible for calling destroy themselves.
Physical devices, actual BFCache restoration, interrupted navigation and final
distribution remain separate gates. Passing desktop player/editor tests does not
complete those gates or establish the old helper/package-name migration.
`yarn test:iframe-history` runs the separate cacheable HTTP fixture and full
Chromium channel, removing Playwright's BFCache-disabling default argument. It
checks actual whole-page restoration through native persisted events, parent/child
witnesses, media and the real commit protocol. Firefox/WebKit reload controls are
reported separately and never counted as cached restoration. Do not replace this
configuration with the default headless shell or simulated lifecycle events.
An entire cached page also freezes the parent. If the private leave message did
not reach it, the original request can remain pending and settle after restoration.
If leave was delivered, that request remains rejected; a late result cannot change
its outcome. Resume retains the same document ID and emits no duplicate public
inject. The test checks actual delivered phases against settlement.
The same command tests native stop, HTTP 204 and a truncated document response.
After a failed navigation, new work still waits for actual injection into the
target document; it is not sent to the unrelated old document. Navigate the frame
to a valid source or destroy the tool to finish/cancel that work. There is no
implicit timeout or automatic fallback to an old source.
Physical devices, native Firefox/WebKit cached restoration and final distribution
remain separate gates. Desktop evidence does not establish the old helper or
package-name migration. See ADR-026 and the task 05 history checkpoint for the
exact fixture, automation limitations and source/main/legacy evidence.
+23
View File
@@ -0,0 +1,23 @@
import process from 'node:process'
import { defineConfig, devices } from '@playwright/test'
const port = Number(process.env.ARTPLAYER_IFRAME_HISTORY_PORT || 8085)
export default defineConfig({
testDir: './test/history',
timeout: 20000,
expect: { timeout: 7000 },
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: 0,
workers: process.env.CI ? 2 : 3,
outputDir: 'refactor/.cache/iframe-history/results',
reporter: [['list'], ['json', { outputFile: 'refactor/.cache/iframe-history/report.json' }]],
use: { baseURL: `http://127.0.0.1:${port}`, trace: 'retain-on-failure', screenshot: 'only-on-failure' },
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'], channel: 'chromium', launchOptions: { ignoreDefaultArgs: ['--disable-back-forward-cache'] } } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: { command: 'node test/history/server.mjs', url: `http://127.0.0.1:${port}/manifest.json`, reuseExistingServer: false, timeout: 60000, env: { ARTPLAYER_IFRAME_HISTORY_PORT: String(port) } },
})
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,122 @@
# PKG-IFRAME-05 native cached history and interrupted navigation checkpoint
Started from b35183c7af10a7309c9409b50fe8d5fda9db9ffb with a clean worktree.
The preceding checkpoint verifies actual player and Monaco integration. This
checkpoint addresses genuine BFCache restoration and external navigation
interruption without changing public APIs or production runtime bytes.
## Reproducible browser environment
The installed Playwright 1.63.0 source explicitly adds
`--disable-back-forward-cache` to Chromium. Its default headless shell also
reloaded this fixture after that argument was removed. The separate
`playwright.iframe-history.config.js` therefore uses the full `chromium` channel
and removes only that BFCache-disabling argument. Ordinary browser tests retain
their original configuration and report directory.
Initial download of full Chromium 153.0.8010.12 timed out. A supplementary probe
with the already installed full 151.0.7922.34 demonstrated actual restoration,
but is not substituted for the pinned-version results. After confirming a local
proxy listener, a process-scoped HTTPS_PROXY allowed the normal Playwright
installer to download full 153.0.8010.12. No machine proxy or repository proxy
configuration was changed. No package dependency version was changed.
`yarn test:iframe-history` uses a separate local fixture server on port 8085 and
records to `refactor/.cache/iframe-history`. The browser receives real HTTP pages,
core/tool scripts and media with cacheable headers. There is no request routing,
synthetic pageshow event, reconstructed document, or mocked media in this matrix.
The parent and child each retain an independent random document witness and
native page lifecycle events. All candidate script and fixture hashes are in
the environment attachment. Artifact overrides never silently fall back to source.
The GitHub browser job now runs this command and uploads its evidence directory.
Local Windows evidence is not evidence that the hosted macOS job has executed.
## History assertions
The history matrix has 36 cases: cores 4.5.9, frozen 5.4.0 and candidate; same
and cross origin; paused and playing; Chromium, Firefox and WebKit. Full Chromium
must show actual `pageshow.persisted`, unchanged parent/child witnesses and
unchanged private document IDs. Player position/rate and the pending resolver
survive in the same document, communication resumes, playback works and resources
can be destroyed. Private resume packets must not become public callbacks or
duplicate the original inject callback.
Whole-page caching freezes the parent too. A child's best-effort pagehide message
may not reach that frozen parent. The test correlates the request state with the
actual delivered private phases: with no delivered leave, the old request stays
pending and can resolve after restoration; with a delivered leave it must already
be rejected and a late resolver cannot change the result. This is distinct from
the existing child-only navigation tests, where an active parent processes leave.
Firefox and WebKit currently recreate both documents. These cases explicitly
record `reload-control`, verify changed witnesses and successful fresh playback,
and do not count toward actual BFCache acceptance. Native Firefox/WebKit cached
restoration and physical devices remain unverified.
## Interrupted navigation assertions
Another 54 cases cover the same three cores and two origins on all three engines:
- Stop a genuinely pending HTTP navigation. Chromium uses native CDP
`Page.stopLoading`; Firefox and WebKit use top-window `window.stop()`.
- Return HTTP 204, retaining the old document.
- Start an HTTP 200 document response, declare more bytes than are sent, then
destroy the connection after writing the partial HTML.
An active old document can still settle a request it already received. A committed
replacement cancels old owned work. Queued new requests cannot execute in the old
document and remain waiting until a subsequent valid iframe source injects. The
test then starts another stalled navigation, destroys the tool and removes the
frame, checking immediate rejection and release of the test server's barrier.
This preserves the existing absence of implicit request timeouts. A failed target
does not automatically authorize commands to run in a different old document.
The application can navigate to another valid source or destroy the tool. These
tests establish those recovery paths, not automatic recovery from every network
failure or an undocumented timeout policy.
## Failed probes retained
`iframe05-history-probe*.json/log` retain shell/full browser comparisons, the
missing full-browser executable, and actual supplementary/pinned-version probes.
The full installer logs retain direct timeout and process-proxy success.
Chromium's automation frame inventory omitted the restored cross-origin child in
the probe even while native parent/child witnesses and SDK communication survived.
The permanent tests inspect the restored child through the actual public commit
protocol rather than relying on an automation-side Frame object.
The first stop probe stalled while evaluating the old cross-origin child during
its pending navigation. Sending a stop signal to that child, and then calling
top-window stop, also failed to terminate the pending cross-process navigation in
Chromium. The final test uses the browser's Page.stopLoading command there. These
failures are preserved in `iframe05-interruption-probe*.json` and complete results
directories. No SDK behavior was changed to work around the driver/browser stop
boundary; the exact stop mechanism is recorded per engine.
Final source/main/legacy results, input hashes, CI and workflow verification are
recorded in `baselines/iframe-history-validation.json`. Task 05 remains doing and
its risk records stay open for remaining device/cache-engine/release acceptance.
No push, tag, npm publication or major-version release is performed.
## Final verification and rollback
- Source/main/legacy each pass 90 cases without skips, retries or flaky results:
270 total, including 36 actual Chromium cache restorations, 72 explicit reload
controls and 162 interruption/recovery cases.
- Full CI passes 1544 tests (1365 unit, 14 engineering, 165 baseline) and strict
checking of 330 production TypeScript files; import/SSR checks pass 3 tests.
- actionlint 1.7.12 validates the updated workflow. All new browser/config files
pass lint. The generated core editor's existing unused directive warning is
unchanged. No new coverage run is claimed for unchanged production source.
- The runtime source and all three iframe distribution files match the preceding
checkpoint byte-for-byte. No package rebuild is necessary for these test/docs
and CI-only changes. Hosted CI, physical devices and other cache engines are
not promoted to passed from local evidence.
Rollback removes the independent history test/config/server/script and CI step
together, and restores the linked documentation/evidence. It does not require a
runtime rollback or change the old public API.
Checkpoint subject: `test(iframe): [PKG-IFRAME-05] verify native cached history and interrupted navigation`.
+7 -1
View File
@@ -1,6 +1,6 @@
# 复用 docs HTML 与在线编辑器进行真实测试
2026-09-10 已核对下列页面及脚本源码,尚未实际运行页面。优先复用现有 HTML 和示例作为浏览器验收入口,保留旧 URL/参数和编辑器功能;另补可靠断言、状态隔离和报告,不把演示能打开当作兼容性通过。
2026-09-10 首次清点时仅核对源码。后续实际运行以各任务证据为准:IFRAME-05 已验证真实iframe页面、Monaco Run和桌面历史导航,见[播放器/编辑器记录](changes/2026-09-13-PKG-IFRAME-05-integration.md)及[缓存/中断记录](changes/2026-09-13-PKG-IFRAME-05-history.md)。保留旧 URL/参数和编辑器功能,补可靠断言、状态隔离和报告,不把演示能打开当作兼容性通过。
## 入口与实际加载方式
@@ -38,6 +38,12 @@ ENG-05 已提供 `yarn test:browser`、8084 隔离服务和 `ARTPLAYER_BROWSER_A
## 任务归属与验收
IFRAME-05 的 `yarn test:iframe-history` 使用8085的独立缓存页面及完整Chromium通道,
移除Playwright默认禁用BFCache的参数。原8084配置保持不变。只有父子文档标识连续且
原生pageshow.persisted为true才记为缓存恢复;Firefox/WebKit的reload-control单独计数。
该命令还验证真实停止加载、HTTP 204、截断文档响应和后续导航/销毁,GitHub浏览器任务
运行并上传对应报告。它不替代8082实际分发、完整站点、物理设备或其他缓存引擎的验收。
| 任务 | 负责交付 |
| --- | --- |
| BASE-04 | 记录上述页面、核心/插件加载路径、存储状态及核心/chapter 的真实行为基线 |
+12 -2
View File
@@ -47,6 +47,15 @@ WindowProxy在导航中保持身份。旧协议只用Date.now的请求ID,无
`The instance has been destroyed`。调用方需要处理commit/postMessage的拒绝。
未确认离开前,原文档已完成的响应仍可结算原请求;这属于活文档的正常完成,不是假定所有导航意图都是换页。
整页进入BFCache时父页也会冻结,子页的best-effort leave不一定送达。实际完整Chromium
恢复证明父子文档及标记可以保持不变:未收到leave的原请求可以在恢复后继续完成;已收到
leave的原请求保持拒绝。resume不伪造公开inject,也不会让已结算的请求复活。测试同时
核对实际私有消息阶段和Promise结果,而不只检查history.back或Navigation Timing类型。
停止加载、HTTP 204及截断响应不会授权把新请求发送给旧目标。旧文档仍活跃时可完成
原请求,新队列继续等实际inject;设置后续有效iframe.src或destroy分别恢复或取消队列。
没有新增隐式超时、自动旧源回退或覆盖window.stop。真实停止方法和网络响应见history测试记录。
公开promises记录保留resove/reject和对象身份。普通响应仍尊重用户替换的公开回调;
内部取消独立持有原Promise拒绝入口,不能因用户删除/替换记录而泄漏原Promise。
@@ -56,8 +65,9 @@ WindowProxy在导航中保持身份。旧协议只用Date.now的请求ID,无
不能通过未升级的一侧可靠证明。完整文档保护需双方使用新实现,旧正常协议继续兼容。
- MutationObserver不可用时,只能在公开方法/轮询时检查可观察的源变化;语法legacy目标不等于补齐DOM能力。
- 未标记的自定义原生消息仍按旧协议接受;其文档级归属由应用管理。SDK提供的消息在协商后携带标记。
- 历史返回用例记录实际pageshow.persisted。普通重新加载不能计作原生BFCache验证;完整播放器、
原生BFCache/设备、外部中断导航与文档站实际组合仍在IFRAME-05及发布复盘核验,未由本轮自动豁免。
- 历史返回必须核对实际pageshow.persisted与文档标识。IFRAME-05新增完整Chromium真实缓存恢复、
实际播放器及编辑器、停止/204/截断响应验收。Firefox/WebKit的重新加载对照不能计作缓存恢复;
原生设备、其余缓存引擎及最终分发仍待验收,不由桌面通过结果自动豁免。
- 本协议不验证父页origin许可,也不把可执行commit变成代码沙箱;嵌入父页和选定子内容仍需可信。
验证入口为test/iframe-navigation.test.js和test/browser/iframe-navigation.spec.js,记录见
+1 -1
View File
@@ -534,5 +534,5 @@
- PKG-IFRAME-02: [记录](changes/2026-09-12-PKG-IFRAME-02-behavior.md) [记录](baselines/iframe-behavior-validation.json)
- PKG-IFRAME-03: [记录](changes/2026-09-12-PKG-IFRAME-03-requests-checkpoint.md) [记录](baselines/iframe-requests-checkpoint.json) [记录](changes/2026-09-12-PKG-IFRAME-03-boundaries-checkpoint.md) [记录](baselines/iframe-boundaries-checkpoint.json) [记录](iframe-message-boundary.md) [记录](changes/2026-09-13-PKG-IFRAME-03-navigation.md) [记录](baselines/iframe-navigation-validation.json) [记录](iframe-document-protocol.md)
- PKG-IFRAME-04: [记录](changes/2026-09-13-PKG-IFRAME-04-types.md) [记录](baselines/iframe-types-validation.json)
- PKG-IFRAME-05: [记录](changes/2026-09-13-PKG-IFRAME-05-integration.md) [记录](baselines/iframe-integration-validation.json)
- PKG-IFRAME-05: [记录](changes/2026-09-13-PKG-IFRAME-05-integration.md) [记录](baselines/iframe-integration-validation.json) [记录](changes/2026-09-13-PKG-IFRAME-05-history.md) [记录](baselines/iframe-history-validation.json)
- PKG-FACTORY-01: [记录](baselines/factory-assignment-gaps.json) [记录](baselines/factory-compatibility-proposals.json) [记录](factory-compatibility-decision.md) [记录](changes/2026-09-12-PKG-FACTORY-01-decision.md)
+15 -2
View File
@@ -1,5 +1,18 @@
# 进度与证据
## PKG-IFRAME-05 原生缓存与中断导航检查点(doing)
新增独立 `yarn test:iframe-history`:完整Chromium通道与可缓存HTTP页面,避免默认
Playwright禁用BFCache产生的错误验收。源码/main/legacy各90项通过,共270项;其中
36项实际Chromium缓存恢复、72项Firefox/WebKit重新加载对照、162项停止/204/截断响应
后的恢复和销毁。核对父子文档标识、原生persisted、媒体状态、请求结算和后续通信。
GitHub浏览器任务已增加运行及报告上传,actionlint通过;未推送或声称远端CI已执行。
全仓CI1544、330生产TS检查和3导入测试通过;不把未重新运行的覆盖率标成本轮新结果。
原生产源码/产物及公开接口未变;整页冻结可能保留未收到leave的原请求,已明确记录。
见[变更](changes/2026-09-13-PKG-IFRAME-05-history.md)与
[验证](baselines/iframe-history-validation.json)。物理设备、Firefox/WebKit实际缓存恢复、
最终分发仍待验收。222项:109 done、10 doing、103 todo。
## PKG-IFRAME-05 播放器与真实编辑器检查点(doing)
冻结原支持核心4.5.9的176个npm文件,与已冻结5.4.0、候选核心组成真实媒体矩阵。
@@ -9,8 +22,8 @@ Monaco页面Run流程。覆盖播放、seek/rate、网页全屏、切源、播
源码和运行产物字节未变,旧公开API保持;失败探针、trace及后续通过报告均留存。
全仓CI1544、330生产TS检查、3导入测试通过,配置覆盖率门槛0违例(224运行文件)。
见[变更](changes/2026-09-13-PKG-IFRAME-05-integration.md)和
[验证](baselines/iframe-integration-validation.json)。实际BFCache、物理设备、外部
中断导航及最终分发仍待验收,不因此关闭任务或风险。222项:109 done、10 doing、103 todo。
[验证](baselines/iframe-integration-validation.json)。后续缓存/中断证据见上方;物理设备与
最终分发仍待验收,不因此关闭任务或风险。222项:109 done、10 doing、103 todo。
## PKG-IFRAME-04 完成:公开类型、模块入口和编辑器
+7 -3
View File
@@ -3776,7 +3776,7 @@
],
"status": "open",
"confirmation": "reproduced",
"compatibleResolution": "Owned requests and ADR-026 document cancellation are implemented. IFRAME-05 now verifies actual desktop players and Monaco Run on source/main/legacy, including pending-request and multiple-player cleanup. Keep open for actual BFCache/device and interrupted-navigation acceptance; unchanged legacy peers retain documented document-identity limits.",
"compatibleResolution": "Owned requests and ADR-026 document cancellation are implemented. Task 05 verifies actual desktop players/Monaco and source/main/legacy cached history plus stop/204/truncated-response recovery. Full Chromium proves native BFCache; Firefox/WebKit only prove reload controls. Keep open for physical devices, remaining cache engines and final release acceptance; legacy peers retain documented document-identity limits.",
"closureCriteria": "Old behavior and candidate fixes have deterministic race/resource tests plus actual iframe integration.",
"evidence": [
"refactor/baselines/iframe-contract.md",
@@ -3792,7 +3792,9 @@
"refactor/baselines/iframe-navigation-validation.json",
"refactor/iframe-document-protocol.md",
"refactor/changes/2026-09-13-PKG-IFRAME-05-integration.md",
"refactor/baselines/iframe-integration-validation.json"
"refactor/baselines/iframe-integration-validation.json",
"refactor/changes/2026-09-13-PKG-IFRAME-05-history.md",
"refactor/baselines/iframe-history-validation.json"
]
},
{
@@ -3819,7 +3821,9 @@
"refactor/baselines/iframe-navigation-validation.json",
"refactor/iframe-document-protocol.md",
"refactor/changes/2026-09-13-PKG-IFRAME-05-integration.md",
"refactor/baselines/iframe-integration-validation.json"
"refactor/baselines/iframe-integration-validation.json",
"refactor/changes/2026-09-13-PKG-IFRAME-05-history.md",
"refactor/baselines/iframe-history-validation.json"
]
},
{
+3 -1
View File
@@ -3710,7 +3710,9 @@
"acceptance": "最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成",
"evidence": [
"changes/2026-09-13-PKG-IFRAME-05-integration.md",
"baselines/iframe-integration-validation.json"
"baselines/iframe-integration-validation.json",
"changes/2026-09-13-PKG-IFRAME-05-history.md",
"baselines/iframe-history-validation.json"
]
},
{
+20
View File
@@ -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>
+43
View File
@@ -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 }
+88
View File
@@ -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())
})
}
}
}
+69
View File
@@ -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.' }) })
})
}
}
}
+16
View File
@@ -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()
}
})
+26
View File
@@ -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>
+127
View File
@@ -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()
})
}