ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Pts 消费者集成测试实战:从 npm 包到 React、Vue、Node 与原生浏览器的全矩阵验证

Pts 消费者集成测试实战:从 npm 包到 React、Vue、Node 与原生浏览器的全矩阵验证
  • 前端

【免费下载链接】pts

A library for visualization and creative-coding

项目地址:https://gitcode.com/gh_mirrors/pt/pts
点击查看免费下载

导读

本文围绕 Pts 仓库的 plans/INTEGRATION-TEST-PLAN.md 集成测试方案,系统讲解如何为发布产物设计"消费者视角"的兼容性测试:以打包后的 npm tarball 为唯一被测对象,在 React + Vite、Vue + Vite、Node +skia-canvas与单文件原生 HTML 四种环境中验证 Pts 的真实行为,而非仅仅证明框架能渲染一个标题。读完本文,你将掌握集成测试的目录结构设计、打包隔离模型、环境矩阵与断言策略、Runner 编排细节,以及从这套测试中实际发现并修复的CanvasSpace.dispose()缺陷。

一、为什么要做"消费者集成测试"

1.1 目标定位:兼容性夹具,而非产品示例

集成测试计划的 Objective 写得非常明确:

These are compatibility fixtures, not product examples. Each must verify real Pts behavior after installation from an npm tarball, not merely prove that its framework can render a heading.

也就是说,每个夹具都必须满足两条硬性约束:

  1. 安装来源是 npm tarball:测试的是"发布出去的包",而不是源码检出目录;
  2. 验证真实 Pts 行为:框架页面里必须真正构造CanvasSpace、真正用 Pts 的表单(Form)绘制内容,而不只是import一下再渲染一个标题。

1.2 与既有质量门禁的分工

计划的 Package isolation model 一节明确指出,集成测试不与现有包级检查重复:

The existing package audit remains responsible for the exact published-file allowlist, CJS, NodeNext, and ATTW/publint. These integration tests add consumer breadth rather than duplicating every package test.

即:

  • 已发布的文件白名单、CJS 兼容、NodeNext 解析、类型声明一致性(ATTW/publint)由包审计负责(对应 scripts/check-artifacts.mjs 中expectedFiles与expectedExports的硬编码清单);
  • 集成测试专司"消费者广度":不同框架、打包器、模块系统、运行时环境能否正确消费这个包。

从根 package.json 的exports字段可以看到 Pts 发布的包契约:

  • import条件指向./dist/index.mjs(类型./dist/index.d.mts);
  • require条件指向./dist/index.js(类型./dist/index.d.ts);
  • unpkg与jsdelivr指向 IIFE 产物./dist/pts.min.js。

集成测试矩阵中的四个夹具,恰好分别覆盖这张契约的四条路径。

二、包管理器决策:根仓库 pnpm + 嵌套私有 npm workspace

2.1 决策内容

计划规定:

  • 根仓库的 Pts 开发流程使用 pnpm(根 package.json 中packageManager: "pnpm@11.21.0");
  • 在test/integrations下维护一个嵌套的、私有的 npm workspace,自带package.json与package-lock.json;
  • 根 Pts 包不转成 workspace 或 monorepo,夹具 workspace 仅作为测试基础设施存在。

2.2 这样设计的原因

从计划原文可以提炼出三点动机:

  1. 依赖隔离:框架专用依赖(React、Vue、Vite、skia-canvas等)不会进入根 pnpm 依赖图,也就不会污染发布包;
  2. 路径演练:npm workspace 本身就是 npm 消费者的安装路径,用它来演练真实安装过程;
  3. 可复制性:package-lock.json被提交进仓库,保证npm ci在 CI 与本地得到一致结果。

看 test/integrations/package.json 的实际内容可以印证:workspace 名为@pts-integrations/workspace、private: true,声明了react-vite、vue-vite、skia-canvas三个子 workspace(vanilla 是纯静态 HTML,不参与 npm workspace),并固定packageManager: "npm@11.16.0"。

2.3 allowScripts:原生依赖的显式授权

test/integrations/package.json 中有一个非常值得注意的字段:

"allowScripts": { "fsevents@2.3.3": true, "skia-canvas@3.0.8": true }

