首页
/ 用组合式 Operation 在真实 Penpot 文档上驱动组件语义测试:Composable Test Suite 插件架构与实战指南

用组合式 Operation 在真实 Penpot 文档上驱动组件语义测试:Composable Test Suite 插件架构与实战指南

2026-09-07 09:13:40作者:姚月梅Lane

导读

Penpot 的组件系统行为细腻而隐蔽——主组件到副本的变更传播、override 与主改的优先级、嵌套与变体切换之间的相互影响,往往只有在真实文档里交互后才能感知。Composable Test Suite 是 Penpot 插件工作区中的一个插件,它把"测试"重新定义为在一份实时 Penpot 文档上、通过公开 Plugin API 逐步操作并进行断言的组合式小程序。读完本文你将掌握:该套件的核心抽象(Situation / Operation / Role)与 choice point 全组合展开机制、如何在插件面板中交互式运行与远程驱动测试、如何以无后端、无头模式在 CI 中运行它,以及如何按照它的设计规范新增一个测试用例。

这套套件要解决什么问题

Penpot 的组件(Component)存在大量微妙的端到端语义。官方文档原文点名的就有四类:

  • overrides——副本上对属性的本地覆盖;
  • 主到副本的传播(propagation from a main to its copies)——修改主组件后副本如何同步;
  • 嵌套(nesting)——组件内再放组件时语义如何叠加;
  • 竞争变更之间的优先级(precedence between competing changes)——同一时刻主改动与副本本地 override 相遇时,谁胜出。

这些行为无法靠阅读源码静态推导来保证回归安全,因为它们最终要体现在真实文档、真实渲染与真实传播的结果上。该套件的定位是:通过与真实集成完全一致的 Plugin API 来驱动这些行为,让测试成为"API 是否把预期的组件语义端到端地暴露出来"的一次真实体检。仓库将其实现为插件目录 plugins/apps/composable-test-suite,测试运行在 Penpot 当前打开的文档上——因此官方文档特别提醒:请在一个临时草稿文件(scratch file)里运行,而不是你珍视内容的文件

核心设计原则:测试是"操作在情境上的组合"

这套套件的灵魂是它的五条设计原则,官方文档指出它们被刻意设计为超越任何具体代码结构而长期存在的原则,理解它们就等于理解整个代码库的组织方式。

1. 测试 = 情境上的操作组合

  • 一个 Situation(情境) 是测试作用其上的状态:被测试配置中的相关形状(shapes),外加一份"已经发生了什么"的记录。
  • 一个 Operation(操作) 是单个步骤——一次编辑、一次结构变更或一次断言。
  • 测试通过组合小而通用的操作来构建,而不是为每个场景写一次性过程代码,因此行为是声明式描述的,且部件可以在不同测试间复用。

在源码中,Operation 被物化为抽象类:core/Operation.ts,注释称其为"strategy pattern"式的可组合、自描述步骤。它每次构造时获得一个递增分配的稳定实例身份id),其 applyTo(situation) 为异步方法——因为 Plugin API 的变更与传播可能异步收敛。

Situation 的实现见 core/Situation.ts:内部持有多张表——roles(角色 → 形状绑定)、appliedLog(有序操作日志)、appliedIds(已应用操作的身份集合)、opDatakeyedData(按操作身份/按共享 key 的通用数据存储)。它的关键设计有两点:角色查找是严格模式get(role) 在角色未绑定时抛出包含已绑定角色清单的诊断性错误而不是返回 null;并且 Situation 并非纯内存模型——操作通过 Plugin API 变更的是实时 Penpot 文档

2. 操作可组合,选择点在运行前展开为全量变体

  • 操作可以顺序组合(sequence);
  • 测试可以表达一个 choice(选择):做这一步或跳过它、从多个候选中挑一个;
  • 运行之前,每个选择都会被展开为具体的变体全集——于是一个紧凑的测试定义会变成覆盖每一种组合的多次独立运行;
  • 每个变体都在一份全新构建的情境上运行,因此变体之间永不互相干扰。

顺序组合的实现是 operations/OpSequence.ts:它从左到右把每个子步骤应用到同一份情境上并登记"已应用",其 enumerateVariants() 返回各步骤变体的笛卡尔积(源码中直接调用 cartesianProduct)。而分支点 operations/OpOneOf.ts 是"恰好取其中一支"的选择——注意它不能直接 apply,其 applyTo 直接抛出 "OneOf must be enumerated, not applied directly",它存在的意义就是被枚举展开成各备选轨迹。

