OpenHands Canvas Extensions 手工测试指南:用 MSW 模拟模式在本地跑通扩展安装、启用与生命周期流程
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.ts、src/fixtures/canvas-extensions/demo-page/extension.js 与 src/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 界面完成):
-
在 Customize -> Extensions 中点击 Add extension。
-
输入这个精确的 source:
src/fixtures/canvas-extensions/demo-page -
Ref 与 Repository path 留空,点击 Install。
-
确认 Demo page 以禁用状态出现。安装后 bundle 不得被执行,导航项也不得添加——这是"安装与启用分离"的 v1 契约。
-
打开扩展开关,接受"受信任代码"确认。
-
确认主左侧栏(left rail)出现 Extension demo 导航项。
-
点开页面,验证显示 Hello from a Canvas Extension。
-
直接访问
/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 fixture。
POST /install会校验 source 是否以src/fixtures/canvas-extensions/demo-page结尾(isDemoSource),否则返回 400;重名安装且未带force时返回 409。 - 安装结果恒为
enabled: false,resolved_ref缺省为mock-working-tree,并预置manifest字段——源码注释说明真实后端只在对应工作项落地后才返回 manifest,mock 提前预览该形状,使 demo 页面的 pages 贡献在开发模式即可生效。 - 状态持久化在
sessionStorage。handler 以键openhands-canvas-extension-dev-installation读写安装记录,模块内变量作内存兜底。这解释了后文"Reload 检查"中"刷新后保留、关标签页即清空"的行为。 - bundle 端点以
Content-Type: application/javascript; charset=utf-8和Cache-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 中的安装记录。
测试一次扩展编辑
要验证"编辑扩展代码后重新加载"的完整闭环:
- 修改 src/fixtures/canvas-extensions/demo-page/extension.js;
- 若 Vite 没有对原始 fixture 导入(
?raw资源)自动重建,重启 mock 前端; - 卸载并重新安装该 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/force 与 resolved_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 执行上述步骤,即可在不搭建后端的情况下完成一次完整的手工回归。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00