这是实现结果中提到的"explicitly reviewed and version-pinned in npm'sallowScriptspolicy":skia-canvas需要安装脚本编译原生绑定,Vite 的fsevents是 macOS 可选依赖,两者的 install script 都被精确钉版本后显式放行。这种做法避免"安装脚本静默失败"或"依赖悄悄更换版本引入新脚本",属于对原生依赖的供应链管控。

三、目录结构规划

计划给出的目标结构(见 test/integrations 目录)为:

test/integrations/ ├── README.md ├── package.json ├── package-lock.json ├── react-vite/ │ ├── index.html │ ├── package.json │ ├── tsconfig files │ ├── vite.config.ts │ └── src/ ├── vue-vite/ │ ├── index.html │ ├── package.json │ ├── tsconfig.json │ ├── vite.config.ts │ └── src/ ├── skia-canvas/ │ ├── package.json │ └── render.mjs └── vanilla/ └── index.html

关键约束:

  • vanilla 必须保持单一 HTML 文件:内联 CSS、内联 JavaScript,用经典<script>引用已安装的pts.min.js,全程不被 Vite 转换;
  • 应用逻辑留在各自夹具内,Runner 只负责编排;新增环境通常意味着新增一个夹具目录 + Runner 里一行矩阵条目,而不是把源代码以字符串形式嵌入 Runner。

test/integrations/README.md 把"如何新增环境"固化为六条准则:精确钉依赖版本、通过file:../../..依赖 Pts、覆盖独特的包/运行时表面、提供确定性的构建或 Node 测试命令、浏览器夹具暴露可断言的 ready 标记/帧数/像素/销毁状态、输出只落在dist或临时目录。

四、打包隔离模型:只测"发布出去的东西"

4.1 开发态与测试态的两条依赖路径

  • 开发态:提交的 workspace manifest 用file:../../..指向 Pts,便于维护者在本地直接针对当前仓库跑夹具(test/integrations/README.md 中的npm ci && npm run build && npm run test:node);
  • 测试态:自动化 Runner 采用严格路径,完整流程见 scripts/check-integrations.mjs:
  1. npm pack --silent --pack-destination <tmp>生成全新 tarball;
  2. tar --extract --gzip --strip-components=1解压到临时 Pts 包根目录——该目录只包含 npm 实际发布的内容;
  3. 把test/integrations复制到解压根下的test/integrations,保留提交的file:../../..相对关系;
  4. 对嵌套 lockfile 执行npm ci,此时本地 Pts 链接指向解压出来的包而非源码检出;
  5. 断言安装的 Pts 真实路径落在临时包根内,且 Node ESM 解析到dist/index.mjs;
  6. 从该临时、锁定的安装环境构建并运行全部夹具;
  7. 即使测试失败,也通过try/finally清理临时 workspace、HTTP 服务器与浏览器进程。

4.2 防"假测试"的三重断言

Runner 用三个断言堵死"测试意外用到 registry 版本或源码检出版本"的漏洞(对应计划 gap 第 13 条):

assert.equal(await realpath(installedPts), await realpath(extractedRoot)); const installedManifest = JSON.parse( await readFile(path.join(installedPts, "package.json"), "utf8"), ); assert.equal(installedManifest.name, "pts"); // Node ESM 解析结果必须等于 临时包根的 dist/index.mjs assert.equal( resolvedPts, pathToFileURL(await realpath(path.join(extractedRoot, "dist", "index.mjs"))).href, ); assert.ok(!resolvedPts.includes(pathToFileURL(projectRoot).href));
  • realpath比较:workspace 链接的 pts 目录真实路径 === 解压出来的包根;
  • manifest 校验:确认安装的是名为pts的包;
  • ESM 解析校验:import.meta.resolve("pts")的结果必须指向临时包根的dist/index.mjs,且不包含源码检出的路径。

完成这一组断言后,Runner 依次执行react-vite与vue-vite的npm run build --workspace、skia-canvas的npm test --workspace,再启动 Playwright Chromium 做浏览器断言。

五、环境矩阵与断言设计

5.1 矩阵总览

计划中的矩阵表定义了四个夹具及其"消费者表面"与"必需断言":