配套的分支算子还包括 OpOptional(做或跳过,跳过的 no-op 不会被记入应用日志,这由 Operation.isRecorded()core/Operation.ts 中控制)以及 OpSkip。全部操作算子集中在 operations 目录下,除上述外还有 OpAssertOpChangePropertyOpCreateNestableComponentOpCreateSimpleComponentWithCopyOpCreateVariantContainerOpDeleteShapeOpInstantiateContentOpReorderShapeOpSequenceOpSwitchVariant——它们覆盖了测试需要的三类步骤:编辑、结构变更、断言

3. 真实 API,真实传播

操作通过 Plugin API 变更实时文档;传播是真实发生的;断言读取的是真实结果状态。套件不模拟、不建模组件行为——它观察行为。这解释了为什么无头 CI 与真人交互测试能共享同一套断言逻辑:被断言的是前端 store 中真实执行的同步逻辑,mock 只扮演持久化角色(详见后文 CI 一节)。

4. 基础操作(foundation)自己暴露其内容

一段起始配置由一个 foundation operation 构建——它总是测试的第一步——它会命名测试要引用的参与者。于是测试按角色(role)寻址配置的各个部分,而不是伸进内部结构。配置如何生长(实例化、嵌套)由 foundation operation 自身提供;它围绕什么内容来构建,则由一个可插拔的内容创建策略(pluggable content-creation strategy) 供给。

源码印证:Role<T> 是一个类型化、具名的绑定键,见 core/Role.ts,其注释举例说明角色如"copy 的子形状"可以独立于具体 id 被引用;T 是记录期望形状类型的 phantom 类型参数。内容创建策略被建模为 ContentCreationStrategy 接口,仓库内置三种实现:矩形(ContentCreationStrategyRectangle)、变体容器实例化(ContentCreationStrategyInstantiateVariantContainer)、兄弟实例(ContentCreationStrategySiblingInstances),见 content-creation

5. 结果以稳定身份寻址

每个测试(每个被展开的变体)在创建时只分配一次稳定身份。UI 渲染测试、每个结果按该身份流式回传——因此"你选择运行的东西"与"你看到报告的东西"永远指向同一对象。在代码中,套件构建时调用 createTestSuite()(见 composable-tests/index.ts),它把所有 case 展开成具体变体并分配稳定 id,同时产出供 UI 渲染的树(TestSuite.tree())与按需运行的 run 请求;相关类型定义于 test-suite 目录(TestSuiteTestCaseTestResultTestTreeRunnableTestTestRunObserver)。

一睹真实用例:MainEditSyncs

当前仓库定义了 6 个测试用例(注册于 cases.ts):

Case 标识符 文件
CopyOverrideSurvivesMainChange caseCopyOverrideSurvivesMainChange.ts
MainEditSyncs caseMainEditSyncs.ts
RemoteMainCopySyncNested caseRemoteMainCopySyncNested.ts
VariantSwitchPropagates caseVariantSwitchPropagates.ts
CopySubheadDeletePreservesSlots caseCopySubheadDeletePreservesSlots.ts
MainReorderKeepsCopySlots caseMainReorderKeepsCopySlots.ts

