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"