夹具消费者表面必需断言
React + Vite + TSESM 条件导出与类型声明生产构建成功;真实 Chromium 挂载 React;CanvasSpace进入 ready、绘制像素、运行动画并在组件卸载时销毁;Pt行为可见于页面状态;无浏览器/页面错误
Vue + Vite + TSESM 条件导出与类型声明生产构建成功;真实 Chromium 挂载 Vue Composition API 组件;同样的像素与显式卸载/销毁生命周期通过;无浏览器/页面错误
skia-canvas+ Node ESM非 DOM Node canvas 实现中的 ESM 导入CanvasForm接受 Skia 2D context;几何与绘制调用完成;PNG 字节有合法签名且非平凡大小;不提交输出图片
Vanilla 单 HTML发布的压缩 IIFE 与globalThis.Pts静态服务器原样服务 HTML 与已安装的pts.min.js;Chromium 观察到全局对象,创建/绘制/动画/销毁CanvasSpace,暴露确定性的通过状态

React 与 Vue 使用 create-vite 风格的 TypeScript 脚手架和官方 Vite 插件,并且刻意使用生产构建——测试的是优化后的 ESM 消费路径,而不是开发服务器的转换路径。

5.2 版本钉定(Implementation outcome)

实现于 2026-08-10,消费者版本全部精确固定:

  • React 19.2.8、React DOM 19.2.8、@vitejs/plugin-react6.0.5;
  • Vue 3.5.41、@vitejs/plugin-vue6.0.8、vue-tsc3.3.9;
  • 两个框架夹具统一 Vite 8.2.1、TypeScript 5.9.3。TypeScript 6/7没有强行推入 Vue 夹具,因为当前vue-tsc版本仍解析 legacy compiler 子路径,而新 TS 版本会封死该子路径;
  • skia-canvas3.0.8,其 install script 与 Vite 可选的fseventsscript 在 npmallowScripts策略中显式审查并钉版本。

各 manifest 印证了钉定细节:react-vite/package.json、vue-vite/package.json、skia-canvas/package.json。

六、夹具实战解析

6.1 React + Vite + TypeScript:useEffect 生命周期内建/拆 Pts

react-vite/src/App.tsx 展示了标准用法:

useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; let active = true; let frames = 0; const space = new CanvasSpace(canvas, () => { if (!active) return; const form = space.getForm(); space.add({ animate: () => { frames += 1; canvas.dataset.frames = String(frames); form.fillOnly("#f97316").point([160, 90], 14, "circle"); }, }); space.play(); }).setup({ bgcolor: "#0f172a", resize: false, retina: false }); return () => { active = false; space.stop(); space.dispose(); setTimeout(() => onDisposed(!space.isPlaying), 50); }; }, [onDisposed]);

要点:

  • 回调式初始化:CanvasSpace的第二个参数是 canvas 挂载就绪后的回调,内部再space.add(...)与space.play();
  • 显式清理:effect cleanup 中先stop()再dispose(),并通过setTimeout回报!space.isPlaying,让"销毁是否生效"成为可断言的状态;
  • 应用状态可见性:App用new Pt(1, 2).add(3).toString()得到"Pt(4, 5)"写入#status的data-point,从而在页面 DOM 中验证 Pt 运算行为;
  • 二次挂载:toggleCanvas驱动mounted状态,实现"卸载→重挂载→再卸载"的完整生命周期验证;
  • 入口 react-vite/src/main.tsx 还启用了 ReactStrictMode,让双挂载效应天然暴露泄漏。

构建命令为tsc --noEmit && vite build,vite 配置仅注册官方 react 插件(react-vite/vite.config.ts)。

6.2 Vue + Vite + TypeScript:onMounted / onBeforeUnmount 生命周期

vue-vite/src/PtsCanvas.vue 采用 Composition API:

onMounted(() => { if (!canvas.value) return; const element = canvas.value; let frames = 0; space = new CanvasSpace(element, () => { if (!active || !space) return; const form = space.getForm(); space.add({ animate: () => { frames += 1; element.dataset.frames = String(frames); form.fillOnly("#f97316").point([160, 90], 14, "circle"); }, }); space.play(); }).setup({ bgcolor: "#0f172a", resize: false, retina: false }); }); onBeforeUnmount(() => { active = false; if (!space) return; const disposedSpace = space; disposedSpace.stop(); disposedSpace.dispose(); setTimeout(() => reportDisposed(!disposedSpace.isPlaying), 50); });