caseMainEditSyncs.ts 为例,它可以完整地示范全部核心概念。它的 TestCase 由三段组成(见 TestCase.tsidentifier + 三段式 description + operation):

  • foundationOpCreateSimpleComponentWithCopy(BASELINE) 创建"一个含单矩形的组件 + 它的副本",并从 foundation 的 roles 解构出 mainChildcopyChildcopyRoot
  • choice 点OpOptional(rotateCopy)——整体旋转副本根 45°(做或不做);OpOneOf(...mainEdits)——对主组件矩形施加若干备选编辑中的一种(改填充色 #00ff00,或改高度为 80,矩形初始为 50×50);
  • 断言OpAsserts.wasApplied(edit) 回溯本轨迹实际应用了哪个编辑,然后断言恰好应用了一个,且该编辑确实反映到了副本矩形上(assertHasChangedProperty(s, copyChild))。

由于两个 choice 点会被展开(OpOptional × OpOneOf 两个备选 = 4 个变体),一条紧凑定义最终变成多趟独立运行。该用例注释说明了它的回归价值:保护"被变换(旋转)过的副本停止接收主组件传播"这类 bug 类别TestCase 的三段式 description 规范(setup → actions/variations → requirement)正是官方文档要求新增用例遵循的写法,也是面板中每组上方描述框的内容来源。

交互式使用:构建、连接与运行

构建并运行插件

插件位于插件工作区的 plugins/apps/composable-test-suite,与工作区内其它插件一样运行。在 plugins/ 目录下(先执行工作区级 pnpm install):

pnpm run start:plugin:composable-test-suite

或者从插件自身目录以自包含方式运行(自装依赖、与周围工作区隔离):

pnpm run bootstrap

两种方式都会构建插件并持续监听重建,同时在本地提供 serve;已连接的插件面板会在每次重建后自动重载。首次构建需要等待一段时间服务器才就绪。

其它可用脚本(见 package.json):

脚本 作用
pnpm run build 一次性构建(tsc && vite build
pnpm start watch + serve(即 vite build --watch
pnpm run init 先 build,再 watch + serve
pnpm run types:check 仅类型检查(tsc --noEmit
pnpm run build:headless 构建无头入口 bundle(vite build --config vite.config.headless.ts
pnpm run test:ci build:headless + 运行 CI 驱动器
pnpm run fmt / pnpm run clean 格式化 / 清理 dist

在 Penpot 中连接插件

打开 Penpot 的插件管理器,按 URL 添加插件:

http://localhost:4202/manifest.json

4202 是该工作区插件共享的惯例开发端口——因此同一时刻只能有一个插件被 serve。连接成功后,插件面板会在 Penpot 内打开。

运行测试:面板交互

面板把每个用例列为一组,组头显示用例标识符与测试数量(例如 MainEditSyncs [4 tests]),右侧边缘显示 passed/failed 计数。你可以:

  • Run all(运行全部),或选中单个测试/整组后 Run selectedClear selection 一键取消所有选中;
  • 实时观察每个测试的状态流转:pending → running → passed / failed
  • 展开(fold open)某个组,阅读该用例的描述——"设置了什么、变化了什么、必须成立什么"——显示在组测试上方的独立框内;
  • 展开某个测试,查看被应用的步骤;若失败还会显示失败信息(细节在测试运行过后才会出现)。

远程控制:面向 Agent 与脚本的驱动接口

面板既可手动操作,也可编程驱动——这使它在 Penpot 的 agentic 开发环境(agentic devenv)中成为可自动化验证组件语义的通道。

稳定 DOM id 约定

每个复选框都带有一个稳定的 DOM id:

  • 用例的组复选框 id 即用例标识符(如 MainEditSyncs);
  • 组内每个测试的 id 为"复合标识符",追加 从 1 开始的序号(如 MainEditSyncs-2)。

这同时解释了 CI 中 TEST_FILTER=MainEditSyncs-2 能精确定位单个变体——两者共用同一套身份命名。

跨域 iframe 与 frame-scoped locator

插件渲染在 Penpot 工作区的一个 <plugin-modal title="Composable Tests"> 元素内,该元素在跨域 iframe 中托管面板。因此顶层页面的选择器无法触达面板元素,必须使用 frame-scoped locator:

const frame = page.getByTitle("Composable Tests").locator("iframe").contentFrame();

// 从干净状态开始:全部取消选中
await frame.getByRole("button", { name: "Clear selection" }).click();

// 选中整个 case(复选框在组头,折叠状态也可用)
await frame.locator("#MainEditSyncs").click();

// 选中单个测试:先点组头标签展开组,再点该测试的复选框
await frame.getByText("MainEditSyncs", { exact: true }).click();
await frame.locator("#MainEditSyncs-2").click();

// 运行已选内容
await frame.getByRole("button", { name: "Run selected" }).click();

状态同样可以读回:例如用 isChecked() 读复选框选中状态(组复选框在部分选中时返回 indeterminate),或从组头文本读取每组 passed/failed 计数。

通过日志补全调试闭环

插件代码中任意位置的 console.log——包括运行在插件沙箱中的测试操作与断言——都会出现在 Penpot 页面的浏览器控制台,因此浏览器自动化桥可以读取它们(Playwright MCP 工具中的 browser_console_messages)。注意页面控制台携带着大量无关流量(Penpot 自身、vite、其它插件),所以官方建议用 case 标识符作为日志前缀,例如 [MainEditSyncs] …,再按此前缀过滤。配合面板状态即可闭环调试:加一行日志 → 按 id 运行失败的测试 → 读日志。

修改代码后的自动重载

开发服务器运行期间(pnpm startpnpm run bootstrap),任何代码变更都会触发重建,实时预览随后自动重载插件——沙箱一并重载,因此改过的测试代码无需任何手动刷新即生效。需要留意的是:重载会彻底重置面板——所有复选框被清空、历史结果消失,改完代码后需重新选择要运行的测试。

无头 CI 运行:mock 后端 + headless 沙箱入口

套件可以不依赖面板和真实 Penpot 实例完全无头运行。在 plugins/ 目录执行:

pnpm --filter composable-test-suite run test:ci

这条命令做了如下几件事(对应脚本 build:headless && tsx ci/run-ci.ts):

  1. 把沙箱内入口 src/ci/headless.ts(实际位于 src/ci/headless.ts)构建为单一自执行 bundledist/headless.js);
  2. 交给驱动器 ci/run-ci.ts:它复用前端 e2e 静态服务器在 3000 端口 serve 预构建的前端 bundle;
  3. 用 Playwright fixtures 拦截每一个后端 RPC——无需后端、无需登录;
  4. 打开被 mock 的工作区文件,通过 globalThis.ɵloadPlugin 把 bundle 直接注入插件沙箱(沙箱是 SES Compartment,自带独立 globalThis,因此 TEST_FILTER 是直接拼进待求值代码里的);
  5. 从页面控制台流式读取每个测试的结果,通过识别 __TEST_RESULT____TEST_DONE____TEST_FATAL__ 前缀标记来汇总,任一测试失败即进程以非零码退出

驱动器源码(ci/run-ci.ts)透露了几个关键的实现事实:

  • 权限与真实插件保持同源:权限从插件随附的 public/manifest.json 解析,保证 CI 沙箱不会偏离用户真实授予的权限;
  • mock 的 fixture 表:工作区加载类 RPC 复用前端 e2e fixtures(frontend/playwright/data),而 get-file 使用自定义的完整功能 fixture(ci/fixtures/get-file.json),该 fixture 必须启用 plugins/runtime、design-tokens/v1、variants/v1 等特性,否则插件运行时根本不会初始化;
  • 持久化 mock 是 200 空响应update-file 返回 {"~:revn":1,"~:lagged":[]}——前端乐观地就地执行变更,mock 只需满足 revn/lagged 字段被读取即可;
  • 官方文档对"mock 后端"的定性:mock 在此不是局限——套件断言的一切都是前端 store 中内存执行的逻辑;后端的唯一角色是持久化,而 mock 以 canned 响应应答它。

前置条件

  • 前端 bundle 必须已存在于 frontend/resources/public(devenv 的 watch 构建即可满足;CI 通过 frontend/scripts/build 构建);
  • Playwright 浏览器已安装:
pnpm --filter composable-test-suite exec playwright install chromium

环境变量选项

变量 含义 默认值
TEST_FILTER 只运行复合标识符包含给定子串的测试(大小写不敏感),如 TEST_FILTER=MainEditSyncs 跑整个 case、TEST_FILTER=MainEditSyncs-2 跑单个变体 无(运行全部)
CI_TIMEOUT_MS 等待结果的整体超时(毫秒) 600000

如何新增一个测试用例

按官方文档的规范,一个新测试就是"作用于一个起始配置之上的操作组合",并加入套件运行的 case 集合。具体步骤:

  1. 给 case 一个有意义的 CamelCase 标识符(如 MainEditSyncs);
  2. 平实的语言写三段式描述:(1) 情境 setup——创建了什么;(2) 施加的动作与变化;(3) 被断言的 requirement;
  3. 优先复用已有的操作与内容创建策略,只有出现真正新型的步骤或配置时才新造一个;
  4. 变体用套件的 choice 操作来表达,而不是把每种组合手工写出来——这样测试的紧凑性与覆盖率才能兼得;
  5. cases.tsallCases() 工厂中登记新 case(注意每个 case 都是工厂函数而非常量,因为每跑一次都要重建 foundation 状态,而 runner 还会为每个被枚举的变体重建配置)。

写在最后

Composable Test Suite 的价值在于它把"组件语义验证"从静态分析提升为对真实运行时行为的可枚举观测:同一份操作定义既是交互面板中的可点选列表,又是 Agent 可通过 Playwright 驱动、可在无后端的 CI 中全自动执行的结果流。它的四个基石——Situation 承载状态、Operation 组合步骤、foundation 以角色寻址参与者、choice 点在运行前展开为全量变体——共同保证了一条声明式、可复用、全覆盖的组件回归防线。若你正在 Penpot 上开发插件或依赖其组件 API 的集成,这套套件既是现成的行为检查工具,也是一个值得照抄的"插件即测试框架"架构范本。

提示:无论交互运行还是 CI 运行,测试都会真实地创建、修改当前文档中的形状——务必在草稿文件中执行,且不要在工作区中与其它插件同时占用 4202 开发端口。

登录后查看全文
热门项目推荐
相关项目推荐