首页
/ OpenHands Canvas Extensions 手工测试指南:用 MSW 模拟模式在本地跑通扩展安装、启用与生命周期流程

OpenHands Canvas Extensions 手工测试指南:用 MSW 模拟模式在本地跑通扩展安装、启用与生命周期流程

2026-09-04 11:06:16作者:乔或婵

Canvas Extensions 是 OpenHands Agent Canvas 中"改变应用本身"的可安装扩展机制——与 Skills、Plugins 改变 agent 不同,Extensions 贡献路由页面、导航项、渲染器等前端产品行为。在 Agent Server 尚未实现 /api/canvas-extensions 系列端点之前,前端团队需要一条可独立验证扩展加载链路的本地路径。本文基于仓库中的 docs/CANVAS_EXTENSIONS_TESTING.md,完整讲解这条 MSW 模拟测试路径的启动命令、fixture 安装步骤、生命周期检查方法,并结合 src/mocks/canvas-extensions-handlers.tssrc/fixtures/canvas-extensions/demo-page/extension.jssrc/extensions/canvas-extension-module-loader.ts 等源码,说明 mock 层的实际实现原理与"安装必须落在禁用态"等产品不变量是如何被逐层强制的。读完后你可以:在零后端依赖下手工验证扩展的安装、启用、热启停、卸载流程,理解 mock 与真实 Agent Server 的契约边界,并清楚知道哪些验证项必须等后端端点落地后才能进行。

为什么需要一条 mock 测试路径

specs/canvas-extensions.md 的产品定义看,Canvas Extensions 采用"受信任的同域代码"模型:没有 iframe、worker 沙箱或细粒度权限系统,扩展一旦被启用,就拥有与 Canvas 自身代码相同的浏览器权限。这意味着"加载一个扩展 bundle"本身就是高风险路径,前端在端点可用之前就必须能独立验证:

  • bundle 的获取与 ESM 导入链路是否走通(认证文本获取 + Blob URL 动态导入);
  • activate 导出与 host API 版本校验是否正确;
  • manifest 声明的页面注册与左侧导航项是否正确挂载/拆除;
  • 安装后必须为禁用态、启用才执行代码这条产品不变量是否成立。

而这条路径的适用边界同样明确:它仅用于前端开发,不覆盖 Agent Server 的安装、文件系统校验、持久化、认证与 Git 解析。这一点在下文"仍需 Agent Server 的验证项"小节会再对照展开。

启动 mock 前端

从仓库根目录执行:

VITE_FRONTEND_PORT=3102 \
VITE_BACKEND_BASE_URL=http://127.0.0.1:8000 \
VITE_SESSION_API_KEY=canvas-extension-dev \
npm run dev:mock

对照 package.json 可以看到 dev:mock 的实际展开:

"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router dev"

dev:mock 会先执行 make-i18n 生成翻译产物,再带上 VITE_MOCK_API=true 启动 React Router 开发服务器。VITE_MOCK_API=true 正是 mock 开关:src/mocks/should-start-mock-worker.ts 中的判定逻辑只有在该环境变量等于字符串 "true" 且存在 window 时才启动 MSW worker:

export function shouldStartMockWorker({
  mockApi = import.meta.env.VITE_MOCK_API,
  hasWindow = typeof window !== "undefined",
}: {
  mockApi?: string;
  hasWindow?: boolean;
} = {}): boolean {
  return hasWindow && mockApi === "true";
}

三个环境变量各自的作用:

环境变量 取值 作用
VITE_FRONTEND_PORT 3102 避开常规本地栈占用 3001 的 Vite 进程,避免端口冲突
VITE_BACKEND_BASE_URL http://127.0.0.1:8000 仅给 Canvas 一个"本地后端身份";扩展相关请求实际被 MSW 在浏览器内拦截,不需要 Agent Server 真正运行
VITE_SESSION_API_KEY canvas-extension-dev 浏览器会话认证密钥。mock 同时覆盖了标记该后端为 healthy 所需的 settings 与 server 信息探测请求(如 GET /api/settings,见 src/mocks/settings-handlers.ts),因此 Agent Server 既不需要提供扩展端点,甚至不需要启动

启动后访问 http://localhost:3102/extensions。这里有一个关键的排错要点:不要使用常规入口 http://localhost:8000 做本测试,因为经 ingress 进入时 /api 流量直达未经修改的 Agent Server,绕过了 mock 浏览器会话,扩展端点会返回 404 而不会命中 MSW handler。

如果浏览器 profile 中已存在不兼容的后端或 onboarding 状态(例如之前配过真实 backend),请使用隐私窗口,或清除 localhost:3102 的 local storage 后重新加载。

安装并启用 demo fixture

