mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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 的真实行为基线 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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