OpenClaw 共享测试助手的插件导入边界统一解析、vi.hoisted 陷阱与 CI 强制机制【免费下载链接】openclawThe AI that really does things. Any OS. Any Platform. The lobster way. 项目地址: https://gitcode.com/GitHub_Trending/cl/openclaw本篇基于 test/helpers/CLAUDE.md 所定义的“共享测试助手边界”展开讲解 OpenClaw 单体仓库中test/helpers目录如何以插件公共面public surface为唯一入口访问extensions/**下的打包插件避免核心测试轨道依赖插件私有目录结构。读完本文你能掌握bundled-plugin-public-surface.ts提供的三个解析 API 的选型方式、vi.hoisted下的模块解析写法以及该边界如何被脚本与 vitest 测试双重强制。1.test/helpers的定位与边界问题的来源test/helpers存放被核心测试与打包插件测试共同复用的共享测试助手shared test helpers目录规模相当可观网关 E2E 与生命周期助手gateway-e2e-harness.ts、qa-gateway-cleanup.ts、gateway-websocket.tsCLI/进程助手openclaw-test-instance.ts、run-node-script.ts、process-wait.ts、stop-child-process.ts提示词快照与 LLM 场景助手agents/happy-path-prompt-snapshots.ts、agents/llm-stream-simple-mock.ts媒体与认证夹具media-generation/bundled-provider-builders.ts、auth-wizard.ts、stage-live-auth-profiles.ts这些助手天然面临一个结构性矛盾OpenClaw 的核心代码src/本身不 importextensions/**下的任何插件而共享测试助手却经常需要加载插件——比如加载某个 provider 插件的入口index.js来构建测试夹具或引用某插件暴露的公共测试 API。如果助手直接写仓库相对路径例如../../extensions/codex/src/...核心测试轨道core test lanes就会隐式依赖每个插件的私有目录布局插件内部重构、文件改名或私有模块拆分都会击穿大量并不相关的核心测试。边界文档与 test/helpers/AGENTS.md 内容一致分别面向 Claude Code 与通用 Agent 读者开宗明义This directory holds shared test helpers reused by core and bundled plugin tests.并给出如下硬性规则与意图本文第 3 节逐条继承并结合源码展开禁止硬编码共享助手不得硬编码仓库相对 import 指向extensions/**统一入口需要插件公共面时必须经由src/test-utils/bundled-plugin-public-surface.tsAPI 选型需要模块 id 或文件系统路径时优先使用resolveRelativeBundledPluginPublicModuleId(...)或resolveBundledPluginPublicModulePath(...)hoisting 陷阱涉及vi.hoisted(...)时不得在 hoisted 回调内调用已导入的助手函数应在回调外解析模块 id或改用vi.doMock(...)债务归属插件局部的深 mock 或对插件私有src/**的了解不允许留在共享助手里应移回所属的打包插件包。文档给出的 Intent意图两条同样必须继承保持共享助手与生产代码使用同一套 public/plugin 边界对齐Keep shared helpers aligned with the same public/plugin boundary that production code uses避免“共享助手债务”——即核心测试轨道开始依赖打包插件私有布局的隐性耦合Avoid shared helper debt that makes core test lanes depend on bundled plugin private layout。2. 实现纵深bundled-plugin-public-surface.ts的三个 API统一入口位于 src/test-utils/bundled-plugin-public-surface.ts。它的设计目标只有一个给定插件 id 和公共面构件名如index.js、test-api.js解析出可安全 import 的位置且完全不依赖调用方知道插件在仓库里的物理路径。2.1 插件 id 解析快路径 清单校验模块内部先做安全目录名校验第 21–23 行function isSafeBundledPluginDirName(pluginId: string): boolean { return /^[a-z0-9][a-z0-9._-]*$/u.test(pluginId); }随后findBundledPluginMetadataFast第 35–56 行在多个候选根目录下定位插件const rawRoots [ resolveBundledPluginsDir(), path.resolve(OPENCLAW_PACKAGE_ROOT, extensions), path.resolve(OPENCLAW_PACKAGE_ROOT, dist-runtime, extensions), path.resolve(OPENCLAW_PACKAGE_ROOT, dist, extensions), ].filter((entry): entry is string Boolean(entry));三个要点值得注意多运行形态兼容候选根同时覆盖源码树extensions、构建产物dist-runtime/extensions、dist/extensions与运行时解析结果resolveBundledPluginsDir()。这意味着同一份共享助手在开发态、打包后的安装态下都能解析到插件而不必关心当前进程处于哪种形态。清单校验而非目录存在性定位不是“目录存在即命中”而是读取插件目录下openclaw.plugin.json并断言其id字段与目标pluginId严格相等readPluginManifestId第 25–33 行防止目录名与插件 id 漂移时解析到错误插件。兜底回退快路径未命中时回退到通用元数据查找findBundledPluginMetadataById来自src/plugins/bundled-plugin-metadata.js彻底失败则抛出Unknown bundled plugin id: ${pluginId}第 58–65 行让测试失败得响亮而不是静默走错路径。2.2resolveBundledPluginPublicModulePath要文件系统路径时用第 83–96 行 导出export function resolveBundledPluginPublicModulePath(params: { pluginId: string; artifactBasename: string; }): string { const metadata findBundledPluginMetadata(params.pluginId); const sourceRoot path.resolve(OPENCLAW_PACKAGE_ROOT, extensions); const sourcePath resolveBundledPluginSourcePublicSurfacePath({ sourceRoot, dirName: metadata.dirName, artifactBasename: params.artifactBasename, }); // Optional contract callers need the validated path even when no artifact exists. return sourcePath ?? path.resolve(sourceRoot, metadata.dirName, params.artifactBasename); }它返回绝对文件系统路径适用于需要fs操作、断言文件存在性或把路径注入配置的场景。注意尾部注释契约类调用方即使构件尚不存在也需要拿到“已校验的路径”所以这里返回回退路径而不是抛错。2.3resolveRelativeBundledPluginPublicModuleId动态 import / mock 时用第 98–112 行 导出export function resolveRelativeBundledPluginPublicModuleId(params: { fromModuleUrl: string; pluginId: string; artifactBasename: string; }): string { const fromFilePath fileURLToPath(params.fromModuleUrl); const targetPath resolveBundledPluginPublicModulePath({ pluginId: params.pluginId, artifactBasename: params.artifactBasename, }); const relativePath path .relative(path.dirname(fromFilePath), targetPath) .replaceAll(path.sep, /); return relativePath.startsWith(.) ? relativePath : ./${relativePath}; }它把绝对路径换算为相对于调用方模块的 POSIX 风格 specifier并强制以./或../开头——这正是文档规则三推荐的形态。为什么要“相对 id”而不是直接传绝对路径给import()在 Vitest 中mock 注册、模块图追踪与 worker 隔离都以模块 specifier 为键由调用方视角生成的稳定相对 id 能让 mock 与真实加载落在同一模块图节点上。文档给出的三类适用场景dynamic import、mocking、加载插件入口如index.js均指向此函数。2.4loadBundledPluginFacade直接加载插件公共面模块第 72–81 行 导出export const loadBundledPluginFacade: AsyncBundledPluginPublicSurfaceLoader async (params) { const modulePath resolveBundledPluginPublicModulePath(params); if (!fs.existsSync(modulePath)) { throw new Error( Unable to resolve bundled plugin public surface ${params.pluginId}/${params.artifactBasename}, ); } // Fixture imports must share the test runners SDK state and mocks. return import(pathToFileURL(modulePath).href); };两个细节决定它是“测试专用”的加载器先做存在性校验把“解析不到”变成带插件 id 与构件名的明确报错再经pathToFileURL转成 URL 动态 import。注释说明了原因夹具 import 必须共享测试运行器的 SDK 状态与 mock——绕过 runner 直接 require 会建立第二份模块图mock 全部失效。2.5 选型速查表API返回典型用途resolveBundledPluginPublicModulePath绝对文件系统路径fs检查、配置注入、契约校验resolveRelativeBundledPluginPublicModuleId相对模块 specifier动态import(id)、Vitest mock 注册loadBundledPluginFacade已加载的模块命名空间加载index.js等插件入口构建夹具3. 规则逐条落地正反示例3.1 正例加载 provider 插件入口test/helpers/media-generation/bundled-provider-builders.ts 是“经公共面加载插件入口”的标准形态全文 第 1–22 行import { loadBundledPluginFacade } from ../../../src/test-utils/bundled-plugin-public-surface.js; type BundledPluginEntryModule { default: { register(api: OpenClawPluginApi): void; }; }; /** Load a bundled provider plugin entrypoint through the public surface helper. */ export async function loadBundledProviderPlugin( pluginId: string, ): PromiseBundledPluginEntryModule[default] { const module await loadBundledPluginFacadeBundledPluginEntryModule({ pluginId, artifactBasename: index.js, }); return module.default; }助手自身对extensions/**零感知它只声明“我要某个插件的index.js”物理位置由公共面解析器负责。3.2 正例vi.hoisted场景下的模块 id 解析边界文档特别警告若测试涉及vi.hoisted(...)不要在 hoisted 回调内调用已导入的助手函数。原因是 Vitest 会把vi.hoisted提升到文件顶部、先于 import 求值此时模块顶层导入的助手在 hoisted 回调里访问会踩到初始化顺序问题。test/helpers/agents/happy-path-prompt-snapshots.ts 展示了标准解法——在模块顶层回调外解析模块 id再在普通函数里动态 import第 152–161 行const CODEX_TEST_API_MODULE_ID resolveRelativeBundledPluginPublicModuleId({ fromModuleUrl: import.meta.url, pluginId: codex, artifactBasename: test-api.js, }); /** Load the Codex public test API without hardcoding plugin-private paths. */ async function loadCodexPromptSnapshotApi(): PromiseCodexPromptSnapshotApi { return (await import(CODEX_TEST_API_MODULE_ID)) as CodexPromptSnapshotApi; }这正是文档规则三优先resolveRelativeBundledPluginPublicModuleId与规则四回调外解析的联合兑现解析发生在模块初始化期import()发生在运行时两者都不与 hoisting 冲突。若无法在顶层拿到import.meta.url上下文文档给出的备选方案是改用vi.doMock(...)不提升、按需注册替代“hoisted 回调 mock”的组合。3.3 反例共享助手里不该出现什么以下形态均被边界文档明确禁止且会被第 4 节的检查器捕获// 反例 1硬编码仓库相对路径进入 extensions/** import { internals } from ../../../extensions/codex/src/app-server.js; // 反例 2把某个插件的私有模块布局知识固化进共享助手 vi.mock(../../../extensions/telegram/src/client.js, () ({ ... })); // 反例 3在 vi.hoisted 回调内调用导入的解析函数 vi.hoisted(() { const id resolveRelativeBundledPluginPublicModuleId({ /* ... */ }); // 初始化顺序风险 vi.mock(id, () ({ ... })); });规则五“插件局部深 mock 与私有src/**知识不得留在共享助手”的处置方式是债务归还这类知识只属于插件自身应下沉到拥有该插件的包内测试让共享助手保持“只认公共面”。4. 自动强制边界检查器与守护测试边界文档不止是口头约定仓库里有一条两级强制链路。4.1 扫描脚本check-test-helper-extension-import-boundary.mtsscripts/check-test-helper-extension-import-boundary.mts第 7–13 行const checker createExtensionImportBoundaryChecker({ roots: [test/helpers], boundaryLabel: test helper, rule: Rule: test/helpers/** must not import bundled plugin files directly, cleanMessage: No test-helper import boundary violations found., inventoryTitle: Test-helper extension import boundary inventory:, });它复用通用工厂 scripts/lib/extension-import-boundary-checker.mts工作机制第 110–139 行 的scanImportBoundaryViolations与 第 177–252 行 的工厂主体收集test/helpers下全部 TypeScript 源文件单文件超过 2 MiB 直接报错防止大文件逃逸用collectModuleReferencesFromSource抽取文件中的静态 import、re-export 与动态 import 引用对每个 specifier 做仓库内解析凡解析结果落在打包插件路径前缀extensions/**语义内即记为违规并标注imports / re-exports / dynamically imports bundled plugin file from test helper boundary结果按文件、行号、引用类型排序去重支持--json输出与人类可读分组输出清单为空时退出码 0否则 1main 函数。值得强调的是第 3 步的“解析后判定”它抓的不是字符串extensions/而是解析结果——无论是../../extensions/...相对路径、别名还是动态拼接只要最终指向打包插件文件即被标记绕路成本很高。4.2 守护测试把检查器钉进 vitesttest/test-helper-extension-import-boundary.test.ts第 6–14 行直接调用脚本的main并断言const exitCode await main([--json], captured.io); expect(exitCode).toBe(0); expect(captured.readStderr()).toBe(); expect(JSON.parse(captured.readStdout())).toStrictEqual([]);即边界清单必须为空、stderr 必须干净、退出码必须为 0。任何人在test/helpers里新增一条指向extensions/**的直接 import这条测试就会红——边界约定从“文档条款”升级为“CI 门禁”。5. 实践清单在test/helpers新增助手时怎么做结合文档规则与上述实现可以沉淀为如下决策路径助手要不要碰插件不碰 → 直接写无额外约束。要加载插件入口构建夹具→ 用loadBundledPluginFacade({ pluginId, artifactBasename })参考 bundled-provider-builders.ts。要 mock 或动态 import 插件公共面→ 模块顶层先resolveRelativeBundledPluginPublicModuleId({ fromModuleUrl: import.meta.url, ... })再import(id)参考 happy-path-prompt-snapshots.ts若受 hoisting 限制无法顶层解析改走vi.doMock(...)。只做文件级断言/配置注入→ 用resolveBundledPluginPublicModulePath。发现自己在写某个插件私有模块的 mock→ 停下把这段逻辑移回所属插件包的测试目录共享助手只保留对公共面的引用。提交前→ 跑 check-test-helper-extension-import-boundary.mts 与守护测试 test-helper-extension-import-boundary.test.ts 确认清单为空。6. 小结test/helpers/CLAUDE.md 用不到三十行定义了一条对大型单体仓库很关键的测试架构规则共享测试层与插件世界之间只存在一道门——src/test-utils/bundled-plugin-public-surface.ts。这条门通过“插件 id 公共面构件名”的间接层同时解决了三件事插件私有布局不外泄到核心测试轨道、多运行形态源码/构建产物下解析一致、vi.hoisted这类 Vitest 时序陷阱有明确规避姿势而 边界检查脚本 与 守护测试 把规则从文档变成了可执行门禁。对维护者而言理解并遵循这条边界是在test/helpers中新增任何跨核心/插件共享能力的前置条件。【免费下载链接】openclawThe AI that really does things. Any OS. Any Platform. The lobster way. 项目地址: https://gitcode.com/GitHub_Trending/cl/openclaw创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考