diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml
index 234b07885..afa9893d3 100644
--- a/.github/workflows/nodejs.yml
+++ b/.github/workflows/nodejs.yml
@@ -20,6 +20,37 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
jobs:
+ browser-smoke:
+ name: Browser playback smoke
+ runs-on: macos-latest
+ timeout-minutes: 20
+ env:
+ CI: true
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: .node-version
+ package-manager-cache: false
+ - name: Install the pinned package manager and dependencies
+ run: |
+ npm install --global --force yarn@1.22.22
+ yarn install --frozen-lockfile --non-interactive
+ - name: Install test browsers
+ run: yarn test:browser:install
+ - name: Run all three engines
+ run: yarn test:browser
+ - name: Upload browser evidence
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: browser-${{ github.run_id }}-${{ github.run_attempt }}
+ path: refactor/.cache/browser/
+ include-hidden-files: true
+ retention-days: 14
+ if-no-files-found: warn
checks:
name: Checks and build
runs-on: ubuntu-latest
diff --git a/package.json b/package.json
index ddc23c2b1..dec329f3c 100644
--- a/package.json
+++ b/package.json
@@ -38,10 +38,10 @@
"test:dash-control": "node --test test/dash-control.test.js",
"dev": "npx cross-env NODE_ENV=development node ./scripts/dev.js",
"build": "npx cross-env NODE_ENV=production node ./scripts/build.js",
- "lint": "eslint \"packages/*/{src,types,package.json}\" \"scripts/*.{js,mjs}\" \"test/*\" \"docs/assets/ts/*\" --no-fix",
+ "lint": "eslint \"packages/*/{src,types,package.json}\" \"scripts/*.{js,mjs}\" \"test/*\" \"docs/assets/ts/*\" playwright.config.js --no-fix",
"build:all": "yarn ci:build && yarn lint",
"check:toolchain": "node scripts/check-toolchain.mjs",
- "lint:fix": "eslint \"packages/*/{src,types,package.json}\" \"scripts/*.{js,mjs}\" \"test/*\" \"docs/assets/ts/*\" --fix",
+ "lint:fix": "eslint \"packages/*/{src,types,package.json}\" \"scripts/*.{js,mjs}\" \"test/*\" \"docs/assets/ts/*\" playwright.config.js --fix",
"check:plan": "node refactor/scripts/plan.mjs --check",
"test:node": "yarn test:unit && node --test test/toolchain.test.js test/build-docs.test.js",
"test:baseline": "node --test refactor/scripts/*.test.mjs",
@@ -50,11 +50,15 @@
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/playback.test.js test/dash-control.test.js test/public-behavior.test.js test/helpers.test.js",
- "test": "yarn test:node && yarn test:baseline"
+ "test": "yarn test:node && yarn test:baseline",
+ "test:browser": "playwright test",
+ "test:browser:install": "playwright install chromium firefox webkit",
+ "test:browser:report": "playwright show-report refactor/.cache/browser/html"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
"@antfu/eslint-config": "5.4.1",
+ "@playwright/test": "1.63.0",
"@vue/compiler-sfc": "3.5.28",
"@yarnpkg/lockfile": "1.1.0",
"cpy": "13.2.3",
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 000000000..3f729d43f
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,39 @@
+import process from 'node:process'
+import { defineConfig, devices } from '@playwright/test'
+
+const port = Number(process.env.ARTPLAYER_BROWSER_PORT || 8084)
+const baseURL = `http://127.0.0.1:${port}`
+
+export default defineConfig({
+ testDir: './test/browser',
+ timeout: 20000,
+ expect: { timeout: 7000 },
+ fullyParallel: true,
+ forbidOnly: Boolean(process.env.CI),
+ retries: 0,
+ workers: process.env.CI ? 2 : 3,
+ outputDir: 'refactor/.cache/browser/results',
+ reporter: [
+ ['list'],
+ ['html', { outputFolder: 'refactor/.cache/browser/html', open: 'never' }],
+ ['json', { outputFile: 'refactor/.cache/browser/report.json' }],
+ ],
+ use: {
+ baseURL,
+ viewport: { width: 960, height: 720 },
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ },
+ projects: [
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
+ { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
+ { name: 'webkit', use: { ...devices['Desktop Safari'] } },
+ ],
+ webServer: {
+ command: 'node test/browser/server.mjs',
+ url: `${baseURL}/test/manifest.json`,
+ reuseExistingServer: false,
+ timeout: 60000,
+ env: { ARTPLAYER_BROWSER_PORT: String(port) },
+ },
+})
diff --git a/refactor/README.md b/refactor/README.md
index 9e9b71622..db4ce4815 100644
--- a/refactor/README.md
+++ b/refactor/README.md
@@ -44,6 +44,7 @@
| [类型检查与迁移入口](typechecking.md) | 根/分包配置、当前/旧编译器、严格正反例和历史声明错误 |
| [JS/TS 构建与开发](build-development.md) | 非交互选包、开发重建、资源夹具和 AMD 全局修复 |
| [测试目录维护说明](../test/README.md) | 统一测试命令、JS/TS loader、受控媒体及新旧公共契约 |
+| [真实浏览器入口](../test/browser/README.md) | 三浏览器、解码画面与媒体状态、Range/失败服务及候选文件映射 |
## 维护规则
diff --git a/refactor/baselines/browser-validation.json b/refactor/baselines/browser-validation.json
new file mode 100644
index 000000000..145c5d7e4
--- /dev/null
+++ b/refactor/baselines/browser-validation.json
@@ -0,0 +1,666 @@
+{
+ "task": "ENG-05",
+ "capturedAt": "2026-09-10T14:21:28.353Z",
+ "node": "24.21.0",
+ "platform": "win32",
+ "playwright": "1.63.0",
+ "mode": "workspace-build",
+ "sourceHashAlgorithm": "sha256-lf",
+ "sourceHashes": {
+ "playwright.config.js": "df2384f5626252ec933725867e400ec22eb92bc5e6f04873059b32decf2f2d56",
+ "test/browser/server.mjs": "d9025f9585f941a3b9b1f5b6c1b7b297c1fde6f50266720cbcc66f1334976f4a",
+ "test/browser/player.html": "179c89abf50f7d69c9083a4c2951cb6cbcd624f61d3b417ead139506a6468eb7",
+ "test/browser/fixtures.js": "313414b8958eb5b03a2da8595fa0bbf6bfdc01484373898c6cb7179545df175d",
+ "test/browser/playback.spec.js": "f4d8f65f35bad9ceb68137e605881f2e799f94bc636c0fdbde07c49d3623d86c"
+ },
+ "manifest": {
+ "kind": "artplayer-browser-test",
+ "resources": {
+ "/candidate/artplayer.js": {
+ "source": {
+ "kind": "workspace-build",
+ "entry": "packages\\artplayer\\src\\index.js"
+ },
+ "sha256": "c6f564431e03a198c81fc026af75ead9f7c624e5c7f2f23cba5ce984ec045290",
+ "bytes": 220281
+ },
+ "/compiled/artplayer.js": {
+ "source": {
+ "kind": "workspace-build",
+ "entry": "packages\\artplayer\\src\\index.js"
+ },
+ "sha256": "c6f564431e03a198c81fc026af75ead9f7c624e5c7f2f23cba5ce984ec045290",
+ "bytes": 220281,
+ "aliasOf": "/candidate/artplayer.js"
+ },
+ "/uncompiled/artplayer/index.js": {
+ "source": {
+ "kind": "workspace-build",
+ "entry": "packages\\artplayer\\src\\index.js"
+ },
+ "sha256": "c6f564431e03a198c81fc026af75ead9f7c624e5c7f2f23cba5ce984ec045290",
+ "bytes": 220281,
+ "aliasOf": "/candidate/artplayer.js"
+ },
+ "/candidate/artplayer-plugin-chapter.js": {
+ "source": {
+ "kind": "workspace-build",
+ "entry": "packages\\artplayer-plugin-chapter\\src\\index.js"
+ },
+ "sha256": "833b81bf3334443531ed1822e28a38a241484056c6dbbdd50cd1ec093303ab94",
+ "bytes": 8178
+ },
+ "/compiled/artplayer-plugin-chapter.js": {
+ "source": {
+ "kind": "workspace-build",
+ "entry": "packages\\artplayer-plugin-chapter\\src\\index.js"
+ },
+ "sha256": "833b81bf3334443531ed1822e28a38a241484056c6dbbdd50cd1ec093303ab94",
+ "bytes": 8178,
+ "aliasOf": "/candidate/artplayer-plugin-chapter.js"
+ },
+ "/uncompiled/artplayer-plugin-chapter/index.js": {
+ "source": {
+ "kind": "workspace-build",
+ "entry": "packages\\artplayer-plugin-chapter\\src\\index.js"
+ },
+ "sha256": "833b81bf3334443531ed1822e28a38a241484056c6dbbdd50cd1ec093303ab94",
+ "bytes": 8178,
+ "aliasOf": "/candidate/artplayer-plugin-chapter.js"
+ },
+ "/published/artplayer.js": {
+ "source": {
+ "kind": "npm-release",
+ "version": "5.4.0",
+ "integrity": "sha512-2B+plbx8N2yNsjK4nJU3+EOG8TULm1LRZk/QPkWRAMEX2Ee/MSnZG/WJYz8kcoZxZuLKcQ3uXifqLuPxZOH29A==",
+ "member": "package/dist/artplayer.js"
+ },
+ "sha256": "f1db1fa3406aa6d41baa7c554749429a8e79f99d56a794bde23345eb9c07573f",
+ "bytes": 134037
+ },
+ "/published/artplayer-plugin-chapter.js": {
+ "source": {
+ "kind": "npm-release",
+ "version": "1.1.0",
+ "integrity": "sha512-ji66Lr2K2S+OjFMuMby3yq+pefWlOC51Sbpop/DIr2N6w3F0smrF+YZbshN1Dy8/Tx1TYpT7N6WkkZWy5Jix1w==",
+ "member": "package/dist/artplayer-plugin-chapter.js"
+ },
+ "sha256": "c2aaee11bf1db765433b01d9f8dd3978e41fa4d39655db00ebd03fc0be3e35e0",
+ "bytes": 4973
+ },
+ "/test/pattern.mp4": {
+ "source": {
+ "kind": "generated-media",
+ "file": "test/browser/media/pattern.mp4"
+ },
+ "sha256": "0ad1d7ad286aec6e0fa9e27fc1e487e7277df2e603e30040fb942365763d0233",
+ "bytes": 251538
+ },
+ "/test/pattern.webm": {
+ "source": {
+ "kind": "generated-media",
+ "file": "test/browser/media/pattern.webm"
+ },
+ "sha256": "9d3c2b3dec9581a599de2d6732992ef7846790e0d1368e6e131bd339b39e984f",
+ "bytes": 162073
+ },
+ "/assets/sample/video.mp4": {
+ "source": {
+ "kind": "docs-media",
+ "file": "docs/assets/sample/video.mp4"
+ },
+ "sha256": "30a2de667ba267b6bc605edcee1c4bd728c048ed76f500ea0e73d1f1a6f8f980",
+ "bytes": 5818706
+ }
+ }
+ },
+ "stats": {
+ "startTime": "2026-09-10T14:21:28.353Z",
+ "duration": 12894.75,
+ "expected": 12,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ },
+ "tests": [
+ {
+ "project": "chromium",
+ "title": "published: real playback, pause, seek, switch and destroy",
+ "status": "passed",
+ "durationMs": 1796,
+ "browser": "153.0.8010.12",
+ "events": [
+ "video:loadedmetadata",
+ "ready",
+ "video:playing",
+ "video:pause",
+ "video:seeked",
+ "video:loadedmetadata",
+ "video:seeked",
+ "video:playing",
+ "destroy"
+ ],
+ "errors": [],
+ "consoleErrors": [],
+ "failedRequests": [
+ {
+ "url": "http://127.0.0.1:8084/test/pattern.mp4?case=b4ed495288f4b512a8ac-846a43d5c11f461bc706",
+ "resourceType": "media",
+ "failure": {
+ "errorText": "net::ERR_ABORTED"
+ }
+ },
+ {
+ "url": "http://127.0.0.1:8084/assets/sample/video.mp4?case=b4ed495288f4b512a8ac-846a43d5c11f461bc706",
+ "resourceType": "media",
+ "failure": {
+ "errorText": "net::ERR_ABORTED"
+ }
+ },
+ {
+ "url": "http://127.0.0.1:8084/assets/sample/video.mp4?case=b4ed495288f4b512a8ac-846a43d5c11f461bc706",
+ "resourceType": "media",
+ "failure": {
+ "errorText": "net::ERR_ABORTED"
+ }
+ }
+ ],
+ "decodedFrame": {
+ "pixels": [
+ [
+ 255,
+ 255,
+ 2,
+ 255
+ ],
+ [
+ 0,
+ 0,
+ 253,
+ 255
+ ],
+ [
+ 1,
+ 255,
+ 255,
+ 255
+ ]
+ ],
+ "mediaRequests": [
+ {
+ "path": "/test/pattern.mp4",
+ "range": "bytes=0-",
+ "method": "GET"
+ },
+ {
+ "path": "/test/pattern.mp4",
+ "range": "bytes=229376-",
+ "method": "GET"
+ }
+ ]
+ }
+ },
+ {
+ "project": "chromium",
+ "title": "candidate: real playback, pause, seek, switch and destroy",
+ "status": "passed",
+ "durationMs": 1727,
+ "browser": "153.0.8010.12",
+ "events": [
+ "video:loadedmetadata",
+ "ready",
+ "video:playing",
+ "video:pause",
+ "video:seeked",
+ "video:loadedmetadata",
+ "video:seeked",
+ "video:playing",
+ "destroy"
+ ],
+ "errors": [],
+ "consoleErrors": [],
+ "failedRequests": [
+ {
+ "url": "http://127.0.0.1:8084/test/pattern.mp4?case=b4ed495288f4b512a8ac-6fc43c2ad3b16ac57feb",
+ "resourceType": "media",
+ "failure": {
+ "errorText": "net::ERR_ABORTED"
+ }
+ },
+ {
+ "url": "http://127.0.0.1:8084/assets/sample/video.mp4?case=b4ed495288f4b512a8ac-6fc43c2ad3b16ac57feb",
+ "resourceType": "media",
+ "failure": {
+ "errorText": "net::ERR_ABORTED"
+ }
+ },
+ {
+ "url": "http://127.0.0.1:8084/assets/sample/video.mp4?case=b4ed495288f4b512a8ac-6fc43c2ad3b16ac57feb",
+ "resourceType": "media",
+ "failure": {
+ "errorText": "net::ERR_ABORTED"
+ }
+ }
+ ],
+ "decodedFrame": {
+ "pixels": [
+ [
+ 255,
+ 255,
+ 2,
+ 255
+ ],
+ [
+ 0,
+ 0,
+ 253,
+ 255
+ ],
+ [
+ 1,
+ 255,
+ 255,
+ 255
+ ]
+ ],
+ "mediaRequests": [
+ {
+ "path": "/test/pattern.mp4",
+ "range": "bytes=0-",
+ "method": "GET"
+ },
+ {
+ "path": "/test/pattern.mp4",
+ "range": "bytes=229376-",
+ "method": "GET"
+ }
+ ]
+ }
+ },
+ {
+ "project": "chromium",
+ "title": "controlled HTTP media failure emits a real video error",
+ "status": "passed",
+ "durationMs": 391,
+ "browser": "153.0.8010.12",
+ "events": [
+ "video:error",
+ "destroy"
+ ],
+ "errors": [],
+ "consoleErrors": [
+ "Failed to load resource: the server responded with a status of 503 (Service Unavailable)"
+ ],
+ "failedRequests": []
+ },
+ {
+ "project": "chromium",
+ "title": "Range, media identity and docs candidate routes",
+ "status": "passed",
+ "durationMs": 308,
+ "browser": "153.0.8010.12",
+ "errors": [],
+ "consoleErrors": [],
+ "failedRequests": []
+ },
+ {
+ "project": "firefox",
+ "title": "published: real playback, pause, seek, switch and destroy",
+ "status": "passed",
+ "durationMs": 3064,
+ "browser": "155.0",
+ "events": [
+ "video:loadedmetadata",
+ "ready",
+ "video:playing",
+ "video:pause",
+ "video:seeked",
+ "video:loadedmetadata",
+ "video:seeked",
+ "video:playing",
+ "destroy"
+ ],
+ "errors": [],
+ "consoleErrors": [],
+ "failedRequests": [],
+ "decodedFrame": {
+ "pixels": [
+ [
+ 255,
+ 241,
+ 2,
+ 255
+ ],
+ [
+ 0,
+ 14,
+ 253,
+ 255
+ ],
+ [
+ 0,
+ 230,
+ 255,
+ 255
+ ]
+ ],
+ "mediaRequests": [
+ {
+ "path": "/test/pattern.mp4",
+ "range": "bytes=0-",
+ "method": "GET"
+ }
+ ]
+ }
+ },
+ {
+ "project": "firefox",
+ "title": "candidate: real playback, pause, seek, switch and destroy",
+ "status": "passed",
+ "durationMs": 3118,
+ "browser": "155.0",
+ "events": [
+ "video:loadedmetadata",
+ "ready",
+ "video:playing",
+ "video:pause",
+ "video:seeked",
+ "video:loadedmetadata",
+ "video:seeked",
+ "video:playing",
+ "destroy"
+ ],
+ "errors": [],
+ "consoleErrors": [],
+ "failedRequests": [],
+ "decodedFrame": {
+ "pixels": [
+ [
+ 255,
+ 241,
+ 2,
+ 255
+ ],
+ [
+ 0,
+ 14,
+ 253,
+ 255
+ ],
+ [
+ 0,
+ 230,
+ 255,
+ 255
+ ]
+ ],
+ "mediaRequests": [
+ {
+ "path": "/test/pattern.mp4",
+ "range": "bytes=0-",
+ "method": "GET"
+ }
+ ]
+ }
+ },
+ {
+ "project": "firefox",
+ "title": "controlled HTTP media failure emits a real video error",
+ "status": "passed",
+ "durationMs": 2403,
+ "browser": "155.0",
+ "events": [
+ "video:error",
+ "destroy"
+ ],
+ "errors": [],
+ "consoleErrors": [],
+ "failedRequests": []
+ },
+ {
+ "project": "firefox",
+ "title": "Range, media identity and docs candidate routes",
+ "status": "passed",
+ "durationMs": 543,
+ "browser": "155.0",
+ "errors": [],
+ "consoleErrors": [],
+ "failedRequests": []
+ },
+ {
+ "project": "webkit",
+ "title": "published: real playback, pause, seek, switch and destroy",
+ "status": "passed",
+ "durationMs": 4626,
+ "browser": "26.6",
+ "events": [
+ "video:loadedmetadata",
+ "ready",
+ "video:playing",
+ "video:pause",
+ "video:seeked",
+ "video:loadedmetadata",
+ "video:seeked",
+ "video:playing",
+ "destroy"
+ ],
+ "errors": [],
+ "consoleErrors": [],
+ "failedRequests": [],
+ "decodedFrame": {
+ "pixels": [
+ [
+ 235,
+ 236,
+ 12,
+ 255
+ ],
+ [
+ 16,
+ 15,
+ 239,
+ 255
+ ],
+ [
+ 13,
+ 237,
+ 237,
+ 255
+ ]
+ ],
+ "mediaRequests": [
+ {
+ "path": "/test/pattern.mp4",
+ "range": "bytes=0-",
+ "method": "GET"
+ }
+ ]
+ }
+ },
+ {
+ "project": "webkit",
+ "title": "candidate: real playback, pause, seek, switch and destroy",
+ "status": "passed",
+ "durationMs": 4628,
+ "browser": "26.6",
+ "events": [
+ "video:loadedmetadata",
+ "ready",
+ "video:playing",
+ "video:pause",
+ "video:seeked",
+ "video:loadedmetadata",
+ "video:seeked",
+ "video:playing",
+ "destroy"
+ ],
+ "errors": [],
+ "consoleErrors": [],
+ "failedRequests": [],
+ "decodedFrame": {
+ "pixels": [
+ [
+ 235,
+ 236,
+ 12,
+ 255
+ ],
+ [
+ 16,
+ 15,
+ 239,
+ 255
+ ],
+ [
+ 13,
+ 237,
+ 237,
+ 255
+ ]
+ ],
+ "mediaRequests": [
+ {
+ "path": "/test/pattern.mp4",
+ "range": "bytes=0-",
+ "method": "GET"
+ }
+ ]
+ }
+ },
+ {
+ "project": "webkit",
+ "title": "controlled HTTP media failure emits a real video error",
+ "status": "passed",
+ "durationMs": 719,
+ "browser": "26.6",
+ "events": [
+ "video:error",
+ "destroy"
+ ],
+ "errors": [],
+ "consoleErrors": [],
+ "failedRequests": []
+ },
+ {
+ "project": "webkit",
+ "title": "Range, media identity and docs candidate routes",
+ "status": "passed",
+ "durationMs": 577,
+ "browser": "26.6",
+ "errors": [],
+ "consoleErrors": [],
+ "failedRequests": []
+ }
+ ],
+ "artifactMode": {
+ "browser": "155.0",
+ "platform": "win32",
+ "stats": {
+ "startTime": "2026-09-10T14:22:28.949Z",
+ "duration": 5939.912,
+ "expected": 4,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ },
+ "resources": {
+ "/candidate/artplayer.js": {
+ "source": {
+ "kind": "artifact",
+ "file": "D:\\github\\ArtPlayer\\refactor\\.cache\\eng-06-build\\packages\\artplayer\\dist\\artplayer.js"
+ },
+ "sha256": "9ad044a601dafc24f449b690403fb1c343b40e99ffb31043272189fc3c1e7bfa",
+ "bytes": 134939
+ },
+ "/compiled/artplayer.js": {
+ "source": {
+ "kind": "artifact",
+ "file": "D:\\github\\ArtPlayer\\refactor\\.cache\\eng-06-build\\packages\\artplayer\\dist\\artplayer.js"
+ },
+ "sha256": "9ad044a601dafc24f449b690403fb1c343b40e99ffb31043272189fc3c1e7bfa",
+ "bytes": 134939,
+ "aliasOf": "/candidate/artplayer.js"
+ },
+ "/uncompiled/artplayer/index.js": {
+ "source": {
+ "kind": "artifact",
+ "file": "D:\\github\\ArtPlayer\\refactor\\.cache\\eng-06-build\\packages\\artplayer\\dist\\artplayer.js"
+ },
+ "sha256": "9ad044a601dafc24f449b690403fb1c343b40e99ffb31043272189fc3c1e7bfa",
+ "bytes": 134939,
+ "aliasOf": "/candidate/artplayer.js"
+ },
+ "/candidate/artplayer-plugin-chapter.js": {
+ "source": {
+ "kind": "artifact",
+ "file": "D:\\github\\ArtPlayer\\refactor\\.cache\\eng-06-build\\packages\\artplayer-plugin-chapter\\dist\\artplayer-plugin-chapter.js"
+ },
+ "sha256": "c2aaee11bf1db765433b01d9f8dd3978e41fa4d39655db00ebd03fc0be3e35e0",
+ "bytes": 4973
+ },
+ "/compiled/artplayer-plugin-chapter.js": {
+ "source": {
+ "kind": "artifact",
+ "file": "D:\\github\\ArtPlayer\\refactor\\.cache\\eng-06-build\\packages\\artplayer-plugin-chapter\\dist\\artplayer-plugin-chapter.js"
+ },
+ "sha256": "c2aaee11bf1db765433b01d9f8dd3978e41fa4d39655db00ebd03fc0be3e35e0",
+ "bytes": 4973,
+ "aliasOf": "/candidate/artplayer-plugin-chapter.js"
+ },
+ "/uncompiled/artplayer-plugin-chapter/index.js": {
+ "source": {
+ "kind": "artifact",
+ "file": "D:\\github\\ArtPlayer\\refactor\\.cache\\eng-06-build\\packages\\artplayer-plugin-chapter\\dist\\artplayer-plugin-chapter.js"
+ },
+ "sha256": "c2aaee11bf1db765433b01d9f8dd3978e41fa4d39655db00ebd03fc0be3e35e0",
+ "bytes": 4973,
+ "aliasOf": "/candidate/artplayer-plugin-chapter.js"
+ },
+ "/published/artplayer.js": {
+ "source": {
+ "kind": "npm-release",
+ "version": "5.4.0",
+ "integrity": "sha512-2B+plbx8N2yNsjK4nJU3+EOG8TULm1LRZk/QPkWRAMEX2Ee/MSnZG/WJYz8kcoZxZuLKcQ3uXifqLuPxZOH29A==",
+ "member": "package/dist/artplayer.js"
+ },
+ "sha256": "f1db1fa3406aa6d41baa7c554749429a8e79f99d56a794bde23345eb9c07573f",
+ "bytes": 134037
+ },
+ "/published/artplayer-plugin-chapter.js": {
+ "source": {
+ "kind": "npm-release",
+ "version": "1.1.0",
+ "integrity": "sha512-ji66Lr2K2S+OjFMuMby3yq+pefWlOC51Sbpop/DIr2N6w3F0smrF+YZbshN1Dy8/Tx1TYpT7N6WkkZWy5Jix1w==",
+ "member": "package/dist/artplayer-plugin-chapter.js"
+ },
+ "sha256": "c2aaee11bf1db765433b01d9f8dd3978e41fa4d39655db00ebd03fc0be3e35e0",
+ "bytes": 4973
+ },
+ "/test/pattern.mp4": {
+ "source": {
+ "kind": "generated-media",
+ "file": "test/browser/media/pattern.mp4"
+ },
+ "sha256": "0ad1d7ad286aec6e0fa9e27fc1e487e7277df2e603e30040fb942365763d0233",
+ "bytes": 251538
+ },
+ "/test/pattern.webm": {
+ "source": {
+ "kind": "generated-media",
+ "file": "test/browser/media/pattern.webm"
+ },
+ "sha256": "9d3c2b3dec9581a599de2d6732992ef7846790e0d1368e6e131bd339b39e984f",
+ "bytes": 162073
+ },
+ "/assets/sample/video.mp4": {
+ "source": {
+ "kind": "docs-media",
+ "file": "docs/assets/sample/video.mp4"
+ },
+ "sha256": "30a2de667ba267b6bc605edcee1c4bd728c048ed76f500ea0e73d1f1a6f8f980",
+ "bytes": 5818706
+ }
+ },
+ "source": "ENG-06 isolated build output, not a newly packed npm candidate"
+ }
+}
diff --git a/refactor/changes/2026-09-10-ENG-05-browser-tests.md b/refactor/changes/2026-09-10-ENG-05-browser-tests.md
new file mode 100644
index 000000000..b094100ef
--- /dev/null
+++ b/refactor/changes/2026-09-10-ENG-05-browser-tests.md
@@ -0,0 +1,53 @@
+# ENG-05:可重复的真实浏览器入口
+
+## 交付与结构
+
+新增 [浏览器测试目录](../../test/browser/README.md) 和根 Playwright 配置。固定
+`@playwright/test` 1.63.0(仅开发依赖;连同 playwright/playwright-core 更新 yarn.lock),
+提供安装、执行和 HTML 报告脚本。原快速 Node 测试独立保留。
+
+服务只绑定 loopback,独占 8084、拒绝复用已有服务;每次从当前 JS/TS 入口构建 core/chapter,
+或明确读取指定候选文件。发布基线校验归档/member hash。候选文件缺失直接失败;不会偷偷
+使用工作区或旧 docs 产物。docs 页面和样本沿用原路径,compiled/uncompiled 的核心和 chapter
+别名映射到已记录 SHA-256 的候选文件。尚未映射的其他包资源拒绝加载。
+
+媒体服务实现单段 Range/后缀 Range、HEAD、416、固定 503 和按用例隔离的请求记录。
+新建两个自生成、无外部画面及音轨的短样本;播放用例还切换到既有 docs video.mp4。
+没有改动核心/插件的生产源码、公开 API 或声明。
+
+CI 新增 macOS 三浏览器 smoke 和 always 上传报告/失败截图/trace;所有操作使用既有固定
+Action SHA。macOS 的选择用于更接近 Safari 的媒体栈;这不代表已经运行远端 CI 或真实 Safari。
+
+## 实际验证
+
+- Windows、Node 24.21.0、Yarn 1.22.22:冻结安装通过,21 个直接工具固定、1339 个依赖 selector。
+- Chromium 153.0.8010.12、Firefox 155.0、WebKit 26.6:12 项全部通过,无跳过、无重试。
+ 同组用例覆盖发布 core 5.4.0 / 当前 core 与当前 chapter;解码画面像素、服务端媒体请求、
+ 时间推进、暂停、seek、切源、销毁、503 与 video:error 均有断言。
+- 指定 ENG-06 隔离构建中的两个 UMD 文件后,Firefox 4 项通过;报告确认使用 artifact 来源。
+ 空候选映射负例在启动前以退出码 1 拒绝。该模式验证映射能力,不代表 ENG-07 tarball 消费完成。
+- `yarn ci:check`:原 54 项检查通过;actionlint 与 Git 差异检查通过。
+- [精简运行证据](../baselines/browser-validation.json) 保存版本、实际资源哈希、测试源指纹、
+ 各用例结果、事件与解码像素。完整本地报告/log 在忽略的 refactor/.cache 下。
+
+首次 WebKit 用例用 videoWidth=320 和 page.waitForResponse 判定失败。隔离原生 video、
+实际截图和解码像素后确认视频确实播放:该 Windows 构建的 videoWidth 随布局变化,媒体请求
+不可靠地出现在 page 网络事件中。最终用解码像素、实际时长和服务端请求联合判定,未删掉
+播放或失败断言。此尺寸差异保留为环境限制,不能据此宣称所有媒体元数据兼容均已验证。
+
+首次 Chromium 把正常媒体 Range 取消当成错误。最终只允许当前用例两条已知媒体 URL、同源、
+resourceType=media、精确 net::ERR_ABORTED;全部原始记录仍保存。其他错误与播放失败仍失败。
+
+本机 Node 下载浏览器连接超时,curl 从 Playwright 官方 URL 下载相同归档,再经临时 loopback
+镜像交给官方 installer 解包;未改依赖或浏览器文件、未关闭 TLS 检查。镜像和诊断服务均已停止。
+Chromium 本机安装 headless shell,已验证 headless 项目;带界面模式需安装完整 Chromium。
+
+## 剩余范围与回退
+
+真实 Monaco Run/TS/存储与完整 docs 页面操作仍归 EX-03;本任务只验证 docs 路由可取及候选映射,
+轻量页面不替代编辑器验收。WebM 尚未加入播放断言;音频、移动真机、SDK、全插件组合和其他
+浏览器最低版本仍按原任务推进。CI-01/CI-04 负责更完整矩阵及远端运行证据;本地全绿不代表远端已绿。
+
+API-01/04/05/07/09 的基础测试入口已经可供 chapter 迁移复用,完整契约覆盖继续随包任务补齐。
+回退本任务提交可移除新增依赖、测试及 CI 作业,不影响生产运行代码。下一任务优先推进 chapter
+契约与测试,再完成 ENG-07 候选消费并进入插件源码拆分/TS 迁移。
diff --git a/refactor/ci-setup.md b/refactor/ci-setup.md
index a68fbe4d0..017a3244a 100644
--- a/refactor/ci-setup.md
+++ b/refactor/ci-setup.md
@@ -12,6 +12,7 @@
| `yarn test:unit` | 原播放/DASH 回归、同夹具的新旧公共契约与 JS/TS loader 验证 |
| `yarn test:node` | test:unit 加工具链/文档构建回归,保留原 test:playback/test:dash-control 入口 |
| `yarn test` | 统一执行 Node 与基线测试,源码/夹具维护入口见仓库 test/README.md |
+| `yarn test:browser` | 隔离服务执行 Chromium/Firefox/WebKit 真实播放;首次先运行 test:browser:install,详见 [入口](../test/browser/README.md) |
| `yarn test:imports` | 3 个既有包导入 smoke,构建后执行以使用新产物;不把 console 示例视为完整行为断言 |
| `yarn test:baseline` | 固定发布包完整性及本地 HTTP 基线测试;首次可能下载已固定归档到缓存 |
| `yarn ci:check` | 严格 Node/Yarn/锁检查、计划、只读 lint、类型、Node 和基线测试;允许写忽略缓存,不修改源码 |
@@ -26,7 +27,7 @@ scripts/build-docs.js 保留原 npm run build 子命令兼容入口,现在传
Actions 固定完整 SHA,Node 来自 .node-version,Yarn 固定 1.22.22;安装使用 frozen-lockfile。actionlint 1.7.12 从官方固定归档取得,先核对提交在 workflow 中的 SHA-256,再执行。没有执行 curl 管道脚本。日志在失败时也尽量上传,包含源码 SHA、工具版本、锁摘要和生成差异概览,保留 14 天。
-本轮不缓存 node_modules;矩阵、下载缓存、覆盖率、浏览器 trace 和候选 tarball 汇总由 CI-01 等后续任务扩展。当前测试通过不代表尚未实现的类型消费者或浏览器矩阵通过。将来的稳定 required 汇总名称由 CI-01/CI-04 核实后设置,不把本地 YAML 当作分支保护已生效。
+不缓存 node_modules。ENG-04 已接入类型消费者;ENG-05 新增 macOS 的 `Browser playback smoke` 作业,运行三个浏览器并 always 上传 HTML/JSON、失败截图和 trace。本地 Windows 三浏览器已验证,远端 macOS 作业尚未运行。完整生态矩阵、下载缓存、覆盖率和候选 tarball 汇总由 CI-01 等后续任务扩展。稳定 required 汇总名称由 CI-01/CI-04 核实后设置,不把本地 YAML 当作分支保护已生效。
## Pages 隔离与启用条件
diff --git a/refactor/docs-browser-testing.md b/refactor/docs-browser-testing.md
index fa4701cd7..fd5a2703f 100644
--- a/refactor/docs-browser-testing.md
+++ b/refactor/docs-browser-testing.md
@@ -34,7 +34,7 @@ chapter 试点可使用 `http://localhost:8082/?libs=./uncompiled/artplayer-plug
在线编辑器兼容用例继续通过实际 Run、键盘和控件交互验证;大量核心状态回归可以复用同一示例和样本在轻量测试页面运行,避免每项测试都依赖 Monaco 初始化。两类用例均保留,不能用轻量页面通过替代编辑器验收。
-发布阶段将相同页面/示例接到候选 tarball 的提取内容,使用明确的测试目录或服务路由映射保留相对路径,并记录映射及完整性。此能力待 ENG-05/ENG-07 实现;当前没有可用的新开关或命令。不能只检查开发 bundle 就批准 npm 发布。
+ENG-05 已提供 `yarn test:browser`、8084 隔离服务和 `ARTPLAYER_BROWSER_ARTIFACTS` 文件映射,见 [浏览器入口](../test/browser/README.md)。当前支持 core/chapter 的 compiled/uncompiled 别名,未映射的构建资源明确失败;每次报告保存实际文件 SHA-256。ENG-07 继续负责生成及核验候选 tarball,不能只检查开发 bundle 就批准 npm 发布。
## 任务归属与验收
diff --git a/refactor/plan.md b/refactor/plan.md
index 89d09ecc9..8bcd1ffdd 100644
--- a/refactor/plan.md
+++ b/refactor/plan.md
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
-状态:todo 186 / doing 0 / blocked 0 / done 28 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
+状态:todo 185 / doing 0 / blocked 0 / done 29 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -78,7 +78,7 @@
| ENG-02 | workspace
拆分只读检查并建立 PR CI | ENG-01, ENG-PM-01 | lint/lint:fix、PR 与主线检查、独立部署任务;遵循 github-ci-cd.md,PR/重构分支触发、最小权限及 workflow 静态检查 | 仓库内检查可执行且不改源码、不发布;required checks 的外部设置状态列入发布台账,不阻塞本地框架建设 | M | done |
| ENG-03 | workspace
建立公共行为与单元测试入口 | ENG-02, BASE-03 | 保留现有 node:test,测试目录/夹具/统一入口 | 已有 19 项回归保留,旧版与候选可用同一夹具运行 | M | done |
| ENG-04 | workspace
建立类型测试基础 | ENG-02, BASE-05 | 根与分包 tsconfig、显式 TS 依赖、正反例测试 | 核心/试点与迁移模块严格检查,未迁移第三方/包历史问题独立台账;明确最低/当前 TS 和各环境类型;复用 BASE-05 的四种消费模式,不以 skipLibCheck 掩盖 BASE-TYPE-01/03 | M | done |
-| ENG-05 | workspace
建立真实浏览器测试服务 | ENG-03, BASE-04, BASE-08 | Playwright projects、本地 Range/失败媒体服务;复用 docs 页面/样本的状态隔离、错误采集与候选资源映射 | Chromium/Firefox/WebKit 的基础播放 smoke 和报告可执行;以媒体状态断言判定通过,区分轻量用例与真实编辑器交互 | M | todo |
+| ENG-05 | workspace
建立真实浏览器测试服务 | ENG-03, BASE-04, BASE-08 | Playwright projects、本地 Range/失败媒体服务;复用 docs 页面/样本的状态隔离、错误采集与候选资源映射 | Chromium/Firefox/WebKit 的基础播放 smoke 和报告可执行;以媒体状态断言判定通过,区分轻量用例与真实编辑器交互 | M | done |
| ENG-06 | workspace
支持按包非交互与 JS/TS 构建 | ENG-02, BASE-05 | build/dev 入口解析、指定包参数、原交互保留 | 三种产物、Less/SVG/worker 和本地 8082 demo 正常;保持 BASE-05 的 AMD 同时写入全局行为及 i18n/legacy 入口 | H | done |
| ENG-07 | workspace
建立 tarball 消费与产物检查 | ENG-04, ENG-06 | 隔离 npm 消费 fixtures、API/声明/入口差分 | 不借 workspace 源码通过,能识别缺文件与默认导出变化 | H | todo |
| ENG-08 | workspace
增加覆盖率、资源与性能报告 | ENG-03, ENG-05, BASE-06 | 覆盖率基线、资源清理断言、性能报告与阈值 | 关键生命周期分支有门槛,报告不靠无意义断言堆数量;将 BASE-06 的同环境多组配对、计时/压缩审查阈值与资源异常分开接入候选,不把历史现象冻结成正常要求 | M | todo |
@@ -430,4 +430,5 @@
- ENG-02: [记录](changes/2026-09-10-ENG-02-readonly-ci.md) [记录](baselines/ci-validation.json)
- ENG-03: [记录](changes/2026-09-10-ENG-03-unit-entry.md)
- ENG-04: [记录](changes/2026-09-10-ENG-04-typechecking.md) [记录](typechecking.md)
+- ENG-05: [记录](changes/2026-09-10-ENG-05-browser-tests.md) [记录](baselines/browser-validation.json)
- ENG-06: [记录](changes/2026-09-10-ENG-06-build-development.md) [记录](build-development.md) [记录](baselines/build-validation.json)
diff --git a/refactor/progress.md b/refactor/progress.md
index 79a01a0ad..bac4c5c5a 100644
--- a/refactor/progress.md
+++ b/refactor/progress.md
@@ -1,5 +1,9 @@
# 进度与证据
+## 当前实施:ENG-05 真实浏览器入口完成
+
+Playwright 三浏览器 12 项真实播放/失败/映射测试通过,指定候选 UMD 文件的 Firefox 4 项通过;新增 CI smoke、报告与自生成媒体。见 [ENG-05](changes/2026-09-10-ENG-05-browser-tests.md)。当前 214 项,29 完成、185 待办;生产源码 TS 迁移仍为零。已具备 chapter 试点所需浏览器入口,下一项 PKG-CHAPTER-01,接特有测试和 ENG-07,再迁移插件源码。远端 CI、完整编辑器及真机尚未验收。
+
## 当前实施:ENG-03 公共行为与单元入口完成
原 19 项播放/DASH 回归保留,JS/TS loader 与受控媒体夹具复用;同一份五组 Emitter 契约在发布 core 5.4.0、工作区和三格式候选上通过。统一 yarn test:unit/test 与 CI 入口已接入,见 [ENG-03](changes/2026-09-10-ENG-03-unit-entry.md)。当前 214 项,28 完成、186 待办;生产源码 TS 迁移仍为零。ENG-06 提交 b53461da,本任务独立提交,下一项 ENG-05 浏览器自动化。
diff --git a/refactor/tasks.json b/refactor/tasks.json
index 538f2fb28..da78a603e 100644
--- a/refactor/tasks.json
+++ b/refactor/tasks.json
@@ -532,11 +532,14 @@
"BASE-04",
"BASE-08"
],
- "status": "todo",
+ "status": "done",
"risk": "M",
"deliverable": "Playwright projects、本地 Range/失败媒体服务;复用 docs 页面/样本的状态隔离、错误采集与候选资源映射",
"acceptance": "Chromium/Firefox/WebKit 的基础播放 smoke 和报告可执行;以媒体状态断言判定通过,区分轻量用例与真实编辑器交互",
- "evidence": []
+ "evidence": [
+ "changes/2026-09-10-ENG-05-browser-tests.md",
+ "baselines/browser-validation.json"
+ ]
},
{
"id": "ENG-06",
diff --git a/test/README.md b/test/README.md
index 4d0b1c693..c022abd25 100644
--- a/test/README.md
+++ b/test/README.md
@@ -19,3 +19,9 @@ Use the pinned Node/Yarn toolchain from `../refactor/toolchain-setup.md`.
Set `ARTPLAYER_TEST_CORE` to a built `.js`, `.legacy.js` or `.mjs` core file to add a candidate to the same public-contract run, then execute `node --test test/public-behavior.test.js`. An invalid path/export fails the run. This is a narrow event contract check, not isolated package installation or complete API compatibility; ENG-07 owns tarball consumption.
When a production module moves, update its loader mapping and its maintenance documentation, preserving the behavioral assertions. New contracts belong in contracts/; test-specific controlled state belongs in helpers/. Do not modify frozen refactor/fixtures or baseline captures just to pass changed behavior. Record historical defects and candidate fixes separately.
+
+# Real browser tests
+
+See [browser/README.md](browser/README.md) for `yarn test:browser`, browser installation,
+published/current combinations, media fixtures, candidate mapping and failure reports.
+These tests are separate from the fast Node suite and run in the Browser playback smoke CI job.
diff --git a/test/browser/README.md b/test/browser/README.md
new file mode 100644
index 000000000..0ea89f9f4
--- /dev/null
+++ b/test/browser/README.md
@@ -0,0 +1,84 @@
+# Browser regression entry
+
+Use the pinned Node and Yarn versions, then run:
+
+```sh
+yarn install --frozen-lockfile
+yarn test:browser:install
+yarn test:browser
+yarn test:browser --project=chromium
+yarn test:browser:report
+```
+
+The three projects run actual Chromium, Firefox and WebKit. They use isolated browser contexts,
+no retries, a loopback server on 8084, and no existing dev server. Set `ARTPLAYER_BROWSER_PORT`
+to use another free port. The server builds core and chapter from current JS/TS entries in memory;
+it does not modify dist or docs outputs. Restart the test command after source changes.
+
+`playback.spec.js` tests published/current core with current chapter, real decoded frame colors,
+media time advancing, pause, seek, source change to the existing docs video, destroy and real
+HTTP failure. These are initial smoke contracts, not full chapter or lifecycle coverage.
+`fixtures.js` captures browser version, actual scripts/media hashes, page errors, console errors,
+failed requests and final state. Tests attach decoded pixels and server-side media requests too.
+Windows WebKit media requests are not reliably visible to Playwright's page network events;
+use the loopback request log to prove that media was fetched. Its `videoWidth` can reflect layout
+size, so intrinsic-size compatibility is a separate test, not the playback-success predicate.
+
+Reports, failure screenshots and traces are under `refactor/.cache/browser/`. They contain local
+test URLs and paths. JSON and HTML are regenerated each run; archive them before another run
+if evidence must be retained. A successful HTTP page fetch does not validate its editor UI.
+
+## Files and routes
+
+- `server.mjs`: current/published artifact provenance, docs serving, Range and controlled failures.
+- `player.html`: lightweight page, real ArtPlayer constructor and user-gesture play/pause buttons.
+- `fixtures.js`: per-test error and provenance attachment; contexts/storage are Playwright-owned.
+- `playback.spec.js`: behavior assertions shared by browser and core version.
+- `media/`: committed synthetic fixtures. CI needs no FFmpeg to use them.
+
+`/test/manifest.json` identifies every mapped core/chapter artifact and synthetic media file by
+SHA-256. `/published/.js` verifies the frozen npm archive/member from BASE-01.
+`/candidate/.js` maps the current build. Existing docs `/compiled/.js` and
+`/uncompiled//index.js` are aliases for that candidate. Other compiled/uncompiled paths
+fail explicitly; add a verified mapping before testing another package, rather than serving stale
+build output. Other docs pages, examples and samples use their existing paths without source edits.
+
+`/test/pattern.mp4` and `.webm` support GET/HEAD and single byte ranges (including suffix ranges).
+Invalid ranges return 416. `/test/fail.mp4` always returns 503. Add a unique `?case=` to
+media URLs and inspect `/test/requests.json?case=` for actual server requests. The log is
+bounded and server-local; it is not a production endpoint or a substitute for decoded frames.
+
+To test extracted candidate UMD files, set `ARTPLAYER_BROWSER_ARTIFACTS` to a JSON file:
+
+```json
+{
+ "artplayer": "./extracted/artplayer/dist/artplayer.js",
+ "artplayer-plugin-chapter": "./extracted/chapter/dist/artplayer-plugin-chapter.js"
+}
+```
+
+Paths resolve relative to that JSON. Both keys are required; missing files abort startup. No source
+fallback is allowed in this mode. ENG-07 owns tarball creation and broader consumer checks.
+For manual docs interaction, run `node test/browser/server.mjs` and open the existing editor URL
+on 8084 with chapter's libs/example parameters. EX-03 still owns the full Monaco Run/TS/storage
+workflow, all examples and external SDKs. The lightweight page does not complete that task.
+
+## Synthetic media provenance
+
+Generated locally from FFmpeg's `testsrc2`, without external footage or audio, using
+`ffmpeg version 2025-11-12-git-6cdd2cbe32-full_build-www.gyan.dev`:
+
+```sh
+ffmpeg -f lavfi -i testsrc2=size=320x180:rate=24 -t 8 -an -c:v libx264 -profile:v baseline -pix_fmt yuv420p -movflags +faststart test/browser/media/pattern.mp4
+ffmpeg -f lavfi -i testsrc2=size=320x180:rate=24 -t 8 -an -c:v libvpx -b:v 160k test/browser/media/pattern.webm
+```
+
+MP4 SHA-256: `0ad1d7ad286aec6e0fa9e27fc1e487e7277df2e603e30040fb942365763d0233`.
+WebM SHA-256: `9d3c2b3dec9581a599de2d6732992ef7846790e0d1368e6e131bd339b39e984f`.
+MP4 is the initial playback contract; WebM is available for subsequent codec-specific cases.
+Do not regenerate fixtures silently: update hashes and decoded-frame checks together.
+
+The docs `assets/sample/video.mp4` remains an existing sample with its original provenance limits
+tracked by BASE-MEDIA-01. The new synthetic fixtures do not resolve rights for existing media.
+Browser binaries/codec support vary by OS; these projects do not replace Safari/iOS/Android
+device checks. See the [official browser guidance](https://playwright.dev/docs/browsers).
diff --git a/test/browser/fixtures.js b/test/browser/fixtures.js
new file mode 100644
index 000000000..a4f88ffde
--- /dev/null
+++ b/test/browser/fixtures.js
@@ -0,0 +1,33 @@
+import process from 'node:process'
+import { test as base, expect } from '@playwright/test'
+
+export const test = base.extend({
+ diagnostics: [async ({ page, browser, request }, use, testInfo) => {
+ const errors = []
+ const consoleErrors = []
+ const failedRequests = []
+ page.on('pageerror', error => errors.push(error.message))
+ page.on('console', (message) => {
+ if (message.type() === 'error')
+ consoleErrors.push(message.text())
+ })
+ page.on('requestfailed', request => failedRequests.push({ url: request.url(), resourceType: request.resourceType(), failure: request.failure() }))
+ await use({ errors, consoleErrors, failedRequests })
+ const manifest = await (await request.get('/test/manifest.json')).json()
+ const state = page.isClosed()
+ ? null
+ : await page.evaluate(() => {
+ const video = document.querySelector('video')
+ return {
+ url: location.href,
+ userAgent: navigator.userAgent,
+ events: window.events,
+ media: video ? { source: video.currentSrc, width: video.videoWidth, height: video.videoHeight, duration: video.duration, currentTime: video.currentTime, error: video.error?.code } : null,
+ }
+ }).catch(() => null)
+ await testInfo.attach('browser-evidence', { contentType: 'application/json', body: JSON.stringify({ browser: browser.version(), platform: process.platform, manifest, state, errors, consoleErrors, failedRequests }, null, 2) })
+ expect(errors, 'Unhandled browser errors').toEqual([])
+ }, { auto: true }],
+})
+
+export { expect }
diff --git a/test/browser/media/pattern.mp4 b/test/browser/media/pattern.mp4
new file mode 100644
index 000000000..a1bb2d26c
Binary files /dev/null and b/test/browser/media/pattern.mp4 differ
diff --git a/test/browser/media/pattern.webm b/test/browser/media/pattern.webm
new file mode 100644
index 000000000..8d7f97532
Binary files /dev/null and b/test/browser/media/pattern.webm differ
diff --git a/test/browser/playback.spec.js b/test/browser/playback.spec.js
new file mode 100644
index 000000000..4838ed1e4
--- /dev/null
+++ b/test/browser/playback.spec.js
@@ -0,0 +1,100 @@
+import { expect, test } from './fixtures.js'
+
+for (const core of ['published', 'candidate']) {
+ test(`${core}: real playback, pause, seek, switch and destroy`, async ({ page, request, diagnostics }, testInfo) => {
+ const caseId = encodeURIComponent(testInfo.testId)
+ await page.goto(`/test/player.html?core=${core}`)
+ await page.evaluate(url => window.createPlayer(url, true), `/test/pattern.mp4?case=${caseId}`)
+ await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
+ expect(await page.locator('video').evaluate(video => video.duration)).toBeCloseTo(8, 1)
+ await expect(page.locator('.art-chapter')).toHaveCount(1)
+ await page.locator('#play').click()
+ await expect.poll(() => page.locator('video').evaluate(video => video.currentTime)).toBeGreaterThan(0.25)
+ // Read decoded frame colors, independent of WebKit Windows' layout-sized videoWidth.
+ const pixels = await page.locator('video').evaluate((video) => {
+ const canvas = document.createElement('canvas')
+ canvas.width = 80
+ canvas.height = 45
+ const ctx = canvas.getContext('2d')
+ ctx.drawImage(video, 0, 0, 80, 45)
+ return [30, 45, 75].map(x => [...ctx.getImageData(x, 2, 1, 1).data])
+ })
+ expect(pixels[0][0]).toBeGreaterThan(180)
+ expect(pixels[0][1]).toBeGreaterThan(180)
+ expect(pixels[0][2]).toBeLessThan(80)
+ expect(pixels[1][0]).toBeLessThan(80)
+ expect(pixels[1][2]).toBeGreaterThan(180)
+ expect(pixels[2][1]).toBeGreaterThan(180)
+ expect(pixels[2][2]).toBeGreaterThan(180)
+ const mediaRequests = await (await request.get(`/test/requests.json?case=${caseId}`)).json()
+ expect(mediaRequests.some(item => item.path === '/test/pattern.mp4')).toBe(true)
+ await testInfo.attach('decoded-frame', { body: JSON.stringify({ pixels, mediaRequests }), contentType: 'application/json' })
+ await page.locator('#pause').click()
+ await expect.poll(() => page.locator('video').evaluate(video => video.paused)).toBe(true)
+ const pausedTime = await page.locator('video').evaluate(video => video.currentTime)
+ // A bounded observation interval checks that pause stops the media clock.
+ await page.waitForTimeout(200)
+ expect(await page.locator('video').evaluate(video => video.currentTime)).toBeCloseTo(pausedTime, 2)
+ await page.evaluate(() => {
+ window.art.seek = 3
+ })
+ await expect.poll(() => page.evaluate(() => window.events.includes('video:seeked'))).toBe(true)
+ expect(await page.locator('video').evaluate(video => video.currentTime)).toBeCloseTo(3, 1)
+ // Reuse a real docs sample, independently of the short synthetic fixture.
+ await page.evaluate(url => window.art.switchUrl(url), `/assets/sample/video.mp4?case=${caseId}`)
+ await expect.poll(() => page.locator('video').evaluate(video => video.duration)).toBeGreaterThan(90)
+ await page.locator('#play').click()
+ await expect.poll(() => page.locator('video').evaluate(video => video.currentTime)).toBeGreaterThan(0.25)
+ const events = await page.evaluate(() => window.events)
+ expect(events).toContain('video:loadedmetadata')
+ expect(events).toContain('ready')
+ expect(events.indexOf('video:loadedmetadata')).toBeLessThan(events.indexOf('ready'))
+ expect(events).toContain('video:playing')
+ expect(events).not.toContain('video:error')
+ await page.evaluate(() => window.art.destroy())
+ await expect(page.locator('.player')).toBeEmpty()
+ expect(await page.evaluate(() => window.Artplayer.instances.length)).toBe(0)
+ expect(diagnostics.consoleErrors).toEqual([])
+ // Chromium cancels media range requests as buffering, seeking and source ownership change.
+ // Only exact cancellations of this test's two media URLs are expected; retain all in evidence.
+ const unexpectedRequests = diagnostics.failedRequests.filter((item) => {
+ const url = new URL(item.url)
+ return !(item.resourceType === 'media' && item.failure?.errorText === 'net::ERR_ABORTED'
+ && url.origin === new URL(page.url()).origin
+ && url.searchParams.get('case') === testInfo.testId
+ && ['/test/pattern.mp4', '/assets/sample/video.mp4'].includes(url.pathname))
+ })
+ expect(unexpectedRequests).toEqual([])
+ })
+}
+
+test('controlled HTTP media failure emits a real video error', async ({ page, request }, testInfo) => {
+ const caseId = encodeURIComponent(testInfo.testId)
+ await page.goto('/test/player.html')
+ await page.evaluate(url => window.createPlayer(url), `/test/fail.mp4?case=${caseId}`)
+ await expect.poll(() => page.evaluate(() => window.events.includes('video:error'))).toBe(true)
+ const requests = await (await request.get(`/test/requests.json?case=${caseId}`)).json()
+ expect(requests.some(item => item.path === '/test/fail.mp4')).toBe(true)
+ expect((await request.get('/test/fail.mp4')).status()).toBe(503)
+ expect(await page.locator('video').evaluate(video => video.error.code)).toBeGreaterThan(0)
+ await page.evaluate(() => window.art.destroy())
+ await expect(page.locator('.player')).toBeEmpty()
+})
+
+test('Range, media identity and docs candidate routes', async ({ request }) => {
+ const manifest = await (await request.get('/test/manifest.json')).json()
+ const candidate = await request.get('/candidate/artplayer.js')
+ const alias = await request.get('/uncompiled/artplayer/index.js')
+ expect(await alias.body()).toEqual(await candidate.body())
+ expect(manifest.resources['/uncompiled/artplayer/index.js'].aliasOf).toBe('/candidate/artplayer.js')
+ const full = await (await request.get('/test/pattern.mp4')).body()
+ for (const [range, start, end] of [['bytes=0-31', 0, 32], ['bytes=-16', full.length - 16, full.length], ['bytes=32-', 32, full.length]]) {
+ const response = await request.get('/test/pattern.mp4', { headers: { Range: range } })
+ expect(response.status()).toBe(206)
+ expect(await response.body()).toEqual(full.subarray(start, end))
+ }
+ expect((await request.get('/test/pattern.mp4', { headers: { Range: 'bytes=999999999-' } })).status()).toBe(416)
+ expect((await request.get('/compiled/missing.js')).status()).toBe(404)
+ expect((await request.get('/mobile.html')).status()).toBe(200)
+ expect((await request.get('/?libs=./uncompiled/artplayer-plugin-chapter/index.js&example=chapter')).status()).toBe(200)
+})
diff --git a/test/browser/player.html b/test/browser/player.html
new file mode 100644
index 000000000..52f0901c0
--- /dev/null
+++ b/test/browser/player.html
@@ -0,0 +1,46 @@
+
+
+
+
+ ArtPlayer browser contracts
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/browser/server.mjs b/test/browser/server.mjs
new file mode 100644
index 000000000..406669f98
--- /dev/null
+++ b/test/browser/server.mjs
@@ -0,0 +1,141 @@
+/* eslint-disable antfu/no-top-level-await -- Build test resources before accepting requests. */
+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 { fileURLToPath } from 'node:url'
+import { build } from 'vite'
+import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
+import { getEntryFile } from '../../scripts/projects.js'
+import { getGlobalName, getViteBuildConfig } from '../../scripts/utils.js'
+
+const workspace = fileURLToPath(new URL('../../', import.meta.url))
+
+const port = Number(process.env.ARTPLAYER_BROWSER_PORT || 8084)
+assert(Number.isInteger(port) && port > 0 && port < 65536, 'Invalid browser test port')
+const files = new Map()
+const requests = new Map()
+const manifest = { kind: 'artplayer-browser-test', resources: {} }
+function add(route, bytes, source) {
+ files.set(route, bytes)
+ manifest.resources[route] = { source, sha256: hash(bytes), bytes: bytes.length }
+}
+
+// An explicit artifact map takes precedence over source builds and never falls back.
+const mapFile = process.env.ARTPLAYER_BROWSER_ARTIFACTS
+const overrides = mapFile ? JSON.parse(fs.readFileSync(mapFile, 'utf8')) : {}
+for (const name of ['artplayer', 'artplayer-plugin-chapter']) {
+ const route = `/candidate/${name}.js`
+ if (mapFile) {
+ assert(Object.hasOwn(overrides, name), `Missing candidate artifact: ${name}`)
+ const filename = path.resolve(path.dirname(path.resolve(mapFile)), overrides[name])
+ add(route, fs.readFileSync(filename), { kind: 'artifact', file: filename })
+ }
+ else {
+ const project = path.join(workspace, 'packages', name)
+ const config = getViteBuildConfig({ entry: getEntryFile(project), name: getGlobalName(name), format: 'umd', fileName: `${name}.js`, minify: false })
+ config.build.write = false
+ const result = await build({ root: project, ...config })
+ const chunks = (Array.isArray(result) ? result : [result]).flatMap(item => item.output).filter(item => item.type === 'chunk')
+ assert.equal(chunks.length, 1, 'Browser source loader requires one self-contained chunk')
+ add(route, Buffer.from(chunks[0].code), { kind: 'workspace-build', entry: path.relative(workspace, getEntryFile(project)) })
+ }
+ for (const alias of [`/compiled/${name}.js`, `/uncompiled/${name}/index.js`]) {
+ files.set(alias, files.get(route))
+ manifest.resources[alias] = { ...manifest.resources[route], aliasOf: route }
+ }
+}
+const releases = JSON.parse(fs.readFileSync(path.join(workspace, 'refactor/baselines/releases.json'), 'utf8')).releases
+for (const release of releases) {
+ const member = `package/${release.manifest.main.replace(/^\.\//, '')}`
+ const bytes = readMember(await ensureArchive(release), member)
+ assert.equal(hash(bytes), release.files[member], 'Published browser artifact differs from frozen baseline')
+ add(`/published/${release.name}.js`, bytes, { kind: 'npm-release', version: release.version, integrity: release.integrity, member })
+}
+for (const extension of ['mp4', 'webm']) {
+ const name = `test/browser/media/pattern.${extension}`
+ add(`/test/pattern.${extension}`, fs.readFileSync(path.join(workspace, name)), { kind: 'generated-media', file: name })
+}
+
+const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.mp4': 'video/mp4', '.webm': 'video/webm', '.vtt': 'text/vtt', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg' }
+function send(req, res, bytes, filename) {
+ res.setHeader('Content-Type', mime[path.extname(filename)] || 'application/octet-stream')
+ res.setHeader('Cache-Control', 'no-store')
+ res.setHeader('Accept-Ranges', 'bytes')
+ if (req.headers.range) {
+ const match = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range)
+ const start = match?.[1] ? Number(match[1]) : Math.max(0, bytes.length - Number(match?.[2]))
+ const end = match?.[1] && match[2] ? Math.min(bytes.length - 1, Number(match[2])) : bytes.length - 1
+ if (!match || (!match[1] && !match[2]) || !Number.isSafeInteger(start) || start > end || start >= bytes.length) {
+ res.writeHead(416, { 'Content-Range': `bytes */${bytes.length}` }).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.end(req.method === 'HEAD' ? undefined : bytes)
+}
+
+add('/assets/sample/video.mp4', fs.readFileSync(path.join(workspace, 'docs/assets/sample/video.mp4')), { kind: 'docs-media', file: 'docs/assets/sample/video.mp4' })
+
+const server = http.createServer((req, res) => {
+ try {
+ assert(['GET', 'HEAD'].includes(req.method), 'Unsupported method')
+ const url = new URL(req.url, `http://127.0.0.1:${port}`)
+ const caseId = url.searchParams.get('case')
+ if (url.pathname === '/test/requests.json') {
+ send(req, res, Buffer.from(JSON.stringify(requests.get(caseId) || [])), 'requests.json')
+ return
+ }
+ if (caseId && /\.(?:mp4|webm)$/.test(url.pathname)) {
+ assert(caseId.length <= 200 && (requests.has(caseId) || requests.size < 10000), 'Test request log limit')
+ const records = requests.get(caseId) || []
+ if (records.length < 100)
+ records.push({ path: url.pathname, range: req.headers.range || null, method: req.method })
+ requests.set(caseId, records)
+ }
+ if (url.pathname === '/favicon.ico') {
+ res.writeHead(204).end()
+ return
+ }
+ if (url.pathname === '/test/manifest.json') {
+ send(req, res, Buffer.from(JSON.stringify(manifest)), 'manifest.json')
+ return
+ }
+ if (url.pathname === '/test/fail.mp4') {
+ res.writeHead(503, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' }).end('Intentional media failure')
+ return
+ }
+ if (files.has(url.pathname)) {
+ send(req, res, files.get(url.pathname), url.pathname)
+ return
+ }
+ if (url.pathname === '/test/player.html') {
+ const core = url.searchParams.get('core') || 'candidate'
+ const chapter = url.searchParams.get('chapter') || 'candidate'
+ assert(['candidate', 'published'].includes(core) && ['candidate', 'published'].includes(chapter), 'Invalid test combination')
+ const html = fs.readFileSync(path.join(workspace, 'test/browser/player.html'), 'utf8')
+ .replace('__CORE__', core)
+ .replace('__CHAPTER__', chapter)
+ send(req, res, Buffer.from(html), 'player.html')
+ return
+ }
+ assert(!/^\/(?:test|candidate|published|compiled|uncompiled)\//.test(url.pathname), 'Unmapped test artifact')
+ const root = fs.realpathSync(path.join(workspace, 'docs'))
+ let filename = path.resolve(root, `.${decodeURIComponent(url.pathname)}`)
+ if (fs.statSync(filename).isDirectory())
+ filename = path.join(filename, 'index.html')
+ const relative = path.relative(root, fs.realpathSync(filename))
+ assert(relative && !relative.startsWith('..') && !path.isAbsolute(relative), 'Invalid docs path')
+ send(req, res, fs.readFileSync(filename), filename)
+ }
+ catch (error) {
+ res.writeHead(404, { 'Content-Type': 'text/plain' }).end(error.message)
+ }
+})
+server.listen(port, '127.0.0.1', () => process.stdout.write(`Browser test server: http://127.0.0.1:${port}\n`))
+for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.close())
diff --git a/yarn.lock b/yarn.lock
index a79f4eb19..b101a9841 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1361,6 +1361,13 @@
resolved "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz"
integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==
+"@playwright/test@1.63.0":
+ version "1.63.0"
+ resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.63.0.tgz#808020c46e8b6b91f9d628cd0ebcbb32bdfd8b6c"
+ integrity sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==
+ dependencies:
+ playwright "1.63.0"
+
"@rollup/rollup-android-arm-eabi@4.57.1":
version "4.57.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz#add5e608d4e7be55bc3ca3d962490b8b1890e088"
@@ -6533,6 +6540,18 @@ pkg-types@^2.3.0:
exsolve "^1.0.7"
pathe "^2.0.3"
+playwright-core@1.63.0:
+ version "1.63.0"
+ resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.63.0.tgz#e57665bc32846c213ac39a1e4d5bc6228e76b376"
+ integrity sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==
+
+playwright@1.63.0:
+ version "1.63.0"
+ resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.63.0.tgz#99b56f9f69b1b70c44f00bf84b2fe52348ae2511"
+ integrity sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==
+ dependencies:
+ playwright-core "1.63.0"
+
pluralize@^8.0.0:
version "8.0.0"
resolved "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz"