test(browser): ENG-05 add real playback projects and artifact mapping

This commit is contained in:
Harvey Zhao committed 2026-09-10 22:25:13 +08:00
1 parent 458bc3215d
commit 7318d33822
20 files changed
+1241 -9

No files matched your search

+31
View File
@@ -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
+7 -3
View File
@@ -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",
+39
View File
@@ -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) },
},
})
+1
View File
@@ -44,6 +44,7 @@
| [类型检查与迁移入口](typechecking.md) | 根/分包配置、当前/旧编译器、严格正反例和历史声明错误 |
| [JS/TS 构建与开发](build-development.md) | 非交互选包、开发重建、资源夹具和 AMD 全局修复 |
| [测试目录维护说明](../test/README.md) | 统一测试命令、JS/TS loader、受控媒体及新旧公共契约 |
| [真实浏览器入口](../test/browser/README.md) | 三浏览器、解码画面与媒体状态、Range/失败服务及候选文件映射 |
## 维护规则
+666
View File
@@ -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"
}
}
@@ -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 迁移。
+2 -1
View File
@@ -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 隔离与启用条件
+1 -1
View File
@@ -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 发布。
## 任务归属与验收
+3 -2
View File
@@ -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<br>拆分只读检查并建立 PR CI | ENG-01, ENG-PM-01 | lint/lint:fix、PR 与主线检查、独立部署任务;遵循 github-ci-cd.md,PR/重构分支触发、最小权限及 workflow 静态检查 | 仓库内检查可执行且不改源码、不发布;required checks 的外部设置状态列入发布台账,不阻塞本地框架建设 | M | done |
| ENG-03 | workspace<br>建立公共行为与单元测试入口 | ENG-02, BASE-03 | 保留现有 node:test,测试目录/夹具/统一入口 | 已有 19 项回归保留,旧版与候选可用同一夹具运行 | M | done |
| ENG-04 | workspace<br>建立类型测试基础 | ENG-02, BASE-05 | 根与分包 tsconfig、显式 TS 依赖、正反例测试 | 核心/试点与迁移模块严格检查,未迁移第三方/包历史问题独立台账;明确最低/当前 TS 和各环境类型;复用 BASE-05 的四种消费模式,不以 skipLibCheck 掩盖 BASE-TYPE-01/03 | M | done |
| ENG-05 | workspace<br>建立真实浏览器测试服务 | ENG-03, BASE-04, BASE-08 | Playwright projects、本地 Range/失败媒体服务;复用 docs 页面/样本的状态隔离、错误采集与候选资源映射 | Chromium/Firefox/WebKit 的基础播放 smoke 和报告可执行;以媒体状态断言判定通过,区分轻量用例与真实编辑器交互 | M | todo |
| ENG-05 | workspace<br>建立真实浏览器测试服务 | ENG-03, BASE-04, BASE-08 | Playwright projects、本地 Range/失败媒体服务;复用 docs 页面/样本的状态隔离、错误采集与候选资源映射 | Chromium/Firefox/WebKit 的基础播放 smoke 和报告可执行;以媒体状态断言判定通过,区分轻量用例与真实编辑器交互 | M | done |
| ENG-06 | workspace<br>支持按包非交互与 JS/TS 构建 | ENG-02, BASE-05 | build/dev 入口解析、指定包参数、原交互保留 | 三种产物、Less/SVG/worker 和本地 8082 demo 正常;保持 BASE-05 的 AMD 同时写入全局行为及 i18n/legacy 入口 | H | done |
| ENG-07 | workspace<br>建立 tarball 消费与产物检查 | ENG-04, ENG-06 | 隔离 npm 消费 fixtures、API/声明/入口差分 | 不借 workspace 源码通过,能识别缺文件与默认导出变化 | H | todo |
| ENG-08 | workspace<br>增加覆盖率、资源与性能报告 | 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)
+4
View File
@@ -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 浏览器自动化。
+5 -2
View File
@@ -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",
+6
View File
@@ -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.
+84
View File
@@ -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/<package>.js` verifies the frozen npm archive/member from BASE-01.
`/candidate/<package>.js` maps the current build. Existing docs `/compiled/<package>.js` and
`/uncompiled/<package>/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=<test-id>` to
media URLs and inspect `/test/requests.json?case=<test-id>` 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).
+33
View File
@@ -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 }
Binary file not shown.
Binary file not shown.
+100
View File
@@ -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)
})
+46
View File
@@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ArtPlayer browser contracts</title>
<style>
.player {
width: 640px;
height: 360px;
}
</style>
<script src="/__CORE__/artplayer.js"></script>
<script src="/__CHAPTER__/artplayer-plugin-chapter.js"></script>
</head>
<body>
<div class="player"></div>
<button id="play">Play</button>
<button id="pause">Pause</button>
<script>
window.createPlayer = (url, chapter = false) => {
window.events = []
window.art = new Artplayer({
container: '.player',
url,
muted: true,
plugins: chapter
? [artplayerPluginChapter({ chapters: [{ start: 0, end: Infinity, title: 'Pattern' }] })]
: [],
})
for (const name of [
'video:loadedmetadata',
'ready',
'video:playing',
'video:pause',
'video:seeked',
'video:error',
'destroy',
]) {
art.on(name, () => events.push(name))
}
document.querySelector('#play').onclick = () => art.play()
document.querySelector('#pause').onclick = () => art.pause()
}
</script>
</body>
</html>
+141
View File
@@ -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())
+19
View File
@@ -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"