
用组合式 Operation 在真实 Penpot 文档上驱动组件语义测试Composable Test Suite 插件架构与实战指南【免费下载链接】penpotPenpot: The open-source design platform for Product teams that need scalable collaboration.项目地址: https://gitcode.com/GitHub_Trending/pe/penpot导读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已应用操作的身份集合、opData与keyedData按操作身份/按共享 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 目录下除上述外还有OpAssert、OpChangeProperty、OpCreateNestableComponent、OpCreateSimpleComponentWithCopy、OpCreateVariantContainer、OpDeleteShape、OpInstantiateContent、OpReorderShape、OpSequence、OpSwitchVariant——它们覆盖了测试需要的三类步骤编辑、结构变更、断言。3. 真实 API真实传播操作通过 Plugin API 变更实时文档传播是真实发生的断言读取的是真实结果状态。套件不模拟、不建模组件行为——它观察行为。这解释了为什么无头 CI 与真人交互测试能共享同一套断言逻辑被断言的是前端 store 中真实执行的同步逻辑mock 只扮演持久化角色详见后文 CI 一节。4. 基础操作foundation自己暴露其内容一段起始配置由一个foundation operation构建——它总是测试的第一步——它会命名测试要引用的参与者。于是测试按角色role寻址配置的各个部分而不是伸进内部结构。配置如何生长实例化、嵌套由 foundation operation 自身提供它围绕什么内容来构建则由一个可插拔的内容创建策略pluggable content-creation strategy供给。源码印证RoleT是一个类型化、具名的绑定键见 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 目录TestSuite、TestCase、TestResult、TestTree、RunnableTest、TestRunObserver。一睹真实用例MainEditSyncs当前仓库定义了 6 个测试用例注册于 cases.tsCase 标识符文件CopyOverrideSurvivesMainChangecaseCopyOverrideSurvivesMainChange.tsMainEditSyncscaseMainEditSyncs.tsRemoteMainCopySyncNestedcaseRemoteMainCopySyncNested.tsVariantSwitchPropagatescaseVariantSwitchPropagates.tsCopySubheadDeletePreservesSlotscaseCopySubheadDeletePreservesSlots.tsMainReorderKeepsCopySlotscaseMainReorderKeepsCopySlots.ts以 caseMainEditSyncs.ts 为例它可以完整地示范全部核心概念。它的TestCase由三段组成见 TestCase.tsidentifier 三段式descriptionoperationfoundationOpCreateSimpleComponentWithCopy(BASELINE)创建一个含单矩形的组件 它的副本并从 foundation 的roles解构出mainChild、copyChild、copyRootchoice 点OpOptional(rotateCopy)——整体旋转副本根 45°做或不做OpOneOf(...mainEdits)——对主组件矩形施加若干备选编辑中的一种改填充色#00ff00或改高度为 80矩形初始为 50×50断言OpAssert用s.wasApplied(edit)回溯本轨迹实际应用了哪个编辑然后断言恰好应用了一个且该编辑确实反映到了副本矩形上assertHasChangedProperty(s, copyChild)。由于两个 choice 点会被展开OpOptional×OpOneOf两个备选 4 个变体一条紧凑定义最终变成多趟独立运行。该用例注释说明了它的回归价值保护被变换旋转过的副本停止接收主组件传播这类 bug 类别。TestCase的三段式 description 规范setup → actions/variations → requirement正是官方文档要求新增用例遵循的写法也是面板中每组上方描述框的内容来源。交互式使用构建、连接与运行构建并运行插件插件位于插件工作区的plugins/apps/composable-test-suite与工作区内其它插件一样运行。在plugins/目录下先执行工作区级pnpm installpnpm run start:plugin:composable-test-suite或者从插件自身目录以自包含方式运行自装依赖、与周围工作区隔离pnpm run bootstrap两种方式都会构建插件并持续监听重建同时在本地提供 serve已连接的插件面板会在每次重建后自动重载。首次构建需要等待一段时间服务器才就绪。其它可用脚本见 package.json脚本作用pnpm run build一次性构建tsc vite buildpnpm startwatch serve即vite build --watchpnpm run init先 build再 watch servepnpm run types:check仅类型检查tsc --noEmitpnpm run build:headless构建无头入口 bundlevite build --config vite.config.headless.tspnpm run test:cibuild:headless 运行 CI 驱动器pnpm run fmt/pnpm run clean格式化 / 清理 dist在 Penpot 中连接插件打开 Penpot 的插件管理器按 URL 添加插件http://localhost:4202/manifest.json4202是该工作区插件共享的惯例开发端口——因此同一时刻只能有一个插件被 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_FILTERMainEditSyncs-2能精确定位单个变体——两者共用同一套身份命名。跨域 iframe 与 frame-scoped locator插件渲染在 Penpot 工作区的一个plugin-modal titleComposable Tests元素内该元素在跨域 iframe中托管面板。因此顶层页面的选择器无法触达面板元素必须使用 frame-scoped locatorconst 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 start或pnpm run bootstrap任何代码变更都会触发重建实时预览随后自动重载插件——沙箱一并重载因此改过的测试代码无需任何手动刷新即生效。需要留意的是重载会彻底重置面板——所有复选框被清空、历史结果消失改完代码后需重新选择要运行的测试。无头 CI 运行mock 后端 headless 沙箱入口套件可以不依赖面板和真实 Penpot 实例完全无头运行。在plugins/目录执行pnpm --filter composable-test-suite run test:ci这条命令做了如下几件事对应脚本build:headless tsx ci/run-ci.ts把沙箱内入口 src/ci/headless.ts实际位于 src/ci/headless.ts构建为单一自执行 bundledist/headless.js交给驱动器 ci/run-ci.ts它复用前端 e2e 静态服务器在 3000 端口 serve 预构建的前端 bundle用 Playwright fixtures拦截每一个后端 RPC——无需后端、无需登录打开被 mock 的工作区文件通过globalThis.ɵloadPlugin把 bundle 直接注入插件沙箱沙箱是 SES Compartment自带独立globalThis因此TEST_FILTER是直接拼进待求值代码里的从页面控制台流式读取每个测试的结果通过识别__TEST_RESULT__、__TEST_DONE__、__TEST_FATAL__前缀标记来汇总任一测试失败即进程以非零码退出。驱动器源码ci/run-ci.ts透露了几个关键的实现事实权限与真实插件保持同源权限从插件随附的 public/manifest.json 解析保证 CI 沙箱不会偏离用户真实授予的权限mock 的 fixture 表工作区加载类 RPC 复用前端 e2e fixturesfrontend/playwright/data而get-file使用自定义的完整功能 fixtureci/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/publicdevenv 的 watch 构建即可满足CI 通过frontend/scripts/build构建Playwright 浏览器已安装pnpm --filter composable-test-suite exec playwright install chromium环境变量选项变量含义默认值TEST_FILTER只运行复合标识符包含给定子串的测试大小写不敏感如TEST_FILTERMainEditSyncs跑整个 case、TEST_FILTERMainEditSyncs-2跑单个变体无运行全部CI_TIMEOUT_MS等待结果的整体超时毫秒600000如何新增一个测试用例按官方文档的规范一个新测试就是作用于一个起始配置之上的操作组合并加入套件运行的 case 集合。具体步骤给 case 一个有意义的 CamelCase 标识符如MainEditSyncs用平实的语言写三段式描述(1) 情境 setup——创建了什么(2) 施加的动作与变化(3) 被断言的 requirement优先复用已有的操作与内容创建策略只有出现真正新型的步骤或配置时才新造一个变体用套件的choice 操作来表达而不是把每种组合手工写出来——这样测试的紧凑性与覆盖率才能兼得在 cases.ts 的allCases()工厂中登记新 case注意每个 case 都是工厂函数而非常量因为每跑一次都要重建 foundation 状态而 runner 还会为每个被枚举的变体重建配置。写在最后Composable Test Suite的价值在于它把组件语义验证从静态分析提升为对真实运行时行为的可枚举观测同一份操作定义既是交互面板中的可点选列表又是 Agent 可通过 Playwright 驱动、可在无后端的 CI 中全自动执行的结果流。它的四个基石——Situation 承载状态、Operation 组合步骤、foundation 以角色寻址参与者、choice 点在运行前展开为全量变体——共同保证了一条声明式、可复用、全覆盖的组件回归防线。若你正在 Penpot 上开发插件或依赖其组件 API 的集成这套套件既是现成的行为检查工具也是一个值得照抄的插件即测试框架架构范本。提示无论交互运行还是 CI 运行测试都会真实地创建、修改当前文档中的形状——务必在草稿文件中执行且不要在工作区中与其它插件同时占用4202开发端口。【免费下载链接】penpotPenpot: The open-source design platform for Product teams that need scalable collaboration.项目地址: https://gitcode.com/GitHub_Trending/pe/penpot创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考