App.vue通过v-if="mounted"控制组件挂载,data-disposed记录销毁状态。构建命令vue-tsc --noEmit && vite build保证类型检查与生产构建双通过(vue-vite/package.json、vue-vite/vite.config.ts)。

6.3 skia-canvas + Node ESM:无 DOM 的服务端绘制

skia-canvas/render.mjs 是纯 Node ESM 测试脚本,核心在于"Skia 不是 DOM 实现,不能构造CanvasSpace",而是直接测试CanvasForm与标准兼容的 2D context:

const canvas = new Canvas(320, 180); const context = canvas.getContext("2d"); const form = new CanvasForm(context); assert.equal(form.ready, true); form.fillOnly("#f97316").point(new Pt(160, 90), 14, "circle"); form.strokeOnly("#38bdf8", 4).line([[20, 20], [300, 160]]); form.fillOnly("#a3e635").circle(Circle.fromCenter([60, 120], 18)); assert.equal(new Pt(1, 2).add(3).toString(), "Pt(4, 5)"); const png = await canvas.png; assert.ok(png.length > 1_000, `PNG output is too small: ${png.length} bytes`); assert.deepEqual([...png.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]);

断言层面验证了:CanvasForm接受 Skia context(form.ready === true)、几何运算正确(Pt(4, 5))、PNG 输出非平凡大小且文件头为合法 PNG 签名(89 50 4E 47 0D 0A 1A 0A)。同时计划明确"no output image is committed"——渲染产物只存在于内存,不污染仓库。

6.4 Vanilla 单文件:验证 IIFE 产物与浏览器全局

vanilla/index.html 是唯一不经过任何打包器转换的夹具,结构为单 HTML:内联 CSS、<script src="/pts/pts.min.js">经典脚本引用、内联业务脚本。业务脚本通过globalThis.Pts命名空间使用库:

status.dataset.point = new Pts.Pt(1, 2).add(3).toString(); const space = new Pts.CanvasSpace(canvas, () => { const form = space.getForm(); space.add({ animate: () => { frames += 1; canvas.dataset.frames = String(frames); form.fillOnly("#f97316").point([160, 90], 14, "circle"); }, }); space.play(); }).setup({ bgcolor: "#0f172a", resize: false, retina: false }); document.querySelector("#toggle").addEventListener("click", () => { space.stop(); space.dispose(); setTimeout(() => { status.dataset.disposed = String( !space.isPlaying && Object.keys(space.players).length === 0, ); }, 50); });

销毁状态在此处是!space.isPlaying && Object.keys(space.players).length === 0,比框架夹具多验证了"players 列表已清空"这一层。

七、Runner 编排设计

7.1 职责边界:只做编排,不做应用逻辑

计划指定一个根脚本 scripts/check-integrations.mjs 专司编排,职责清单:

  • pack、解压并准备隔离 workspace;
  • 用提交的 lockfile 安装精确的夹具依赖;
  • 断言 tarball 模块解析;
  • 调用各夹具自己的 build/test 脚本;
  • 用进程内 HTTP 服务器在临时端口服务构建产物/静态文件;
  • 复用根仓库已有的 Playwright 安装做浏览器断言;
  • 收集页面错误与失败的网络响应;
  • 用try/finally保证清理。

7.2 内置静态服务器

startServer基于node:http实现,以127.0.0.1监听0端口(操作系统分配临时端口,天然避免端口冲突),并做两件关键事:

  1. 路径逃逸防护:解析请求路径后断言其落在 fixture 根目录内,否则拒绝(request escaped the fixture root);
  2. 路由覆盖:vanilla 夹具通过routesMap 把/pts/pts.min.js直接映射到临时包根的dist/pts.min.js,保证服务的是"已发布包里的 IIFE 产物"而不是源码编译产物。

7.3 浏览器断言流程(Playwright)

