test: add comprehensive Curio installation and runtime fixtures
This commit is contained in:
@@ -6,3 +6,4 @@ target/
|
||||
.env.*
|
||||
*.curio
|
||||
.DS_Store
|
||||
/curios/*/backend/
|
||||
|
||||
@@ -1,14 +1,52 @@
|
||||
# Trove Curios
|
||||
|
||||
第一方 Curio 与官方软件源仓库。当前尚无已发布插件,不把 SDK 教学示例当作正式产品。
|
||||
第一方 Curio 与官方软件源仓库。目前包含一套用于验证 Trove 底座的测试插件,尚无正式发布的产品插件。测试源与 `source/index.json` 官方源分开,打包不会修改官方源。
|
||||
|
||||
## 底座测试套件
|
||||
|
||||
15 个插件工程(14 个插件 ID,其中升级插件有两个版本),覆盖 UI、Host 服务、Rust 原生后端、跨插件服务、可选/必需依赖、版本约束、生命周期、故障恢复和升级回滚。另有 22 个故意无效的安装包和 4 个异常软件源。
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# 已安装 trove 命令时可省略此变量;也可指向本机 app 仓库构建产物。
|
||||
export TROVE_BIN=/absolute/path/to/app/target/debug/trove
|
||||
pnpm run pack
|
||||
|
||||
# 可选:使用真实底座组件在临时目录自动验证。
|
||||
export TROVE_APP_DIR=/absolute/path/to/app
|
||||
pnpm run verify
|
||||
```
|
||||
|
||||
打包需要 macOS、Rust/Cargo、Node.js 22+、pnpm、Python 3 和 Trove CLI。原生包只面向当前构建机器的架构;安装后的插件无需 Node.js、Python 或 Cargo。SDK 使用仓库内的版本化 `@trove/plugin-sdk@0.1.0` 包,离线复制到 UI 中。
|
||||
|
||||
生成结果:
|
||||
|
||||
- `dist/packages/`:15 个可安装 `.curio` 包。
|
||||
- `dist/index.json`:常规测试源,8 个插件 ID / 9 个版本包。
|
||||
- `dist/faults-index.json`:6 个故障/异常依赖插件,按需安装。
|
||||
- `dist/invalid/`:22 个无效包、4 个异常源及预期错误码。
|
||||
- `dist/verification.json`:自动验证的逐项结果、环境和观察项。
|
||||
|
||||
在 Trove 添加**本地软件源**,选择 `dist/index.json` 的绝对路径,即可安装常规测试插件。若要测试 HTTP 下载:
|
||||
|
||||
```sh
|
||||
pnpm run serve
|
||||
# 在底座添加 HTTP 软件源:http://127.0.0.1:8765/index.json
|
||||
# 用 QA_HTTP_PORT 环境变量更换端口,Ctrl-C 停止。
|
||||
```
|
||||
|
||||
完整步骤、覆盖矩阵与当前限制见 [TESTING.md](TESTING.md)。
|
||||
|
||||
## 目录
|
||||
|
||||
- `curios/<name>/`:各插件独立的 curio.json、UI 和可选后端。
|
||||
- `curios/<name>/`:各插件独立 Manifest 与公共服务契约。
|
||||
- `shared/ui/`、`shared/backend/`:测试面板和原生后端源码;构建时复制到各插件。
|
||||
- `source/index.json`:官方源的初始空索引。
|
||||
- `tools/`:后续构建、打包与索引生成脚本(Bun/Rust)。
|
||||
- `tools/`:构建、打包、测试源和真实底座验证适配器。
|
||||
- `vendor/`:版本化 SDK 分发包与来源说明。
|
||||
|
||||
项目依赖使用 pnpm;SDK 通过版本化的 @trove/plugin-sdk 引用,不通过 ../app 引用源文件。App 仓库保留协议规范和教学模板。
|
||||
项目依赖使用 pnpm;插件通过版本化的 `@trove/plugin-sdk` 引用 SDK,不通过 `../app` 引用源文件。仅自动验证适配器通过显式的 `TROVE_APP_DIR` 编译待测底座组件,插件构建与运行不依赖该路径。App 仓库保留协议规范和教学模板。
|
||||
|
||||
## 发布约定
|
||||
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
# Trove 底座测试指南
|
||||
|
||||
本套件是可安装、可运行的验收材料。以当前 Manifest v1、Wire Protocol v1 和 `@trove/plugin-sdk@0.1.0` 为基础;插件 ID 均使用 `dev.trove.qa.*`,不占用正式插件命名空间。
|
||||
|
||||
## 快速验收
|
||||
|
||||
1. 按 README 执行 `pnpm install --frozen-lockfile`、`pnpm run pack`。
|
||||
2. 在底座添加本地源 `dist/index.json`,先安装「01 · Host 能力检查」「02 · 数据隔离对照」「03 · 原生后端检查」。逐一打开,点击「运行常规检查」。
|
||||
3. 安装「04 · 公共服务提供方」,再安装并打开「05 · 跨插件调用」。提供方没有 UI;调用时才启动。
|
||||
4. 安装「06 · 可选依赖缺失」,检查缺失服务报错的同时 Host 仍然可调用。
|
||||
5. 在 Host 检查页按需执行文件选择、确认对话框、通知、剪贴板、快捷键和窗口操作。结果中「已执行 · 请观察」不代表系统效果已经通过人工验收。
|
||||
6. 使用「保存 JSON 报告」将报告写入本插件的数据目录;结果会显示路径。也可展开「原始报告」查看全部错误码和返回值。
|
||||
|
||||
`pnpm run pack` 会重新构建并覆盖本地生成文件。它不安装插件、不启动桌面应用、不修改官方软件源。源码修改后生成同 ID/版本的新内容,可能被底座的版本不可变校验拒绝;重复开发时先卸载该测试插件,或主动提高版本。
|
||||
|
||||
## 插件矩阵
|
||||
|
||||
| 工程 | ID 后缀 / 版本 | 覆盖与预期 |
|
||||
| --- | --- | --- |
|
||||
| `test-host` | `host` / 1.0.0 | 无后端 UI;SDK 上下文、Unicode/JSON、32 路并发、服务发现、存储/文件 CRUD、错误码、桌面手动操作 |
|
||||
| `test-isolation` | `isolation` / 1.0.0 | 与 host 使用同一个持久化键;读到各自 ID,关闭并重新打开后保留 |
|
||||
| `test-self` | `self` / 1.0.0 | UI 打开时启动后端;`@self`、环境与参数、24 路乱序请求、200 KiB 以上消息、取消/超时、Host 回调、事件 |
|
||||
| `test-provider` | `provider` / 1.0.0 | Backend-only;服务 `dev.trove.qa.provider.probe@1.0.0`;按需启动、20 秒空闲退出 |
|
||||
| `test-consumer` | `consumer` / 1.0.0 | 必需依赖 provider 1.x;公开服务调用、事件、输入/输出契约 |
|
||||
| `test-optional` | `optional` / 1.0.0 | 可选服务不存在;UI 与 Host 正常,缺失服务调用返回 `service.not_found` |
|
||||
| `test-autostart` | `autostart` / 1.0.0 | Backend-only;重启底座后应自动运行,公开服务为 `dev.trove.qa.autostart.probe` |
|
||||
| `test-upgrade-v1` | `upgrade` / 1.0.0 | 安装基线,在面板手动写入升级标记 |
|
||||
| `test-upgrade-v2` | `upgrade` / 1.1.0 | 同 ID 更新;读取 1.0.0 写入的标记,支持回滚验收 |
|
||||
| `test-required` | `required` / 1.0.0 | 故障源:必需服务不存在;当前底座调用时返回 `service.not_found` |
|
||||
| `test-incompatible` | `incompatible` / 1.0.0 | 故障源:要求 provider 2.x;安装 provider 1.x 后调用返回 `service.version_mismatch` |
|
||||
| `test-crash` | `crash` / 1.0.0 | 故障源:按钮让自己的后端 exit 42;观察重启、连续崩溃熔断与手动重试 |
|
||||
| `test-start-timeout` | `start-timeout` / 1.0.0 | 故障源:收到 welcome 但不发送 ready;返回 `provider.start_timeout` |
|
||||
| `test-bad-handshake` | `bad-handshake` / 1.0.0 | 故障源:错误会话令牌;Host 拒绝连接,进程启动失败 |
|
||||
| `test-stubborn` | `stubborn` / 1.0.0 | 故障源:忽略 shutdown;超过 500 ms 宽限期后应被底座终止 |
|
||||
|
||||
每个工程都有自己的 `curio.json`。UI 源文件来自 `shared/ui/`,构建到 `dist/ui/`;Rust 源码在 `shared/backend/`,构建产物复制到各包的 `backend/probe`。生产包包含 SDK、静态资源和所需原生可执行文件,不依赖工作区相邻目录。
|
||||
|
||||
## 生命周期、状态与数据
|
||||
|
||||
- **按需启动**:先安装 provider,不进行任何服务调用;在底座看其未启动。打开 consumer 运行检查后应启动;停止调用约 20 秒后退出,再调用得到新 PID。查询 `host.services.getStatus` 不应启动提供方。
|
||||
- **随底座启动**:安装 autostart,完全退出并重启 Trove。在进行 `info` 调用前,用 Host 检查页的「检查随底座启动的服务」确认 `Available`,或在管理界面确认进程已运行。单纯调用成功不能证明 app-start 生效。
|
||||
- **UI 生命周期**:重复打开同一插件应聚焦已有窗口;关闭后无失效窗口。重开后读取标记仍存在;退出底座应清理所有测试后端、连接和快捷键。
|
||||
- **禁用与卸载**:禁用 provider 后 consumer 调用应报告 `provider.disabled`;启用后恢复。卸载后调用应报告 `service.not_found`。
|
||||
- **崩溃隔离**:安装 crash,先读取 PID,再触发崩溃,读取新 PID。短时间连续崩溃三次后应得到 `provider.crash_loop`。同时操作 Host 与 self 插件,确认未被连带中断;在底座管理页手动重试恢复。
|
||||
- **停止无响应进程**:打开 stubborn,记录 PID,在底座停止或禁用;约 500 ms 宽限期后该 PID 应消失。
|
||||
- **存储隔离**:分别运行 host 与 isolation;两者的 `qa.namespace-owner` 值必须是自己的 ID。后端回调 Host storage 的数据归属后端提供方,不能落到 consumer 的命名空间。
|
||||
|
||||
常规检查只清理自己创建的临时文件/键。`qa.namespace-owner` 与固定的 `qa.backend-roundtrip` 保留供人工检查,报告和升级标记也会保留。存储大小限制测试使用超过 1 MiB 的值;不会填满整个存储配额。剪贴板按钮会替换系统剪贴板;快捷键使用 Cmd+Shift+Option+F9,等待最多 15 秒,并在结束时注销。系统通知是否可见需人工确认。
|
||||
|
||||
## 升级与回滚
|
||||
|
||||
常规软件源同时包含 1.0.0 与 1.1.0;默认安装可能直接选到最新版。要测试完整路径,先手动安装基线包:
|
||||
|
||||
```sh
|
||||
"$TROVE_BIN" curio install dist/packages/dev.trove.qa.upgrade-1.0.0.curio --no-input
|
||||
```
|
||||
|
||||
打开插件,点击「写入升级基线标记」,记录 token,再安装更新:
|
||||
|
||||
```sh
|
||||
"$TROVE_BIN" curio install dist/packages/dev.trove.qa.upgrade-1.1.0.curio --no-input
|
||||
```
|
||||
|
||||
运行中更新应正确暂存或切换,不混用两个版本的 UI 资源。关闭并重新打开后应显示 1.1.0,标记仍由 1.0.0 写入、token 不变。使用底座的版本切换/回滚操作回到 1.0.0,验证数据继续保留。卸载时分别验证保留数据和删除数据,再重装读取。
|
||||
|
||||
## 故意无效的安装材料
|
||||
|
||||
`dist/invalid/cases.json` 列出每个 `.curio` 和应返回的错误码。覆盖 22 种情况:
|
||||
|
||||
- JSON 损坏、未知字段、schemaVersion 不支持、保留 ID、非法版本、引擎不兼容。
|
||||
- UI 缺失、UI/Backend 均未声明、Manifest 路径越界、覆盖保留环境变量。
|
||||
- 没有后端却提供服务、服务命名空间错误、后端/契约文件缺失、契约版本不一致、非法 JSON Schema。
|
||||
- ZIP 路径越界、绝对路径、反斜杠路径、大小写碰撞、符号链接、损坏压缩包。
|
||||
|
||||
`dist/invalid/source-cases.json` 列出 4 种合法索引携带异常下载元数据的情况:SHA-256 错误、大小错误、目标架构不匹配、索引与包身份不一致。它们不加入常规软件源。
|
||||
|
||||
无效安装应有明确错误,已安装列表和当前版本不变化,staging 清理完成。不要将所有 `.curio` 不加区分地批量安装;`dist/packages/` 才是结构合法的测试包。
|
||||
|
||||
## 自动验证
|
||||
|
||||
```sh
|
||||
export TROVE_BIN=/absolute/path/to/app/target/debug/trove
|
||||
export TROVE_APP_DIR=/absolute/path/to/app
|
||||
# 可选:复用现有 Cargo 缓存;默认使用 dist/host-target。
|
||||
export TROVE_QA_TARGET_DIR=/absolute/path/to/app/target
|
||||
pnpm run verify
|
||||
```
|
||||
|
||||
验证流程:
|
||||
|
||||
1. 编译 `tools/host-check.rs`,链接指定 app 仓库的真实 `PackageManager`、`ServiceBus`、`BackendSupervisor` 和 Unix Gateway,禁用桌面特性。
|
||||
2. 用 Trove CLI 的 `protocol test` 检查后端的握手、分片接收、并发请求 ID、结构化错误和优雅退出。
|
||||
3. 在 `/tmp/trove-qa-*` 创建隔离数据目录,安装真实包;验证无效材料、软件源本地/HTTP 下载、持久化、升级/回滚和卸载。
|
||||
4. 通过测试桥将**真实生产 SDK** 连接至真实 Service Bus。直接运行 `shared/ui/checks.js` 中的检查;后端走真实 Unix socket 和原生子进程,不使用 mock 服务。
|
||||
5. 默认策略下检查实际行为,再**单独开启严格契约模式**验证参数和返回值;随后恢复默认模式。测试取消清理、服务禁用、启动超时、认证失败、空闲退出、强制停止、崩溃隔离/熔断。
|
||||
6. 写入 `dist/verification.json`,失败时非零退出;正常结束时停止测试 HTTP 服务、后端进程,删除隔离目录。不会触碰用户的 Trove 安装注册表或现有插件数据。
|
||||
|
||||
首次编译可能需要较长时间。自动结果不等于桌面 WebView/系统交互的完整验收;真实 WebView 注入、窗口视觉、系统权限、快捷键触发、通知展示、app-start 的应用启动调度仍应按上文手动检查。
|
||||
|
||||
## 当前能力边界
|
||||
|
||||
- 当前底座默认未调用 `strict_contracts(true)`。公共服务的无效返回值可能直接透传,因此 consumer 面板的「公共服务返回值契约」会报告失败;这是能力探针,不能将其误判成自动通过。验证脚本把默认策略列为观察项,严格模式断言单独标注,不修改底座源码。
|
||||
- 必需依赖与可选依赖目前都在服务调用阶段暴露缺失;不要预设安装器或打开窗口时一定拦截。两个插件用于观察和比较这一行为。
|
||||
- 以实际已注册服务为准。规范草案中的 `host.process`、`host.screen`、`host.keychain`、辅助功能、文件 watch 等未被本套件宣称为已实现;没有用模拟服务填补这些能力。
|
||||
- macOS 原生构建按当前架构出包;另一架构需在对应机器重新构建。自动测试覆盖目标架构拒绝,不验证跨架构二进制执行、签名、公证、真实公网 TLS、断电原子性或系统资源耗尽。
|
||||
- `dist/` 中本地索引包含绝对 `file://` 地址;移动仓库后重新打包生成索引。HTTP 源由 `pnpm run serve` 动态改写为本机地址,仅监听 127.0.0.1。
|
||||
@@ -0,0 +1,208 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"service": "dev.trove.qa.autostart.probe",
|
||||
"version": "1.0.0",
|
||||
"description": "Trove QA service; fault methods are intentionally opt-in.",
|
||||
"methods": {
|
||||
"echo": {
|
||||
"params": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"number",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"result": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"number",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"result": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"pid",
|
||||
"curioId",
|
||||
"version",
|
||||
"label",
|
||||
"args",
|
||||
"activeRequests",
|
||||
"subscriptions"
|
||||
]
|
||||
}
|
||||
},
|
||||
"delay": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ms": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 10000
|
||||
},
|
||||
"value": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"number",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ms"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"result": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"number",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fail": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"result": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"number",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
},
|
||||
"invalidResult": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"result": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"storageRoundtrip": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"number",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"key",
|
||||
"value"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"result": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"found",
|
||||
"value"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uppercase": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"text"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"result": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"text"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"progress": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"taskId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"taskId"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"taskId": {
|
||||
"type": "string"
|
||||
},
|
||||
"progress": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"taskId",
|
||||
"progress"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "dev.trove.qa.autostart",
|
||||
"name": "07 · 随底座启动",
|
||||
"description": "无 UI 的 app-start 后端;重启底座后检查状态和 PID。",
|
||||
"version": "1.0.0",
|
||||
"engine": {
|
||||
"trove": ">=0.1.0 <1.0.0"
|
||||
},
|
||||
"backend": {
|
||||
"entry": "backend/probe",
|
||||
"args": [],
|
||||
"env": {
|
||||
"QA_LABEL": "test-autostart"
|
||||
},
|
||||
"lifecycle": "app-start",
|
||||
"transport": "unix",
|
||||
"startWithUi": false,
|
||||
"startupTimeoutMs": 5000,
|
||||
"shutdownGraceMs": 500,
|
||||
"idleTimeoutMs": 0
|
||||
},
|
||||
"services": {
|
||||
"requires": [
|
||||
{
|
||||
"id": "host.storage",
|
||||
"version": "^1.0.0"
|
||||
}
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
"id": "dev.trove.qa.autostart.probe",
|
||||
"version": "1.0.0",
|
||||
"contract": "contracts/probe.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"command": [
|
||||
"node",
|
||||
"../../tools/build.mjs",
|
||||
"test-autostart"
|
||||
]
|
||||
},
|
||||
"package": {
|
||||
"files": [
|
||||
"backend",
|
||||
"contracts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "dev.trove.qa.bad-handshake",
|
||||
"name": "故障 · 握手认证失败",
|
||||
"description": "发送错误会话令牌,期望底座拒绝该连接。",
|
||||
"version": "1.0.0",
|
||||
"engine": {
|
||||
"trove": ">=0.1.0 <1.0.0"
|
||||
},
|
||||
"ui": {
|
||||
"entry": "dist/ui/index.html",
|
||||
"window": {
|
||||
"title": "故障 · 握手认证失败",
|
||||
"width": 960,
|
||||
"height": 760,
|
||||
"minWidth": 600,
|
||||
"minHeight": 400,
|
||||
"resizable": true
|
||||
}
|
||||
},
|
||||
"backend": {
|
||||
"entry": "backend/probe",
|
||||
"args": [
|
||||
"--bad-token"
|
||||
],
|
||||
"env": {
|
||||
"QA_LABEL": "test-bad-handshake"
|
||||
},
|
||||
"lifecycle": "on-demand",
|
||||
"transport": "unix",
|
||||
"startWithUi": false,
|
||||
"startupTimeoutMs": 1000,
|
||||
"shutdownGraceMs": 500,
|
||||
"idleTimeoutMs": 0
|
||||
},
|
||||
"services": {
|
||||
"requires": [
|
||||
{
|
||||
"id": "host.runtime",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.services",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.storage",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.fs",
|
||||
"version": "^1.0.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"command": [
|
||||
"node",
|
||||
"../../tools/build.mjs",
|
||||
"test-bad-handshake"
|
||||
]
|
||||
},
|
||||
"package": {
|
||||
"files": [
|
||||
"dist/ui",
|
||||
"backend"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "dev.trove.qa.consumer",
|
||||
"name": "05 · 跨插件调用",
|
||||
"description": "依赖公共服务;验证发现、契约、事件与后端回调。",
|
||||
"version": "1.0.0",
|
||||
"engine": {
|
||||
"trove": ">=0.1.0 <1.0.0"
|
||||
},
|
||||
"ui": {
|
||||
"entry": "dist/ui/index.html",
|
||||
"window": {
|
||||
"title": "05 · 跨插件调用",
|
||||
"width": 960,
|
||||
"height": 760,
|
||||
"minWidth": 600,
|
||||
"minHeight": 400,
|
||||
"resizable": true
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"requires": [
|
||||
{
|
||||
"id": "host.runtime",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.services",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.storage",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "dev.trove.qa.provider.probe",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.fs",
|
||||
"version": "^1.0.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"command": [
|
||||
"node",
|
||||
"../../tools/build.mjs",
|
||||
"test-consumer"
|
||||
]
|
||||
},
|
||||
"package": {
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "dev.trove.qa.crash",
|
||||
"name": "故障 · 崩溃与恢复",
|
||||
"description": "手动令自己的后端以 42 退出,检查隔离、重试与熔断。",
|
||||
"version": "1.0.0",
|
||||
"engine": {
|
||||
"trove": ">=0.1.0 <1.0.0"
|
||||
},
|
||||
"ui": {
|
||||
"entry": "dist/ui/index.html",
|
||||
"window": {
|
||||
"title": "故障 · 崩溃与恢复",
|
||||
"width": 960,
|
||||
"height": 760,
|
||||
"minWidth": 600,
|
||||
"minHeight": 400,
|
||||
"resizable": true
|
||||
}
|
||||
},
|
||||
"backend": {
|
||||
"entry": "backend/probe",
|
||||
"args": [
|
||||
"--allow-crash"
|
||||
],
|
||||
"env": {
|
||||
"QA_LABEL": "test-crash"
|
||||
},
|
||||
"lifecycle": "on-demand",
|
||||
"transport": "unix",
|
||||
"startWithUi": true,
|
||||
"startupTimeoutMs": 5000,
|
||||
"shutdownGraceMs": 500,
|
||||
"idleTimeoutMs": 0
|
||||
},
|
||||
"services": {
|
||||
"requires": [
|
||||
{
|
||||
"id": "host.runtime",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.services",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.storage",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.fs",
|
||||
"version": "^1.0.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"command": [
|
||||
"node",
|
||||
"../../tools/build.mjs",
|
||||
"test-crash"
|
||||
]
|
||||
},
|
||||
"package": {
|
||||
"files": [
|
||||
"dist/ui",
|
||||
"backend"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "dev.trove.qa.host",
|
||||
"name": "01 · Host 能力检查",
|
||||
"description": "UI、SDK 桥接、服务发现、存储、文件和桌面交互。",
|
||||
"version": "1.0.0",
|
||||
"engine": {
|
||||
"trove": ">=0.1.0 <1.0.0"
|
||||
},
|
||||
"ui": {
|
||||
"entry": "dist/ui/index.html",
|
||||
"window": {
|
||||
"title": "01 · Host 能力检查",
|
||||
"width": 960,
|
||||
"height": 760,
|
||||
"minWidth": 600,
|
||||
"minHeight": 400,
|
||||
"resizable": true
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"requires": [
|
||||
{
|
||||
"id": "host.runtime",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.services",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.storage",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.fs",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.window",
|
||||
"version": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"id": "host.clipboard",
|
||||
"version": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"id": "host.dialog",
|
||||
"version": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"id": "host.notification",
|
||||
"version": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"id": "host.shortcut",
|
||||
"version": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"id": "host.application",
|
||||
"version": "^1.0.0",
|
||||
"optional": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"command": [
|
||||
"node",
|
||||
"../../tools/build.mjs",
|
||||
"test-host"
|
||||
]
|
||||
},
|
||||
"package": {
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "dev.trove.qa.incompatible",
|
||||
"name": "故障 · 服务版本不兼容",
|
||||
"description": "要求 provider.probe 2.x;已安装提供方仅支持 1.x。",
|
||||
"version": "1.0.0",
|
||||
"engine": {
|
||||
"trove": ">=0.1.0 <1.0.0"
|
||||
},
|
||||
"ui": {
|
||||
"entry": "dist/ui/index.html",
|
||||
"window": {
|
||||
"title": "故障 · 服务版本不兼容",
|
||||
"width": 960,
|
||||
"height": 760,
|
||||
"minWidth": 600,
|
||||
"minHeight": 400,
|
||||
"resizable": true
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"requires": [
|
||||
{
|
||||
"id": "host.runtime",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.services",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.storage",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "dev.trove.qa.provider.probe",
|
||||
"version": "^2.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.fs",
|
||||
"version": "^1.0.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"command": [
|
||||
"node",
|
||||
"../../tools/build.mjs",
|
||||
"test-incompatible"
|
||||
]
|
||||
},
|
||||
"package": {
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "dev.trove.qa.isolation",
|
||||
"name": "02 · 数据隔离对照",
|
||||
"description": "与 Host 检查使用相同存储键,验证插件间独立命名空间。",
|
||||
"version": "1.0.0",
|
||||
"engine": {
|
||||
"trove": ">=0.1.0 <1.0.0"
|
||||
},
|
||||
"ui": {
|
||||
"entry": "dist/ui/index.html",
|
||||
"window": {
|
||||
"title": "02 · 数据隔离对照",
|
||||
"width": 960,
|
||||
"height": 760,
|
||||
"minWidth": 600,
|
||||
"minHeight": 400,
|
||||
"resizable": true
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"requires": [
|
||||
{
|
||||
"id": "host.runtime",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.services",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.storage",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.fs",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.window",
|
||||
"version": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"id": "host.clipboard",
|
||||
"version": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"id": "host.dialog",
|
||||
"version": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"id": "host.notification",
|
||||
"version": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"id": "host.shortcut",
|
||||
"version": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"id": "host.application",
|
||||
"version": "^1.0.0",
|
||||
"optional": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"command": [
|
||||
"node",
|
||||
"../../tools/build.mjs",
|
||||
"test-isolation"
|
||||
]
|
||||
},
|
||||
"package": {
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "dev.trove.qa.optional",
|
||||
"name": "06 · 可选依赖缺失",
|
||||
"description": "缺失可选服务时 UI 仍可工作,调用返回 service.not_found。",
|
||||
"version": "1.0.0",
|
||||
"engine": {
|
||||
"trove": ">=0.1.0 <1.0.0"
|
||||
},
|
||||
"ui": {
|
||||
"entry": "dist/ui/index.html",
|
||||
"window": {
|
||||
"title": "06 · 可选依赖缺失",
|
||||
"width": 960,
|
||||
"height": 760,
|
||||
"minWidth": 600,
|
||||
"minHeight": 400,
|
||||
"resizable": true
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"requires": [
|
||||
{
|
||||
"id": "host.runtime",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.services",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.storage",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "dev.trove.qa.absent.probe",
|
||||
"version": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"id": "host.fs",
|
||||
"version": "^1.0.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"command": [
|
||||
"node",
|
||||
"../../tools/build.mjs",
|
||||
"test-optional"
|
||||
]
|
||||
},
|
||||
"package": {
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"service": "dev.trove.qa.provider.probe",
|
||||
"version": "1.0.0",
|
||||
"description": "Trove QA service; fault methods are intentionally opt-in.",
|
||||
"methods": {
|
||||
"echo": {
|
||||
"params": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"number",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"result": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"number",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"result": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"pid",
|
||||
"curioId",
|
||||
"version",
|
||||
"label",
|
||||
"args",
|
||||
"activeRequests",
|
||||
"subscriptions"
|
||||
]
|
||||
}
|
||||
},
|
||||
"delay": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ms": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 10000
|
||||
},
|
||||
"value": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"number",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ms"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"result": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"number",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fail": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"result": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"number",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
},
|
||||
"invalidResult": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"result": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"storageRoundtrip": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": [
|
||||
"null",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"number",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"key",
|
||||
"value"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"result": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"found",
|
||||
"value"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uppercase": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"text"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"result": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"text"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"progress": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"taskId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"taskId"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"taskId": {
|
||||
"type": "string"
|
||||
},
|
||||
"progress": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"taskId",
|
||||
"progress"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "dev.trove.qa.provider",
|
||||
"name": "04 · 公共服务提供方",
|
||||
"description": "无 UI,按需启动;20 秒空闲后退出。",
|
||||
"version": "1.0.0",
|
||||
"engine": {
|
||||
"trove": ">=0.1.0 <1.0.0"
|
||||
},
|
||||
"backend": {
|
||||
"entry": "backend/probe",
|
||||
"args": [],
|
||||
"env": {
|
||||
"QA_LABEL": "test-provider"
|
||||
},
|
||||
"lifecycle": "on-demand",
|
||||
"transport": "unix",
|
||||
"startWithUi": false,
|
||||
"startupTimeoutMs": 5000,
|
||||
"shutdownGraceMs": 500,
|
||||
"idleTimeoutMs": 20000
|
||||
},
|
||||
"services": {
|
||||
"requires": [
|
||||
{
|
||||
"id": "host.storage",
|
||||
"version": "^1.0.0"
|
||||
}
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
"id": "dev.trove.qa.provider.probe",
|
||||
"version": "1.0.0",
|
||||
"contract": "contracts/probe.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"command": [
|
||||
"node",
|
||||
"../../tools/build.mjs",
|
||||
"test-provider"
|
||||
]
|
||||
},
|
||||
"package": {
|
||||
"files": [
|
||||
"backend",
|
||||
"contracts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "dev.trove.qa.required",
|
||||
"name": "故障 · 必需依赖缺失",
|
||||
"description": "声明不存在的必需服务,观察底座与调用错误。",
|
||||
"version": "1.0.0",
|
||||
"engine": {
|
||||
"trove": ">=0.1.0 <1.0.0"
|
||||
},
|
||||
"ui": {
|
||||
"entry": "dist/ui/index.html",
|
||||
"window": {
|
||||
"title": "故障 · 必需依赖缺失",
|
||||
"width": 960,
|
||||
"height": 760,
|
||||
"minWidth": 600,
|
||||
"minHeight": 400,
|
||||
"resizable": true
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"requires": [
|
||||
{
|
||||
"id": "host.runtime",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.services",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.storage",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "dev.trove.qa.absent.probe",
|
||||
"version": "^1.0.0",
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
"id": "host.fs",
|
||||
"version": "^1.0.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"command": [
|
||||
"node",
|
||||
"../../tools/build.mjs",
|
||||
"test-required"
|
||||
]
|
||||
},
|
||||
"package": {
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "dev.trove.qa.self",
|
||||
"name": "03 · 原生后端检查",
|
||||
"description": "UI → @self → 原生 Rust 后端 → Host;并发、取消、事件。",
|
||||
"version": "1.0.0",
|
||||
"engine": {
|
||||
"trove": ">=0.1.0 <1.0.0"
|
||||
},
|
||||
"ui": {
|
||||
"entry": "dist/ui/index.html",
|
||||
"window": {
|
||||
"title": "03 · 原生后端检查",
|
||||
"width": 960,
|
||||
"height": 760,
|
||||
"minWidth": 600,
|
||||
"minHeight": 400,
|
||||
"resizable": true
|
||||
}
|
||||
},
|
||||
"backend": {
|
||||
"entry": "backend/probe",
|
||||
"args": [],
|
||||
"env": {
|
||||
"QA_LABEL": "test-self"
|
||||
},
|
||||
"lifecycle": "on-demand",
|
||||
"transport": "unix",
|
||||
"startWithUi": true,
|
||||
"startupTimeoutMs": 5000,
|
||||
"shutdownGraceMs": 500,
|
||||
"idleTimeoutMs": 0
|
||||
},
|
||||
"services": {
|
||||
"requires": [
|
||||
{
|
||||
"id": "host.runtime",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.services",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.storage",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.fs",
|
||||
"version": "^1.0.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"command": [
|
||||
"node",
|
||||
"../../tools/build.mjs",
|
||||
"test-self"
|
||||
]
|
||||
},
|
||||
"package": {
|
||||
"files": [
|
||||
"dist/ui",
|
||||
"backend"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "dev.trove.qa.start-timeout",
|
||||
"name": "故障 · 启动超时",
|
||||
"description": "握手 welcome 后不发送 ready,期望启动在 1 秒内超时。",
|
||||
"version": "1.0.0",
|
||||
"engine": {
|
||||
"trove": ">=0.1.0 <1.0.0"
|
||||
},
|
||||
"ui": {
|
||||
"entry": "dist/ui/index.html",
|
||||
"window": {
|
||||
"title": "故障 · 启动超时",
|
||||
"width": 960,
|
||||
"height": 760,
|
||||
"minWidth": 600,
|
||||
"minHeight": 400,
|
||||
"resizable": true
|
||||
}
|
||||
},
|
||||
"backend": {
|
||||
"entry": "backend/probe",
|
||||
"args": [
|
||||
"--no-ready"
|
||||
],
|
||||
"env": {
|
||||
"QA_LABEL": "test-start-timeout"
|
||||
},
|
||||
"lifecycle": "on-demand",
|
||||
"transport": "unix",
|
||||
"startWithUi": false,
|
||||
"startupTimeoutMs": 1000,
|
||||
"shutdownGraceMs": 500,
|
||||
"idleTimeoutMs": 0
|
||||
},
|
||||
"services": {
|
||||
"requires": [
|
||||
{
|
||||
"id": "host.runtime",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.services",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.storage",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.fs",
|
||||
"version": "^1.0.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"command": [
|
||||
"node",
|
||||
"../../tools/build.mjs",
|
||||
"test-start-timeout"
|
||||
]
|
||||
},
|
||||
"package": {
|
||||
"files": [
|
||||
"dist/ui",
|
||||
"backend"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "dev.trove.qa.stubborn",
|
||||
"name": "故障 · 拒绝优雅退出",
|
||||
"description": "忽略 shutdown,检查底座在宽限期后清理进程。",
|
||||
"version": "1.0.0",
|
||||
"engine": {
|
||||
"trove": ">=0.1.0 <1.0.0"
|
||||
},
|
||||
"ui": {
|
||||
"entry": "dist/ui/index.html",
|
||||
"window": {
|
||||
"title": "故障 · 拒绝优雅退出",
|
||||
"width": 960,
|
||||
"height": 760,
|
||||
"minWidth": 600,
|
||||
"minHeight": 400,
|
||||
"resizable": true
|
||||
}
|
||||
},
|
||||
"backend": {
|
||||
"entry": "backend/probe",
|
||||
"args": [
|
||||
"--ignore-shutdown"
|
||||
],
|
||||
"env": {
|
||||
"QA_LABEL": "test-stubborn"
|
||||
},
|
||||
"lifecycle": "on-demand",
|
||||
"transport": "unix",
|
||||
"startWithUi": true,
|
||||
"startupTimeoutMs": 5000,
|
||||
"shutdownGraceMs": 500,
|
||||
"idleTimeoutMs": 0
|
||||
},
|
||||
"services": {
|
||||
"requires": [
|
||||
{
|
||||
"id": "host.runtime",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.services",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.storage",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.fs",
|
||||
"version": "^1.0.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"command": [
|
||||
"node",
|
||||
"../../tools/build.mjs",
|
||||
"test-stubborn"
|
||||
]
|
||||
},
|
||||
"package": {
|
||||
"files": [
|
||||
"dist/ui",
|
||||
"backend"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "dev.trove.qa.upgrade",
|
||||
"name": "08 · 升级与数据保留",
|
||||
"description": "1.0.0 基线;写入标记后安装 1.1.0 检查数据保留。",
|
||||
"version": "1.0.0",
|
||||
"engine": {
|
||||
"trove": ">=0.1.0 <1.0.0"
|
||||
},
|
||||
"ui": {
|
||||
"entry": "dist/ui/index.html",
|
||||
"window": {
|
||||
"title": "08 · 升级与数据保留",
|
||||
"width": 960,
|
||||
"height": 760,
|
||||
"minWidth": 600,
|
||||
"minHeight": 400,
|
||||
"resizable": true
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"requires": [
|
||||
{
|
||||
"id": "host.runtime",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.services",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.storage",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.fs",
|
||||
"version": "^1.0.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"command": [
|
||||
"node",
|
||||
"../../tools/build.mjs",
|
||||
"test-upgrade-v1"
|
||||
]
|
||||
},
|
||||
"package": {
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "dev.trove.qa.upgrade",
|
||||
"name": "08 · 升级与数据保留",
|
||||
"description": "1.1.0 更新;显示版本并读取基线标记。",
|
||||
"version": "1.1.0",
|
||||
"engine": {
|
||||
"trove": ">=0.1.0 <1.0.0"
|
||||
},
|
||||
"ui": {
|
||||
"entry": "dist/ui/index.html",
|
||||
"window": {
|
||||
"title": "08 · 升级与数据保留",
|
||||
"width": 960,
|
||||
"height": 760,
|
||||
"minWidth": 600,
|
||||
"minHeight": 400,
|
||||
"resizable": true
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"requires": [
|
||||
{
|
||||
"id": "host.runtime",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.services",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.storage",
|
||||
"version": "^1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "host.fs",
|
||||
"version": "^1.0.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"command": [
|
||||
"node",
|
||||
"../../tools/build.mjs",
|
||||
"test-upgrade-v2"
|
||||
]
|
||||
},
|
||||
"package": {
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "trove-curio-test-lab",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.23.0",
|
||||
"scripts": {
|
||||
"build": "node tools/build.mjs",
|
||||
"pack": "node tools/pack.mjs",
|
||||
"verify": "node tools/verify.mjs",
|
||||
"serve": "node tools/serve.mjs",
|
||||
"test": "node tools/verify.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trove/plugin-sdk": "file:vendor/trove-plugin-sdk-0.1.0.tgz"
|
||||
}
|
||||
}
|
||||
Generated
+23
@@ -0,0 +1,23 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@trove/plugin-sdk':
|
||||
specifier: file:vendor/trove-plugin-sdk-0.1.0.tgz
|
||||
version: file:vendor/trove-plugin-sdk-0.1.0.tgz
|
||||
|
||||
packages:
|
||||
|
||||
'@trove/plugin-sdk@file:vendor/trove-plugin-sdk-0.1.0.tgz':
|
||||
resolution: {integrity: sha512-jd6JJO3FGogsGkoFHlNYTDDkLu++HUctWI0sUV05Ry7JxfJEgQkz8BjHVs58lU/euoLAVTUh3itGM/gnjwRI+A==, tarball: file:vendor/trove-plugin-sdk-0.1.0.tgz}
|
||||
version: 0.1.0
|
||||
|
||||
snapshots:
|
||||
|
||||
'@trove/plugin-sdk@file:vendor/trove-plugin-sdk-0.1.0.tgz': {}
|
||||
Generated
+112
@@ -0,0 +1,112 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trove-qa-backend"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "trove-qa-backend"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,492 @@
|
||||
// Framing and request lifecycle adapted from Trove app/examples/backend-rust (Wire v1).
|
||||
// This diagnostic backend is test-only; fault modes require explicit manifest args.
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
env, fs,
|
||||
io::{Read, Write},
|
||||
os::unix::net::UnixStream,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
|
||||
mpsc, Arc, Mutex,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
const MAX_FRAME: usize = 8 * 1024 * 1024;
|
||||
const MAX_IN_FLIGHT: usize = 256;
|
||||
static ACTIVE_REQUESTS: AtomicUsize = AtomicUsize::new(0);
|
||||
fn active_count() -> usize {
|
||||
ACTIVE_REQUESTS.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
type WireResult = std::result::Result<Value, Value>;
|
||||
type Pending = Arc<Mutex<HashMap<String, mpsc::Sender<Value>>>>;
|
||||
type Active = Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>;
|
||||
type Subscriptions = Arc<Mutex<HashMap<String, Subscription>>>;
|
||||
struct Subscription {
|
||||
task_id: String,
|
||||
caller: Value,
|
||||
seq: u64,
|
||||
}
|
||||
struct Queued {
|
||||
bytes: Vec<u8>,
|
||||
flushed: Option<mpsc::Sender<()>>,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Writer {
|
||||
sender: mpsc::SyncSender<Queued>,
|
||||
bytes: Arc<AtomicUsize>,
|
||||
}
|
||||
impl Writer {
|
||||
fn new(mut socket: UnixStream) -> Self {
|
||||
let (sender, receiver) = mpsc::sync_channel::<Queued>(512);
|
||||
let bytes = Arc::new(AtomicUsize::new(0));
|
||||
let budget = bytes.clone();
|
||||
thread::spawn(move || {
|
||||
for next in receiver {
|
||||
let result = socket.write_all(&next.bytes).and_then(|_| socket.flush());
|
||||
budget.fetch_sub(next.bytes.len(), Ordering::Relaxed);
|
||||
if result.is_err() {
|
||||
break;
|
||||
}
|
||||
if let Some(flushed) = next.flushed {
|
||||
let _ = flushed.send(());
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { sender, bytes }
|
||||
}
|
||||
fn send(&self, message: Value) -> Result<()> {
|
||||
let payload = serde_json::to_vec(&message)?;
|
||||
if payload.is_empty() || payload.len() > MAX_FRAME {
|
||||
return Err(anyhow!("Frame exceeds 8 MiB"));
|
||||
}
|
||||
let size = payload.len() + 4;
|
||||
if self
|
||||
.bytes
|
||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |used| {
|
||||
(used + size <= 16 * 1024 * 1024).then_some(used + size)
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return Err(anyhow!("Outbound byte queue is full"));
|
||||
}
|
||||
let mut bytes = (payload.len() as u32).to_be_bytes().to_vec();
|
||||
bytes.extend(payload);
|
||||
if let Err(error) = self.sender.try_send(Queued {
|
||||
bytes,
|
||||
flushed: None,
|
||||
}) {
|
||||
self.bytes.fetch_sub(size, Ordering::Relaxed);
|
||||
return Err(anyhow!("Outbound queue unavailable: {error}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn flush(&self) {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
if self
|
||||
.sender
|
||||
.try_send(Queued {
|
||||
bytes: vec![],
|
||||
flushed: Some(tx),
|
||||
})
|
||||
.is_ok()
|
||||
{
|
||||
let _ = rx.recv_timeout(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
fn response(&self, id: Value, result: WireResult) {
|
||||
let message = match result {
|
||||
Ok(result) => json!({"v":1,"type":"response","id":id,"result":result}),
|
||||
Err(error) => json!({"v":1,"type":"response","id":id,"error":error}),
|
||||
};
|
||||
let _ = self.send(message);
|
||||
}
|
||||
}
|
||||
fn error(code: &str, message: impl ToString) -> Value {
|
||||
json!({"code":code,"message":message.to_string(),"retryable":false})
|
||||
}
|
||||
fn key(id: &Value) -> String {
|
||||
id.to_string()
|
||||
}
|
||||
fn read(reader: &mut UnixStream) -> Result<Value> {
|
||||
let mut header = [0; 4];
|
||||
reader.read_exact(&mut header)?;
|
||||
let length = u32::from_be_bytes(header) as usize;
|
||||
if length == 0 || length > MAX_FRAME {
|
||||
return Err(anyhow!("Invalid frame length"));
|
||||
}
|
||||
let mut payload = vec![0; length];
|
||||
reader.read_exact(&mut payload)?;
|
||||
let message: Value = serde_json::from_slice(&payload)?;
|
||||
if !message.is_object() || message["v"] != 1 {
|
||||
return Err(anyhow!("Invalid message version"));
|
||||
}
|
||||
Ok(message)
|
||||
}
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
fn progress(writer: &Writer, subscriptions: &Subscriptions, context: &Value, amount: f64) {
|
||||
for (id, sub) in subscriptions.lock().unwrap().iter_mut() {
|
||||
if sub.caller == context["caller"] {
|
||||
sub.seq += 1;
|
||||
let _ = writer.send(json!({"v":1,"type":"event","subscriptionId":id,"event":"progress","seq":sub.seq,"data":{"taskId":sub.task_id,"progress":amount}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
fn call_host(
|
||||
writer: &Writer,
|
||||
pending: &Pending,
|
||||
ids: &AtomicU64,
|
||||
service: &str,
|
||||
method: &str,
|
||||
params: Value,
|
||||
context: &Value,
|
||||
cancelled: &AtomicBool,
|
||||
) -> WireResult {
|
||||
let id = format!("rust_{}", ids.fetch_add(1, Ordering::Relaxed));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
{
|
||||
let mut pending = pending.lock().unwrap();
|
||||
if pending.len() >= MAX_IN_FLIGHT {
|
||||
return Err(error(
|
||||
"request.too_many_in_flight",
|
||||
"Host request limit reached",
|
||||
));
|
||||
}
|
||||
pending.insert(id.clone(), tx);
|
||||
}
|
||||
let timeout_ms = context["deadlineUnixMs"]
|
||||
.as_u64()
|
||||
.unwrap_or(now_ms() + 30_000)
|
||||
.saturating_sub(now_ms())
|
||||
.min(30_000);
|
||||
if let Err(problem) = writer.send(json!({"v":1,"type":"request","id":id,"service":service,"method":method,"params":params,"options":{"timeoutMs":timeout_ms},"context":context})) {
|
||||
pending.lock().unwrap().remove(&id); return Err(error("transport.backpressure", problem));
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
|
||||
let result = loop {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
break Err(error("request.cancelled", "Request cancelled"));
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
break Err(error("request.timeout", "Host request timed out"));
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(10)) {
|
||||
Ok(response) => {
|
||||
break match response.get("error") {
|
||||
Some(error) => Err(error.clone()),
|
||||
None => Ok(response["result"].clone()),
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(_) => break Err(error("provider.disconnected", "Host disconnected")),
|
||||
}
|
||||
};
|
||||
if pending.lock().unwrap().remove(&id).is_some() {
|
||||
let _ = writer.send(json!({"v":1,"type":"cancel","id":id}));
|
||||
}
|
||||
result
|
||||
}
|
||||
fn request(
|
||||
message: &Value,
|
||||
writer: &Writer,
|
||||
pending: &Pending,
|
||||
ids: &AtomicU64,
|
||||
subscriptions: &Subscriptions,
|
||||
cancelled: &AtomicBool,
|
||||
) -> WireResult {
|
||||
if !matches!(
|
||||
message["service"].as_str(),
|
||||
Some(service) if service == "@self" || service == format!("{}.probe", env::var("TROVE_CURIO_ID").unwrap_or_default())
|
||||
) {
|
||||
return Err(error("service.not_found", "Unknown service"));
|
||||
}
|
||||
let context = &message["context"];
|
||||
match message["method"].as_str() {
|
||||
Some("echo") => return Ok(message["params"].clone()),
|
||||
Some("info") => {
|
||||
return Ok(json!({
|
||||
"pid":std::process::id(), "curioId":env::var("TROVE_CURIO_ID").unwrap_or_default(),
|
||||
"version":env::var("TROVE_CURIO_VERSION").unwrap_or_default(),
|
||||
"label":env::var("QA_LABEL").unwrap_or_default(), "args":env::args().skip(1).collect::<Vec<_>>(),
|
||||
"activeRequests":active_count(), "subscriptions":subscriptions.lock().unwrap().len()
|
||||
}))
|
||||
}
|
||||
Some("delay") => {
|
||||
let ms = message["params"]["ms"]
|
||||
.as_u64()
|
||||
.filter(|ms| *ms <= 10_000)
|
||||
.ok_or_else(|| {
|
||||
error(
|
||||
"request.invalid_params",
|
||||
"ms must be an integer in 0..10000",
|
||||
)
|
||||
})?;
|
||||
let deadline = Instant::now() + Duration::from_millis(ms);
|
||||
while Instant::now() < deadline {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Err(error("request.cancelled", "Delay cancelled"));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
return Ok(message["params"]["value"].clone());
|
||||
}
|
||||
Some("fail") => {
|
||||
return Err(
|
||||
json!({"code":"qa.intentional_error","message":"Expected QA failure","retryable":false,"data":{"fixture":true}}),
|
||||
)
|
||||
}
|
||||
Some("invalidResult") => return Ok(json!({"intentionally":"not the contract string"})),
|
||||
Some("storageRoundtrip") => {
|
||||
let key = message["params"]["key"]
|
||||
.as_str()
|
||||
.filter(|key| key.starts_with("qa."))
|
||||
.ok_or_else(|| error("request.invalid_params", "key must start with qa."))?;
|
||||
call_host(
|
||||
writer,
|
||||
pending,
|
||||
ids,
|
||||
"host.storage",
|
||||
"set",
|
||||
message["params"].clone(),
|
||||
context,
|
||||
cancelled,
|
||||
)?;
|
||||
return call_host(
|
||||
writer,
|
||||
pending,
|
||||
ids,
|
||||
"host.storage",
|
||||
"get",
|
||||
json!({"key":key}),
|
||||
context,
|
||||
cancelled,
|
||||
);
|
||||
}
|
||||
Some("crash") if env::args().any(|arg| arg == "--allow-crash") => {
|
||||
eprintln!("qa: deliberate exit 42");
|
||||
std::process::exit(42);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let text = match message["method"].as_str() {
|
||||
Some("uppercase") => message["params"]["text"]
|
||||
.as_str()
|
||||
.ok_or_else(|| error("request.invalid_params", "text must be a string"))?
|
||||
.to_owned(),
|
||||
_ => return Err(error("service.method_not_found", "Unknown method")),
|
||||
};
|
||||
progress(writer, subscriptions, context, 0.0);
|
||||
let total = text.chars().count();
|
||||
let mut output = String::new();
|
||||
for (index, character) in text.chars().enumerate() {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Err(error("request.cancelled", "Request cancelled"));
|
||||
}
|
||||
output.extend(character.to_uppercase());
|
||||
if index % 4096 == 0 {
|
||||
progress(
|
||||
writer,
|
||||
subscriptions,
|
||||
context,
|
||||
index as f64 / total.max(1) as f64,
|
||||
);
|
||||
thread::yield_now();
|
||||
}
|
||||
}
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Err(error("request.cancelled", "Request cancelled"));
|
||||
}
|
||||
progress(writer, subscriptions, context, 1.0);
|
||||
Ok(json!({"text":output}))
|
||||
}
|
||||
fn session() -> Result<(String, String)> {
|
||||
if let Ok(path) = env::var("TROVE_DEV_SESSION_FILE") {
|
||||
let value: Value = serde_json::from_slice(&fs::read(path)?)?;
|
||||
return Ok((
|
||||
value["socketPath"]
|
||||
.as_str()
|
||||
.context("missing socketPath")?
|
||||
.into(),
|
||||
value["sessionToken"]
|
||||
.as_str()
|
||||
.context("missing sessionToken")?
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok((
|
||||
env::var("TROVE_SOCKET_PATH")?,
|
||||
env::var("TROVE_SESSION_TOKEN")?,
|
||||
))
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let (path, mut token) = session()?;
|
||||
if env::args().any(|arg| arg == "--bad-token") {
|
||||
token = "qa-deliberately-invalid-token".into();
|
||||
}
|
||||
println!("qa: backend started pid={}", std::process::id());
|
||||
eprintln!("qa: label={}", env::var("QA_LABEL").unwrap_or_default());
|
||||
let mut reader = UnixStream::connect(path)?;
|
||||
let writer = Writer::new(reader.try_clone()?);
|
||||
let pending: Pending = Arc::new(Mutex::new(HashMap::new()));
|
||||
let active: Active = Arc::new(Mutex::new(HashMap::new()));
|
||||
let subscriptions: Subscriptions = Arc::new(Mutex::new(HashMap::new()));
|
||||
let ids = Arc::new(AtomicU64::new(1));
|
||||
writer.send(json!({"v":1,"type":"hello","client":"backend","sessionToken":token,"protocol":{"min":1,"max":1},"features":["call","cancel","subscribe"],"implementation":{"name":"trove-qa-backend","version":"1.0.0","language":"rust"}}))?;
|
||||
let welcome = read(&mut reader)?;
|
||||
if welcome["type"] != "welcome" || welcome["protocol"] != 1 {
|
||||
return Err(anyhow!("Expected welcome"));
|
||||
}
|
||||
if env::args().any(|arg| arg == "--no-ready") {
|
||||
// Stay connected but never become ready; the Host must terminate this process.
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
writer.send(json!({"v":1,"type":"ready"}))?;
|
||||
while let Ok(message) = read(&mut reader) {
|
||||
let id = message["id"].clone();
|
||||
match message["type"].as_str() {
|
||||
Some("request") => {
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let mut calls = active.lock().unwrap();
|
||||
if calls.contains_key(&key(&id)) {
|
||||
writer.send(json!({"v":1,"type":"protocolError","error":error("protocol.duplicate_id","Duplicate active request ID")}))?;
|
||||
continue;
|
||||
}
|
||||
if calls.len() >= MAX_IN_FLIGHT {
|
||||
writer.response(
|
||||
id,
|
||||
Err(error(
|
||||
"request.too_many_in_flight",
|
||||
"Backend request limit reached",
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
calls.insert(key(&id), cancelled.clone());
|
||||
}
|
||||
let (writer, pending, ids, subscriptions, active) = (
|
||||
writer.clone(),
|
||||
pending.clone(),
|
||||
ids.clone(),
|
||||
subscriptions.clone(),
|
||||
active.clone(),
|
||||
);
|
||||
ACTIVE_REQUESTS.fetch_add(1, Ordering::Relaxed);
|
||||
thread::spawn(move || {
|
||||
let result = request(
|
||||
&message,
|
||||
&writer,
|
||||
&pending,
|
||||
&ids,
|
||||
&subscriptions,
|
||||
&cancelled,
|
||||
);
|
||||
active.lock().unwrap().remove(&key(&id));
|
||||
ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed);
|
||||
writer.response(id, result);
|
||||
});
|
||||
}
|
||||
Some("response") => {
|
||||
if let Some(id) = id.as_str() {
|
||||
if let Some(tx) = pending.lock().unwrap().remove(id) {
|
||||
let _ = tx.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("cancel") => {
|
||||
if let Some(cancelled) = active.lock().unwrap().get(&key(&id)) {
|
||||
cancelled.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Some("subscribe") => {
|
||||
if !matches!(
|
||||
message["service"].as_str(),
|
||||
Some(service) if service == "@self" || service == format!("{}.probe", env::var("TROVE_CURIO_ID").unwrap_or_default())
|
||||
) || message["topic"] != "progress"
|
||||
{
|
||||
writer.response(
|
||||
id,
|
||||
Err(error("service.method_not_found", "Unknown event topic")),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let Some(task_id) = message["params"]["taskId"]
|
||||
.as_str()
|
||||
.filter(|id| !id.is_empty())
|
||||
else {
|
||||
writer.response(
|
||||
id,
|
||||
Err(error(
|
||||
"request.invalid_params",
|
||||
"taskId must be a nonempty string",
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let mut subs = subscriptions.lock().unwrap();
|
||||
if subs.len() >= MAX_IN_FLIGHT {
|
||||
writer.response(
|
||||
id,
|
||||
Err(error(
|
||||
"request.too_many_in_flight",
|
||||
"Subscription limit reached",
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let sub_id = format!("rust_sub_{}", ids.fetch_add(1, Ordering::Relaxed));
|
||||
subs.insert(
|
||||
sub_id.clone(),
|
||||
Subscription {
|
||||
task_id: task_id.into(),
|
||||
caller: message["context"]["caller"].clone(),
|
||||
seq: 0,
|
||||
},
|
||||
);
|
||||
writer.response(id, Ok(json!({"subscriptionId":sub_id})));
|
||||
}
|
||||
Some("unsubscribe") => {
|
||||
let closed = message["subscriptionId"]
|
||||
.as_str()
|
||||
.is_some_and(|id| subscriptions.lock().unwrap().remove(id).is_some());
|
||||
writer.response(id, Ok(json!({"closed":closed})));
|
||||
}
|
||||
Some("shutdown") => {
|
||||
if env::args().any(|arg| arg == "--ignore-shutdown") {
|
||||
eprintln!("qa: deliberately ignoring shutdown");
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
eprintln!("qa: graceful shutdown");
|
||||
writer.send(json!({"v":1,"type":"goodbye"}))?;
|
||||
writer.flush();
|
||||
// Keep the read half alive while the Host finishes its side of shutdown.
|
||||
// The grace bound also allows exit if the Host never sends EOF.
|
||||
reader.set_read_timeout(Some(Duration::from_millis(250)))?;
|
||||
let mut eof = [0u8; 1];
|
||||
let _ = reader.read(&mut eof);
|
||||
break;
|
||||
}
|
||||
Some("protocolError") => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for cancelled in active.lock().unwrap().values() {
|
||||
cancelled.store(true, Ordering::Relaxed);
|
||||
}
|
||||
subscriptions.lock().unwrap().clear();
|
||||
pending.lock().unwrap().clear();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
export function assert(condition, message) { if (!condition) throw new Error(message); }
|
||||
export function equal(actual, expected, label = '返回值不匹配') {
|
||||
// Compare JSON objects independently of property insertion order.
|
||||
const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object'
|
||||
? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
|
||||
assert(JSON.stringify(canonical(actual)) === JSON.stringify(canonical(expected)), `${label}: ${JSON.stringify(actual)}`);
|
||||
}
|
||||
export async function expectError(action, expected) {
|
||||
try { await action(); } catch (error) {
|
||||
assert(error.code === expected, `预期 ${expected},实际 ${error.code ?? error.message}`);
|
||||
return { expected, actual: error.code, message: error.message };
|
||||
}
|
||||
throw new Error(`预期 ${expected},调用却成功了`);
|
||||
}
|
||||
|
||||
export function hostChecks(client) {
|
||||
const call = (service, method, params = {}) => client.call(service, method, params);
|
||||
return [
|
||||
['SDK 与运行上下文', async () => {
|
||||
const context = await call('host.runtime', 'getContext');
|
||||
equal(context.curioId, client.context.curioId);
|
||||
equal(context.curioVersion, client.context.curioVersion);
|
||||
equal(context.apiVersion, 1);
|
||||
return context;
|
||||
}],
|
||||
['Host 信息与数据目录', async () => {
|
||||
const info = await call('host.runtime', 'getHostInfo');
|
||||
const paths = await call('host.runtime', 'getPaths');
|
||||
for (const key of ['data', 'cache', 'logs', 'temp']) assert(paths[key]?.startsWith('/'), `缺少绝对路径 ${key}`);
|
||||
equal(info.protocol, 1);
|
||||
return { info, paths };
|
||||
}],
|
||||
['JSON 与 Unicode 往返', async () => {
|
||||
const value = { text: '中文 / emoji 🧪 / e\u0301 / \n换行', number: 12.5, null: null, array: [false, {}, ''] };
|
||||
equal(await call('host.runtime', 'echo', value), value);
|
||||
return value;
|
||||
}],
|
||||
['32 路并发响应匹配', async () => {
|
||||
const values = await Promise.all(Array.from({ length: 32 }, (_, index) => call('host.runtime', 'echo', { index })));
|
||||
values.forEach((value, index) => equal(value, { index }));
|
||||
return { responses: values.length };
|
||||
}],
|
||||
['服务发现、描述、解析与状态', async () => {
|
||||
const { services } = await call('host.services', 'list', { prefix: 'host.' });
|
||||
for (const id of ['host.runtime', 'host.storage', 'host.fs']) assert(services.some(item => item.id === id), `缺少 ${id}`);
|
||||
const description = await call('host.services', 'describe', { service: 'host.storage' });
|
||||
assert(description.contract.methods.set, '缺少 storage.set 契约');
|
||||
await call('host.services', 'resolve', { service: 'host.storage', version: '^1.0.0' });
|
||||
equal((await call('host.services', 'getStatus', { service: 'host.storage' })).state, 'Available');
|
||||
return services;
|
||||
}],
|
||||
['独立存储、null 与不存在、前缀查询、删除', async () => {
|
||||
const key = `qa.temporary.${crypto.randomUUID()}`;
|
||||
try {
|
||||
equal((await call('host.storage', 'get', { key })).found, false);
|
||||
await call('host.storage', 'set', { key, value: null });
|
||||
equal(await call('host.storage', 'get', { key }), { found: true, value: null });
|
||||
const value = { owner: client.context.curioId, unicode: '存储 🧪', array: [1, false] };
|
||||
await call('host.storage', 'set', { key, value });
|
||||
equal((await call('host.storage', 'get', { key })).value, value);
|
||||
assert((await call('host.storage', 'list', { prefix: key })).keys.includes(key), '前缀查询缺少测试键');
|
||||
await call('host.storage', 'delete', { key });
|
||||
equal((await call('host.storage', 'get', { key })).found, false);
|
||||
return { namespace: client.context.curioId };
|
||||
} finally { await call('host.storage', 'delete', { key }); }
|
||||
}],
|
||||
['持久化与同名键隔离标记', async () => {
|
||||
const key = 'qa.namespace-owner';
|
||||
const previous = await call('host.storage', 'get', { key });
|
||||
if (previous.found) equal(previous.value, client.context.curioId, '存储命名空间混用');
|
||||
await call('host.storage', 'set', { key, value: client.context.curioId });
|
||||
return { previous, stored: client.context.curioId, next: '在对照插件运行检查,然后关闭并重新打开本插件再运行。' };
|
||||
}],
|
||||
['存储单值大小限制', () => expectError(() => call('host.storage', 'set', { key: 'qa.oversize', value: 'x'.repeat(1024 * 1024 + 1) }), 'host.storage.value_too_large')],
|
||||
['文件 CRUD、二进制、复制与移动', async () => {
|
||||
const paths = await call('host.runtime', 'getPaths');
|
||||
const directory = `${paths.temp}/qa-${crypto.randomUUID()}`;
|
||||
const text = `${directory}/中文 空格.txt`;
|
||||
await call('host.fs', 'createDirectory', { path: directory });
|
||||
try {
|
||||
await call('host.fs', 'writeText', { path: text, content: 'Trove 文件 🧪\n第二行' });
|
||||
equal((await call('host.fs', 'readText', { path: text })).content, 'Trove 文件 🧪\n第二行');
|
||||
equal((await call('host.fs', 'stat', { path: text })).type, 'file');
|
||||
assert((await call('host.fs', 'list', { path: directory })).entries.some(item => item.name === '中文 空格.txt'), '目录列表缺少文件');
|
||||
const binary = `${directory}/bytes.bin`;
|
||||
await call('host.fs', 'writeBinary', { path: binary, data: 'AAECA/7/' });
|
||||
equal((await call('host.fs', 'readBinary', { path: binary })).data, 'AAECA/7/');
|
||||
await call('host.fs', 'copy', { from: binary, to: `${directory}/copy.bin` });
|
||||
await call('host.fs', 'move', { from: `${directory}/copy.bin`, to: `${directory}/moved.bin` });
|
||||
equal((await call('host.fs', 'exists', { path: `${directory}/copy.bin` })).exists, false);
|
||||
equal((await call('host.fs', 'readBinary', { path: `${directory}/moved.bin` })).data, 'AAECA/7/');
|
||||
const temporary = await call('host.fs', 'createTempFile');
|
||||
await call('host.fs', 'remove', temporary);
|
||||
return { unicodeFilename: true, binaryBytes: 6, directory };
|
||||
} finally { await call('host.fs', 'remove', { path: directory, recursive: true }); }
|
||||
}],
|
||||
['未知服务错误', () => expectError(() => call('dev.trove.qa.absent.probe', 'echo'), 'service.not_found')],
|
||||
['未知方法错误', () => expectError(() => call('host.runtime', 'unknownQaMethod'), 'service.method_not_found')],
|
||||
['Host 参数契约校验', () => expectError(() => call('host.storage', 'get', { key: 123 }), 'request.invalid_params')],
|
||||
['服务版本约束', () => expectError(() => call('host.services', 'resolve', { service: 'host.storage', version: '^99.0.0' }), 'service.version_mismatch')],
|
||||
];
|
||||
}
|
||||
|
||||
export function backendChecks(client, service) {
|
||||
const call = (method, params = {}, options) => client.call(service, method, params, options);
|
||||
return [
|
||||
['后端启动、身份、环境与参数', async () => {
|
||||
const info = await call('info');
|
||||
assert(info.pid > 0 && info.curioId.startsWith('dev.trove.qa.'), '后端身份缺失');
|
||||
assert(info.label.startsWith('test-'), 'Manifest 环境变量未传入');
|
||||
return info;
|
||||
}],
|
||||
['原生 Unicode 与超过 200 KiB 消息', async () => {
|
||||
const value = { text: '中🧪'.repeat(32768), null: null, nested: [false, { n: 3 }] };
|
||||
equal(await call('echo', value), value);
|
||||
return { utf8Bytes: new TextEncoder().encode(JSON.stringify(value)).length };
|
||||
}],
|
||||
['24 路乱序并发响应匹配', async () => {
|
||||
const responses = await Promise.all(Array.from({ length: 24 }, (_, index) => call('delay', { ms: (23 - index) * 8, value: { index } })));
|
||||
responses.forEach((value, index) => equal(value, { index }));
|
||||
return { responses: responses.length };
|
||||
}],
|
||||
['结构化业务错误', () => expectError(() => call('fail'), 'qa.intentional_error')],
|
||||
['未知后端方法', () => expectError(() => call('unknownQaMethod'), 'service.method_not_found')],
|
||||
['请求超时与后续可用性', async () => {
|
||||
const error = await expectError(() => call('delay', { ms: 1500 }, { timeoutMs: 80 }), 'request.timeout');
|
||||
equal(await call('echo', { recovered: true }), { recovered: true });
|
||||
return error;
|
||||
}],
|
||||
['AbortSignal 取消与后续可用性', async () => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 60);
|
||||
try {
|
||||
const error = await expectError(() => call('delay', { ms: 1500 }, { signal: controller.signal }), 'request.cancelled');
|
||||
equal(await call('echo', { recovered: true }), { recovered: true });
|
||||
return error;
|
||||
} finally { clearTimeout(timer); }
|
||||
}],
|
||||
['后端 → Host 存储调用', async () => {
|
||||
const key = 'qa.backend-roundtrip';
|
||||
const value = { unicode: '双向 🧪', caller: client.context.curioId };
|
||||
const response = await call('storageRoundtrip', { key, value });
|
||||
equal(response, { found: true, value });
|
||||
// Backend writes to its own namespace. Fixed small probes are retained there for inspection.
|
||||
return { key, value, note: '存储所有者为后端提供方;可在插件数据目录检查。' };
|
||||
}],
|
||||
['事件订阅、顺序、取消订阅', async () => {
|
||||
const events = [];
|
||||
const taskId = crypto.randomUUID();
|
||||
const subscription = await client.subscribe(service, 'progress', { taskId }, data => events.push(data));
|
||||
try {
|
||||
equal(await call('uppercase', { text: 'hello 中文' }), { text: 'HELLO 中文' });
|
||||
const deadline = Date.now() + 1500;
|
||||
while (!events.some(item => item.progress === 1) && Date.now() < deadline) await sleep(20);
|
||||
assert(events.length >= 2 && events[0].progress === 0 && events.at(-1).progress === 1, '未收到完整进度序列');
|
||||
assert(events.every((item, i) => item.taskId === taskId && (i === 0 || item.progress >= events[i - 1].progress)), '事件身份或顺序不正确');
|
||||
} finally { await subscription.close(); }
|
||||
const before = events.length;
|
||||
await call('uppercase', { text: 'after unsubscribe' });
|
||||
await sleep(100);
|
||||
equal(events.length, before, '取消订阅后仍收到事件');
|
||||
return { events };
|
||||
}],
|
||||
...(service === '@self' ? [] : [
|
||||
['公共服务参数契约', () => expectError(() => call('uppercase', { text: 123 }), 'request.invalid_params')],
|
||||
['公共服务返回值契约', () => expectError(() => call('invalidResult'), 'service.contract_violation')],
|
||||
]),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Trove 插件测试台</title>
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<p class="eyebrow">TROVE / PLUGIN TEST LAB</p>
|
||||
<h1 id="title">插件测试台</h1>
|
||||
<p id="context">正在连接 Trove…</p>
|
||||
</header>
|
||||
<main>
|
||||
<section aria-label="测试控制">
|
||||
<p id="description"></p>
|
||||
<div class="actions">
|
||||
<button id="run" disabled>运行常规检查</button>
|
||||
<button id="save-report" disabled>保存 JSON 报告</button>
|
||||
</div>
|
||||
<p id="status" role="status">等待连接。</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>检查结果</h2>
|
||||
<p class="hint">通过代表断言已满足;需观察的系统交互不会计入自动通过。</p>
|
||||
<ol id="results" aria-live="polite"></ol>
|
||||
</section>
|
||||
<section id="manual-section">
|
||||
<h2>手动检查</h2>
|
||||
<p class="hint">这些操作可能打开窗口、改写剪贴板、注册快捷键或终止本插件后端。按需逐项执行。</p>
|
||||
<div id="manual" class="actions"></div>
|
||||
</section>
|
||||
<details>
|
||||
<summary>原始报告</summary>
|
||||
<pre id="report">[]</pre>
|
||||
</details>
|
||||
</main>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,184 @@
|
||||
import { trove } from './sdk/index.js';
|
||||
import config from './config.js';
|
||||
import { assert, equal, expectError, hostChecks, backendChecks } from './checks.js';
|
||||
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const results = [];
|
||||
let running = false;
|
||||
const call = (service, method, params = {}, options) => trove.call(service, method, params, options);
|
||||
const report = () => ({ schemaVersion: 1, fixture: config, context: trove.context, generatedAt: new Date().toISOString(), results });
|
||||
const serializeError = error => ({ code: error.code ?? 'qa.assertion_failed', message: error.message ?? String(error), data: error.data });
|
||||
|
||||
function refresh() {
|
||||
$('#results').replaceChildren(...results.map(result => {
|
||||
const row = document.createElement('li');
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'result-heading';
|
||||
const title = document.createElement('strong');
|
||||
title.className = result.status;
|
||||
const labels = { pass: '通过', fail: '失败', observed: '已执行 · 请观察', skip: '跳过' };
|
||||
title.textContent = `${labels[result.status]} · ${result.name}`;
|
||||
const duration = document.createElement('small');
|
||||
duration.textContent = `${result.durationMs} ms`;
|
||||
heading.append(title, duration);
|
||||
const detail = document.createElement('details');
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = result.error?.message || '查看返回值';
|
||||
const pre = document.createElement('pre');
|
||||
pre.textContent = JSON.stringify(result.error || result.value, null, 2);
|
||||
detail.append(summary, pre);
|
||||
row.append(heading, detail);
|
||||
return row;
|
||||
}));
|
||||
$('#report').textContent = JSON.stringify(report(), null, 2);
|
||||
const counts = status => results.filter(result => result.status === status).length;
|
||||
$('#status').textContent = `${running ? '运行中' : '已完成'} · 通过 ${counts('pass')} / 失败 ${counts('fail')} / 待观察 ${counts('observed')} / 跳过 ${counts('skip')}`;
|
||||
}
|
||||
|
||||
async function check(name, action, status = 'pass') {
|
||||
const start = performance.now();
|
||||
try { results.push({ name, status, value: await action(), durationMs: Math.round(performance.now() - start) }); }
|
||||
catch (error) { results.push({ name, status: 'fail', error: serializeError(error), durationMs: Math.round(performance.now() - start) }); }
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function exclusive(action) {
|
||||
if (running) return;
|
||||
running = true;
|
||||
document.querySelectorAll('button').forEach(button => { button.disabled = true; });
|
||||
try { await action(); }
|
||||
finally {
|
||||
running = false;
|
||||
document.querySelectorAll('button').forEach(button => { button.disabled = false; });
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function manual(name, action, status = 'observed') {
|
||||
const button = document.createElement('button');
|
||||
button.textContent = name;
|
||||
button.onclick = () => exclusive(() => check(name, action, status));
|
||||
$('#manual').append(button);
|
||||
}
|
||||
|
||||
const descriptions = {
|
||||
host: '检查真实 Host 桥接与数据操作。常规检查只在本插件命名空间和临时目录写入测试数据;隔离标记会保留以便复测。',
|
||||
backend: `检查 ${config.service}:原生进程、并发、超时、取消、Host 回调和事件。跨插件测试需先安装公共服务提供方。公共服务返回值校验要求底座开启严格契约模式;默认未开启时,该项会报告失败。`,
|
||||
dependency: `此插件声明了 ${config.service}。预期调用错误为 ${config.expectedError};“service.not_found”不能代替版本不兼容错误。`,
|
||||
fault: '此后端故意无法完成启动。点击运行检查记录实际错误码;恢复与重试应在底座管理界面操作。',
|
||||
upgrade: '先安装 1.0.0 并写入基线标记,再安装 1.1.0。关闭后重新打开,检查运行版本与数据保留;也可在底座回滚。',
|
||||
};
|
||||
$('#title').textContent = config.name;
|
||||
document.title = config.name;
|
||||
$('#description').textContent = descriptions[config.mode];
|
||||
|
||||
function configureManualActions() {
|
||||
manual('读取持久化标记', () => call('host.storage', 'get', { key: config.mode === 'upgrade' ? 'qa.upgrade-marker' : 'qa.namespace-owner' }), 'pass');
|
||||
if (config.mode === 'host') {
|
||||
manual('读取本窗口尺寸', () => call('host.window', 'getBounds'), 'pass');
|
||||
manual('设置窗口标题', () => call('host.window', 'setTitle', { title: `${config.name} · 标题测试成功` }));
|
||||
manual('窗口居中', () => call('host.window', 'center'));
|
||||
manual('文件选择(可取消)', () => call('host.dialog', 'openFile', { title: 'Trove QA:选择文件或取消' }, { timeoutMs: 120000 }));
|
||||
manual('目录选择(可取消)', () => call('host.dialog', 'openDirectory', { title: 'Trove QA:选择目录或取消' }, { timeoutMs: 120000 }));
|
||||
manual('保存对话框(仅选择路径)', () => call('host.dialog', 'saveFile', { title: 'Trove QA:只选择路径,不写入文件' }, { timeoutMs: 120000 }));
|
||||
manual('确认对话框', () => call('host.dialog', 'confirm', { title: 'Trove QA', message: '确认和取消都应返回明确结果。' }, { timeoutMs: 120000 }));
|
||||
manual('写入并读取测试剪贴板', async () => {
|
||||
const text = 'Trove QA 剪贴板 🧪';
|
||||
await call('host.clipboard', 'writeText', { text });
|
||||
equal((await call('host.clipboard', 'readText')).text, text);
|
||||
return { text, note: '系统剪贴板已被替换为测试文本。' };
|
||||
}, 'pass');
|
||||
manual('发送测试通知', () => call('host.notification', 'show', { title: 'Trove QA', body: `来自 ${config.id} 的测试通知` }));
|
||||
manual('快捷键触发(15 秒)', async () => {
|
||||
const events = [];
|
||||
let registration;
|
||||
const subscription = await trove.subscribe('host.shortcut', 'triggered', {}, event => events.push(event));
|
||||
try {
|
||||
registration = await call('host.shortcut', 'register', { shortcut: 'CommandOrControl+Shift+Alt+F9' });
|
||||
$('#status').textContent = '请在 15 秒内按 Cmd+Shift+Option+F9(部分键盘需要 Fn)。';
|
||||
const end = Date.now() + 15000;
|
||||
while (!events.length && Date.now() < end) await new Promise(resolve => setTimeout(resolve, 50));
|
||||
assert(events.some(event => event.registrationId === registration.registrationId), '15 秒内没有收到匹配的快捷键事件');
|
||||
return events;
|
||||
} finally {
|
||||
try { if (registration) await call('host.shortcut', 'unregister', registration); }
|
||||
finally { await subscription.close(); }
|
||||
}
|
||||
}, 'pass');
|
||||
manual('列出正在运行的应用', () => call('host.application', 'listRunning'), 'pass');
|
||||
manual('检查随底座启动的服务', async () => {
|
||||
const service = 'dev.trove.qa.autostart.probe';
|
||||
// Discovery does not start the backend; keep this separate from info().
|
||||
return call('host.services', 'getStatus', { service });
|
||||
});
|
||||
manual('关闭本窗口', () => call('host.window', 'close'));
|
||||
}
|
||||
if (config.mode === 'backend') {
|
||||
manual('读取后端 PID 与活动数', () => call(config.service, 'info'), 'pass');
|
||||
if (config.service !== '@self') manual('查询提供方状态(不启动)', () => call('host.services', 'getStatus', { service: config.service }));
|
||||
if (config.directory === 'test-crash') manual('触发本后端崩溃(exit 42)', async () => {
|
||||
try { await call('@self', 'crash', {}, { timeoutMs: 6000 }); }
|
||||
catch (error) {
|
||||
assert(['provider.disconnected', 'provider.crashed'].includes(error.code), `预期进程断开,实际 ${error.code}`);
|
||||
return { error: serializeError(error), next: '再次读取 PID 验证恢复;短时间反复触发可观察熔断。' };
|
||||
}
|
||||
throw new Error('后端未断开');
|
||||
});
|
||||
}
|
||||
if (config.mode === 'upgrade') {
|
||||
manual('写入升级基线标记', async () => {
|
||||
const value = { token: crypto.randomUUID(), writtenByVersion: trove.context.curioVersion, writtenAt: new Date().toISOString() };
|
||||
await call('host.storage', 'set', { key: 'qa.upgrade-marker', value });
|
||||
return value;
|
||||
}, 'pass');
|
||||
}
|
||||
}
|
||||
|
||||
$('#run').onclick = () => exclusive(async () => {
|
||||
results.length = 0;
|
||||
if (config.mode === 'host') for (const [name, action] of hostChecks(trove)) await check(name, action);
|
||||
if (config.mode === 'backend') for (const [name, action] of backendChecks(trove, config.service)) await check(name, action);
|
||||
if (config.mode === 'dependency') {
|
||||
await check('缺失依赖时 Host 仍可用', async () => equal(await call('host.runtime', 'echo', { alive: true }), { alive: true }));
|
||||
await check('依赖调用返回预期错误', () => expectError(() => call(config.service, 'echo'), config.expectedError));
|
||||
}
|
||||
if (config.mode === 'fault') {
|
||||
await check('启动失败被限制在截止时间内', async () => {
|
||||
try { await call('@self', 'info', {}, { timeoutMs: 6000 }); }
|
||||
catch (error) {
|
||||
const expected = config.directory === 'test-start-timeout'
|
||||
? ['provider.start_timeout']
|
||||
: ['provider.start_failed', 'provider.disconnected', 'provider.crashed', 'provider.start_timeout'];
|
||||
assert(expected.includes(error.code), `非预期错误 ${error.code}`);
|
||||
return { actual: serializeError(error), next: '查看底座后端日志确认具体启动原因。' };
|
||||
}
|
||||
throw new Error('故障后端意外启动成功');
|
||||
});
|
||||
}
|
||||
if (config.mode === 'upgrade') {
|
||||
await check('运行版本与打包版本一致', async () => { equal(trove.context.curioVersion, config.version); return trove.context; });
|
||||
await check('升级基线标记保留', async () => {
|
||||
const marker = await call('host.storage', 'get', { key: 'qa.upgrade-marker' });
|
||||
assert(marker.found, '尚无基线标记,请在 1.0.0 中写入后再验证升级');
|
||||
equal(marker.value.writtenByVersion, '1.0.0', '标记不是由基线版本写入');
|
||||
return marker.value;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$('#save-report').onclick = () => exclusive(() => check('保存 JSON 报告', async () => {
|
||||
const paths = await call('host.runtime', 'getPaths');
|
||||
const path = `${paths.data}/qa-report-${Date.now()}.json`;
|
||||
await call('host.fs', 'writeText', { path, content: JSON.stringify(report(), null, 2) });
|
||||
return { path };
|
||||
}));
|
||||
|
||||
try {
|
||||
await trove.ready();
|
||||
$('#context').textContent = `${trove.context.curioId} · v${trove.context.curioVersion} · ${trove.context.mode} · ${trove.context.instanceId}`;
|
||||
configureManualActions();
|
||||
document.querySelectorAll('button').forEach(button => { button.disabled = false; });
|
||||
$('#status').textContent = '已连接真实 Trove Host,点击运行常规检查。';
|
||||
} catch (error) {
|
||||
$('#status').textContent = `连接失败:${error.code ?? ''} ${error.message}。请从 Trove 安装并打开本插件。`;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
:root { color-scheme: light dark; font: 14px/1.6 -apple-system, BlinkMacSystemFont, sans-serif; color: #203129; background: #f3f5f1; }
|
||||
* { box-sizing: border-box; }
|
||||
body { max-width: 1080px; margin: auto; padding: 28px; }
|
||||
header { margin-bottom: 24px; }
|
||||
.eyebrow { font-size: 11px; font-weight: 650; letter-spacing: 2px; color: #53745e; }
|
||||
h1 { font-size: 27px; line-height: 1.3; margin: 8px 0; }
|
||||
h2 { font-size: 16px; margin: 0 0 8px; }
|
||||
p { margin: 8px 0; }
|
||||
#context, .hint { color: #626f66; overflow-wrap: anywhere; }
|
||||
section, details { padding: 20px; background: #fff; border: 1px solid #dce3db; border-radius: 12px; margin-bottom: 16px; }
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 14px 0; }
|
||||
button { font: inherit; color: inherit; background: #f1f5f0; border: 1px solid #cbd7ca; border-radius: 7px; padding: 8px 12px; cursor: pointer; }
|
||||
#run { background: #286047; border-color: #286047; color: #fff; }
|
||||
button:hover { filter: brightness(.94); }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
button:focus-visible, summary:focus-visible { outline: 3px solid #629d78; outline-offset: 3px; }
|
||||
ol { list-style: none; margin: 0; padding: 0; }
|
||||
li { padding: 12px 0; border-bottom: 1px solid #e7ece5; }
|
||||
li:last-child { border: 0; }
|
||||
.pass { color: #236b3b; } .fail { color: #ba352d; } .observed, .skip { color: #8c5e15; }
|
||||
.result-heading { display: flex; gap: 12px; justify-content: space-between; }
|
||||
small { color: #68746b; }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.6 ui-monospace, monospace; max-height: 360px; overflow: auto; }
|
||||
summary { cursor: pointer; }
|
||||
@media (max-width: 640px) { body { padding: 14px; } section { padding: 14px; } .result-heading { display: block; } }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { color: #e1e9df; background: #172019; }
|
||||
section, details { background: #202b23; border-color: #37473a; }
|
||||
button { background: #2d3c30; border-color: #506153; }
|
||||
#context, .hint, small { color: #a4b4a6; }
|
||||
li { border-color: #37473a; }
|
||||
.pass { color: #8adb9e; } .fail { color: #ff9c92; } .observed, .skip { color: #edc579; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { copyFile, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { root, run, suite } from './suite.mjs';
|
||||
|
||||
const selected = process.argv[2] ? suite.filter(item => item.directory === process.argv[2]) : suite;
|
||||
if (!selected.length) throw new Error(`Unknown fixture: ${process.argv[2]}`);
|
||||
let nativeBuilt = false;
|
||||
for (const item of selected) {
|
||||
const directory = path.join(root, 'curios', item.directory);
|
||||
const manifest = JSON.parse(await readFile(path.join(directory, 'curio.json'), 'utf8'));
|
||||
if (manifest.backend) {
|
||||
if (process.platform !== 'darwin') throw new Error('Native packages must be built on macOS for the target architecture.');
|
||||
if (!nativeBuilt) {
|
||||
run('cargo', ['build', '--manifest-path', 'shared/backend/Cargo.toml', '--release', '--locked']);
|
||||
nativeBuilt = true;
|
||||
}
|
||||
const destination = path.join(directory, manifest.backend.entry);
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
await copyFile(path.join(root, 'shared/backend/target/release/trove-qa-backend'), destination);
|
||||
}
|
||||
if (manifest.ui) {
|
||||
const ui = path.join(directory, 'dist/ui');
|
||||
await rm(ui, { recursive: true, force: true });
|
||||
await mkdir(ui, { recursive: true });
|
||||
await cp(path.join(root, 'shared/ui'), ui, { recursive: true });
|
||||
const sdk = path.dirname(fileURLToPath(import.meta.resolve('@trove/plugin-sdk')));
|
||||
await cp(sdk, path.join(ui, 'sdk'), { recursive: true });
|
||||
await writeFile(path.join(ui, 'config.js'), `export default ${JSON.stringify({ ...item, id: manifest.id, name: manifest.name, version: manifest.version })};\n`);
|
||||
}
|
||||
console.log(`Built ${item.directory} (${manifest.version})`);
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
//! Test adapter around the real Trove package manager, service bus and UDS supervisor.
|
||||
//! stdin/stdout carry SDK bridge messages only; all state lives in a temporary directory.
|
||||
use anyhow::{anyhow, ensure, Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
future::Future,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::Instant,
|
||||
};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use trove::{
|
||||
package::{AppPaths, InstalledPackage, PackageManager, SourceConfig, SourceKind},
|
||||
protocol::ProtocolMessage,
|
||||
runtime::{BackendRegistration, BackendSupervisor},
|
||||
service::{
|
||||
builtin::{fs::FsService, runtime::RuntimeService, storage::Storage},
|
||||
Caller, CurioRegistration, ProviderRef, ServiceBus, ServiceDescriptor,
|
||||
},
|
||||
transport::{uds::UnixGateway, Outbound, Peer},
|
||||
};
|
||||
|
||||
fn emit(value: Value) {
|
||||
println!("{}", value);
|
||||
}
|
||||
fn read_json(path: impl AsRef<Path>) -> Result<Value> {
|
||||
Ok(serde_json::from_slice(&std::fs::read(path)?)?)
|
||||
}
|
||||
fn source(id: &str, index: &Path) -> SourceConfig {
|
||||
SourceConfig {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
kind: SourceKind::Local,
|
||||
index: index.display().to_string(),
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
refresh_interval_seconds: 3600,
|
||||
}
|
||||
}
|
||||
fn target() -> &'static str {
|
||||
if cfg!(target_arch = "aarch64") {
|
||||
"aarch64-apple-darwin"
|
||||
} else {
|
||||
"x86_64-apple-darwin"
|
||||
}
|
||||
}
|
||||
fn expected_error<T>(result: trove::error::Result<T>, code: &str) -> Result<Value> {
|
||||
match result {
|
||||
Err(error) => {
|
||||
ensure!(
|
||||
error.code == code,
|
||||
"Expected {code}, got {}: {}",
|
||||
error.code,
|
||||
error.message
|
||||
);
|
||||
Ok(json!({"code":error.code}))
|
||||
}
|
||||
Ok(_) => Err(anyhow!("Expected {code}, operation succeeded")),
|
||||
}
|
||||
}
|
||||
async fn check<F: Future<Output = Result<Value>>>(
|
||||
rows: &mut Vec<Value>,
|
||||
name: impl Into<String>,
|
||||
operation: F,
|
||||
) {
|
||||
let name = name.into();
|
||||
let start = Instant::now();
|
||||
let row = match operation.await {
|
||||
Ok(value) => {
|
||||
json!({"name":name,"status":"pass","value":value,"durationMs":start.elapsed().as_millis()})
|
||||
}
|
||||
Err(error) => {
|
||||
json!({"name":name,"status":"fail","error":{"message":format!("{error:#}")},"durationMs":start.elapsed().as_millis()})
|
||||
}
|
||||
};
|
||||
eprintln!("{} {}", row["status"].as_str().unwrap(), name);
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
async fn package_checks(
|
||||
dist: &Path,
|
||||
paths: &AppPaths,
|
||||
packages: &PackageManager,
|
||||
) -> Result<Vec<Value>> {
|
||||
let suite = read_json(dist.join("suite.json"))?;
|
||||
let mut rows = Vec::new();
|
||||
for entry in suite.as_array().context("suite is an array")? {
|
||||
if entry["directory"] == "test-upgrade-v2" {
|
||||
continue;
|
||||
}
|
||||
check(
|
||||
&mut rows,
|
||||
format!("安装并读取 {}", entry["directory"].as_str().unwrap()),
|
||||
async {
|
||||
let package = packages
|
||||
.install_local(Path::new(entry["file"].as_str().unwrap()), true)
|
||||
.await?;
|
||||
ensure!(
|
||||
package.manifest.id == entry["manifest"]["id"],
|
||||
"Installed ID changed"
|
||||
);
|
||||
ensure!(
|
||||
package.manifest.development.is_none()
|
||||
&& package.manifest.build.is_none()
|
||||
&& package.manifest.package.is_none(),
|
||||
"Source-only config leaked into package"
|
||||
);
|
||||
if let Some(ui) = &package.manifest.ui {
|
||||
let resolved = trove::runtime::assets::resolve_asset(
|
||||
&package.root,
|
||||
&package.manifest.id,
|
||||
&ui.entry,
|
||||
&format!("/{}/{}", package.manifest.id, ui.entry),
|
||||
)?;
|
||||
ensure!(
|
||||
std::fs::read_to_string(resolved)?.contains("./main.js"),
|
||||
"UI entry is not the test console"
|
||||
);
|
||||
let base = Path::new(&ui.entry).parent().unwrap();
|
||||
for relative in [
|
||||
"main.js",
|
||||
"config.js",
|
||||
"checks.js",
|
||||
"style.css",
|
||||
"sdk/index.js",
|
||||
"sdk/bridge.js",
|
||||
] {
|
||||
ensure!(
|
||||
package.root.join(base).join(relative).is_file(),
|
||||
"Missing UI resource {relative}"
|
||||
);
|
||||
}
|
||||
expected_error(
|
||||
trove::runtime::assets::resolve_asset(
|
||||
&package.root,
|
||||
&package.manifest.id,
|
||||
&ui.entry,
|
||||
&format!("/{}/curio.json", package.manifest.id),
|
||||
),
|
||||
"asset.forbidden",
|
||||
)?;
|
||||
}
|
||||
if let Some(backend) = &package.manifest.backend {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
ensure!(
|
||||
std::fs::metadata(package.root.join(&backend.entry))?
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o111
|
||||
!= 0,
|
||||
"Backend isn't executable after install"
|
||||
);
|
||||
}
|
||||
Ok(json!({"id":package.manifest.id,"version":package.active_version}))
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
check(
|
||||
&mut rows,
|
||||
"重复安装同版本被拒绝且保留当前状态",
|
||||
async {
|
||||
let before = std::fs::read(paths.registry_file())?;
|
||||
let value = expected_error(
|
||||
packages
|
||||
.install_local(&dist.join("packages/dev.trove.qa.host-1.0.0.curio"), true)
|
||||
.await,
|
||||
"package.version_exists",
|
||||
)?;
|
||||
ensure!(
|
||||
std::fs::read(paths.registry_file())? == before,
|
||||
"Duplicate install changed registry"
|
||||
);
|
||||
ensure!(
|
||||
packages.get("dev.trove.qa.host")?.active_version == "1.0.0",
|
||||
"Duplicate install lost active version"
|
||||
);
|
||||
Ok(value)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
for case in read_json(dist.join("invalid/cases.json"))?
|
||||
.as_array()
|
||||
.unwrap()
|
||||
{
|
||||
check(
|
||||
&mut rows,
|
||||
format!("拒绝无效包 {}", case["name"].as_str().unwrap()),
|
||||
async {
|
||||
let before = std::fs::read(paths.registry_file())?;
|
||||
let result = packages
|
||||
.install_local(Path::new(case["file"].as_str().unwrap()), true)
|
||||
.await;
|
||||
let value = expected_error(result, case["expectedError"].as_str().unwrap())?;
|
||||
ensure!(
|
||||
std::fs::read(paths.registry_file())? == before,
|
||||
"Failed install changed registry"
|
||||
);
|
||||
ensure!(
|
||||
std::fs::read_dir(paths.support.join(".staging"))?
|
||||
.next()
|
||||
.is_none(),
|
||||
"Failed install leaked staging files"
|
||||
);
|
||||
Ok(value)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
for case in read_json(dist.join("invalid/source-cases.json"))?
|
||||
.as_array()
|
||||
.unwrap()
|
||||
{
|
||||
check(
|
||||
&mut rows,
|
||||
format!("拒绝异常软件源 {}", case["name"].as_str().unwrap()),
|
||||
async {
|
||||
let sandbox = tempfile::tempdir()?;
|
||||
let manager =
|
||||
PackageManager::open(AppPaths::under(sandbox.path()), "0.1.0", target())?;
|
||||
let id = case["id"].as_str().unwrap();
|
||||
manager.add_source(source(id, Path::new(case["file"].as_str().unwrap())))?;
|
||||
manager.refresh_source(id).await?;
|
||||
let result = manager
|
||||
.install_from_source(
|
||||
id,
|
||||
case["curioId"].as_str().unwrap_or("dev.trove.qa.host"),
|
||||
"1.0.0",
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let value = expected_error(result, case["expectedError"].as_str().unwrap())?;
|
||||
ensure!(
|
||||
manager.list().is_empty(),
|
||||
"Invalid source installed a package"
|
||||
);
|
||||
Ok(value)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
check(
|
||||
&mut rows,
|
||||
"本地软件源刷新、安装、缓存恢复和禁用",
|
||||
async {
|
||||
let sandbox = tempfile::tempdir()?;
|
||||
let source_paths = AppPaths::under(sandbox.path());
|
||||
let manager = PackageManager::open(source_paths.clone(), "0.1.0", target())?;
|
||||
manager.add_source(source("trove-qa", &dist.join("index.json")))?;
|
||||
manager.refresh_source("trove-qa").await?;
|
||||
let count = manager.catalog().len();
|
||||
ensure!(count == 8, "Expected 8 normal Curio IDs, got {count}");
|
||||
let installed = manager
|
||||
.install_from_source("trove-qa", "dev.trove.qa.host", "1.0.0", true)
|
||||
.await?;
|
||||
ensure!(
|
||||
installed.source_id.as_deref() == Some("trove-qa"),
|
||||
"Source origin was lost"
|
||||
);
|
||||
drop(manager);
|
||||
let manager = PackageManager::open(source_paths, "0.1.0", target())?;
|
||||
ensure!(
|
||||
manager.catalog().len() == count && manager.list().len() == 1,
|
||||
"Durable source cache or installation lost"
|
||||
);
|
||||
let mut config = source("trove-qa", &dist.join("index.json"));
|
||||
config.enabled = false;
|
||||
manager.add_source(config)?;
|
||||
expected_error(
|
||||
manager
|
||||
.install_from_source("trove-qa", "dev.trove.qa.isolation", "1.0.0", true)
|
||||
.await,
|
||||
"source.disabled",
|
||||
)?;
|
||||
Ok(json!({"catalogIds":count,"sourceCacheRestored":true}))
|
||||
},
|
||||
)
|
||||
.await;
|
||||
check(&mut rows, "HTTP 软件源刷新、下载和校验安装", async {
|
||||
let sandbox = tempfile::tempdir()?;
|
||||
let manager = PackageManager::open(AppPaths::under(sandbox.path()), "0.1.0", target())?;
|
||||
let mut config = source("trove-qa", &dist.join("index.json"));
|
||||
config.kind = SourceKind::Http;
|
||||
config.index = std::env::var("QA_HTTP_SOURCE_URL")?;
|
||||
manager.add_source(config)?;
|
||||
manager.refresh_source("trove-qa").await?;
|
||||
let package = manager
|
||||
.install_from_source("trove-qa", "dev.trove.qa.host", "1.0.0", true)
|
||||
.await?;
|
||||
ensure!(
|
||||
package.manifest.id == "dev.trove.qa.host",
|
||||
"HTTP install identity changed"
|
||||
);
|
||||
Ok(json!({"downloaded":true,"hashVerified":true,"version":package.active_version}))
|
||||
})
|
||||
.await;
|
||||
check(&mut rows, "升级暂存、激活、回滚、卸载与数据保留", async {
|
||||
let id = "dev.trove.qa.upgrade";
|
||||
let data = paths.data_dir(id);
|
||||
std::fs::create_dir_all(&data)?;
|
||||
let marker = data.join("qa-upgrade-marker.txt");
|
||||
std::fs::write(&marker, "baseline-1.0.0")?;
|
||||
let file = dist.join("packages/dev.trove.qa.upgrade-1.1.0.curio");
|
||||
let pending = packages.install_local(&file, false).await?;
|
||||
ensure!(pending.active_version == "1.0.0" && pending.pending_version.as_deref() == Some("1.1.0"), "Update wasn't staged");
|
||||
ensure!(packages.activate(id, "1.1.0")?.active_version == "1.1.0", "Activation failed");
|
||||
ensure!(packages.rollback(id)?.active_version == "1.0.0", "Rollback failed");
|
||||
ensure!(std::fs::read_to_string(&marker)? == "baseline-1.0.0", "Update changed data");
|
||||
packages.set_enabled(id, false)?;
|
||||
ensure!(!packages.get(id)?.enabled, "Disable not persisted");
|
||||
packages.set_enabled(id, true)?;
|
||||
packages.uninstall(id, false)?;
|
||||
ensure!(marker.is_file(), "Keep-data uninstall deleted data");
|
||||
packages.install_local(&dist.join("packages/dev.trove.qa.upgrade-1.0.0.curio"), true).await?;
|
||||
ensure!(std::fs::read_to_string(&marker)? == "baseline-1.0.0", "Reinstall changed data");
|
||||
packages.uninstall(id, true)?;
|
||||
ensure!(!data.exists(), "Remove-data uninstall retained data");
|
||||
expected_error(packages.get(id), "package.not_installed")?;
|
||||
Ok(json!({"staged":true,"activated":true,"rolledBack":true,"reinstalled":true,"dataRemoved":true}))
|
||||
}).await;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn register(
|
||||
package: &InstalledPackage,
|
||||
paths: &AppPaths,
|
||||
runtime: &Arc<BackendSupervisor>,
|
||||
bus: &Arc<ServiceBus>,
|
||||
) -> Result<()> {
|
||||
let manifest = &package.manifest;
|
||||
bus.register_curio(CurioRegistration {
|
||||
curio_id: manifest.id.clone(),
|
||||
version: manifest.version.clone(),
|
||||
enabled: package.enabled,
|
||||
provides: package
|
||||
.contracts
|
||||
.iter()
|
||||
.map(|contract| ServiceDescriptor {
|
||||
id: contract.service.clone(),
|
||||
version: contract.version.clone(),
|
||||
provider: ProviderRef::CurioBackend {
|
||||
curio_id: manifest.id.clone(),
|
||||
},
|
||||
contract: contract.clone(),
|
||||
default_timeout_ms: 5000,
|
||||
})
|
||||
.collect(),
|
||||
requires: manifest
|
||||
.services
|
||||
.requires
|
||||
.iter()
|
||||
.map(|r| (r.id.clone(), r.version.clone()))
|
||||
.collect(),
|
||||
})?;
|
||||
if let Some(definition) = &manifest.backend {
|
||||
runtime.register(BackendRegistration {
|
||||
curio_id: manifest.id.clone(),
|
||||
version: manifest.version.clone(),
|
||||
root: package.root.clone(),
|
||||
definition: definition.clone(),
|
||||
data_dir: paths.data_dir(&manifest.id),
|
||||
cache_dir: paths.cache_dir(&manifest.id),
|
||||
log_dir: paths.log_dir(&manifest.id),
|
||||
instance_id: None,
|
||||
enabled: package.enabled,
|
||||
command: None,
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let dist = PathBuf::from(std::env::args().nth(1).context("dist directory required")?)
|
||||
.canonicalize()?;
|
||||
let sandbox = tempfile::Builder::new()
|
||||
.prefix("trove-qa-")
|
||||
.tempdir_in("/tmp")?;
|
||||
let paths = AppPaths::under(sandbox.path());
|
||||
let packages = Arc::new(PackageManager::open(paths.clone(), "0.1.0", target())?);
|
||||
let package_results = package_checks(&dist, &paths, &packages).await?;
|
||||
let runtime = BackendSupervisor::new(sandbox.path().join("host.sock"));
|
||||
let bus = ServiceBus::new(runtime.clone());
|
||||
bus.register_builtin(Arc::new(RuntimeService {
|
||||
paths: paths.clone(),
|
||||
packages: packages.clone(),
|
||||
}))?;
|
||||
bus.register_builtin(Arc::new(Arc::new(Storage::new(paths.support.join("data")))))?;
|
||||
bus.register_builtin(Arc::new(FsService {
|
||||
paths: paths.clone(),
|
||||
}))?;
|
||||
for package in packages.list() {
|
||||
register(&package, &paths, &runtime, &bus)?;
|
||||
}
|
||||
let gateway =
|
||||
UnixGateway::bind(runtime.socket_path().into(), runtime.clone(), bus.clone()).await?;
|
||||
emit(json!({"ready":true,"packageResults":package_results,"temporaryRoot":sandbox.path()}));
|
||||
let mut peers: HashMap<String, Arc<Peer>> = HashMap::new();
|
||||
let mut lines = BufReader::new(tokio::io::stdin()).lines();
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
let envelope: Value = serde_json::from_str(&line)?;
|
||||
let op = envelope["op"].as_str().unwrap_or("");
|
||||
if op == "shutdown" {
|
||||
break;
|
||||
}
|
||||
if op == "send" {
|
||||
let peer = peers
|
||||
.get(envelope["connection"].as_str().unwrap_or(""))
|
||||
.context("Unknown connection")?
|
||||
.clone();
|
||||
let message = ProtocolMessage::from_value(envelope["message"].clone())?;
|
||||
let bus = bus.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = bus.dispatch(peer, message).await {
|
||||
emit(json!({"fatal":error}));
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let result: Result<Value> = async {
|
||||
let id = envelope["curioId"].as_str().unwrap_or("");
|
||||
match op {
|
||||
"connect" => {
|
||||
let package = packages.get(id)?;
|
||||
let connection = envelope["connection"].as_str().context("connection required")?.to_string();
|
||||
let (outbound, mut receiver) = Outbound::channel(512, 16 * 1024 * 1024);
|
||||
let peer = Arc::new(Peer { id: connection.clone(), caller: Caller { curio_id: Some(id.into()), instance_id: Some(connection.clone()), endpoint: "ui".into() }, outbound, closed: Default::default(), features: vec!["call".into(),"cancel".into(),"subscribe".into()] });
|
||||
bus.attach_peer(peer.clone());
|
||||
peers.insert(connection.clone(), peer);
|
||||
let key = connection.clone();
|
||||
tokio::spawn(async move { while let Some(outbound) = receiver.recv().await { emit(json!({"connection":key,"message":outbound.message})); } });
|
||||
Ok(json!({"curioId":id,"curioVersion":package.active_version,"instanceId":connection,"apiVersion":1,"mode":"production"}))
|
||||
}
|
||||
"disconnect" => {
|
||||
let key = envelope["connection"].as_str().context("connection required")?;
|
||||
bus.disconnect(key).await; peers.remove(key); Ok(json!({"disconnected":true}))
|
||||
}
|
||||
"status" => Ok(runtime.status(id).await),
|
||||
"diagnostics" => Ok(bus.diagnostics()),
|
||||
"strictContracts" => {
|
||||
let enabled = envelope["value"].as_bool().context("strictContracts boolean required")?;
|
||||
bus.strict_contracts(enabled);
|
||||
Ok(json!({"strictContracts":enabled}))
|
||||
}
|
||||
"start" => { runtime.ensure_connected(id).await?; Ok(runtime.status(id).await) }
|
||||
"retry" => { runtime.retry(id).await?; Ok(runtime.status(id).await) }
|
||||
"stop" => { runtime.shutdown(id, "qa-test").await?; Ok(runtime.status(id).await) }
|
||||
"enabled" => {
|
||||
let enabled = envelope["value"].as_bool().context("enabled boolean required")?;
|
||||
runtime.shutdown(id, "qa-toggle").await?;
|
||||
let package = packages.set_enabled(id, enabled)?;
|
||||
register(&package, &paths, &runtime, &bus)?;
|
||||
Ok(json!({"enabled":enabled}))
|
||||
}
|
||||
_ => Err(anyhow!("Unknown operation {op}")),
|
||||
}
|
||||
}.await;
|
||||
match result {
|
||||
Ok(value) => emit(json!({"controlId":envelope["controlId"],"result":value})),
|
||||
Err(error) => emit(
|
||||
json!({"controlId":envelope["controlId"],"error":{"message":format!("{error:#}")}}),
|
||||
),
|
||||
}
|
||||
}
|
||||
bus.shutdown().await;
|
||||
runtime.shutdown_all().await;
|
||||
gateway.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Generate deliberately invalid .curio archives. Never publish these in the normal source."""
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OUT = ROOT / 'dist' / 'invalid'
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
BASE = {
|
||||
'schemaVersion': 1, 'id': 'dev.trove.qa.invalid', 'name': '故意无效的测试包',
|
||||
'version': '1.0.0', 'engine': {'trove': '>=0.1.0 <1.0.0'},
|
||||
'ui': {'entry': 'ui/index.html'},
|
||||
}
|
||||
cases = []
|
||||
|
||||
|
||||
def archive(name, code, mutate=None, entries=None, raw=None):
|
||||
manifest = copy.deepcopy(BASE)
|
||||
if mutate:
|
||||
mutate(manifest)
|
||||
file = OUT / f'{name}.curio'
|
||||
with zipfile.ZipFile(file, 'w', zipfile.ZIP_DEFLATED) as bundle:
|
||||
bundle.writestr('curio.json', raw if raw is not None else json.dumps(manifest))
|
||||
bundle.writestr('ui/index.html', '<!doctype html><title>Invalid fixture</title>')
|
||||
for entry, content in entries or []:
|
||||
bundle.writestr(entry, content)
|
||||
cases.append({'name': name, 'file': str(file), 'expectedError': code})
|
||||
|
||||
|
||||
archive('malformed-manifest', 'package.invalid_manifest', raw='{broken')
|
||||
archive('unknown-manifest-field', 'package.invalid_manifest', lambda m: m.update(unknown=True))
|
||||
archive('unsupported-schema', 'package.invalid_manifest', lambda m: m.update(schemaVersion=2))
|
||||
archive('reserved-id', 'package.invalid_id', lambda m: m.update(id='host.qa.invalid'))
|
||||
archive('invalid-version', 'package.invalid_manifest', lambda m: m.update(version='not-semver'))
|
||||
archive('incompatible-engine', 'package.engine_incompatible', lambda m: m.update(engine={'trove': '>=99.0.0'}))
|
||||
archive('missing-ui', 'package.missing_entry', lambda m: m['ui'].update(entry='ui/missing.html'))
|
||||
archive('no-ui-or-backend', 'package.invalid_manifest', lambda m: m.pop('ui'))
|
||||
archive('manifest-path-traversal', 'package.invalid_manifest', lambda m: m['ui'].update(entry='../escape.html'))
|
||||
archive('reserved-backend-env', 'package.invalid_manifest', lambda m: m.update(backend={
|
||||
'entry': 'backend/probe', 'lifecycle': 'on-demand', 'transport': 'unix', 'env': {'TROVE_SESSION_TOKEN': 'override'}
|
||||
}))
|
||||
archive('service-without-backend', 'package.invalid_manifest', lambda m: m.update(services={'provides': [
|
||||
{'id': m['id'] + '.probe', 'version': '1.0.0', 'contract': 'contracts/probe.json'}
|
||||
]}))
|
||||
|
||||
|
||||
def provider(m):
|
||||
m['backend'] = {'entry': 'backend/probe', 'lifecycle': 'on-demand', 'transport': 'unix'}
|
||||
m['services'] = {'provides': [{'id': m['id'] + '.probe', 'version': '1.0.0', 'contract': 'contracts/probe.json'}]}
|
||||
|
||||
|
||||
def wrong_namespace(m):
|
||||
provider(m)
|
||||
m['services']['provides'][0]['id'] = 'dev.someone.else.probe'
|
||||
|
||||
|
||||
archive('service-namespace', 'package.invalid_manifest', wrong_namespace)
|
||||
archive('missing-backend', 'package.missing_entry', provider)
|
||||
archive('missing-contract', 'package.missing_entry', provider, [('backend/probe', 'placeholder')])
|
||||
contract = {'schemaVersion': 1, 'service': BASE['id'] + '.probe', 'version': '2.0.0', 'methods': {}}
|
||||
archive('contract-version-mismatch', 'package.contract_mismatch', provider,
|
||||
[('backend/probe', 'placeholder'), ('contracts/probe.json', json.dumps(contract))])
|
||||
contract['version'] = '1.0.0'
|
||||
contract['methods'] = {'probe': {'params': {'type': 'not-a-json-type'}, 'result': {}}}
|
||||
archive('invalid-contract-schema', 'package.invalid_contract', provider,
|
||||
[('backend/probe', 'placeholder'), ('contracts/probe.json', json.dumps(contract))])
|
||||
archive('zip-path-traversal', 'package.unsafe_path', entries=[('../qa-escape.txt', 'must not escape')])
|
||||
archive('zip-absolute-path', 'package.unsafe_path', entries=[('/tmp/trove-qa-escape.txt', 'must not escape')])
|
||||
archive('zip-backslash-path', 'package.unsafe_path', entries=[('ui\\escape.txt', 'must not be accepted')])
|
||||
archive('zip-case-collision', 'package.unsafe_archive', entries=[('UI/INDEX.HTML', 'collides on macOS')])
|
||||
symlink = zipfile.ZipInfo('ui/link')
|
||||
symlink.create_system = 3
|
||||
symlink.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
archive('zip-symlink', 'package.unsafe_archive', entries=[(symlink, '../../outside')])
|
||||
truncated = OUT / 'truncated-archive.curio'
|
||||
truncated.write_bytes(b'PK\x03\x04broken')
|
||||
cases.append({'name': 'truncated-archive', 'file': str(truncated), 'expectedError': 'package.invalid_archive'})
|
||||
(OUT / 'cases.json').write_text(json.dumps(cases, ensure_ascii=False, indent=2) + '\n')
|
||||
|
||||
# Valid source schemas carrying wrong download metadata test checks after index parsing.
|
||||
baseline = json.loads((ROOT / 'dist/index.json').read_text())
|
||||
baseline['curios'] = [next(item for item in baseline['curios'] if item['id'] == 'dev.trove.qa.host')]
|
||||
source_cases = []
|
||||
for name, field, value, expected in [
|
||||
('hash-mismatch', 'sha256', '0' * 64, 'package.hash_mismatch'),
|
||||
('size-mismatch', 'size', 1, 'package.size_mismatch'),
|
||||
('wrong-target', 'target', 'x86_64-apple-darwin' if __import__('platform').machine() == 'arm64' else 'aarch64-apple-darwin', 'package.target_mismatch'),
|
||||
]:
|
||||
index = copy.deepcopy(baseline)
|
||||
index['id'] = 'qa-' + name
|
||||
index['curios'][0]['versions'][0]['artifacts'][0][field] = value
|
||||
file = OUT / f'{name}-index.json'
|
||||
file.write_text(json.dumps(index, ensure_ascii=False, indent=2) + '\n')
|
||||
source_cases.append({'name': name, 'id': index['id'], 'file': str(file), 'expectedError': expected})
|
||||
index = copy.deepcopy(baseline)
|
||||
index['id'] = 'qa-identity-mismatch'
|
||||
index['curios'][0]['id'] = 'dev.trove.qa.wrong-identity'
|
||||
file = OUT / 'identity-mismatch-index.json'
|
||||
file.write_text(json.dumps(index, ensure_ascii=False, indent=2) + '\n')
|
||||
source_cases.append({'name': 'identity-mismatch', 'id': index['id'], 'file': str(file), 'expectedError': 'package.identity_mismatch', 'curioId': 'dev.trove.qa.wrong-identity'})
|
||||
(OUT / 'source-cases.json').write_text(json.dumps(source_cases, ensure_ascii=False, indent=2) + '\n')
|
||||
print(f'Generated {len(cases)} invalid archives and {len(source_cases)} adverse sources in {OUT}')
|
||||
@@ -0,0 +1,37 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { root, run, suite, output, troveBin } from './suite.mjs';
|
||||
|
||||
run(process.execPath, ['tools/build.mjs']);
|
||||
await mkdir(path.join(output, 'packages'), { recursive: true });
|
||||
const entries = [];
|
||||
for (const item of suite) {
|
||||
const directory = path.join(root, 'curios', item.directory);
|
||||
const manifest = JSON.parse(await readFile(path.join(directory, 'curio.json'), 'utf8'));
|
||||
run(troveBin, ['curio', 'check', directory, '--json']);
|
||||
const file = path.join(output, 'packages', `${manifest.id}-${manifest.version}.curio`);
|
||||
run(troveBin, ['curio', 'pack', directory, '--out', file, '--overwrite', '--no-input', '--json']);
|
||||
const bytes = await readFile(file);
|
||||
entries.push({ ...item, manifest, file, artifact: {
|
||||
target: manifest.backend ? `${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}-apple-darwin` : 'any',
|
||||
url: pathToFileURL(file).href,
|
||||
sha256: createHash('sha256').update(bytes).digest('hex'), size: bytes.length,
|
||||
} });
|
||||
}
|
||||
const date = new Date().toISOString();
|
||||
function index(id, name, selected) {
|
||||
const curios = [];
|
||||
for (const { manifest: m, artifact } of selected) {
|
||||
let curio = curios.find(curio => curio.id === m.id);
|
||||
if (!curio) { curio = { id: m.id, name: m.name, description: m.description, versions: [] }; curios.push(curio); }
|
||||
curio.versions.unshift({ version: m.version, engine: m.engine, releasedAt: date, artifacts: [artifact] });
|
||||
}
|
||||
return { schemaVersion: 1, id, name, generatedAt: date, curios };
|
||||
}
|
||||
await writeFile(path.join(output, 'index.json'), JSON.stringify(index('trove-qa', 'Trove 常规测试插件', entries.filter(item => !item.fault)), null, 2) + '\n');
|
||||
await writeFile(path.join(output, 'faults-index.json'), JSON.stringify(index('trove-qa-faults', 'Trove 故障注入插件(手动安装)', entries.filter(item => item.fault)), null, 2) + '\n');
|
||||
await writeFile(path.join(output, 'suite.json'), JSON.stringify(entries, null, 2) + '\n');
|
||||
run('python3', ['tools/invalid-fixtures.py']);
|
||||
console.log(`\n常规软件源:${path.join(output, 'index.json')}\n故障软件源:${path.join(output, 'faults-index.json')}`);
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { output } from './suite.mjs';
|
||||
|
||||
// A local-only source server; only the two indexes and package files are exposed.
|
||||
export async function serve(port = 0) {
|
||||
let base;
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') { response.writeHead(405).end(); return; }
|
||||
const pathname = new URL(request.url, 'http://localhost').pathname;
|
||||
if (['/index.json', '/faults-index.json'].includes(pathname)) {
|
||||
const index = JSON.parse(await readFile(path.join(output, pathname.slice(1)), 'utf8'));
|
||||
for (const curio of index.curios) for (const version of curio.versions) for (const artifact of version.artifacts) {
|
||||
artifact.url = `${base}/packages/${encodeURIComponent(path.basename(new URL(artifact.url).pathname))}`;
|
||||
}
|
||||
const body = JSON.stringify(index);
|
||||
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store' });
|
||||
response.end(request.method === 'HEAD' ? undefined : body);
|
||||
} else if (/^\/packages\/[a-z0-9][a-z0-9.+-]*\.curio$/.test(pathname)) {
|
||||
const file = path.join(output, pathname.slice(1));
|
||||
const metadata = await stat(file);
|
||||
response.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': metadata.size });
|
||||
if (request.method === 'HEAD') response.end();
|
||||
else await pipeline(createReadStream(file), response);
|
||||
} else { response.writeHead(404).end(); }
|
||||
} catch (error) {
|
||||
if (!response.headersSent) response.writeHead(error.code === 'ENOENT' ? 404 : 500);
|
||||
response.end();
|
||||
}
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(port, '127.0.0.1', resolve);
|
||||
});
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
return { base, close: () => new Promise(resolve => server.close(resolve)) };
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
const port = Number(process.env.QA_HTTP_PORT || 8765);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('QA_HTTP_PORT must be 1..65535');
|
||||
const server = await serve(port);
|
||||
console.log(`常规 HTTP 源:${server.base}/index.json\n故障 HTTP 源:${server.base}/faults-index.json\nCtrl-C 停止服务。`);
|
||||
process.once('SIGINT', () => { void server.close(); });
|
||||
process.once('SIGTERM', () => { void server.close(); });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
export const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
export const suite = [
|
||||
{ directory: 'test-host', mode: 'host' },
|
||||
{ directory: 'test-isolation', mode: 'host' },
|
||||
{ directory: 'test-self', mode: 'backend', service: '@self' },
|
||||
{ directory: 'test-provider' },
|
||||
{ directory: 'test-consumer', mode: 'backend', service: 'dev.trove.qa.provider.probe' },
|
||||
{ directory: 'test-optional', mode: 'dependency', service: 'dev.trove.qa.absent.probe', expectedError: 'service.not_found' },
|
||||
{ directory: 'test-required', mode: 'dependency', service: 'dev.trove.qa.absent.probe', expectedError: 'service.not_found', fault: true },
|
||||
{ directory: 'test-incompatible', mode: 'dependency', service: 'dev.trove.qa.provider.probe', expectedError: 'service.version_mismatch', fault: true },
|
||||
{ directory: 'test-autostart' },
|
||||
{ directory: 'test-crash', mode: 'backend', service: '@self', fault: true },
|
||||
{ directory: 'test-start-timeout', mode: 'fault', service: '@self', fault: true },
|
||||
{ directory: 'test-bad-handshake', mode: 'fault', service: '@self', fault: true },
|
||||
{ directory: 'test-stubborn', mode: 'backend', service: '@self', fault: true },
|
||||
{ directory: 'test-upgrade-v1', mode: 'upgrade' },
|
||||
{ directory: 'test-upgrade-v2', mode: 'upgrade' },
|
||||
];
|
||||
|
||||
export function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, { cwd: root, stdio: 'inherit', ...options });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`${command} exited with ${result.status ?? result.signal}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Tooling accepts the installed CLI or an explicit build; plugin packages are self-contained.
|
||||
export const troveBin = process.env.TROVE_BIN || 'trove';
|
||||
export const output = path.join(root, 'dist');
|
||||
@@ -0,0 +1,280 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { mkdir, writeFile, access, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createTroveClient } from '@trove/plugin-sdk';
|
||||
import { root, output, run, troveBin } from './suite.mjs';
|
||||
import { assert, equal, expectError, hostChecks, backendChecks, sleep } from '../shared/ui/checks.js';
|
||||
import { serve } from './serve.mjs';
|
||||
|
||||
// Only this optional integration adapter depends on the app checkout.
|
||||
await rm(path.join(output, 'verification.json'), { force: true });
|
||||
const app = process.env.TROVE_APP_DIR;
|
||||
if (!app) throw new Error('Set TROVE_APP_DIR to the app repository, then run pnpm run verify.');
|
||||
await access(path.join(output, 'suite.json')).catch(() => { throw new Error('Run pnpm run pack first.'); });
|
||||
const harness = path.join(output, 'host-check');
|
||||
await mkdir(harness, { recursive: true });
|
||||
const manifest = `[package]
|
||||
name = "trove-qa-host-check"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[[bin]]
|
||||
name = "trove-qa-host-check"
|
||||
path = ${JSON.stringify(path.join(root, 'tools/host-check.rs'))}
|
||||
|
||||
[dependencies]
|
||||
trove = { path = ${JSON.stringify(path.join(path.resolve(app), 'src-tauri'))}, default-features = false }
|
||||
anyhow = "1"
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
`;
|
||||
await writeFile(path.join(harness, 'Cargo.toml'), manifest);
|
||||
const target = process.env.TROVE_QA_TARGET_DIR || path.join(output, 'host-target');
|
||||
try {
|
||||
run('cargo', ['build', '--manifest-path', path.join(harness, 'Cargo.toml'), '--target-dir', target]);
|
||||
run(troveBin, ['protocol', 'test', '--command', path.join(root, 'shared/backend/target/release/trove-qa-backend')]);
|
||||
} catch (error) {
|
||||
await writeFile(path.join(output, 'verification.json'), JSON.stringify({
|
||||
schemaVersion: 1, generatedAt: new Date().toISOString(), passed: 0, failed: 1, observed: 0,
|
||||
results: [{ name: '编译适配器 / CLI 协议检查', status: 'fail', error: { message: error.message } }],
|
||||
}, null, 2) + '\n');
|
||||
throw error;
|
||||
}
|
||||
|
||||
const sourceServer = await serve();
|
||||
const child = spawn(path.join(target, 'debug/trove-qa-host-check'), [output], {
|
||||
cwd: root, stdio: ['pipe', 'pipe', 'inherit'], env: { ...process.env, QA_HTTP_SOURCE_URL: `${sourceServer.base}/index.json` },
|
||||
});
|
||||
const controls = new Map();
|
||||
const listeners = new Map();
|
||||
const clients = [];
|
||||
const results = [];
|
||||
let nextId = 1;
|
||||
let readyResolve, readyReject;
|
||||
const ready = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
|
||||
let exitResolve;
|
||||
const exited = new Promise(resolve => { exitResolve = resolve; });
|
||||
let fatal;
|
||||
let closing = false;
|
||||
child.once('error', error => { readyReject(error); });
|
||||
child.once('exit', (code, signal) => {
|
||||
exitResolve({ code, signal });
|
||||
if (!closing) {
|
||||
fatal = new Error(`Host adapter exited unexpectedly: ${code ?? signal}`);
|
||||
readyReject(fatal);
|
||||
for (const pending of controls.values()) pending.reject(fatal);
|
||||
for (const listener of listeners.values()) listener({ v: 1, type: 'goodbye' });
|
||||
}
|
||||
});
|
||||
const lines = createInterface({ input: child.stdout });
|
||||
lines.on('line', line => {
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
if (message.ready) readyResolve(message);
|
||||
else if (message.controlId) {
|
||||
const pending = controls.get(message.controlId);
|
||||
controls.delete(message.controlId);
|
||||
if (message.error) pending?.reject(new Error(message.error.message));
|
||||
else pending?.resolve(message.result);
|
||||
} else if (message.connection) listeners.get(message.connection)?.(message.message);
|
||||
else if (message.fatal) {
|
||||
fatal = new Error(JSON.stringify(message.fatal));
|
||||
for (const listener of listeners.values()) listener({ v: 1, type: 'protocolError', error: message.fatal });
|
||||
}
|
||||
} catch (error) { fatal = error; readyReject(error); }
|
||||
});
|
||||
function send(value) {
|
||||
if (fatal) return Promise.reject(fatal);
|
||||
return new Promise((resolve, reject) => child.stdin.write(JSON.stringify(value) + '\n', error => error ? reject(error) : resolve()));
|
||||
}
|
||||
function control(op, params = {}) {
|
||||
const controlId = String(nextId++);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { controls.delete(controlId); reject(new Error(`Control timeout: ${op}`)); }, 15000);
|
||||
controls.set(controlId, { resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } });
|
||||
send({ op, ...params, controlId }).catch(error => { controls.get(controlId)?.reject(error); controls.delete(controlId); });
|
||||
});
|
||||
}
|
||||
async function client(curioId) {
|
||||
const connection = `qa-ui-${crypto.randomUUID()}`;
|
||||
const context = await control('connect', { curioId, connection });
|
||||
const instance = createTroveClient({ context, bridge: {
|
||||
protocolVersion: 1,
|
||||
connect: async listener => { listeners.set(connection, listener); },
|
||||
send: message => send({ op: 'send', connection, message }),
|
||||
disconnect: () => { listeners.delete(connection); void control('disconnect', { connection }).catch(() => {}); },
|
||||
} });
|
||||
await instance.ready();
|
||||
clients.push(instance);
|
||||
return instance;
|
||||
}
|
||||
async function check(name, action, status = 'pass') {
|
||||
const start = performance.now();
|
||||
try { results.push({ name, status, value: await action(), durationMs: Math.round(performance.now() - start) }); }
|
||||
catch (error) { results.push({ name, status: 'fail', error: { code: error.code, message: error.message }, durationMs: Math.round(performance.now() - start) }); }
|
||||
console.log(`${results.at(-1).status.toUpperCase()} ${name}${results.at(-1).error ? `: ${results.at(-1).error.message}` : ''}`);
|
||||
}
|
||||
async function waitFor(action, predicate, timeoutMs = 3000) {
|
||||
const end = Date.now() + timeoutMs;
|
||||
do { const value = await action(); if (predicate(value)) return value; await sleep(50); } while (Date.now() < end);
|
||||
throw new Error(`Condition did not settle within ${timeoutMs} ms`);
|
||||
}
|
||||
let temporaryRoot;
|
||||
try {
|
||||
const readyTimer = setTimeout(() => readyReject(new Error('Package checks exceeded 60 seconds')), 60000);
|
||||
let initial;
|
||||
try { initial = await ready; } finally { clearTimeout(readyTimer); }
|
||||
temporaryRoot = initial.temporaryRoot;
|
||||
results.push(...initial.packageResults);
|
||||
const host = await client('dev.trove.qa.host');
|
||||
for (const [name, action] of hostChecks(host)) await check(`Host / ${name}`, action);
|
||||
const isolation = await client('dev.trove.qa.isolation');
|
||||
for (const [name, action] of hostChecks(isolation).filter(([name]) => name.includes('持久化'))) await check(`隔离 / ${name}`, action);
|
||||
await check('隔离 / 两插件同名键相互独立', async () => {
|
||||
equal((await host.call('host.storage', 'get', { key: 'qa.namespace-owner' })).value, host.context.curioId);
|
||||
equal((await isolation.call('host.storage', 'get', { key: 'qa.namespace-owner' })).value, isolation.context.curioId);
|
||||
return { isolated: true };
|
||||
});
|
||||
await check('持久化 / SDK 断开重连后读取', async () => {
|
||||
host.disconnect();
|
||||
const reopened = await client('dev.trove.qa.host');
|
||||
equal((await reopened.call('host.storage', 'get', { key: 'qa.namespace-owner' })).value, reopened.context.curioId);
|
||||
return { reopened: true };
|
||||
});
|
||||
const self = await client('dev.trove.qa.self');
|
||||
for (const [name, action] of backendChecks(self, '@self')) await check(`私有后端 / ${name}`, action);
|
||||
const consumer = await client('dev.trove.qa.consumer');
|
||||
const service = 'dev.trove.qa.provider.probe';
|
||||
await check('公共后端 / 第一次调用前尚未启动', async () => {
|
||||
const status = await control('status', { curioId: 'dev.trove.qa.provider' });
|
||||
equal(status.state, 'stopped'); return status;
|
||||
});
|
||||
await check('公共后端 / 默认返回值契约策略(观察)', async () => {
|
||||
try {
|
||||
const result = await consumer.call(service, 'invalidResult');
|
||||
return { enforced: false, result, note: '默认底座允许不符合返回值契约的结果;后续单独开启严格模式验证。' };
|
||||
} catch (error) {
|
||||
equal(error.code, 'service.contract_violation');
|
||||
return { enforced: true, actualError: error.code };
|
||||
}
|
||||
}, 'observed');
|
||||
for (const [name, action] of backendChecks(consumer, service).filter(([name]) => !name.startsWith('公共服务'))) await check(`公共后端 / ${name}`, action);
|
||||
await control('strictContracts', { value: true });
|
||||
try {
|
||||
for (const [name, action] of backendChecks(consumer, service).filter(([name]) => name.startsWith('公共服务'))) await check(`严格模式 / ${name}`, action);
|
||||
} finally { await control('strictContracts', { value: false }); }
|
||||
await check('公共后端 / Host 存储归属于提供方', async () => {
|
||||
const key = 'qa.provider-namespace';
|
||||
await consumer.call(service, 'storageRoundtrip', { key, value: 'provider-only' });
|
||||
equal((await consumer.call('host.storage', 'get', { key })).found, false);
|
||||
const provider = await client('dev.trove.qa.provider');
|
||||
equal((await provider.call('host.storage', 'get', { key })).value, 'provider-only');
|
||||
await provider.call('host.storage', 'delete', { key });
|
||||
return { namespace: 'dev.trove.qa.provider' };
|
||||
});
|
||||
await check('取消 / 后端活动请求最终归零', async () => {
|
||||
return waitFor(() => consumer.call(service, 'info'), value => value.activeRequests === 1 && value.subscriptions === 0);
|
||||
});
|
||||
for (const [id, expected] of [['optional', 'service.not_found'], ['required', 'service.not_found'], ['incompatible', 'service.version_mismatch']]) {
|
||||
await check(`依赖 / ${id}`, async () => {
|
||||
const instance = await client(`dev.trove.qa.${id}`);
|
||||
equal(await instance.call('host.runtime', 'echo', { alive: true }), { alive: true });
|
||||
return expectError(() => instance.call(id === 'incompatible' ? service : 'dev.trove.qa.absent.probe', 'echo'), expected);
|
||||
});
|
||||
}
|
||||
await check('启动 / 缺失 ready 触发超时', () => expectError(async () => {
|
||||
const instance = await client('dev.trove.qa.start-timeout'); await instance.self.call('info');
|
||||
}, 'provider.start_timeout'));
|
||||
await check('启动 / 错误令牌被拒绝', async () => {
|
||||
const instance = await client('dev.trove.qa.bad-handshake');
|
||||
const result = await expectError(() => instance.self.call('info'), 'provider.start_failed');
|
||||
equal((await control('status', { curioId: instance.context.curioId })).state, 'failed');
|
||||
return result;
|
||||
});
|
||||
await check('禁用 / 提供方拒绝调用并可重新启用', async () => {
|
||||
await control('enabled', { curioId: 'dev.trove.qa.provider', value: false });
|
||||
try { await expectError(() => consumer.call(service, 'echo'), 'provider.disabled'); }
|
||||
finally { await control('enabled', { curioId: 'dev.trove.qa.provider', value: true }); }
|
||||
equal(await consumer.call(service, 'echo', { restored: true }), { restored: true });
|
||||
return { enabledAgain: true };
|
||||
});
|
||||
await check('退出 / 优雅关闭后可再次启动', async () => {
|
||||
const before = await self.self.call('info');
|
||||
const stopped = await control('stop', { curioId: self.context.curioId });
|
||||
equal(stopped.state, 'stopped');
|
||||
const after = await self.self.call('info');
|
||||
assert(after.pid !== before.pid, 'Shutdown retained old process');
|
||||
return { before: before.pid, after: after.pid };
|
||||
});
|
||||
await check('退出 / 忽略 shutdown 的进程被回收', async () => {
|
||||
const instance = await client('dev.trove.qa.stubborn');
|
||||
const before = await instance.self.call('info');
|
||||
const stopped = await control('stop', { curioId: instance.context.curioId });
|
||||
equal(stopped.state, 'stopped');
|
||||
let gone = false;
|
||||
try { process.kill(before.pid, 0); } catch (error) { if (error.code === 'ESRCH') gone = true; else throw error; }
|
||||
assert(gone, `Process ${before.pid} survived shutdown grace period`);
|
||||
return { terminatedPid: before.pid };
|
||||
});
|
||||
await check('崩溃 / 隔离、重启、熔断和手动重试', async () => {
|
||||
const instance = await client('dev.trove.qa.crash');
|
||||
const first = await instance.self.call('info');
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await expectError(() => instance.self.call('crash'), 'provider.disconnected');
|
||||
if (i < 2) {
|
||||
const restarted = await instance.self.call('info');
|
||||
assert(restarted.pid !== first.pid, 'Crash recovery retained dead PID');
|
||||
}
|
||||
}
|
||||
await expectError(() => instance.self.call('info'), 'provider.crash_loop');
|
||||
equal(await self.self.call('echo', { unaffected: true }), { unaffected: true });
|
||||
await control('retry', { curioId: instance.context.curioId });
|
||||
const after = await instance.self.call('info');
|
||||
return { oldPid: first.pid, retriedPid: after.pid };
|
||||
});
|
||||
await check('空闲 / 20 秒无调用后退出并重新按需启动', async () => {
|
||||
const before = await consumer.call(service, 'info');
|
||||
await waitFor(() => control('status', { curioId: 'dev.trove.qa.provider' }), value => value.state === 'stopped', 24000);
|
||||
const after = await consumer.call(service, 'info');
|
||||
assert(before.pid !== after.pid, 'Idle shutdown retained old PID');
|
||||
return { before: before.pid, after: after.pid };
|
||||
});
|
||||
await check('清理 / 无悬挂调用和订阅', async () => {
|
||||
return waitFor(() => control('diagnostics'), value => value.pendingCalls === 0 && value.incomingCalls === 0 && value.subscriptions === 0);
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({ name: '验证进程', status: 'fail', error: { message: error.stack || error.message } });
|
||||
} finally {
|
||||
for (const instance of clients) instance.disconnect();
|
||||
// A barrier lets all disconnect cleanup finish before shutting down the adapter.
|
||||
if (!fatal && child.exitCode === null) await control('diagnostics').catch(() => {});
|
||||
closing = true;
|
||||
child.stdin.end(JSON.stringify({ op: 'shutdown' }) + '\n');
|
||||
const killTimer = setTimeout(() => child.kill('SIGTERM'), 10000);
|
||||
const exit = await exited;
|
||||
clearTimeout(killTimer);
|
||||
await sourceServer.close();
|
||||
if (exit.code !== 0) results.push({ name: 'Host adapter exit', status: 'fail', error: exit });
|
||||
if (temporaryRoot) {
|
||||
let removed = false;
|
||||
try { await access(temporaryRoot); } catch (error) { if (error.code === 'ENOENT') removed = true; else throw error; }
|
||||
results.push({ name: '隔离数据目录已清理', status: removed ? 'pass' : 'fail', value: { temporaryRoot } });
|
||||
}
|
||||
const report = {
|
||||
schemaVersion: 1, generatedAt: new Date().toISOString(),
|
||||
environment: { platform: process.platform, arch: process.arch, appRepository: path.resolve(app), sdk: '0.1.0' },
|
||||
scope: 'Real PackageManager + ServiceBus + BackendSupervisor + UDS + production SDK over a test bridge. Default policy is used except explicitly labeled strict-contract checks. Desktop WebView/native dialogs and app-start orchestration are manual checks.',
|
||||
passed: results.filter(row => row.status === 'pass').length,
|
||||
failed: results.filter(row => row.status === 'fail').length,
|
||||
observed: results.filter(row => row.status === 'observed').length,
|
||||
results,
|
||||
};
|
||||
await writeFile(path.join(output, 'verification.json'), JSON.stringify(report, null, 2) + '\n');
|
||||
console.log(`\n${report.passed} passed, ${report.failed} failed, ${report.observed} observations. Report: ${path.join(output, 'verification.json')}`);
|
||||
process.exitCode = report.failed ? 1 : 0;
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
# 测试 SDK 分发包
|
||||
|
||||
`trove-plugin-sdk-0.1.0.tgz` 是 `@trove/plugin-sdk@0.1.0` 的版本化分发包,复制自 Trove app 仓库 `src-tauri/templates/trove-plugin-sdk-0.1.0.tgz`(参考提交 `66a294a`)。
|
||||
|
||||
SHA-256:`9715052f98ff0121f83c13fd538f2b1d3d05028fc498f75f1efadb86624d0272`
|
||||
|
||||
根 package.json 使用 `file:vendor/trove-plugin-sdk-0.1.0.tgz` 依赖,pnpm-lock.yaml 固定完整性。构建时从该包复制发布的 `dist/` 到每个 UI;不导入相邻 app 仓库的 SDK 源码,也不依赖公共 CDN。替换 SDK 时应同时更新版本、来源、完整性锁和验证记录。
|
||||
|
||||
`shared/backend/src/main.rs` 的 Wire v1 framing、并发/取消与写队列实现基于同一 app 仓库的 Rust 教学后端,扩展为测试探针和显式故障模式;它作为独立 Rust crate 编译。
|
||||
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user