- 前端
【免费下载链接】pts
A library for visualization and creative-coding
导读
本文围绕 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.
也就是说,每个夹具都必须满足两条硬性约束:
- 安装来源是 npm tarball:测试的是"发布出去的包",而不是源码检出目录;
- 验证真实 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 这样设计的原因
从计划原文可以提炼出三点动机:
- 依赖隔离:框架专用依赖(React、Vue、Vite、
skia-canvas等)不会进入根 pnpm 依赖图,也就不会污染发布包; - 路径演练:npm workspace 本身就是 npm 消费者的安装路径,用它来演练真实安装过程;
- 可复制性:
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:
npm pack --silent --pack-destination <tmp>生成全新 tarball;tar --extract --gzip --strip-components=1解压到临时 Pts 包根目录——该目录只包含 npm 实际发布的内容;- 把
test/integrations复制到解压根下的test/integrations,保留提交的file:../../..相对关系; - 对嵌套 lockfile 执行
npm ci,此时本地 Pts 链接指向解压出来的包而非源码检出; - 断言安装的 Pts 真实路径落在临时包根内,且 Node ESM 解析到
dist/index.mjs; - 从该临时、锁定的安装环境构建并运行全部夹具;
- 即使测试失败,也通过
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 + TS | ESM 条件导出与类型声明 | 生产构建成功;真实 Chromium 挂载 React;CanvasSpace进入 ready、绘制像素、运行动画并在组件卸载时销毁;Pt行为可见于页面状态;无浏览器/页面错误 |
| Vue + Vite + TS | ESM 条件导出与类型声明 | 生产构建成功;真实 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 还启用了 React
StrictMode,让双挂载效应天然暴露泄漏。
构建命令为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端口(操作系统分配临时端口,天然避免端口冲突),并做两件关键事:
- 路径逃逸防护:解析请求路径后断言其落在 fixture 根目录内,否则拒绝(
request escaped the fixture root); - 路由覆盖:vanilla 夹具通过
routesMap 把/pts/pts.min.js直接映射到临时包根的dist/pts.min.js,保证服务的是"已发布包里的 IIFE 产物"而不是源码编译产物。
7.3 浏览器断言流程(Playwright)
testBrowserFixture是浏览器端通用断言函数,流程如下:
- 错误监听:收集
console.error、pageerror、失败的本地请求(requestfailed)与非 2xx 响应(response),最后断言错误列表为空; - ready 轮询:
waitForFunction在 10 秒超时内等待三个条件同时成立——#status的data-environment匹配、data-point === "Pt(4, 5)"、canvas.dataset.frames > 0。这里采用"轮询应用自有 ready 标记 + 有界超时",而非精确帧数计数,就是为了规避动画时序抖动(gap 第 7 条); - 像素级验证:
getImageData(160, 90, 1, 1)读取画布中心像素,断言 RGBA 接近 Pts 绘制色#f97316(R>150、G>50、B<100、A=255)——"画了动画帧但实际没画出来"的假阳性由此堵死(gap 第 17 条); - vanilla 专属断言:
typeof globalThis.Pts === "object",验证 IIFE 暴露了浏览器全局; - 生命周期测试:点击
#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 个潜在漏洞,这里按主题归并:
打包与解析真实性
- workspace 符号链接可能掩盖发布失败 → 用临时 workspace 中的打包安装替换;
- 测试可能意外用到 registry 或源码检出版本 → 在本地依赖目标处解压 tarball、比较真实路径、检查
pts/package.json、断言解析出的模块路径; - 临时安装可能绕过 lockfile 却改变 Pts 路径 → 保留本地依赖 spec 与目录关系,在目标处解压并用
npm ci原样安装; - Skia 不是 DOM 实现 → 用标准兼容 2D context 测
CanvasForm,不构造需要 DOM 的CanvasSpace; - 原生依赖可能与 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:node10.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
相关推荐
AssetRipper 免费 Unity 资产提取工具:十分钟把游戏文件还原成能打开的工程
AssetRipper 免费 Unity 资产提取工具:十分钟把游戏文件还原成能打开的工程 AssetRipper 是一款免费开源的 Unity 资产提取工具,
开发工具逆向工程游戏开发WebdriverIO跨浏览器测试矩阵:自动化测试覆盖与兼容性报告生成
WebdriverIO跨浏览器测试矩阵:自动化测试覆盖与兼容性报告生成 一、测试矩阵构建痛点与解决方案 1.1 企业级测试的三大核心挑战 碎片化环境 :全球浏览
测试质量保障NProgress 测试与发布全流程:从 npm 测试、浏览器手动验证到 Component 构建与版本发布
NProgress 测试与发布全流程:从 npm 测试、浏览器手动验证到 Component 构建与版本发布 本篇指南以 NProgress 仓库的 Notes
前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考