mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
test(mediabunny): [PKG-MB-09] add sustained playback checkpoint
This commit is contained in:
1 parent
190fdb2776
commit
401c5e9684
12 files changed
+7815
-7
No files matched your search
+3
-1
@@ -77,7 +77,9 @@
|
||||
"test:dpip": "node --test test/dpip.test.js test/dpip-lifecycle.test.js",
|
||||
"test:dpip-types-package": "node refactor/scripts/dpip-package-types.mjs",
|
||||
"test:mediabunny": "node --test test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js",
|
||||
"test:mediabunny-types-package": "node refactor/scripts/mb-package-types.mjs"
|
||||
"test:mediabunny-types-package": "node refactor/scripts/mb-package-types.mjs",
|
||||
"test:mediabunny-soak": "playwright test --config playwright.soak.config.js",
|
||||
"generate:media:hls": "node scripts/generate-hls-fixture.mjs"
|
||||
},
|
||||
"browserslist": "last 1 Chrome version",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
import base from './playwright.config.js'
|
||||
|
||||
export default defineConfig({
|
||||
...base,
|
||||
testDir: './test/soak',
|
||||
timeout: 270000,
|
||||
workers: 2,
|
||||
outputDir: 'refactor/.cache/soak/results',
|
||||
reporter: [
|
||||
['list'],
|
||||
['json', { outputFile: 'refactor/.cache/soak/report.json' }],
|
||||
],
|
||||
use: { ...base.use, trace: 'off', video: 'off' },
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,68 @@
|
||||
# PKG-MB-09 持续播放检查点
|
||||
|
||||
任务保持doing。本轮新增独立的持续播放测试入口,验证发布核心与候选核心下的MediaBunny
|
||||
已构建main产物。没有更改生产实现、公开API、依赖或发布版本。
|
||||
|
||||
## 可重复运行
|
||||
|
||||
HLS生成脚本新增第三个参数duration(12到3600整数秒),省略时仍生成原12秒素材。
|
||||
禁止覆盖已存在目录,manifest记录FFmpeg版本、完整参数、每个文件的SHA256及大小。
|
||||
600秒素材包含160×90/320×180、24fps H.264和English/French两条AAC正弦音轨。
|
||||
长素材放在缓存,未替换已经冻结的短素材。每次测试验证manifest中所有媒体文件哈希。
|
||||
|
||||
```powershell
|
||||
yarn generate:media:hls refactor/.cache/mb-long-hls 600
|
||||
$env:ARTPLAYER_MB_ARTIFACT = (Resolve-Path packages/artplayer-proxy-mediabunny/dist/artplayer-proxy-mediabunny.js).Path
|
||||
$env:ARTPLAYER_MB_SOAK_MEDIA = (Resolve-Path refactor/.cache/mb-long-hls).Path
|
||||
yarn test:mediabunny-soak
|
||||
```
|
||||
|
||||
使用现有已验证的构建流程生成待测产物。显式产物和媒体目录为必需参数,不允许悄悄回退
|
||||
到源码构建。测试核心来源仍由browser-evidence中的manifest标识,默认候选核心为工作区
|
||||
构建;若使用ARTPLAYER_BROWSER_ARTIFACTS,则按既有服务器规则读取指定核心产物。
|
||||
|
||||
独立playwright.soak.config.js读取test/soak,两个worker,每项270秒超时、零重试,
|
||||
不会被日常test:browser自动发现。沿用8084服务器,禁止与普通浏览器测试同时运行。
|
||||
结果保存refactor/.cache/soak/report.json和results;下一轮前归档整个目录。
|
||||
当前入口用于本地专项验收,尚未接入GitHub托管长播放job;后续CI/发布复盘需明确安排。
|
||||
|
||||
## 验证内容
|
||||
|
||||
- 每个支持环境连续90秒墙钟1×、90秒墙钟2×,不循环短素材、不跳转伪造持续时间。
|
||||
- 每5秒确认媒体时钟与画面绘制继续前进、前台可见、未暂停或报错,以及实际播放速率。
|
||||
- 包装真实SDK迭代器取得解码Canvas时间戳,比较实际AudioContext主时钟;每阶段保留
|
||||
帧数、最大/累计偏差和固定大小的毫秒直方图,阈值250ms。切换操作之间单独记录阶段。
|
||||
- 连续播放后seek到100秒,再切换90P与French音轨,各阶段继续实际播放至少5秒。
|
||||
- 包装真实AudioContext节点创建/断开,仅持有在用节点和未完成迭代器;每次采样节点少于96、
|
||||
迭代器不超过2。销毁后节点/队列/迭代器归零,AudioContext关闭,创建与断开数量相同,
|
||||
再等待300ms确认没有新节点或画面绘制。
|
||||
- 缺少必要原生构造器的环境单独记录能力对照,不构造播放器,不计作播放通过。
|
||||
|
||||
本轮结果与完整CI已写入下方证据。失败记录保留,遇到实际缺陷先定位、修复并回归,
|
||||
不会通过放宽阈值或把失败改为能力缺失来完成验收。
|
||||
|
||||
## 证据边界
|
||||
|
||||
这是至少195秒的实际墙钟组合,不能推导数小时音画漂移、声学输出同步、GC堆无泄漏、
|
||||
后台节流或物理设备支持。资源断开计数只证明被测原生节点/迭代器的生命周期,不是完整
|
||||
内存分析。前面的原生Document PiP检查点也只覆盖可见opener;MB-CAP-01、MB-LIFE-01
|
||||
和MB-LICENSE-01仍按各自完整关闭条件继续,不由本检查点自动关闭。
|
||||
|
||||
## 本机验收结果
|
||||
|
||||
六项全部通过,其中Chromium/Firefox四项实际播放;两项Windows WebKit缺失能力对照
|
||||
不计播放。四个原生场景都完成至少195秒墙钟播放、seek/quality/audio及destroy清理。
|
||||
|
||||
| 引擎 / 核心 | 五阶段墙钟合计 | 最大AV时钟偏差 | 创建 / 断开节点 | 同时在用峰值 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| chromium / published | 195.48s | 42.00ms | 13661 / 13661 | 64 |
|
||||
| chromium / candidate | 195.52s | 41.33ms | 13661 / 13661 | 64 |
|
||||
| firefox / published | 195.62s | 45.67ms | 13659 / 13659 | 64 |
|
||||
| firefox / candidate | 195.62s | 51.33ms | 13675 / 13675 | 64 |
|
||||
|
||||
完整CI1418项和44重复契约通过,324生产TS。默认12秒生成结果与原冻结manifest、
|
||||
FFmpeg版本、命令和32文件哈希完全一致。新长素材1208文件的manifest按LF归一化保存,
|
||||
缓存报告保留原始字节哈希及完整results;每项原始附件包含固定直方图及逐5秒采样。
|
||||
见[持续播放证据](../baselines/mb-sustained-validation.json)和[素材清单](../baselines/mb-sustained-media.json)。
|
||||
本轮未发现需要修改生产实现的新运行时缺陷;新增的是可重复专项验证与工程入口。
|
||||
任务保持doing并专用检查点提交,无推送或发布。
|
||||
@@ -110,3 +110,14 @@ video-only输入及替换无sink仍readyState=4;任务保持doing,下一步
|
||||
关闭字段遗漏,失败日志保留。见[变更](changes/2026-09-12-PKG-MB-09-native-pip-checkpoint.md)
|
||||
和[证据](baselines/mb-native-pip-checkpoint.json)。222项:105 done、8 doing、109 todo。
|
||||
MB-09仍doing;接续持续播放/AV同步/更多组合与设备,DPIP-05仍独立验收。
|
||||
|
||||
## PKG-MB-09 持续播放检查点(doing)
|
||||
|
||||
独立yarn test:mediabunny-soak读取600秒固定HLS,四个Chromium/Firefox新旧核心场景
|
||||
各连续90秒1×/90秒2×,再seek/切质量/切音轨,共至少195秒实际墙钟。六项通过,
|
||||
两项WebKit为缺失API对照。最大AV时钟偏差51.34ms;每组创建约1.37万节点,
|
||||
同时在用峰值64,destroy后全部断开,迭代器/队列归零且无迟到绘制。
|
||||
完整CI1418与44重复契约通过,324生产TS;默认生成器保留原32文件哈希。
|
||||
见[变更](changes/2026-09-12-PKG-MB-09-sustained-playback-checkpoint.md)和
|
||||
[证据](baselines/mb-sustained-validation.json)。222项:105 done、8 doing、109 todo。
|
||||
MB-09仍doing;数小时/声学/物理设备/后台与完整插件组合未由本轮关闭,托管长测job待接续。
|
||||
+1
-1
@@ -528,5 +528,5 @@
|
||||
- PKG-MB-06: [记录](baselines/mb-audio-source.json) [记录](changes/2026-09-12-PKG-MB-06-audio.md) [记录](baselines/mb-audio-validation.json)
|
||||
- PKG-MB-07: [记录](baselines/mb-hls-source.json) [记录](changes/2026-09-12-PKG-MB-07-hls.md) [记录](baselines/mb-hls-validation.json)
|
||||
- PKG-MB-08: [记录](baselines/mb-entry-source.json) [记录](changes/2026-09-12-PKG-MB-08-entry-checkpoint.md) [记录](baselines/mb-entry-checkpoint.json) [记录](baselines/mb-capability-source.json) [记录](changes/2026-09-12-PKG-MB-08-capability.md) [记录](baselines/mb-capability-validation.json)
|
||||
- PKG-MB-09: [记录](changes/2026-09-12-PKG-MB-09-native-pip-checkpoint.md) [记录](baselines/mb-native-pip-checkpoint.json)
|
||||
- PKG-MB-09: [记录](changes/2026-09-12-PKG-MB-09-native-pip-checkpoint.md) [记录](baselines/mb-native-pip-checkpoint.json) [记录](changes/2026-09-12-PKG-MB-09-sustained-playback-checkpoint.md) [记录](baselines/mb-sustained-validation.json) [记录](baselines/mb-sustained-media.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)
|
||||
@@ -1,5 +1,16 @@
|
||||
# 进度与证据
|
||||
|
||||
## PKG-MB-09 持续播放检查点(doing)
|
||||
|
||||
独立yarn test:mediabunny-soak读取600秒固定HLS,四个Chromium/Firefox新旧核心场景
|
||||
各连续90秒1×/90秒2×,再seek/切质量/切音轨,共至少195秒实际墙钟。六项通过,
|
||||
两项WebKit为缺失API对照。最大AV时钟偏差51.34ms;每组创建约1.37万节点,
|
||||
同时在用峰值64,destroy后全部断开,迭代器/队列归零且无迟到绘制。
|
||||
完整CI1418与44重复契约通过,324生产TS;默认生成器保留原32文件哈希。
|
||||
见[变更](changes/2026-09-12-PKG-MB-09-sustained-playback-checkpoint.md)和
|
||||
[证据](baselines/mb-sustained-validation.json)。222项:105 done、8 doing、109 todo。
|
||||
MB-09仍doing;数小时/声学/物理设备/后台与完整插件组合未由本轮关闭,托管长测job待接续。
|
||||
|
||||
## PKG-MB-09 原生画中画检查点(doing)
|
||||
|
||||
36个新旧核心/媒体/关闭方式/浏览器场景通过,24项实际原生窗口中Canvas解码播放,
|
||||
|
||||
+4
-2
@@ -3648,7 +3648,7 @@
|
||||
"PKG-MB-07",
|
||||
"PKG-MB-09"
|
||||
],
|
||||
"compatibleResolution": "Reproduce pending operations and destroy/source-switch races before migrating each owner; preserve public event and promise behavior while preventing stale resource use. MB-02 now reproduces late load rejection and uncancelled waiting/loadstart timers after destroy in all three historical implementations using a controlled host. Other interleavings and full-core native behavior are not yet verified. Expanded MB-02 baseline reproduces play-after-pause/destroy, rejected-play false state, seek-after-destroy and retained successful-load timeout timers in all three actual historical implementations; Range and active-timeout normal controls remain separate. MB-03 candidate owns pending Input before track selection and cancels load/HEAD/timers on source replacement, timeout and destroy; source/main/legacy regressions pass. Pending play/seek, late decoder work and full readiness coordination remain MB-04/05/06, so this risk stays open. MB-04 shim checkpoint fixes synthetic RAF retention and terminal event-listener cleanup, including engine cleanup exceptions. Closing the event bridge does not prove pending engine operations stopped; coordination remains in progress. MB-04 now guards play/seek/replacement completion, preserves explicit pause, observes stale failures and isolates reentrant event sequences. Decoder-internal late frames/audio and HLS UI races remain MB-05/06/07; risk stays open. MB-05 migrates VideoEngine and owns iterators, late first/second seek frames, pending reads, per-instance RAF and poster callbacks; same native race assertions fail on old main and pass on candidate. Independent CanvasSink pools also prevent multiple obsolete seek samples from overwriting queued current-frame pixels. Audio nodes, long-run AV sync and HLS UI topology remain MB-06/07/09. MB-06 migrates AudioEngine into six checked modules, owns context/gain/nodes/iterators and removable cancellation waits, preserves clock and gain formulas, and invalidates pending resume/decoding across pause/source/destroy. Same old main fails 29 of 37 audio assertions and four native pause-cleanup cases; candidate passes with short native 1x/2x AV-clock samples. Long-run AV sync, full combinations and HLS UI topology remain MB-07/09, so risk stays open. MB-07 migrates HLS UI to four checked modules, guards stale refresh/selection callbacks, cleans each surface and owns terminal listeners. Quality/audio queries share a latest-intent token and retry pairing against changed media snapshots. Forty-three old-main assertions yield 11 pass/32 fail; sixteen old native UI/query cases fail and twenty candidate native cases pass across old/new cores. Long-run AV sync and full combinations remain MB-09; capability/type/package gates remain MB-08/10.",
|
||||
"compatibleResolution": "Reproduce pending operations and destroy/source-switch races before migrating each owner; preserve public event and promise behavior while preventing stale resource use. MB-02 now reproduces late load rejection and uncancelled waiting/loadstart timers after destroy in all three historical implementations using a controlled host. Other interleavings and full-core native behavior are not yet verified. Expanded MB-02 baseline reproduces play-after-pause/destroy, rejected-play false state, seek-after-destroy and retained successful-load timeout timers in all three actual historical implementations; Range and active-timeout normal controls remain separate. MB-03 candidate owns pending Input before track selection and cancels load/HEAD/timers on source replacement, timeout and destroy; source/main/legacy regressions pass. Pending play/seek, late decoder work and full readiness coordination remain MB-04/05/06, so this risk stays open. MB-04 shim checkpoint fixes synthetic RAF retention and terminal event-listener cleanup, including engine cleanup exceptions. Closing the event bridge does not prove pending engine operations stopped; coordination remains in progress. MB-04 now guards play/seek/replacement completion, preserves explicit pause, observes stale failures and isolates reentrant event sequences. Decoder-internal late frames/audio and HLS UI races remain MB-05/06/07; risk stays open. MB-05 migrates VideoEngine and owns iterators, late first/second seek frames, pending reads, per-instance RAF and poster callbacks; same native race assertions fail on old main and pass on candidate. Independent CanvasSink pools also prevent multiple obsolete seek samples from overwriting queued current-frame pixels. Audio nodes, long-run AV sync and HLS UI topology remain MB-06/07/09. MB-06 migrates AudioEngine into six checked modules, owns context/gain/nodes/iterators and removable cancellation waits, preserves clock and gain formulas, and invalidates pending resume/decoding across pause/source/destroy. Same old main fails 29 of 37 audio assertions and four native pause-cleanup cases; candidate passes with short native 1x/2x AV-clock samples. Long-run AV sync, full combinations and HLS UI topology remain MB-07/09, so risk stays open. MB-07 migrates HLS UI to four checked modules, guards stale refresh/selection callbacks, cleans each surface and owns terminal listeners. Quality/audio queries share a latest-intent token and retry pairing against changed media snapshots. Forty-three old-main assertions yield 11 pass/32 fail; sixteen old native UI/query cases fail and twenty candidate native cases pass across old/new cores. Long-run AV sync and full combinations remain MB-09; capability/type/package gates remain MB-08/10. MB-09 checkpoints add 24 actual native Document PiP playback/lifecycle cases with visible openers and four native old/new-core sustained HLS cases, each at least 195 wall seconds across 1x/2x, seek, quality and audio. Maximum sampled frame/audio-clock delta is 51.34ms; active nodes peak at 64 and all approximately 13.7k created nodes per case disconnect on teardown, with iterators/queues empty. This does not close background behavior, complete plugin combinations, physical-device capability, acoustic synchronization or hour-scale validation.",
|
||||
"closureCriteria": "Historical failing interleavings and candidate cancellation/cleanup pass with real media and event evidence, including long playback and track topology.",
|
||||
"status": "open",
|
||||
"evidence": [
|
||||
@@ -3668,7 +3668,9 @@
|
||||
"refactor/changes/2026-09-12-PKG-MB-06-audio.md",
|
||||
"refactor/baselines/mb-audio-validation.json",
|
||||
"refactor/changes/2026-09-12-PKG-MB-07-hls.md",
|
||||
"refactor/baselines/mb-hls-validation.json"
|
||||
"refactor/baselines/mb-hls-validation.json",
|
||||
"refactor/baselines/mb-native-pip-checkpoint.json",
|
||||
"refactor/baselines/mb-sustained-validation.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+4
-1
@@ -3576,7 +3576,10 @@
|
||||
"acceptance": "资源释放、事件顺序、AV sync 与支持范围满足基线",
|
||||
"evidence": [
|
||||
"changes/2026-09-12-PKG-MB-09-native-pip-checkpoint.md",
|
||||
"baselines/mb-native-pip-checkpoint.json"
|
||||
"baselines/mb-native-pip-checkpoint.json",
|
||||
"changes/2026-09-12-PKG-MB-09-sustained-playback-checkpoint.md",
|
||||
"baselines/mb-sustained-validation.json",
|
||||
"baselines/mb-sustained-media.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,13 +8,15 @@ import { hash } from '../refactor/scripts/releases.mjs'
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url))
|
||||
const output = path.resolve(root, process.argv[2] || 'test/browser/media/hls')
|
||||
const duration = Number(process.argv[3] || 12)
|
||||
assert(Number.isInteger(duration) && duration >= 12 && duration <= 3600, 'Duration must be 12..3600 whole seconds')
|
||||
assert(!fs.existsSync(output), 'Output must be a new directory; never overwrite frozen media')
|
||||
const ffmpeg = process.env.ARTPLAYER_FFMPEG || 'ffmpeg'
|
||||
const version = execFileSync(ffmpeg, ['-version'], { encoding: 'utf8' }).split(/\r?\n/)[0]
|
||||
fs.mkdirSync(output, { recursive: true })
|
||||
const commands = []
|
||||
function generate(name, input, encoding) {
|
||||
const args = ['-hide_banner', '-loglevel', 'error', '-n', '-f', 'lavfi', '-i', input, '-t', '12', ...encoding, '-f', 'hls', '-hls_time', '2', '-hls_playlist_type', 'vod', '-hls_segment_filename', `${name}-%02d.mpegts`, `${name}.m3u8`]
|
||||
const args = ['-hide_banner', '-loglevel', 'error', '-n', '-f', 'lavfi', '-i', input, '-t', String(duration), ...encoding, '-f', 'hls', '-hls_time', '2', '-hls_playlist_type', 'vod', '-hls_segment_filename', `${name}-%02d.mpegts`, `${name}.m3u8`]
|
||||
execFileSync(ffmpeg, args, { cwd: output, stdio: 'pipe' })
|
||||
commands.push(args)
|
||||
}
|
||||
@@ -27,5 +29,5 @@ const variants = '#EXT-X-STREAM-INF:BANDWIDTH=200000,RESOLUTION=160x90,CODECS="a
|
||||
fs.writeFileSync(path.join(output, 'master.m3u8'), `#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,URI="english.m3u8"\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="French",LANGUAGE="fr",DEFAULT=NO,AUTOSELECT=YES,URI="french.m3u8"\n${variants}`)
|
||||
fs.writeFileSync(path.join(output, 'video-only.m3u8'), '#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-STREAM-INF:BANDWIDTH=200000,RESOLUTION=160x90,CODECS="avc1.42c01e"\nlow.m3u8\n')
|
||||
const files = Object.fromEntries(fs.readdirSync(output).sort().map(name => [name, { sha256: hash(fs.readFileSync(path.join(output, name))), bytes: fs.statSync(path.join(output, name)).size }]))
|
||||
fs.writeFileSync(path.join(output, 'manifest.json'), `${JSON.stringify({ kind: 'generated-hls-media', version, duration: 12, commands, files }, null, 2)}\n`)
|
||||
fs.writeFileSync(path.join(output, 'manifest.json'), `${JSON.stringify({ kind: 'generated-hls-media', version, duration, commands, files }, null, 2)}\n`)
|
||||
console.log(`Generated ${Object.keys(files).length} HLS files in ${output}`)
|
||||
@@ -0,0 +1,197 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { expect, test } from '../browser/fixtures.js'
|
||||
import { mbCandidate } from '../helpers/mediabunny.js'
|
||||
|
||||
const implementation = await mbCandidate()
|
||||
assert(process.env.ARTPLAYER_MB_ARTIFACT, 'Soak requires an explicit built MediaBunny artifact')
|
||||
assert(process.env.ARTPLAYER_MB_SOAK_MEDIA, 'Generate and specify the long HLS fixture directory')
|
||||
const directory = path.resolve(process.env.ARTPLAYER_MB_SOAK_MEDIA)
|
||||
const manifestBytes = fs.readFileSync(path.join(directory, 'manifest.json'))
|
||||
const manifest = JSON.parse(manifestBytes)
|
||||
assert(manifest.duration >= 600, 'Soak needs at least ten minutes of media')
|
||||
const media = new Map(Object.entries(manifest.files).map(([name, expected]) => {
|
||||
assert.equal(path.basename(name), name)
|
||||
const bytes = fs.readFileSync(path.join(directory, name))
|
||||
assert.equal(hash(bytes), expected.sha256, name)
|
||||
return [name, bytes]
|
||||
}))
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`MediaBunny sustained HLS playback with ${core} core`, async ({ page }, testInfo) => {
|
||||
await page.route('**/mb-soak/**', async (route) => {
|
||||
const name = new URL(route.request().url()).pathname.split('/').at(-1)
|
||||
const body = media.get(name)
|
||||
await route.fulfill({ status: body ? 200 : 404, body: body || 'Missing fixture', contentType: name.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t' })
|
||||
})
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
const capabilities = await page.evaluate(() => ({ audio: typeof window.AudioContext, videoDecoder: typeof window.VideoDecoder, audioDecoder: typeof window.AudioDecoder }))
|
||||
const evidence = { core, implementation: implementation.name, sha256: hash(implementation.code), manifestSha256: hash(manifestBytes), capabilities, outcome: 'incomplete', phases: [], checkpoints: [] }
|
||||
if (Object.values(capabilities).some(value => value !== 'function')) {
|
||||
evidence.outcome = 'missing-native-capability-control'
|
||||
await testInfo.attach('mediabunny-soak', { contentType: 'application/json', body: JSON.stringify(evidence) })
|
||||
return
|
||||
}
|
||||
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${implementation.code}; window.mbFactory = module.exports.default || module.exports; })();` })
|
||||
await page.evaluate(() => {
|
||||
const active = new Set()
|
||||
const iterators = new Set()
|
||||
const counters = { created: 0, disconnected: 0, maxActive: 0, draws: 0 }
|
||||
window.mbSoak = { active, iterators, counters, phase: null, stats: {}, contexts: new Set() }
|
||||
const create = AudioContext.prototype.createBufferSource
|
||||
AudioContext.prototype.createBufferSource = function (...args) {
|
||||
window.mbSoak.contexts.add(this)
|
||||
const node = create.apply(this, args)
|
||||
active.add(node)
|
||||
counters.created++
|
||||
counters.maxActive = Math.max(counters.maxActive, active.size)
|
||||
const disconnect = node.disconnect.bind(node)
|
||||
node.disconnect = (...args) => {
|
||||
const result = disconnect(...args)
|
||||
if (active.delete(node))
|
||||
counters.disconnected++
|
||||
return result
|
||||
}
|
||||
return node
|
||||
}
|
||||
window.art = new window.Artplayer({ container: '.player', url: '/mb-soak/master.m3u8', muted: true, proxy: window.mbFactory() })
|
||||
window.mbCanvas = window.art.video
|
||||
document.querySelector('#play').onclick = () => {
|
||||
window.mbPlaying = window.art.play()
|
||||
}
|
||||
})
|
||||
try {
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await page.evaluate(async () => {
|
||||
const engine = window.mbCanvas.engine
|
||||
const times = new WeakMap()
|
||||
for (const [sink, method] of [[engine.video.videoSink, 'canvases'], [engine.audio.audioSink, 'buffers']]) {
|
||||
const prototype = Object.getPrototypeOf(sink)
|
||||
const original = prototype[method]
|
||||
prototype[method] = function (...args) {
|
||||
const iterator = original.apply(this, args)
|
||||
window.mbSoak.iterators.add(iterator)
|
||||
const next = iterator.next.bind(iterator)
|
||||
const finish = iterator.return.bind(iterator)
|
||||
iterator.next = async (...args) => {
|
||||
try {
|
||||
const value = await next(...args)
|
||||
if (value.done)
|
||||
window.mbSoak.iterators.delete(iterator)
|
||||
else if (method === 'canvases')
|
||||
times.set(value.value.canvas, value.value.timestamp)
|
||||
return value
|
||||
}
|
||||
catch (error) {
|
||||
window.mbSoak.iterators.delete(iterator)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
iterator.return = async (...args) => {
|
||||
try {
|
||||
return await finish(...args)
|
||||
}
|
||||
finally { window.mbSoak.iterators.delete(iterator) }
|
||||
}
|
||||
return iterator
|
||||
}
|
||||
}
|
||||
const draw = engine.video.ctx.drawImage.bind(engine.video.ctx)
|
||||
engine.video.ctx.drawImage = (...args) => {
|
||||
const state = window.mbSoak
|
||||
state.counters.draws++
|
||||
if (state.phase && times.has(args[0]) && !window.mbCanvas.paused) {
|
||||
const stats = state.stats[state.phase]
|
||||
const delta = Math.abs(times.get(args[0]) - engine.audio.currentTime)
|
||||
stats.frames++
|
||||
stats.maxError = Math.max(stats.maxError, delta)
|
||||
stats.sumError += delta
|
||||
stats.histogram[Math.min(1000, Math.floor(delta * 1000))]++
|
||||
}
|
||||
return draw(...args)
|
||||
}
|
||||
await engine.seek(1)
|
||||
})
|
||||
await page.click('#play')
|
||||
await page.evaluate(() => window.mbPlaying)
|
||||
const snapshot = () => page.evaluate(() => ({ wall: performance.now(), time: window.mbCanvas.currentTime, rate: window.mbCanvas.playbackRate, active: window.mbSoak.active.size, queued: window.mbCanvas.engine.audio.queuedNodes.size, iterators: window.mbSoak.iterators.size, draws: window.mbSoak.counters.draws, visibility: document.visibilityState, paused: window.mbCanvas.paused, error: window.mbCanvas.error }))
|
||||
async function sustain(name, seconds, rate) {
|
||||
await page.evaluate((name) => {
|
||||
window.mbSoak.stats[name] = { frames: 0, maxError: 0, sumError: 0, histogram: Array.from({ length: 1001 }, () => 0) }
|
||||
window.mbSoak.phase = name
|
||||
}, name)
|
||||
const start = await snapshot()
|
||||
let last = start
|
||||
while (last.wall - start.wall < seconds * 1000) {
|
||||
await page.waitForTimeout(5000)
|
||||
const current = await snapshot()
|
||||
evidence.checkpoints.push({ phase: name, ...current })
|
||||
expect(current.visibility).toBe('visible')
|
||||
expect(current.error).toBeNull()
|
||||
expect(current.paused).toBe(false)
|
||||
expect(current.rate).toBe(rate)
|
||||
expect(current.time).toBeGreaterThan(last.time + 2)
|
||||
expect(current.draws).toBeGreaterThan(last.draws + 20)
|
||||
expect(current.active).toBeLessThan(96)
|
||||
expect(current.iterators).toBeLessThanOrEqual(2)
|
||||
last = current
|
||||
}
|
||||
const stats = await page.evaluate(() => {
|
||||
const state = window.mbSoak
|
||||
const stats = state.stats[state.phase]
|
||||
state.phase = null
|
||||
return stats
|
||||
})
|
||||
const wallSeconds = (last.wall - start.wall) / 1000
|
||||
evidence.phases.push({ name, wallSeconds, mediaSeconds: last.time - start.time, rate, ...stats })
|
||||
expect(wallSeconds).toBeGreaterThanOrEqual(seconds)
|
||||
expect(Math.abs(last.time - start.time - wallSeconds * rate)).toBeLessThan(1)
|
||||
expect(stats.frames).toBeGreaterThan(seconds * 12)
|
||||
expect(stats.maxError).toBeLessThan(0.25)
|
||||
}
|
||||
await sustain('continuous-1x', 90, 1)
|
||||
await page.evaluate(() => {
|
||||
window.mbCanvas.playbackRate = 2
|
||||
})
|
||||
await sustain('continuous-2x', 90, 2)
|
||||
await page.evaluate(async () => {
|
||||
window.mbCanvas.playbackRate = 1
|
||||
await window.mbCanvas.engine.seek(100)
|
||||
})
|
||||
await sustain('after-seek', 5, 1)
|
||||
await page.evaluate(async () => {
|
||||
const state = await window.mbCanvas.getM3u8State()
|
||||
await window.mbCanvas.switchM3u8Quality(state.levels.find(level => level.height === 90).id)
|
||||
})
|
||||
await sustain('after-quality', 5, 1)
|
||||
await page.evaluate(async () => {
|
||||
const state = await window.mbCanvas.getM3u8State()
|
||||
await window.mbCanvas.switchM3u8Audio(state.audios.find(audio => audio.lang === 'fr').id)
|
||||
})
|
||||
await sustain('after-audio', 5, 1)
|
||||
const selection = await page.evaluate(async () => {
|
||||
const state = await window.mbCanvas.getM3u8State()
|
||||
return { height: state.currentLevel.height, language: state.currentAudio.lang }
|
||||
})
|
||||
expect(selection).toEqual({ height: 90, language: 'fr' })
|
||||
evidence.selection = selection
|
||||
evidence.outcome = 'native-sustained-hls-playback'
|
||||
}
|
||||
finally {
|
||||
await page.evaluate(() => window.art.destroy(false))
|
||||
await expect.poll(() => page.evaluate(() => ({ active: window.mbSoak.active.size, iterators: window.mbSoak.iterators.size, contextsClosed: [...window.mbSoak.contexts].every(context => context.state === 'closed') }))).toEqual({ active: 0, iterators: 0, contextsClosed: true })
|
||||
const cleanup = await page.evaluate(() => ({ ...window.mbSoak.counters, audioIterator: Boolean(window.mbCanvas.engine.audio.audioIterator), videoIterator: Boolean(window.mbCanvas.engine.video.videoIterator), queued: window.mbCanvas.engine.audio.queuedNodes.size, destroyed: window.mbCanvas.engine.destroyed }))
|
||||
await page.waitForTimeout(300)
|
||||
expect(await page.evaluate(() => window.mbSoak.counters.draws)).toBe(cleanup.draws)
|
||||
expect(await page.evaluate(() => window.mbSoak.counters.created)).toBe(cleanup.created)
|
||||
expect(cleanup).toMatchObject({ audioIterator: false, videoIterator: false, queued: 0, destroyed: true })
|
||||
expect(cleanup.created).toBe(cleanup.disconnected)
|
||||
evidence.cleanup = cleanup
|
||||
evidence.scope = 'Actual 600s generated HLS input, at least 180s uninterrupted wall playback at 1x/2x then seek/quality/audio; decoded frame vs audio clock, not acoustic output, hour-scale drift, heap leak proof or physical devices. Only active nodes/iterators are retained by instrumentation.'
|
||||
await testInfo.attach('mediabunny-soak', { contentType: 'application/json', body: JSON.stringify(evidence) })
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user