操作步骤(全部在 Customize -> Extensions 界面完成):

  1. Customize -> Extensions 中点击 Add extension

  2. 输入这个精确的 source:

    src/fixtures/canvas-extensions/demo-page
    
  3. RefRepository path 留空,点击 Install

  4. 确认 Demo page禁用状态出现。安装后 bundle 不得被执行,导航项也不得添加——这是"安装与启用分离"的 v1 契约。

  5. 打开扩展开关,接受"受信任代码"确认。

  6. 确认主左侧栏(left rail)出现 Extension demo 导航项。

  7. 点开页面,验证显示 Hello from a Canvas Extension

  8. 直接访问 /extensions/demo-page/hello/nested,验证页面渲染出 Nested extension path: nested

fixture 之所以能支撑这些步骤,可以从它的三个文件印证。manifest src/fixtures/canvas-extensions/demo-page/canvas-extension.json 遵循 manifest schema 1:

{
  "schema_version": 1,
  "name": "demo-page",
  "display_name": "Demo page",
  "version": "0.1.0",
  "description": "A dependency-free fixture for the Canvas Extension page ABI.",
  "entrypoint": "extension.js",
  "contributes": {
    "pages": [
      {
        "id": "hello",
        "title": "Hello from an extension",
        "path": "/hello",
        "nav_label": "Extension demo"
      }
    ]
  }
}

入口 src/fixtures/canvas-extensions/demo-page/extension.js 是一个零依赖的自包含浏览器 ESM 模块,导出 activate 并在注册 hello 页面时展示路由余量(remainder path),这正是第 8 步能验证嵌套路径的原因:

export function activate(host) {
  return host.registerPage("hello", ({ container, path }) => {
    // 构建 section 容器与标题 "Hello from a Canvas Extension"
    const detail = document.createElement("p");
    detail.textContent = path
      ? `Nested extension path: ${path}`
      : `Host API ${host.apiVersion} on backend ${host.backend.id}`;
    // ...
    return () => wrapper.remove(); // 卸载时清理 DOM
  });
}

注意入口返回的 cleanup 函数:按 specs/canvas-extensions.md 的运行时契约,禁用/卸载时 Canvas 会调用扩展 disposer 并拆除所有已注册 surface。fixture 的 return () => wrapper.remove() 让"禁用后页面消失"这一生命周期断言在手工测试中可观察。

mock 层如何拦截这条安装流

上一步的每一次操作都对应 src/mocks/canvas-extensions-handlers.ts 中的 MSW handler,与真实契约的端点一一镜像:

操作 MSW handler 对应契约端点
列表 GET */api/canvas-extensions/installed 列出已安装扩展,包裹在 canvas_extensions 数组
安装 POST */api/canvas-extensions/install 从 git 或后端本地路径安装,结果必为禁用态
读取 GET */api/canvas-extensions/installed/:name 读取单个安装
启停 PATCH */api/canvas-extensions/installed/:name 设置 enabled 状态
卸载 DELETE */api/canvas-extensions/installed/:name 卸载
取 bundle GET */api/canvas-extensions/installed/:name/bundle 以 JavaScript 文本返回入口

几个值得注意的实现细节:

  • 只接受 demo fixturePOST /install 会校验 source 是否以 src/fixtures/canvas-extensions/demo-page 结尾(isDemoSource),否则返回 400;重名安装且未带 force 时返回 409。
  • 安装结果恒为 enabled: falseresolved_ref 缺省为 mock-working-tree,并预置 manifest 字段——源码注释说明真实后端只在对应工作项落地后才返回 manifest,mock 提前预览该形状,使 demo 页面的 pages 贡献在开发模式即可生效。
  • 状态持久化在 sessionStorage。handler 以键 openhands-canvas-extension-dev-installation 读写安装记录,模块内变量作内存兜底。这解释了后文"Reload 检查"中"刷新后保留、关标签页即清空"的行为。
  • bundle 端点以 Content-Type: application/javascript; charset=utf-8Cache-Control: no-cache 直接返回 extension.js?raw 导入的源码文本。

生命周期检查

fixture 安装成功后,依次执行以下四项检查:

  • Disable(禁用):关闭扩展。左侧栏导航项应消失,其路由也不再渲染贡献的页面。
  • Re-enable(重新启用):再次打开。导航项和页面应无需重启 Canvas 即恢复——即规格中"Enablement is hot"的体现。
  • Uninstall(卸载):点击 Uninstall 并确认。扩展清单(inventory)与导航项应变为空。
  • Reload(刷新):刷新页面后确认安装与启用状态在本浏览器标签页内被保留。注意 mock 使用 session storage:卸载或结束浏览器会话即清空,不会跨标签页共享。

从源码结构看,这四项分别对应运行时链路的不同环节:启用/禁用走 PATCH handler 改 enabled 字段;页面与导航项的挂载由前端扩展运行时按启用状态驱动,且注册仅接受 manifest 中声明过的 ID;卸载对应 DELETE handler 清空 session storage 中的安装记录。

测试一次扩展编辑

要验证"编辑扩展代码后重新加载"的完整闭环:

  1. 修改 src/fixtures/canvas-extensions/demo-page/extension.js
  2. 若 Vite 没有对原始 fixture 导入(?raw 资源)自动重建,重启 mock 前端;
  3. 卸载并重新安装该 fixture。