testBrowserFixture是浏览器端通用断言函数,流程如下:

  1. 错误监听:收集console.error、pageerror、失败的本地请求(requestfailed)与非 2xx 响应(response),最后断言错误列表为空;
  2. ready 轮询:waitForFunction在 10 秒超时内等待三个条件同时成立——#status的data-environment匹配、data-point === "Pt(4, 5)"、canvas.dataset.frames > 0。这里采用"轮询应用自有 ready 标记 + 有界超时",而非精确帧数计数,就是为了规避动画时序抖动(gap 第 7 条);
  3. 像素级验证:getImageData(160, 90, 1, 1)读取画布中心像素,断言 RGBA 接近 Pts 绘制色#f97316(R>150、G>50、B<100、A=255)——"画了动画帧但实际没画出来"的假阳性由此堵死(gap 第 17 条);
  4. vanilla 专属断言:typeof globalThis.Pts === "object",验证 IIFE 暴露了浏览器全局;
  5. 生命周期测试:点击#toggle触发条件卸载,等待data-disposed === "true";对 React/Vue(removesCanvas: true)额外断言[data-pts-canvas]数量归零,再点击#toggle重挂载,验证重新绘制像素,然后再次卸载;最后assert.deepEqual(errors, [])。

这套流程对应了计划中"React 和 Vue 额外完成第二次 mount/draw/unmount 循环,捕获泄漏的动画或 DOM 状态"的实现结果。

7.4 根命令暴露

根 package.json 提供两条命令:

  • test:integrations:node scripts/check-integrations.mjs,跑完整的打包矩阵;
  • check:在常规 build/package 门禁之后串联pnpm test:integrations。

Runner 从 lockfile 安装临时嵌套 workspace,因此在根仓库安装完成后,pnpm test:integrations是自包含的。CI 在固定的主 Node 版本上运行它;既有的 Node 20/22/24 运行时矩阵继续专注于轻量的发布 ESM/CJS 运行时检查,框架工具链不在三个 job 上重复铺开。

八、Plan Gap Review:18 个已知风险与对策

计划在实现前系统性地排查了 18 个潜在漏洞,这里按主题归并:

打包与解析真实性

  1. workspace 符号链接可能掩盖发布失败 → 用临时 workspace 中的打包安装替换;
  2. 测试可能意外用到 registry 或源码检出版本 → 在本地依赖目标处解压 tarball、比较真实路径、检查pts/package.json、断言解析出的模块路径;
  3. 临时安装可能绕过 lockfile 却改变 Pts 路径 → 保留本地依赖 spec 与目录关系,在目标处解压并用npm ci原样安装;
  4. Skia 不是 DOM 实现 → 用标准兼容 2D context 测CanvasForm,不构造需要 DOM 的CanvasSpace;
  5. 原生依赖可能与 JS 依赖以不同方式失败 → 钉住skia-canvas、放行其受支持的安装机制、失败显式化,CI 不静默跳过。

构建 ≠ 运行时2. 成功的 Vite 构建不能证明运行时行为 → React/Vue 构建必须在 Chromium 中跑CanvasSpace生命周期与绘制; 17. canvas API 可能运行了却没画出东西 → 动画后读取已知像素而非只看帧数; 3. 框架代码可能只测了 Pts 几何 → 两个组件必须构造真实 Pts canvas 并使用 Pts form。

vanilla 与打包器边界4. Vite 服务的 vanilla 文件不是真 vanilla → 无转换地服务单 HTML,经典 script URL 直接映射到已安装包。

测试稳定性与泄漏7. 动画时序导致 flaky → 有界超时轮询应用自有 ready 标记,断言至少一帧而非精确帧数; 11. 夹具输出污染仓库 → 只在临时副本中构建,并防御性扩展 ignores; 12. 端口与浏览器进程在失败后泄漏 → 临时端口,finally中关闭每个 server/page/browser。

生命周期与错误可见性8. React cleanup 被生产环境专属行为隐藏 → 从应用暴露 cleanup/销毁状态,测试显式 unmount/remount 生命周期; 9. 浏览器控制台错误易漏 →pageerror、意外 console 错误、失败本地资源请求全部 fail; 18. cleanup 断言可能在无框架生命周期参与下通过 → 从浏览器测试触发条件组件移除,并从 React/Vue 的 cleanup hook 暴露销毁状态; 10. 嵌套夹具依赖泄漏进 npm 发布 → 置于根依赖图之外,保留精确的打包文件白名单检查。

