mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-13 15:56:15 -08:00
feat(vue): [EX-02] verify installed consumers and preserve lifecycle contracts
This commit is contained in:
1 parent
4df9689364
commit
d62ab13a35
29 files changed
+2326
-82
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,105 @@
|
||||
# EX-02 Vue 消费者与原有更新/卸载契约
|
||||
|
||||
起点 `4df96893649ee0d3ffe1504af0609c3c6e023409`。本任务完成 Vue 示例的自有
|
||||
TS 入口、配置职责拆分、严格 SFC 检查、实际 tarball 消费和维护说明;不将
|
||||
插件专项/设备/发布任务一起完成。
|
||||
|
||||
## 实现与兼容边界
|
||||
|
||||
- `Player.vue` 保留 `option: Partial<Option>`、`getInstance` 事件和
|
||||
`onMounted` 初始化。将示例配置抽到 `player-options.ts`,维持法语、词典、
|
||||
Danmuku/Document PiP 的覆盖顺序及每实例创建插件工厂。
|
||||
- DOM 容器和播放器均由 shallowRef 持有;去掉容器类型断言。卸载先取出并
|
||||
清空内部引用,再 `destroy(false)`,保留 Vue 对外层 div 的所有权。
|
||||
- 保留数组式 `defineEmits(['getInstance'])`,没有收窄旧 `$emit`/监听器的
|
||||
类型形状。内部交付的实例有准确 Artplayer 类型,App 也显式标注回调参数。
|
||||
- 保留仅挂载时读取 options:字段变化或替换对象不自动切源或重建;用户通过
|
||||
收到的实例 `switchUrl` 更新,通过组件 key 重建。没有增加深度 watch。
|
||||
- 保留 script-setup 的私有状态边界:组件 ref 有 Vue `$el/$props`,不公开 art。
|
||||
class/style 保持透传;旧 JS 中的 id/data 属性透传单独实际验证。
|
||||
- Vue 捕获事件监听器错误后,仍挂载的组件保留播放器,到卸载时释放。此处
|
||||
没有照搬 React 的异常处理。KeepAlive 停用也不销毁/暂停,所有者卸载才清理。
|
||||
依据 [Vue 生命周期](https://vuejs.org/api/composition-api-lifecycle.html) 和
|
||||
[script-setup 公开边界](https://vuejs.org/api/sfc-script-setup.html#defineexpose),
|
||||
再用旧/新包装的实际浏览器结果验证。
|
||||
|
||||
`main.js` 迁移到 `main.ts` 并同步 HTML;`tsconfig.json` 替代 jsconfig,保留 @
|
||||
别名,启用 strict、strictTemplates、checkJs、skipLibCheck:false。App 对选项
|
||||
使用窄字段推导和 satisfies 校验,避免直接给 reactive 传入全部 Partial<Option>
|
||||
造成 DOM 字段类型展开。包内核心/插件公开声明、运行源码和产物均未修改。
|
||||
|
||||
## 类型问题与测试修正
|
||||
|
||||
原 App/Player 的固定 Git 内容在当前工作区、新 vue-tsc 下,App 第 23 行复现
|
||||
一个 TS2322。当前树可见 @vue/reactivity 3.5.13 与 Vue/runtime-dom 3.5.28 共存,
|
||||
诊断涉及 DOM 深层类型展开;不据此断言所有 Vue 环境均有该问题。新示例严格
|
||||
检查通过,隔离安装中的旧包装也通过,工作区类型问题与运行兼容分开记录。
|
||||
|
||||
初版测试的两个假设已纠正:具体 Vue 组件实例不能随意赋给无 props/emits
|
||||
泛型的 ComponentPublicInstance;strictTemplates 不会自动把任意原生属性
|
||||
识别为组件声明的 props。保留真实 `$props`/私有 ref 正反例,任意属性透传在
|
||||
未改成 TS 的 JS SFC 中验证,没有扩大组件 props 或关闭严格模板检查。
|
||||
|
||||
初版隔离校验拒绝了工作区 vue-tsc 引用的 language-core 模板 helper。最终连
|
||||
vue-tsc/TypeScript 也安装在隔离消费者中,全部 90 个编译输入都须位于该目录,
|
||||
没有给工作区类型增加例外。三个 @ts-expect-error 分别拒绝错误 URL、缺失
|
||||
option 和读取私有 art;原数组式 event 监听器形状仍可赋值。
|
||||
|
||||
## 工具与所有权
|
||||
|
||||
根 devDependencies 新增固定 Vue 3.5.28、@vitejs/plugin-vue 6.0.1、
|
||||
vue-tsc 3.3.11。Vue 版本与已固定 compiler-sfc 一致;vue-tsc 的实际 registry
|
||||
元数据要求 TS >=5.0,复用 TS 5.9.3、Vite 7.3.6。只更新根 yarn.lock,示例
|
||||
manifest 同步版本/用途,移除未使用的可选 devtools overlay 依赖。
|
||||
这些是示例/测试工具,不改变 Artplayer 包的生产依赖。
|
||||
|
||||
新增 `dev:vue`、`typecheck:vue`、`lint:vue`、`build:vue`、`test:vue-consumer`。
|
||||
`test/vue/` 分离实际 SFC 场景、纯 JS 调用和只编译类型反例;编排脚本复用
|
||||
已有 package-consumer 的隔离目录/命令/安全清理,避免引入另一套包安装逻辑。
|
||||
|
||||
消费者测试顺序:pack 三包并记录哈希;仓库外 offline 安装和 frozen force
|
||||
重装;逐文件对照 tarball;隔离 vue-tsc 严格编译;Vite 构建实际 App、生命周期
|
||||
页和 JS 页;三引擎逐个测试。浏览器、预览服务和临时安装均在 finally 释放。
|
||||
观察 Worker 的包装实际调用原生构造/terminate,没有替换 worker 执行逻辑。
|
||||
|
||||
## 实际结果
|
||||
|
||||
见 [机器证据](../baselines/vue-consumer-validation.json)。Windows、Node
|
||||
24.21.0、Yarn 1.22.22,Chromium 153.0.8010.12 / Firefox 155.0 / WebKit 26.6。
|
||||
|
||||
- 新包装和固定旧 Player.vue:分别 development/production × 3 引擎,共
|
||||
12 组通过,每组 15 类检查,无 retry/skip。覆盖原事件/组件 ref、选项修改与
|
||||
替换、播放/暂停/seek/像素、实例切源、key 重建、兄弟实例、可选监听器、
|
||||
mount 前取消、连续三次挂卸载、捕获错误、监听器触发卸载、KeepAlive、旧 JS
|
||||
用法、原 App 入口。候选每组创建 19 个 Worker,旧包装生产 WebKit 为 18 个、
|
||||
其余为 19 个;创建总数不是兼容断言,全部最终存活 0,实例/DOM 清理。
|
||||
- 新依赖下 React 实际消费六组回归通过;全仓 406 个生产 TS 文件及兼容类型
|
||||
矩阵通过;50 项 CI 回归通过;根/示例 lint、frozen 安装、严格工具链、核心
|
||||
声明无漂移检查和示例生产构建通过。根 lint 保留 1 条既有生成声明注释警告。
|
||||
- GitHub 三系统 browser-smoke 加入 Vue 消费及 always 证据上传;验证器会拒绝
|
||||
删除、跳过该步骤或遗漏其证据。这里只证明本地配置/测试,没有远端运行证据。
|
||||
|
||||
## 内置浏览器补充验证
|
||||
|
||||
真实启动 `yarn dev:vue --host 127.0.0.1 --port 5188 --strictPort`,Vite 7.3.6
|
||||
在 322ms 就绪。通过已授权 Codex 内置浏览器打开实际开发页,不拦截样例请求:
|
||||
公网 video.mp4、XML 弹幕正常加载;点击 Lire 后播放推进至 74.60414s,原生
|
||||
duration 90.045011s、640×360、readyState 4、error null。Pause 后操作进度条
|
||||
Home,UI 和原生 currentTime 回到 0,paused=true,查询到的 error/warn 为空。
|
||||
|
||||
User-Agent 报告 Chrome/152.0.0.0(Windows);接口不支持 Browser.getVersion,
|
||||
不虚构完整补丁版本。该次开发页与 headless 自动化分开记账,没有据此声称 Chrome
|
||||
扩展已恢复或 Safari/真机通过。UI 截图已在工具会话查看,未作为仓库图片保存。
|
||||
临时页已关闭,开发服务器已主动终止。暂停 seek 后旧弹幕仍显示符合已记录的
|
||||
Danmuku 历史池语义,未将其误报为新缺陷,见该包 ARCHITECTURE.md 和原基线测试。
|
||||
|
||||
## 边界与回退
|
||||
|
||||
自动化媒体/XML 是受控夹具;内置浏览器只补充一次公网样例/开发页实播,不证明
|
||||
普遍 CDN 可用性。Worker 终止计数不等于进程内存测量。未验证真实 PiP 窗口、
|
||||
完整弹幕调度、SSR/HMR、macOS/iOS Safari、旧核心/旧插件全部组合;对应已有
|
||||
包/工程/发布门槛继续保留。`--before` 是历史包装源码对照,不是旧 npm 包矩阵。
|
||||
|
||||
回退 EX-02 提交恢复旧示例、配置和工具锁;不涉及数据迁移。将来生产包内容或
|
||||
工具链变化后重新打包验证,不复用本次 tarball 哈希作 npm 发布证明。无版本
|
||||
升级、推送或发布;Vue 示例的私有 0.0.0 不属于 22 workspace 包的大版本发布。
|
||||
@@ -9,6 +9,8 @@
|
||||
| `yarn lint` | 只读 ESLint,覆盖包源码/声明、JS/MJS 工具、测试和编辑器声明 |
|
||||
| `yarn lint:fix` | 显式自动修复相同范围 |
|
||||
| `yarn typecheck` | 根/迁移包严格检查、当前与兼容 TS 消费;历史 NodeNext ESM 错误单独核对,见 typechecking.md |
|
||||
| `yarn typecheck:react` / `yarn typecheck:vue` | 原 React TSX / Vue SFC 示例严格检查,ci:check 同时执行对应 lint |
|
||||
| `yarn test:react-consumer` / `yarn test:vue-consumer` | 仓库外 tarball 安装、原框架示例、开发/生产三引擎;browser-smoke 执行并上传独立目录,本地证据不代替远端矩阵 |
|
||||
| `yarn test:unit` | 原播放/DASH 回归、同夹具的新旧公共契约与 JS/TS loader 验证 |
|
||||
| `yarn test:node` | test:unit 加工具链/文档构建回归,保留原 test:playback/test:dash-control 入口 |
|
||||
| `yarn test` | 统一执行 Node 与基线测试,源码/夹具维护入口见仓库 test/README.md |
|
||||
|
||||
+3
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 238 项,范围 22 个包及工作区/示例。
|
||||
|
||||
状态:todo 61 / doing 15 / blocked 0 / done 162 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
状态:todo 60 / doing 15 / blocked 0 / done 163 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
|
||||
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
|
||||
|
||||
@@ -387,7 +387,7 @@
|
||||
| SITE-05 | artplayer-vitepress<br>构建文档站和验证链接/示例 | SITE-04, EX-01, EX-02 | VitePress 构建、链接与嵌入 demo 检查 | 文档构建、链接、嵌入路径与声明注入通过;真实完整 demo 保留 EX-03 独立门槛;核对 ENG-PM-01 登记的搜索 peer 范围和真实搜索行为 | M | todo |
|
||||
| SITE-06 | artplayer-vitepress<br>文档站交付验收 | SITE-05 | 维护指南和站点变更记录 | 未手改 generated 目录,旧 URL 可用、部署与检查分离 | M | todo |
|
||||
| EX-01 | example/react.js, workspace<br>验证 React 消费者与 TS | CORE-22, ENG-07 | React 挂载/卸载/重挂载、引用和插件样例 | 真实 tarball + TS 消费通过,保留既有 React 集成 API | M | done |
|
||||
| EX-02 | example/vue.js<br>验证 Vue 消费者与更新卸载 | CORE-22, ENG-07 | Vue 实例/ref、参数更新、卸载及插件样例 | 旧 JS 组件用法无需修改,重复挂载不泄漏 | M | todo |
|
||||
| EX-02 | example/vue.js, workspace<br>验证 Vue 消费者与更新卸载 | CORE-22, ENG-07 | Vue 实例/ref、参数更新、卸载及插件样例 | 旧 JS 组件用法无需修改,重复挂载不泄漏 | M | done |
|
||||
| EX-03 | workspace<br>验证全部原生 demo 与外部播放集成 | EX-01, EX-02, PKG-CHAPTER-06, PKG-AMBILIGHT-06, PKG-AUDIO-06, PKG-AUTO-THUMB-06, PKG-VTT-THUMB-06, PKG-HLS-06, PKG-DASH-06, PKG-MULTI-SUB-06, PKG-JASSUB-06, PKG-MASK-06, PKG-ASR-06, PKG-ADS-06, PKG-VAST-06, PKG-CAST-06, PKG-DPIP-06, PKG-DANMUKU-09, PKG-CANVAS-06, PKG-MB-10, PKG-IFRAME-06, PKG-TOOL-THUMB-06 | 8082 全 demo、HLS/DASH/FLV/MPEGTS/WebTorrent 集成记录 | 旧 URL/参数/脚本加载保持;网络/SDK 限制明确,不静默跳过;覆盖编辑器重复 Run 和 ESM/i18n/mobile/iframe,记录实际候选脚本及状态隔离证据;依据 BASE-04 路径台账逐项消除 not-run,处理 BASE-DEMO-01 | H | todo |
|
||||
|
||||
## 7 工具链与性能
|
||||
@@ -605,6 +605,7 @@
|
||||
- PKG-TOOL-THUMB-03: [记录](changes/2026-09-13-PKG-TOOL-THUMB-03-input-checkpoint.md) [记录](baselines/thumbnail-input-checkpoint.json) [记录](changes/2026-09-13-PKG-TOOL-THUMB-03-lifecycle.md) [记录](baselines/thumbnail-lifecycle-validation.json)
|
||||
- PKG-TOOL-THUMB-04: [记录](changes/2026-09-13-PKG-TOOL-THUMB-04-runtime-types.md) [记录](baselines/thumbnail-runtime-types-validation.json) [记录](changes/2026-09-13-PKG-TOOL-THUMB-04-public-types.md) [记录](baselines/thumbnail-public-types-validation.json) [记录](changes/2026-09-13-PKG-TOOL-THUMB-04-emitter.md) [记录](baselines/thumbnail-emitter-validation.json)
|
||||
- EX-01: [记录](changes/2026-09-14-EX-01-react-consumer.md) [记录](baselines/react-consumer-validation.json) [记录](scripts/react-consumer.mjs)
|
||||
- EX-02: [记录](changes/2026-09-14-EX-02-vue-consumer.md) [记录](baselines/vue-consumer-validation.json) [记录](scripts/vue-consumer.mjs)
|
||||
- PKG-FACTORY-01: [记录](baselines/factory-assignment-gaps.json) [记录](baselines/factory-compatibility-proposals.json) [记录](factory-compatibility-decision.md) [记录](changes/2026-09-12-PKG-FACTORY-01-decision.md) [记录](type-compatibility-policy.md) [记录](baselines/factory-compatibility-validation.json) [记录](changes/2026-09-13-PKG-FACTORY-01-compatible-types.md)
|
||||
- CORE-25: [记录](changes/2026-09-13-CORE-25-defaults-ssr.md) [记录](baselines/defaults-ssr-validation.json)
|
||||
- ENG-12: [记录](changes/2026-09-13-ENG-12-library-public.md) [记录](baselines/library-public-validation.json)
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 进度与证据
|
||||
|
||||
## EX-02 Vue 消费者完成
|
||||
|
||||
Vue 示例入口和配置职责完成 TS 整理;保留挂载时读取 option、原 getInstance
|
||||
事件/类型和私有 ref 边界。严格检查实际 SFC/模板,原 JS 调用单独验证。候选
|
||||
与旧包装分别开发/生产 × 三引擎,各 15 类检查共 12 组通过,实际 Worker、
|
||||
实例和 DOM 均清理。新依赖下 React 六组回归、全仓 406 个生产 TS 文件、50 项
|
||||
CI 回归、lint(0 error/1 既有 warning)、frozen 安装、工具链、声明漂移和
|
||||
Vue 构建检查通过。内置浏览器另验证真实开发页公网样例播放/暂停/Home,UA
|
||||
报告 Chrome/152.0.0.0,补丁版本未提供;临时页和服务已关闭。见
|
||||
[变更](changes/2026-09-14-EX-02-vue-consumer.md)及
|
||||
[证据](baselines/vue-consumer-validation.json)。未关闭 PiP/Danmuku 等专项和
|
||||
真实设备/发布门槛;远端 CI 未验证,无推送/发布。当前 238 项:163 done、
|
||||
15 doing、60 todo。下一步推进 SITE-01 文档站入口/资产核对,VAST 兼容选择和
|
||||
Auto Thumbnail 首帧仍未关闭。
|
||||
|
||||
|
||||
## ENG-LINT-01 全仓 lint 恢复
|
||||
|
||||
在 EX-01 独立提交 e0d785662 并通过提交审计后,只调整 Mask manifest 顶层字段
|
||||
|
||||
@@ -87,6 +87,9 @@ export function validateCIWorkflow(source) {
|
||||
const reactIndex = browser.steps.findIndex(step => step.run === 'yarn test:react-consumer 2>&1 | tee refactor/.cache/ci/react-consumer.log')
|
||||
assert(reactIndex > consumerIndex && !Object.hasOwn(browser.steps[reactIndex], 'if'), 'Run React installed consumers after restoring canonical Node')
|
||||
assert(browser.steps.some(step => step.uses?.startsWith('actions/upload-artifact@') && step.if === 'always()' && step.with.path.split('\n').includes('refactor/.cache/react-consumer-*/')), 'Retain React consumer failure evidence')
|
||||
const vueIndex = browser.steps.findIndex(step => step.run === 'yarn test:vue-consumer 2>&1 | tee refactor/.cache/ci/vue-consumer.log')
|
||||
assert(vueIndex > consumerIndex && !Object.hasOwn(browser.steps[vueIndex], 'if'), 'Run Vue installed consumers after restoring canonical Node')
|
||||
assert(browser.steps.some(step => step.uses?.startsWith('actions/upload-artifact@') && step.if === 'always()' && step.with.path.split('\n').includes('refactor/.cache/vue-consumer-*/')), 'Retain Vue consumer failure evidence')
|
||||
const pages = workflow.jobs.checks.steps.find(step => step.uses?.startsWith('actions/upload-pages-artifact@'))
|
||||
assert.equal(pages?.if, 'inputs.pages-artifact && github.ref == \'refs/heads/master\' && matrix.os == \'ubuntu-latest\'', 'Only one trusted matrix leg can prepare Pages')
|
||||
return { jobs: requiredJobs, systems, summary: summary.name }
|
||||
|
||||
@@ -37,6 +37,9 @@ for (const [name, mutate] of [
|
||||
['skipped React consumer', w => w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:react-consumer')).if = 'false'],
|
||||
['removed React consumer', w => w.jobs['browser-smoke'].steps = w.jobs['browser-smoke'].steps.filter(s => !s.run?.startsWith('yarn test:react-consumer'))],
|
||||
['missing React evidence', w => w.jobs['browser-smoke'].steps.find(s => s.uses?.startsWith('actions/upload-artifact@')).with.path = 'refactor/.cache/ci/'],
|
||||
['skipped Vue consumer', w => w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:vue-consumer')).if = 'false'],
|
||||
['removed Vue consumer', w => w.jobs['browser-smoke'].steps = w.jobs['browser-smoke'].steps.filter(s => !s.run?.startsWith('yarn test:vue-consumer'))],
|
||||
['missing Vue evidence', w => w.jobs['browser-smoke'].steps.find(s => s.uses?.startsWith('actions/upload-artifact@')).with.path = w.jobs['browser-smoke'].steps.find(s => s.uses?.startsWith('actions/upload-artifact@')).with.path.replace('refactor/.cache/vue-consumer-*/', '')],
|
||||
['Pages uploaded by both matrix legs', w => w.jobs.checks.steps.find(s => s.uses?.startsWith('actions/upload-pages-artifact@')).if = 'inputs.pages-artifact && github.ref == \'refs/heads/master\''],
|
||||
]) {
|
||||
test(`CI rejects ${name}`, () => {
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { chromium, firefox, webkit } from '@playwright/test'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { build, preview } from 'vite'
|
||||
import { packedFiles } from '../../scripts/package-check.mjs'
|
||||
import { consumerDirectory, removeConsumer, run, workspace, writeJson } from '../../scripts/package-consumer.mjs'
|
||||
import { hash, readMember } from './releases.mjs'
|
||||
|
||||
assert.equal(process.version.slice(1), fs.readFileSync(path.join(workspace, '.node-version'), 'utf8').trim())
|
||||
assert.equal(process.env.npm_config_user_agent?.split(' ')[0], 'yarn/1.22.22')
|
||||
assert(process.argv.slice(2).every(arg => arg === '--before'))
|
||||
const yarn = process.env.npm_execpath
|
||||
assert(yarn && fs.existsSync(yarn))
|
||||
const before = process.argv.includes('--before')
|
||||
const output = fs.mkdtempSync(path.join(workspace, 'refactor/.cache/vue-consumer-'))
|
||||
const consumer = consumerDirectory()
|
||||
const report = { task: 'EX-02', before, node: process.version, source: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: workspace, encoding: 'utf8' }).trim(), packages: [], browsers: [], passed: false }
|
||||
console.log(`Vue consumer evidence: ${output}`)
|
||||
try {
|
||||
const dependencies = {}
|
||||
for (const name of ['artplayer', 'artplayer-plugin-danmuku', 'artplayer-plugin-document-pip']) {
|
||||
const archive = path.join(output, `${name}.tgz`)
|
||||
fs.writeFileSync(path.join(output, `${name}-pack.log`), run([yarn, 'pack', '--filename', archive], path.join(workspace, 'packages', name)))
|
||||
dependencies[name] = `file:${archive.replaceAll('\\', '/')}`
|
||||
report.packages.push({ name, archive, sha256: hash(fs.readFileSync(archive)) })
|
||||
}
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(workspace, 'package.json'), 'utf8'))
|
||||
dependencies.vue = manifest.devDependencies.vue
|
||||
const devDependencies = { 'vue-tsc': manifest.devDependencies['vue-tsc'], 'typescript': manifest.devDependencies.typescript }
|
||||
writeJson(path.join(consumer, 'package.json'), { name: 'vue-artplayer-isolated-consumer', private: true, type: 'module', dependencies, devDependencies })
|
||||
fs.writeFileSync(path.join(output, 'install.log'), run([yarn, 'install', '--offline', '--ignore-scripts', '--non-interactive'], consumer))
|
||||
const lock = fs.readFileSync(path.join(consumer, 'yarn.lock'))
|
||||
fs.writeFileSync(path.join(output, 'consumer-yarn.lock'), lock)
|
||||
fs.writeFileSync(path.join(output, 'frozen.log'), run([yarn, 'install', '--offline', '--frozen-lockfile', '--force', '--ignore-scripts', '--non-interactive'], consumer))
|
||||
assert.deepEqual(fs.readFileSync(path.join(consumer, 'yarn.lock')), lock)
|
||||
for (const pkg of report.packages) {
|
||||
for (const member of packedFiles(pkg.archive)) assert.deepEqual(fs.readFileSync(path.join(consumer, 'node_modules', pkg.name, member.replace(/^package\//, ''))), readMember(pkg.archive, member))
|
||||
}
|
||||
const example = path.join(workspace, 'example/vue.js')
|
||||
fs.cpSync(path.join(example, 'src'), path.join(consumer, 'src'), { recursive: true })
|
||||
for (const file of ['index.html', 'tsconfig.json']) fs.copyFileSync(path.join(example, file), path.join(consumer, file))
|
||||
if (before) {
|
||||
const old = execFileSync('git', ['show', '4df96893649ee0d3ffe1504af0609c3c6e023409:example/vue.js/src/Player.vue'], { cwd: workspace })
|
||||
fs.writeFileSync(path.join(consumer, 'src/Player.vue'), old)
|
||||
report.beforeWrapper = hash(old)
|
||||
}
|
||||
for (const file of ['Lifecycle.vue', 'entry.ts', 'LegacyConsumer.vue', 'legacy-entry.js', 'consumer.ts']) fs.copyFileSync(path.join(workspace, 'test/vue', file), path.join(consumer, file))
|
||||
for (const [file, entry] of [['harness.html', 'entry.ts'], ['legacy.html', 'legacy-entry.js']]) fs.writeFileSync(path.join(consumer, file), `<!doctype html><html><body><div id="root"></div><script type="module" src="/${entry}"></script></body></html>`)
|
||||
fs.mkdirSync(path.join(consumer, 'public'))
|
||||
const media = fs.readFileSync(path.join(workspace, 'test/browser/media/pattern.mp4'))
|
||||
fs.writeFileSync(path.join(consumer, 'public/pattern.mp4'), media)
|
||||
fs.copyFileSync(path.join(example, 'public/favicon.ico'), path.join(consumer, 'public/favicon.ico'))
|
||||
report.media = { file: 'test/browser/media/pattern.mp4', sha256: hash(media) }
|
||||
report.inputs = Object.fromEntries(['src/Player.vue', 'src/player-options.ts', 'src/App.vue', 'src/main.ts', 'index.html', 'Lifecycle.vue', 'entry.ts', 'LegacyConsumer.vue', 'legacy-entry.js', 'consumer.ts'].map(file => [file, hash(fs.readFileSync(path.join(consumer, file)))]))
|
||||
const config = JSON.parse(fs.readFileSync(path.join(consumer, 'tsconfig.json'), 'utf8'))
|
||||
config.include = ['src', 'Lifecycle.vue', 'entry.ts', 'consumer.ts']
|
||||
writeJson(path.join(consumer, 'tsconfig.json'), config)
|
||||
const typeLog = run([path.join(consumer, 'node_modules/vue-tsc/bin/vue-tsc.js'), '-p', 'tsconfig.json', '--noEmit', '--listFiles', '--pretty', 'false'], consumer)
|
||||
fs.writeFileSync(path.join(output, 'types.log'), typeLog)
|
||||
const files = typeLog.trim().split(/\r?\n/)
|
||||
for (const file of files) {
|
||||
const actual = fs.realpathSync(file)
|
||||
assert(actual.startsWith(fs.realpathSync(consumer) + path.sep), `Types escaped consumer: ${actual}`)
|
||||
}
|
||||
report.types = { vueTsc: manifest.devDependencies['vue-tsc'], typescript: manifest.devDependencies.typescript, vue: dependencies.vue, files: files.length, isolated: true, negativeCases: 3 }
|
||||
for (const mode of ['development', 'production']) {
|
||||
const outDir = path.join(output, mode)
|
||||
await build({ root: consumer, configFile: false, plugins: [vue({ isProduction: mode === 'production' })], mode, define: { 'process.env.NODE_ENV': JSON.stringify(mode) }, build: { outDir, emptyOutDir: false, rollupOptions: { input: { app: path.join(consumer, 'index.html'), harness: path.join(consumer, 'harness.html'), legacy: path.join(consumer, 'legacy.html') } } } })
|
||||
const server = await preview({ root: consumer, configFile: false, build: { outDir }, preview: { host: '127.0.0.1', port: 0, open: false } })
|
||||
const base = `http://127.0.0.1:${server.httpServer.address().port}`
|
||||
try {
|
||||
for (const [engine, type] of Object.entries({ chromium, firefox, webkit })) {
|
||||
const browser = await type.launch()
|
||||
const row = { mode, engine, version: browser.version(), checks: [], passed: false }
|
||||
report.browsers.push(row)
|
||||
const page = await browser.newPage()
|
||||
await page.addInitScript(() => {
|
||||
const active = new Set()
|
||||
let created = 0
|
||||
const NativeWorker = window.Worker
|
||||
window.Worker = class extends NativeWorker {
|
||||
constructor(url, options) {
|
||||
super(url, options)
|
||||
active.add(this)
|
||||
created++
|
||||
}
|
||||
|
||||
terminate() {
|
||||
active.delete(this)
|
||||
return super.terminate()
|
||||
}
|
||||
}
|
||||
window.workerProbe = () => ({ active: active.size, created })
|
||||
})
|
||||
const unexpected = []
|
||||
page.on('pageerror', error => unexpected.push(error.message))
|
||||
await page.route('https://artplayer.org/assets/sample/danmuku.xml', route => route.fulfill({ contentType: 'text/xml', body: '<i><d p="0,1,25,16777215,0,0,0,0">Vue lifecycle sample</d></i>' }))
|
||||
await page.route('https://artplayer.org/assets/sample/video.mp4', route => route.fulfill({ contentType: 'video/mp4', body: media }))
|
||||
const snapshot = () => page.evaluate(() => window.vueProbe.snapshot())
|
||||
const act = action => page.evaluate(action => window.vueProbe.act(action), action)
|
||||
const wait = async (count) => {
|
||||
await page.waitForFunction(count => window.vueProbe?.snapshot().active === count, count)
|
||||
return snapshot()
|
||||
}
|
||||
try {
|
||||
await page.goto(`${base}/harness.html`)
|
||||
let state = await wait(1)
|
||||
assert.equal(state.created, 1)
|
||||
assert.equal(state.componentRef, true)
|
||||
assert.equal(state.exposesArt, false)
|
||||
assert.equal(await page.locator('.primary-player').count(), 1)
|
||||
await page.waitForFunction(() => window.vueProbe.current().plugins.artplayerPluginDanmuku && window.vueProbe.current().plugins.artplayerPluginDocumentPip)
|
||||
row.checks.push('mount-event-closed-component-ref-and-plugin-registration')
|
||||
const id = await page.evaluate(() => window.vueProbe.current().id)
|
||||
for (const mode of ['mutate', 'replace']) {
|
||||
await act(mode)
|
||||
assert.equal((await snapshot()).created, 1)
|
||||
assert.equal(await page.evaluate(() => window.vueProbe.current().id), id)
|
||||
assert.equal(await page.evaluate(() => window.vueProbe.current().option.url), '/pattern.mp4')
|
||||
row.checks.push(`${mode}-option-retains-mount-only-contract`)
|
||||
}
|
||||
await page.waitForFunction(() => window.vueProbe.current().duration > 1)
|
||||
await page.evaluate(() => window.vueProbe.current().play())
|
||||
await page.waitForFunction(() => window.vueProbe.current().currentTime > 0.15)
|
||||
row.media = await page.evaluate(async () => {
|
||||
const art = window.vueProbe.current()
|
||||
art.pause()
|
||||
const time = art.currentTime
|
||||
const seeked = new Promise(resolve => art.video.addEventListener('seeked', resolve, { once: true }))
|
||||
art.currentTime = 1
|
||||
await seeked
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 8
|
||||
canvas.height = 8
|
||||
const context = canvas.getContext('2d')
|
||||
context.drawImage(art.video, 0, 0, 8, 8)
|
||||
return { time, paused: art.playing === false, seek: art.currentTime, width: art.video.videoWidth, rgba: Array.from(context.getImageData(0, 0, 8, 8).data) }
|
||||
})
|
||||
assert(row.media.time > 0.15 && row.media.paused && Math.abs(row.media.seek - 1) < 0.1 && row.media.width > 0)
|
||||
assert(row.media.rgba.some((value, index) => index % 4 !== 3 && value > 50))
|
||||
row.checks.push('native-play-pause-seek-and-decoded-pixels')
|
||||
await page.evaluate(() => window.vueProbe.current().switchUrl('/pattern.mp4?api'))
|
||||
assert.equal(await page.evaluate(() => window.vueProbe.current().id), id)
|
||||
assert((await page.evaluate(() => window.vueProbe.current().url)).includes('?api'))
|
||||
row.checks.push('instance-api-updates-source-without-remount')
|
||||
await act('key')
|
||||
state = await wait(1)
|
||||
assert.equal(state.created, 2)
|
||||
assert.equal(state.records[0].destroyed, 1)
|
||||
assert.equal(await page.evaluate(() => window.vueProbe.current().option.url), '/pattern.mp4?replacement')
|
||||
row.checks.push('key-change-reconstructs-with-current-props')
|
||||
const retainedId = await page.evaluate(() => window.vueProbe.current().id)
|
||||
await act('siblings')
|
||||
await wait(2)
|
||||
await act('remove-sibling')
|
||||
await wait(1)
|
||||
assert.equal(await page.evaluate(() => window.vueProbe.current().id), retainedId)
|
||||
row.checks.push('sibling-cleanup-retains-other-instance')
|
||||
await act('bare')
|
||||
await wait(1)
|
||||
await act('remove-bare')
|
||||
state = await wait(0)
|
||||
assert.equal(state.players, 0)
|
||||
row.checks.push('optional-listener-and-unmount')
|
||||
const prior = state.created
|
||||
await act('cancel')
|
||||
assert.equal((await snapshot()).created, prior)
|
||||
row.checks.push('cancel-before-mount-does-not-allocate')
|
||||
for (let index = 0; index < 3; index++) {
|
||||
await act('show')
|
||||
await wait(1)
|
||||
await act('hide')
|
||||
state = await wait(0)
|
||||
assert.equal(state.players, 0)
|
||||
assert(state.records.every(record => record.isDestroy && record.destroyed === 1))
|
||||
assert.equal((await page.evaluate(() => window.workerProbe())).active, 0)
|
||||
}
|
||||
row.checks.push('three-mount-unmount-cycles-clean-once')
|
||||
await act('throw')
|
||||
state = await wait(1)
|
||||
assert.equal(state.errors.length, 1)
|
||||
assert.equal(state.errors[0].same, true)
|
||||
await act('hide')
|
||||
await wait(0)
|
||||
row.checks.push('vue-captured-listener-error-retains-instance-until-unmount')
|
||||
await act('unmount')
|
||||
await wait(0)
|
||||
await act('normal')
|
||||
row.checks.push('listener-triggered-unmount-cleans-created-instance')
|
||||
await act('cache')
|
||||
await wait(1)
|
||||
const cachedId = await page.evaluate(() => window.vueProbe.current().id)
|
||||
await act('deactivate')
|
||||
state = await wait(1)
|
||||
assert.equal(state.players, 0)
|
||||
await act('activate')
|
||||
assert.equal(await page.evaluate(() => window.vueProbe.current().id), cachedId)
|
||||
await page.evaluate(() => window.unmountVue())
|
||||
state = await wait(0)
|
||||
assert.equal(state.videos, 0)
|
||||
assert(state.records.every(record => record.isDestroy && record.destroyed === 1))
|
||||
row.final = state
|
||||
row.workers = await page.evaluate(() => window.workerProbe())
|
||||
assert(row.workers.created > 0)
|
||||
assert.equal(row.workers.active, 0)
|
||||
row.checks.push('keepalive-retains-on-deactivation-and-destroys-on-owner-unmount')
|
||||
assert.deepEqual(unexpected, [])
|
||||
await page.goto(`${base}/legacy.html`)
|
||||
await page.waitForFunction(() => Boolean(document.body.dataset.instance))
|
||||
assert.equal(await page.locator('#legacy-player').getAttribute('data-consumer'), 'preserved')
|
||||
await page.locator('#toggle').click()
|
||||
await page.waitForFunction(() => document.body.dataset.destroyed === 'yes')
|
||||
assert.equal(await page.locator('.art-video-player').count(), 0)
|
||||
await page.locator('#toggle').click()
|
||||
await page.locator('.art-video-player').waitFor()
|
||||
row.checks.push('plain-js-event-prop-usage-and-remount')
|
||||
await page.goto(base)
|
||||
await page.locator('.art-video-player').waitFor()
|
||||
await page.waitForFunction(() => document.querySelector('video')?.readyState >= 2)
|
||||
row.checks.push('original-app-entry-loads-media')
|
||||
assert.deepEqual(unexpected, [])
|
||||
row.passed = true
|
||||
}
|
||||
catch (error) {
|
||||
row.error = error.stack
|
||||
await page.screenshot({ path: path.join(output, `${mode}-${engine}-failure.png`) })
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
await browser.close()
|
||||
writeJson(path.join(output, 'report.json'), report)
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
await new Promise(resolve => server.httpServer.close(resolve))
|
||||
}
|
||||
}
|
||||
report.passed = true
|
||||
}
|
||||
catch (error) {
|
||||
report.error = error.stack
|
||||
fs.writeFileSync(path.join(output, 'failure.log'), `${error.stack}\n${error.stdout || ''}\n${error.stderr || ''}`)
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
writeJson(path.join(output, 'report.json'), report)
|
||||
removeConsumer(consumer)
|
||||
}
|
||||
console.log('Vue installed consumer types and six native browser profiles passed.')
|
||||
+8
-3
@@ -4228,17 +4228,22 @@
|
||||
"phase": "6 文档与消费者",
|
||||
"title": "验证 Vue 消费者与更新卸载",
|
||||
"scope": [
|
||||
"example/vue.js"
|
||||
"example/vue.js",
|
||||
"workspace"
|
||||
],
|
||||
"dependsOn": [
|
||||
"CORE-22",
|
||||
"ENG-07"
|
||||
],
|
||||
"status": "todo",
|
||||
"status": "done",
|
||||
"risk": "M",
|
||||
"deliverable": "Vue 实例/ref、参数更新、卸载及插件样例",
|
||||
"acceptance": "旧 JS 组件用法无需修改,重复挂载不泄漏",
|
||||
"evidence": []
|
||||
"evidence": [
|
||||
"changes/2026-09-14-EX-02-vue-consumer.md",
|
||||
"baselines/vue-consumer-validation.json",
|
||||
"scripts/vue-consumer.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "EX-03",
|
||||
|
||||
Reference in new issue
Block a user