这条编辑流使得页面挂载、清理、子路由以及 host API 的使用都能在后端支持尚不可用时先行开发。

对 fixture 本身有一条硬性约束:它必须保持自包含的浏览器 ESM 模块——不得依赖裸包导入(bare package imports),也不得产生额外的输出 chunk。这条约束直接由加载器决定。src/extensions/canvas-extension-module-loader.ts 中,bundle 源码先经认证 HTTP 客户端取为文本,再包成 Blob 做临时 URL 动态导入:

export async function loadCanvasExtensionModule(
  source: string,
): Promise<CanvasExtensionModule> {
  const blob = new Blob([source], { type: "text/javascript" });
  const moduleUrl = URL.createObjectURL(blob);
  try {
    const imported: unknown = await import(/* @vite-ignore */ moduleUrl);
    assertCanvasExtensionModule(imported);
    return imported;
  } finally {
    URL.revokeObjectURL(moduleUrl);
  }
}

这里有两个契约级原因:其一,<script src> 或直接 import(backendUrl) 无法携带 X-Session-API-Key,所以 v1 加载路径必须是"认证取文本 + Blob URL 导入";其二,assertCanvasExtensionModule 会校验模块必须导出 activate 函数,否则抛出 InvalidCanvasExtensionModuleError。而"取到 bundle 并不等于激活"——Canvas 只对已启用的安装调用 activate,这与手工测试第 4 步"安装后必须禁用、导航项不得出现"的断言是同一不变量的两端。

仍需 Agent Server 的验证项

mock 路径验证完毕后,待后端契约落地,需要将同一流程对照 http://localhost:8000/extensions 重跑一遍,并额外验证以下事项:

  • Git 与后端本地路径安装;
  • 不可变修订号解析(immutable revision resolution);
  • manifest、路径穿越(traversal)、符号链接与入口点校验;
  • 跨 Agent Server 与浏览器重启的持久化;
  • 会话认证的 bundle 分发(session-authenticated bundle delivery);
  • 在多个激活后端之间切换时的隔离性。

后端契约与验收标准完整记录在 specs/canvas-extensions.md,包括安装请求/响应 JSON 形状(source/ref/repo_path/forceresolved_ref/install_path/manifest 字段)、前端运行时按"后端 ID + 组织作用域 + 连接修订"键控的设计,以及 404 必须被解释为"该后端不支持 Canvas Extensions"而非网络错误的状态语义。

自动化的兜底:mock-LLM E2E 规格

手工流程并非孤例。tests/e2e/mock-llm/canvas-extensions/mock-llm-canvas-extensions.spec.ts 用 Playwright 驱动生产构建跑完同样的四段生命周期(安装 → 启用 → 页面渲染(含嵌套余量路径)→ 禁用 → 卸载),并驱动真实的 ingress、backend registry 与会话认证栈。由于当前 pin 的 agent-server 尚无扩展端点,该规格通过 serveCanvasExtensionApi 辅助函数在浏览器层从同一 demo fixture 应答 API 契约;注释中明确写了:一旦 pin 版本包含端点,删除该 helper、改为绝对路径安装 fixture 即可,UI 步骤保持不变。

该规格文件头注释还点出了一个测试金字塔上的特殊位置:它是唯一一个在真实浏览器中导入扩展 bundle 的测试(走 canvas-extension-module-loader.ts 的 Blob URL ESM 导入路径),单元测试则注入模块加载器桩来避开这条链路。前端相关的 service 侧单测见 src/api/canvas-extensions-service.test.ts

小结

这条 mock 测试路径的价值在于把"可信代码加载"这条高风险链路拆成了前后端两段各自可验证的部分:MSW 层以与真实契约逐端点镜像的方式(src/mocks/canvas-extensions-handlers.ts)让前端在不依赖 Agent Server 的前提下验证安装-启用-热启停-卸载的全部产品不变量;而后端契约(安装校验、修订解析、认证分发、后端隔离)则留待 http://localhost:8000 的真实端点落地后按 specs/canvas-extensions.md 的验收清单逐项补齐。开发或排查 Canvas Extensions 前端行为时,按本文的命令启动 npm run dev:mock、以 src/fixtures/canvas-extensions/demo-page 为唯一可装 fixture 执行上述步骤,即可在不搭建后端的情况下完成一次完整的手工回归。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
903
1.82 K
docsdocs
暂无描述
Markdown
888
5.78 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.51 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384
flutter_flutterflutter_flutter
本仓库是 Flutter SDK 与 Flutter Engine 的 OpenHarmony 适配版本,由 CPF-Flutter 团队维护。开发者可使用熟悉的 Flutter 技术栈开发 OpenHarmony 应用,3.35.7 及以后的适配版本可基于本仓库源码构建支持 OpenHarmony 的 Flutter Engine。
Dart
1.17 K
341