演进与依赖管理14. 未来夹具沦为复制粘贴示例 → 文档化新增夹具的最低 package/build/runtime/cleanup 断言要求; 15. 依赖更新悄悄改变矩阵 → 精确钉定工具/框架版本并提交嵌套 lockfile。

九、真实产出:发现的 Pts 缺陷与修复

Implementation outcome 记录了这套测试最重要的价值——抓到一个真实的 Pts bug:

The suite found one real Pts defect:CanvasSpace.dispose()assumed that aResizeObserverexisted even whenresize: false. The implementation now guards that optional resource, and all three browser fixtures exercise the fixed path.

即CanvasSpace.dispose()在resize: false时仍假定ResizeObserver存在并尝试断开它。查看 src/Canvas.ts 的修复后实现可以确认:

dispose(): this { if (this._disposed) return this; this._disposed = true; if (this._readyTimer !== undefined) { window.clearTimeout(this._readyTimer); this._readyTimer = undefined; } if (this._readyObserver) { this._readyObserver.disconnect(); this._readyObserver = undefined; } if (this._resizeObserver) { this._resizeObserver.disconnect(); this._resizeObserver = undefined; } this._unbindAll(); this._cancelAnimation(); this.removeAll(); this._isReady = false; if (this._ownsCanvas) this._canvas.remove(); if (this._ownsContainer && this._container.childNodes.length === 0) this._container.remove(); return this; }

修复要点:

  • _disposed标志保证 dispose 幂等(重复调用直接返回);
  • _resizeObserver现在被可选守卫(if (this._resizeObserver))包裹,与_readyObserver同样处理;
  • 这与 src/Canvas.ts 中autoResizesetter 的逻辑一致:只有auto为真时才创建ResizeObserver(this._resizeObserver = new ResizeObserver(...)),因此 dispose 时必须容忍它不存在。

三个浏览器夹具(React、Vue、vanilla)的setup({ resize: false })全部覆盖了这条修复路径,这也是矩阵设计"每个夹具都要真实走 Pts 生命周期"的回报。

十、运行方式与退出标准

10.1 如何运行

从仓库根目录运行完整打包矩阵测试:

pnpm test:integrations

或作为完整质量门禁的一部分(构建、包检查、发布检查之后执行):

pnpm check

本地针对当前源码检出做夹具开发调试,进入 test/integrations 目录后:

npm ci npm run build npm run test:node

10.2 退出标准(全部满足)

  • 四个夹具已提交、可读,且能通过一条根命令运行;
  • React 与 Vue 通过 typecheck、build,并从 tarball 通过真实浏览器 Pts 生命周期测试;
  • skia-canvas在 Node 中渲染 Pts 输出并校验生成 PNG;
  • Vanilla 保持单一未转换 HTML 文件,使用 IIFE 产物在 Chromium 中通过;
  • 临时安装解析到精确的打包 Pts 包及其 ESM 入口;
  • 根check与 CI 包含新套件;
  • 重复运行后,除预期的 Pts 现代化改动外,三个仓库保持干净。

结语

Pts 的集成测试计划提供了一个可复用的"消费者验证"范式:用打包产物而非源码做被测对象、用隔离临时 workspace 防假阳性、用像素与生命周期断言防假阴性、用错误收集与 finally 清理保稳定。它的工程价值不仅在于证明"Pts 能在 React/Vue/Node/vanilla 中跑起来",更在于——通过resize: false时CanvasSpace.dispose()的崩溃,证明了只有真正以消费者身份、在真实浏览器里跑真实绘制与销毁,才能发现单元测试难以触及的集成缺陷。对于任何发布 npm 库的团队,这份计划的矩阵划分、gap 审查清单与 Runner 编排思路都值得直接借鉴。

  • 前端

【免费下载链接】pts

A library for visualization and creative-coding

项目地址:https://gitcode.com/gh_mirrors/pt/pts
点击查看免费下载

相关推荐

上一篇:tick-stock-panel价格提醒教程:3步设置高低价突破监控与盘中弹窗
下一篇:treg run --server 远程执行原理:密钥永不到达你机器的完整机制(6层防护拆解)

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进