1
0

Compare commits

..

2 Commits

Author SHA1 Message Date
b432581444 refactor: 清理测试代码 eslint-disable 指令,消除文件级和重复局部禁用 2026-05-21 00:35:08 +08:00
ccd16a583e feat: 跨平台发布打包,支持 7 个目标平台交叉编译和 tar.gz 分发
- 新增 scripts/release.ts,支持 7 个编译目标(linux/darwin/windows + musl 变体)
- 从 build.ts 提取共享构建逻辑到 build-common.ts,现有 build 行为不变
- 使用 tar-stream + node:zlib 创建 tar.gz,精确控制 Unix 权限位
- SHA256 校验和文件格式兼容 sha256sum -c
- 支持 --target 参数选择特定平台编译
- 新增 devDependency: tar-stream、@types/tar-stream
- 更新 README.md 和 DEVELOPMENT.md 文档
- 同步 openspec specs
2026-05-20 23:24:36 +08:00
17 changed files with 1077 additions and 304 deletions

1
.gitignore vendored
View File

@@ -422,3 +422,4 @@ backend/cmd/desktop/rsrc_windows_*.syso
# Bun # Bun
.build/ .build/
*.bun-build *.bun-build
dist/release/

View File

@@ -950,7 +950,9 @@ bun run build
#### 构建流程 #### 构建流程
`scripts/build.ts` 执行三步流水线: 构建逻辑拆分为两个文件:`scripts/build-common.ts`(共享函数)和 `scripts/build.ts`(编排逻辑)。
`scripts/build.ts` 执行三步流水线(函数来自 `build-common.ts`
``` ```
1. Vite build → dist/web/ (前端静态资源,含 code splitting) 1. Vite build → dist/web/ (前端静态资源,含 code splitting)
@@ -992,10 +994,64 @@ bun run build
```bash ```bash
bun run clean bun run clean
# 清理 dist/ 构建产物和 .build/ 临时文件 # 清理 dist/ 构建产物、dist/release/ 发布产物和 .build/ 临时文件
``` ```
### 3.4 开发工作流 ### 3.4 跨平台发布
#### 发布命令
```bash
bun run release # 编译全部 7 个目标平台
bun run release --target linux-x64 # 编译指定平台
bun run release --target linux-x64,windows-x64,darwin-arm64 # 多平台
```
#### 发布流程
`scripts/release.ts` 复用 `build-common.ts` 的前端构建和代码生成,然后执行多目标交叉编译和打包:
```
1. Vite build → dist/web/ (前端静态资源,只执行一次)
2. Code generation → .build/ (资源嵌入代码)
3. 多目标 Bun compile → dist/release/binaries/ (7 个目标平台二进制)
4. tar.gz 打包 → dist/release/packages/ (压缩包 + SHA256 校验和)
```
#### 支持的目标平台
| CLI 参数 | Bun CompileTarget | 说明 |
| ------------------ | ---------------------- | ------------------- |
| `linux-x64` | `bun-linux-x64` | Linux x64 glibc |
| `linux-arm64` | `bun-linux-arm64` | Linux ARM64 glibc |
| `linux-x64-musl` | `bun-linux-x64-musl` | Linux x64 musl |
| `linux-arm64-musl` | `bun-linux-arm64-musl` | Linux ARM64 musl |
| `windows-x64` | `bun-windows-x64` | Windows x64 |
| `darwin-x64` | `bun-darwin-x64` | macOS Intel |
| `darwin-arm64` | `bun-darwin-arm64` | macOS Apple Silicon |
#### 产出物
```
dist/release/
├── binaries/ ← 裸二进制
│ ├── dial-server-{version}-{os}-{arch}[.exe]
│ └── ...
└── packages/ ← 压缩包 + 校验和
├── dial-server_{version}_{os}_{arch}.tar.gz
├── dial-server_{version}_{os}_{arch}.tar.gz.sha256
└── ...
```
压缩包内含可执行文件(`dial-server``dial-server.exe`)、`probes.example.yaml``LICENSE`
#### 命名规范
- 裸二进制:`dial-server-{version}-{os}-{arch}[.exe]`,如 `dial-server-0.1.0-linux-x64`
- 压缩包:`dial-server_{version}_{os}_{arch}.tar.gz`,如 `dial-server_0.1.0_linux_x64.tar.gz`
- 校验和:`<压缩包文件名>.sha256`,格式兼容 `sha256sum -c`
### 3.5 开发工作流
#### 日常开发循环 #### 日常开发循环
@@ -1015,29 +1071,30 @@ bun run verify
`verify` 适合 CI 或正式提交前会完整验证类型检查、lint、格式、单元测试和生产构建。 `verify` 适合 CI 或正式提交前会完整验证类型检查、lint、格式、单元测试和生产构建。
### 3.5 Executable/E2E 验证 ### 3.6 Executable/E2E 验证
`scripts/smoke.ts` 覆盖过薄,已从当前工作流移除。后续如需验证 production executable 的 API、静态资源服务、SPA fallback 行为,应重新设计独立的 executable/E2E 测试。 `scripts/smoke.ts` 覆盖过薄,已从当前工作流移除。后续如需验证 production executable 的 API、静态资源服务、SPA fallback 行为,应重新设计独立的 executable/E2E 测试。
### 3.6 脚本说明 ### 3.7 脚本说明
| 脚本 | 文件 | 说明 | | 脚本 | 文件 | 说明 |
| ---------------------- | ----------------------------------- | ---------------------------------------- | | ---------------------- | ----------------------------------- | -------------------------------------------------- |
| `bun run dev` | `scripts/dev.ts` | 双进程开发服务Vite :5173 + API :3000 | | `bun run dev` | `scripts/dev.ts` | 双进程开发服务Vite :5173 + API :3000 |
| `bun run dev:server` | `src/server/dev.ts` | 仅启动后端 API server | | `bun run dev:server` | `src/server/dev.ts` | 仅启动后端 API server |
| `bun run dev:web` | Vite CLI | 仅启动 Vite dev server | | `bun run dev:web` | Vite CLI | 仅启动 Vite dev server |
| `bun run build` | `scripts/build.ts` | Vite → codegen → Bun compile 三步构建 | | `bun run build` | `scripts/build.ts` | Vite → codegen → Bun compile 三步构建 |
| `bun run schema` | `scripts/generate-config-schema.ts` | 生成 `probe-config.schema.json` | | `bun run release` | `scripts/release.ts` | 跨平台发布打包(多目标交叉编译 + tar.gz + SHA256 |
| `bun run schema:check` | `scripts/generate-config-schema.ts` | 检查配置 schema 导出物是否同步 | | `bun run schema` | `scripts/generate-config-schema.ts` | 生成 `probe-config.schema.json` |
| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 | | `bun run schema:check` | `scripts/generate-config-schema.ts` | 检查配置 schema 导出物是否同步 |
| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 |
### 3.7 环境变量 ### 3.8 环境变量
| 变量 | 用途 | 默认值 | | 变量 | 用途 | 默认值 |
| --------------------------- | ----------------------------------------------- | -------- | | --------------------------- | ----------------------------------------------- | -------- |
| `BUN_TARGET`/`BUILD_TARGET` | 交叉编译目标平台(仅在 `bun run build` 时有效) | 当前平台 | | `BUN_TARGET`/`BUILD_TARGET` | 交叉编译目标平台(仅在 `bun run build` 时有效) | 当前平台 |
### 3.8 项目配置文件 ### 3.9 项目配置文件
| 文件 | 用途 | | 文件 | 用途 |
| ---------------------- | ---------------------------------------------- | | ---------------------- | ---------------------------------------------- |
@@ -1050,14 +1107,14 @@ bun run verify
| `probes.example.yaml` | 配置文件示例 | | `probes.example.yaml` | 配置文件示例 |
| `opencode.json` | OpenCode 工具配置TDesign MCP server | | `opencode.json` | OpenCode 工具配置TDesign MCP server |
### 3.9 依赖管理 ### 3.10 依赖管理
- **包管理器**:仅使用 `bun`,禁止使用 npm、pnpm、yarn - **包管理器**:仅使用 `bun`,禁止使用 npm、pnpm、yarn
- **安装依赖**`bun install` - **安装依赖**`bun install`
- **运行工具**:使用 `bunx`,禁止使用 `npx``pnpx` - **运行工具**:使用 `bunx`,禁止使用 `npx``pnpx`
- **锁文件**`bun.lock` - **锁文件**`bun.lock`
### 3.10 目录约定 ### 3.11 目录约定
| 目录 | 约定 | | 目录 | 约定 |
| ------------- | ---------------------------------------------------- | | ------------- | ---------------------------------------------------- |
@@ -1100,6 +1157,15 @@ bun run check # 一键运行 schema:check + typecheck + lint + test
| `eslint-plugin-import` | 导入路径验证、循环依赖检测、重复导入合并 | | `eslint-plugin-import` | 导入路径验证、循环依赖检测、重复导入合并 |
| `eslint-plugin-prettier` recommended + `eslint-config-prettier` | 将 Prettier 格式集成为 ESLint 规则,禁用冲突规则 | | `eslint-plugin-prettier` recommended + `eslint-config-prettier` | 将 Prettier 格式集成为 ESLint 规则,禁用冲突规则 |
### 测试代码 ESLint 规范
测试代码与业务代码使用相同的 ESLint 规则集,应优先通过类型化 helper、类型化 mock、显式 no-op 和受控断言模式满足已启用的类型感知规则,最小化 `eslint-disable` 的使用。具体约定:
- 使用类型化 mock 变量(`vi.fn()`)替代动态 `require` 获取 mocked module
- 异步错误断言使用 helper 或显式 try/catch避免依赖 Bun `expect(...).rejects``await-thenable` 规则的类型不匹配
- polyfill 中的 intentional no-op 使用显式可解释写法(如 `() => undefined` 或共享 `noop` 函数)
-`process.exit` 等系统 API 使用 `spyOn`(从 `bun:test` 导入)受控 mock 而非手动 monkey patch
### Prettier 配置 ### Prettier 配置
配置文件:`.prettierrc.json`,通过 `eslint-plugin-prettier` 集成为 ESLint 规则(`lint` 命令同时检查格式),也可通过 `format` 命令独立运行。 配置文件:`.prettierrc.json`,通过 `eslint-plugin-prettier` 集成为 ESLint 规则(`lint` 命令同时检查格式),也可通过 `format` 命令独立运行。

View File

@@ -78,6 +78,35 @@ bun run build
构建产物为独立可执行文件,只需一个 YAML 配置文件即可运行。 构建产物为独立可执行文件,只需一个 YAML 配置文件即可运行。
### 跨平台发布打包
```bash
# 编译全部 7 个目标平台
bun run release
# 编译指定平台
bun run release --target linux-x64
bun run release --target linux-x64,windows-x64,darwin-arm64
```
支持的目标平台:`linux-x64``linux-arm64``linux-x64-musl``linux-arm64-musl``windows-x64``darwin-x64``darwin-arm64`
**产出物结构:**
```
dist/release/
├── binaries/ ← 裸二进制(带版本号和平台标识)
│ ├── dial-server-0.1.0-linux-x64
│ ├── dial-server-0.1.0-windows-x64.exe
│ └── ...
└── packages/ ← tar.gz 压缩包 + SHA256 校验和
├── dial-server_0.1.0_linux_x64.tar.gz
├── dial-server_0.1.0_linux_x64.tar.gz.sha256
└── ...
```
压缩包内含可执行文件、`probes.example.yaml``LICENSE`,解压后可直接使用。
## 配置文件 ## 配置文件
程序通过 YAML 配置文件定义所有运行参数,完整示例参见 [`probes.example.yaml`](probes.example.yaml)。 程序通过 YAML 配置文件定义所有运行参数,完整示例参见 [`probes.example.yaml`](probes.example.yaml)。

View File

@@ -32,6 +32,7 @@
"@types/jsdom": "^28.0.3", "@types/jsdom": "^28.0.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/tar-stream": "^3.1.4",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.3.0", "eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
@@ -45,6 +46,7 @@
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"lint-staged": "^17.0.4", "lint-staged": "^17.0.4",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"tar-stream": "^3.2.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.59.3", "typescript-eslint": "^8.59.3",
"vite": "^8.0.13", "vite": "^8.0.13",
@@ -310,6 +312,8 @@
"@types/sortablejs": ["@types/sortablejs@1.15.9", "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.9.tgz", {}, "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ=="], "@types/sortablejs": ["@types/sortablejs@1.15.9", "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.9.tgz", {}, "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ=="],
"@types/tar-stream": ["@types/tar-stream@3.1.4", "https://registry.npmmirror.com/@types/tar-stream/-/tar-stream-3.1.4.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg=="],
"@types/tough-cookie": ["@types/tough-cookie@4.0.5", "https://registry.npmmirror.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "https://registry.npmmirror.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
@@ -416,8 +420,22 @@
"available-typed-arrays": ["available-typed-arrays@1.0.7", "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"b4a": ["b4a@1.8.1", "https://registry.npmmirror.com/b4a/-/b4a-1.8.1.tgz", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="],
"balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"bare-events": ["bare-events@2.8.3", "https://registry.npmmirror.com/bare-events/-/bare-events-2.8.3.tgz", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw=="],
"bare-fs": ["bare-fs@4.7.1", "https://registry.npmmirror.com/bare-fs/-/bare-fs-4.7.1.tgz", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw=="],
"bare-os": ["bare-os@3.9.1", "https://registry.npmmirror.com/bare-os/-/bare-os-3.9.1.tgz", {}, "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ=="],
"bare-path": ["bare-path@3.0.0", "https://registry.npmmirror.com/bare-path/-/bare-path-3.0.0.tgz", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="],
"bare-stream": ["bare-stream@2.13.1", "https://registry.npmmirror.com/bare-stream/-/bare-stream-2.13.1.tgz", { "dependencies": { "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-abort-controller", "bare-buffer", "bare-events"] }, "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow=="],
"bare-url": ["bare-url@2.4.3", "https://registry.npmmirror.com/bare-url/-/bare-url-2.4.3.tgz", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.28", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.28.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.28", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.28.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA=="],
"bidi-js": ["bidi-js@1.0.3", "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], "bidi-js": ["bidi-js@1.0.3", "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
@@ -620,12 +638,16 @@
"eventemitter3": ["eventemitter3@5.0.4", "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "eventemitter3": ["eventemitter3@5.0.4", "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"events-universal": ["events-universal@1.0.1", "https://registry.npmmirror.com/events-universal/-/events-universal-1.0.1.tgz", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="],
"eventsource-parser": ["eventsource-parser@3.0.8", "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.0.8.tgz", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], "eventsource-parser": ["eventsource-parser@3.0.8", "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.0.8.tgz", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-diff": ["fast-diff@1.3.0", "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], "fast-diff": ["fast-diff@1.3.0", "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
"fast-fifo": ["fast-fifo@1.3.2", "https://registry.npmmirror.com/fast-fifo/-/fast-fifo-1.3.2.tgz", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], "fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
@@ -1032,6 +1054,8 @@
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
"streamx": ["streamx@2.25.0", "https://registry.npmmirror.com/streamx/-/streamx-2.25.0.tgz", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="],
"string-argv": ["string-argv@0.3.2", "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], "string-argv": ["string-argv@0.3.2", "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@7.2.0", "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "string-width": ["string-width@7.2.0", "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
@@ -1052,10 +1076,16 @@
"synckit": ["synckit@0.11.12", "https://registry.npmmirror.com/synckit/-/synckit-0.11.12.tgz", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="], "synckit": ["synckit@0.11.12", "https://registry.npmmirror.com/synckit/-/synckit-0.11.12.tgz", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
"tar-stream": ["tar-stream@3.2.0", "https://registry.npmmirror.com/tar-stream/-/tar-stream-3.2.0.tgz", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="],
"tdesign-icons-react": ["tdesign-icons-react@0.6.4", "https://registry.npmmirror.com/tdesign-icons-react/-/tdesign-icons-react-0.6.4.tgz", { "dependencies": { "@babel/runtime": "^7.16.5", "classnames": "^2.2.6" }, "peerDependencies": { "react": ">=16.13.1", "react-dom": ">=16.13.1" } }, "sha512-USAoi9vBWcwcJT45VqR3dRqX1MeAsn/RhHVx4bLwplhrlvE80ZQ1N9V+6F3HqE1Qe9mMDbtRM8Ul80+lALScww=="], "tdesign-icons-react": ["tdesign-icons-react@0.6.4", "https://registry.npmmirror.com/tdesign-icons-react/-/tdesign-icons-react-0.6.4.tgz", { "dependencies": { "@babel/runtime": "^7.16.5", "classnames": "^2.2.6" }, "peerDependencies": { "react": ">=16.13.1", "react-dom": ">=16.13.1" } }, "sha512-USAoi9vBWcwcJT45VqR3dRqX1MeAsn/RhHVx4bLwplhrlvE80ZQ1N9V+6F3HqE1Qe9mMDbtRM8Ul80+lALScww=="],
"tdesign-react": ["tdesign-react@1.16.9", "https://registry.npmmirror.com/tdesign-react/-/tdesign-react-1.16.9.tgz", { "dependencies": { "@babel/runtime": "~7.26.7", "@popperjs/core": "~2.11.2", "@types/sortablejs": "^1.10.7", "@types/validator": "^13.1.3", "classnames": "~2.5.1", "dayjs": "1.11.10", "hoist-non-react-statics": "~3.3.2", "lodash-es": "^4.17.21", "mitt": "^3.0.0", "raf": "~3.4.1", "react-fast-compare": "^3.2.2", "react-is": "^18.2.0", "react-transition-group": "~4.4.1", "sortablejs": "^1.15.0", "tdesign-icons-react": "^0.6.4", "tslib": "~2.3.1", "validator": "~13.15.0" }, "peerDependencies": { "react": ">=16.13.1", "react-dom": ">=16.13.1" } }, "sha512-C3uZRTkJ1iQ62BrMkuvqvBK+4HEuhl82rABxa6kAHGHL3eBI4DPfzAJGF0T3b+DKCBeJxb0x10elumT6NkQEaw=="], "tdesign-react": ["tdesign-react@1.16.9", "https://registry.npmmirror.com/tdesign-react/-/tdesign-react-1.16.9.tgz", { "dependencies": { "@babel/runtime": "~7.26.7", "@popperjs/core": "~2.11.2", "@types/sortablejs": "^1.10.7", "@types/validator": "^13.1.3", "classnames": "~2.5.1", "dayjs": "1.11.10", "hoist-non-react-statics": "~3.3.2", "lodash-es": "^4.17.21", "mitt": "^3.0.0", "raf": "~3.4.1", "react-fast-compare": "^3.2.2", "react-is": "^18.2.0", "react-transition-group": "~4.4.1", "sortablejs": "^1.15.0", "tdesign-icons-react": "^0.6.4", "tslib": "~2.3.1", "validator": "~13.15.0" }, "peerDependencies": { "react": ">=16.13.1", "react-dom": ">=16.13.1" } }, "sha512-C3uZRTkJ1iQ62BrMkuvqvBK+4HEuhl82rABxa6kAHGHL3eBI4DPfzAJGF0T3b+DKCBeJxb0x10elumT6NkQEaw=="],
"teex": ["teex@1.0.1", "https://registry.npmmirror.com/teex/-/teex-1.0.1.tgz", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="],
"text-decoder": ["text-decoder@1.2.7", "https://registry.npmmirror.com/text-decoder/-/text-decoder-1.2.7.tgz", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyexec": ["tinyexec@1.1.2", "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.1.2.tgz", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], "tinyexec": ["tinyexec@1.1.2", "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.1.2.tgz", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],

View File

@@ -124,3 +124,26 @@
#### Scenario: 完整验证失败 #### Scenario: 完整验证失败
- **WHEN** `verify` 中任一阶段失败 - **WHEN** `verify` 中任一阶段失败
- **THEN** `verify` MUST 以非零状态退出且不能继续声明验证成功 - **THEN** `verify` MUST 以非零状态退出且不能继续声明验证成功
### Requirement: 测试代码 ESLint 禁用最小化
项目测试代码 SHALL 优先通过类型化 helper、类型化 mock、显式 no-op 和受控断言模式满足已启用的 ESLint 类型感知规则。受本变更审计的项目自有测试文件 MUST NOT 保留用于压制可通过代码结构解决的 `eslint-disable` 指令。
#### Scenario: 消除组件测试文件级禁用
- **WHEN** ESLint 检查 `tests/web/components/App.test.tsx`
- **THEN** 该文件 MUST 不使用文件级 `eslint-disable` 关闭 `@typescript-eslint/no-require-imports``@typescript-eslint/no-unsafe-*` 规则,并且测试中的 hook mock SHALL 使用类型化引用或等价方式访问 mock API
#### Scenario: 消除配置加载测试重复 await 禁用
- **WHEN** `tests/server/checker/config-loader.test.ts` 断言 `loadConfig()` 异步失败
- **THEN** 测试 SHALL 使用 helper 或显式 try/catch 断言错误实例与消息MUST 不通过逐行 `eslint-disable-next-line @typescript-eslint/await-thenable` 压制 Bun `expect(...).rejects` 类型不匹配
#### Scenario: 测试环境 no-op polyfill 保持可解释
- **WHEN** `tests/setup.ts` 为 jsdom 测试环境定义浏览器 API polyfill
- **THEN** intentional no-op SHALL 使用显式可解释写法表达MUST 不通过文件级 `eslint-disable @typescript-eslint/no-empty-function` 关闭空函数检查
#### Scenario: release 测试拦截 process.exit 保持窄作用域
- **WHEN** `tests/scripts/release.test.ts` 验证无效 release target 会触发 `process.exit(1)`
- **THEN** 测试 SHALL 使用受控 mock 或等价窄作用域替换并在断言后恢复MUST 不通过 `eslint-disable-next-line @typescript-eslint/unbound-method` 保存未绑定方法
#### Scenario: 质量门禁验证禁用清理
- **WHEN** 开发者运行 `bun run lint`
- **THEN** ESLint MUST 检查项目自有测试代码并在无上述 `eslint-disable` 指令的情况下通过

View File

@@ -0,0 +1,132 @@
## Purpose
定义多平台交叉编译发布流程包括目标平台矩阵、可执行文件命名规范、压缩包打包、SHA256 校验和生成、CLI 接口。
## Requirements
### Requirement: Release 目标平台矩阵
Release 流程 SHALL 支持以下 7 个编译目标,覆盖所有指定平台:
| Bun CompileTarget | OS | Arch |
|---|---|---|
| bun-linux-x64 | linux | x64 |
| bun-linux-arm64 | linux | arm64 |
| bun-linux-x64-musl | linux | x64-musl |
| bun-linux-arm64-musl | linux | arm64-musl |
| bun-windows-x64 | windows | x64 |
| bun-darwin-x64 | darwin | x64 |
| bun-darwin-arm64 | darwin | arm64 |
#### Scenario: 默认全平台编译
- **WHEN** 开发者运行 `bun run release` 不带额外参数
- **THEN** 系统 SHALL 依次编译上述 7 个目标
#### Scenario: 指定单一目标编译
- **WHEN** 开发者运行 `bun run release --target linux-x64`
- **THEN** 系统 SHALL 只编译 `bun-linux-x64` 目标
#### Scenario: 指定多个目标编译
- **WHEN** 开发者运行 `bun run release --target linux-x64,darwin-arm64,windows-x64`
- **THEN** 系统 SHALL 编译指定的 3 个目标
#### Scenario: 无效 target 参数
- **WHEN** 开发者传入不存在的 `--target`
- **THEN** 系统 MUST 报错退出,并列出所有可用的 target 值
### Requirement: Release 构建流水线
Release 流程 SHALL 执行四步流水线Vite 前端构建 → code generation → 多目标交叉编译 → 打包与校验和。
#### Scenario: Release 构建顺序
- **WHEN** 开发者运行 `bun run release`
- **THEN** 系统 MUST 依次执行 Vite build、code generation、多目标 Bun compile、tar.gz 打包和 SHA256 校验和生成
#### Scenario: Vite 构建失败
- **WHEN** Vite build 步骤失败
- **THEN** 系统 MUST 停止后续步骤,不执行 code generation 或编译
#### Scenario: 单目标编译失败
- **WHEN** 某个目标的 Bun compile 失败
- **THEN** 系统 MUST 停止后续打包,报告失败的目标
#### Scenario: 前端构建只执行一次
- **WHEN** release 流程编译多个目标
- **THEN** Vite build 和 code generation MUST 只执行一次,所有目标共用同一份前端产出
#### Scenario: 构建完成后清理
- **WHEN** release 流程完成(无论成功或失败)
- **THEN** 系统 SHALL 清理 `.build/` 临时目录
### Requirement: 可执行文件命名规范
Release 产出的裸二进制 SHALL 使用 `dial-server-{version}-{os}-{arch}` 命名Windows 平台附加 `.exe` 后缀。
#### Scenario: Linux x64 可执行文件命名
- **WHEN** 编译目标为 bun-linux-x64 且版本为 0.1.0
- **THEN** 裸二进制文件名 SHALL 为 `dial-server-0.1.0-linux-x64`
#### Scenario: Windows 可执行文件命名
- **WHEN** 编译目标为 bun-windows-x64 且版本为 0.1.0
- **THEN** 裸二进制文件名 SHALL 为 `dial-server-0.1.0-windows-x64.exe`
#### Scenario: musl 变体命名
- **WHEN** 编译目标为 bun-linux-x64-musl 且版本为 0.1.0
- **THEN** 裸二进制文件名 SHALL 为 `dial-server-0.1.0-linux-x64-musl`
### Requirement: 压缩包打包
每个目标 SHALL 生成一个 tar.gz 压缩包,内含可执行文件、示例配置和许可证。
#### Scenario: 压缩包命名
- **WHEN** 编译 linux-x64 目标且版本为 0.1.0
- **THEN** 压缩包文件名 SHALL 为 `dial-server_0.1.0_linux_x64.tar.gz`
#### Scenario: 压缩包内部目录结构
- **WHEN** 解压 `dial-server_0.1.0_linux_x64.tar.gz`
- **THEN** 产出目录 SHALL 为 `dial-server_0.1.0_linux_x64/`,内含 `dial-server`(可执行文件)、`probes.example.yaml`(示例配置)、`LICENSE`(许可证)
#### Scenario: Windows 目标压缩包
- **WHEN** 编译 windows-x64 目标且版本为 0.1.0
- **THEN** 压缩包 SHALL 使用 `.tar.gz` 格式,内部可执行文件名 SHALL 为 `dial-server.exe`
#### Scenario: 压缩包内可执行文件权限
- **WHEN** 在 Linux/macOS 上解压非 Windows 目标的压缩包
- **THEN** 可执行文件 SHALL 具有 0o755 权限(可执行),配置文件和许可证 SHALL 具有 0o644 权限
#### Scenario: 版本号来源
- **WHEN** release 流程执行
- **THEN** 版本号 SHALL 从 `package.json.version` 读取,与 build 命令使用同一来源
### Requirement: SHA256 校验和
每个压缩包 SHALL 附带一个 SHA256 校验和文件,格式兼容 `sha256sum -c`
#### Scenario: 校验和文件命名
- **WHEN** 压缩包为 `dial-server_0.1.0_linux_x64.tar.gz`
- **THEN** 校验和文件名 SHALL 为 `dial-server_0.1.0_linux_x64.tar.gz.sha256`
#### Scenario: 校验和文件内容格式
- **WHEN** 读取 `.sha256` 文件
- **THEN** 内容 SHALL 为 `<hash> <filename>\n` 格式,其中 `<hash>` 为 64 位小写十六进制字符串,`<filename>` 为对应压缩包文件名,中间以两个空格分隔
#### Scenario: 校验和正确性
- **WHEN** 使用 `sha256sum -c` 命令验证 `.sha256` 文件
- **THEN** 校验 MUST 通过
### Requirement: 产出物目录结构
Release 产出物 SHALL 按类型分目录存放在 `dist/release/` 下。
#### Scenario: 产出物目录布局
- **WHEN** release 流程成功完成
- **THEN** `dist/release/binaries/` SHALL 包含所有裸二进制文件,`dist/release/packages/` SHALL 包含所有压缩包和校验和文件
#### Scenario: 清理命令覆盖
- **WHEN** 开发者运行 `bun run clean`
- **THEN** 系统 SHALL 清理 `dist/release/` 目录
### Requirement: Release 报告
Release 流程完成时 SHALL 输出构建报告,包含各产出物的文件大小。
#### Scenario: 成功构建报告
- **WHEN** release 流程成功完成
- **THEN** 系统 SHALL 输出每个裸二进制和压缩包的文件路径和大小
#### Scenario: 失败构建报告
- **WHEN** release 流程失败
- **THEN** 系统 SHALL 输出失败的具体步骤和目标平台

View File

@@ -7,6 +7,8 @@
### Requirement: 生产构建顺序 ### Requirement: 生产构建顺序
生产构建 MUST 通过三步流水线完成Vite 前端构建 → code generation → Bun compile。 生产构建 MUST 通过三步流水线完成Vite 前端构建 → code generation → Bun compile。
构建步骤 1-2Vite build、code generation的逻辑 SHALL 从 `scripts/build-common.ts` 导入,`scripts/build.ts` 只保留当前平台编译和编排逻辑。
#### Scenario: 运行生产构建 #### Scenario: 运行生产构建
- **WHEN** 开发者运行生产构建命令 - **WHEN** 开发者运行生产构建命令
- **THEN** 系统 MUST 依次执行 Vite build、资源导入 code generation、Bun.build compile最终输出单可执行文件 - **THEN** 系统 MUST 依次执行 Vite build、资源导入 code generation、Bun.build compile最终输出单可执行文件
@@ -19,6 +21,10 @@
- **WHEN** Bun.build compile 步骤失败 - **WHEN** Bun.build compile 步骤失败
- **THEN** 系统 MUST 清理 `.build/` 临时目录,不保留 stale executable - **THEN** 系统 MUST 清理 `.build/` 临时目录,不保留 stale executable
#### Scenario: 重构后 build 行为不变
- **WHEN** `scripts/build.ts` 改为从 `scripts/build-common.ts` 导入共享构建函数
- **THEN** `bun run build` 的产出文件路径(`dist/dial-server`)、构建步骤顺序和错误处理行为 SHALL 与重构前完全一致
### Requirement: 单 executable 输出 ### Requirement: 单 executable 输出
生产构建 SHALL 输出一个 standalone executable其中包含 Bun 后端和通过 `import with { type: "file" }` 嵌入的 Vite 前端产出。 生产构建 SHALL 输出一个 standalone executable其中包含 Bun 后端和通过 `import with { type: "file" }` 嵌入的 Vite 前端产出。
@@ -37,13 +43,15 @@
### Requirement: 构建中间产物管理 ### Requirement: 构建中间产物管理
构建流程 SHALL 使用 `.build/` 临时目录存放 code generation 产物,构建完成后清理。 构建流程 SHALL 使用 `.build/` 临时目录存放 code generation 产物,构建完成后清理。
清理逻辑 SHALL 定义在 `scripts/build-common.ts` 中,供 `build.ts``release.ts` 共用。
#### Scenario: 构建成功后清理中间产物 #### Scenario: 构建成功后清理中间产物
- **WHEN** 生产构建成功完成并输出 executable - **WHEN** 生产构建成功完成并输出 executable
- **THEN** 系统 SHALL 删除 `.build/` 临时目录 - **THEN** 系统 SHALL 通过 `build-common.ts` 中的 `cleanup()` 函数删除 `.build/` 临时目录
#### Scenario: 构建失败时清理中间产物 #### Scenario: 构建失败时清理中间产物
- **WHEN** 生产构建在 Bun compile 步骤失败 - **WHEN** 生产构建在 Bun compile 步骤失败
- **THEN** 系统 SHALL 删除 `.build/` 临时目录和 stale executable - **THEN** 系统 SHALL 通过 `build-common.ts` 中的 `cleanup()` 函数删除 `.build/` 临时目录和 stale executable
### Requirement: 外部运行时配置 ### Requirement: 外部运行时配置
executable MUST 将环境相关运行时配置保留在嵌入的前端和 server bundle 之外。 executable MUST 将环境相关运行时配置保留在嵌入的前端和 server bundle 之外。

View File

@@ -16,6 +16,7 @@
"verify": "bun run check && bun run build", "verify": "bun run check && bun run build",
"test": "bun test", "test": "bun test",
"clean": "bun run scripts/clean.ts", "clean": "bun run scripts/clean.ts",
"release": "bun run scripts/release.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"prepare": "husky", "prepare": "husky",
"version:patch": "bun run scripts/bump-version.ts patch", "version:patch": "bun run scripts/bump-version.ts patch",
@@ -33,6 +34,7 @@
"@types/jsdom": "^28.0.3", "@types/jsdom": "^28.0.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/tar-stream": "^3.1.4",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.3.0", "eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
@@ -46,6 +48,7 @@
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"lint-staged": "^17.0.4", "lint-staged": "^17.0.4",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"tar-stream": "^3.2.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.59.3", "typescript-eslint": "^8.59.3",
"vite": "^8.0.13" "vite": "^8.0.13"

123
scripts/build-common.ts Normal file
View File

@@ -0,0 +1,123 @@
import { readdir, rm, writeFile } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { validateVersion } from "./bump-version-logic";
export const projectRoot = fileURLToPath(new URL("..", import.meta.url));
export const distWebDir = join(projectRoot, "dist/web");
export const buildDir = join(projectRoot, ".build");
export const packageJsonPath = join(projectRoot, "package.json");
export async function cleanup() {
await rm(buildDir, { force: true, recursive: true });
}
export async function codeGeneration() {
console.log("Step 2/3: Code generation...");
await rm(buildDir, { force: true, recursive: true });
await Bun.write(join(buildDir, ".gitkeep"), "");
const packageJson = (await Bun.file(packageJsonPath).json()) as { version: string };
const version = packageJson.version;
if (typeof version !== "string") {
console.error("package.json does not have a valid version field");
process.exit(1);
}
validateVersion(version);
const allFiles = await scanDir(distWebDir, "/");
const importLines: string[] = [];
const fileEntries: string[] = [];
let indexHtmlVar = "";
for (let i = 0; i < allFiles.length; i++) {
const urlPath = allFiles[i]!;
const varName = `f${i}`;
const filePath = toImportSpecifier(buildDir, join(distWebDir, urlPath.slice(1)));
importLines.push(`import ${varName} from "./${filePath}" with { type: "file" };`);
if (urlPath === "/index.html") {
indexHtmlVar = varName;
} else {
fileEntries.push(` "${urlPath}": Bun.file(${varName}),`);
}
}
if (!indexHtmlVar) {
console.error("index.html not found in dist/web/");
process.exit(1);
}
const staticAssetsTs = [
`import type { StaticAssets } from "../src/server/static";`,
"",
...importLines,
"",
`export const staticAssets: StaticAssets = {`,
` files: {`,
...fileEntries,
` },`,
` indexHtml: Bun.file(${indexHtmlVar}),`,
`};`,
"",
].join("\n");
await writeFile(join(buildDir, "static-assets.ts"), staticAssetsTs);
const serverEntryTs = [
`import { bootstrap } from "../src/server/bootstrap";`,
`import { readRuntimeConfig } from "../src/server/config";`,
`import { staticAssets } from "./static-assets";`,
"",
`const APP_VERSION = "${version}" as const;`,
"",
`async function main() {`,
` const { configPath } = readRuntimeConfig();`,
` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`,
`}`,
"",
`void main().catch((error) => {`,
` console.error("启动失败:", error instanceof Error ? error.message : error);`,
` process.exit(1);`,
`});`,
"",
].join("\n");
await writeFile(join(buildDir, "server-entry.ts"), serverEntryTs);
return version;
}
export async function scanDir(dir: string, prefix: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const paths: string[] = [];
for (const entry of entries) {
const fullPath = join(dir, entry.name);
const urlPath = `${prefix}${entry.name}`;
if (entry.isDirectory()) {
paths.push(...(await scanDir(fullPath, `${urlPath}/`)));
} else {
paths.push(urlPath);
}
}
return paths;
}
export function toImportSpecifier(fromDir: string, targetPath: string) {
return relative(fromDir, targetPath).split(sep).join("/");
}
export async function viteBuild() {
console.log("Step 1/3: Vite build...");
const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], {
cwd: projectRoot,
stderr: "inherit",
stdout: "inherit",
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
console.error("Vite build failed");
process.exit(1);
}
}

View File

@@ -1,14 +1,9 @@
import { readdir, rm, writeFile } from "node:fs/promises"; import { rm } from "node:fs/promises";
import { join, relative, sep } from "node:path"; import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { validateVersion } from "./bump-version-logic"; import { buildDir, cleanup, codeGeneration, projectRoot, viteBuild } from "./build-common";
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
const distWebDir = join(projectRoot, "dist/web");
const buildDir = join(projectRoot, ".build");
const executablePath = join(projectRoot, "dist/dial-server"); const executablePath = join(projectRoot, "dist/dial-server");
const packageJsonPath = join(projectRoot, "package.json");
async function build() { async function build() {
try { try {
@@ -54,115 +49,4 @@ async function bunCompile() {
} }
} }
async function cleanup() {
await rm(buildDir, { force: true, recursive: true });
}
async function codeGeneration() {
console.log("Step 2/3: Code generation...");
await rm(buildDir, { force: true, recursive: true });
await Bun.write(join(buildDir, ".gitkeep"), "");
const packageJson = (await Bun.file(packageJsonPath).json()) as { version: string };
const version = packageJson.version;
if (typeof version !== "string") {
console.error("package.json does not have a valid version field");
process.exit(1);
}
validateVersion(version);
const allFiles = await scanDir(distWebDir, "/");
const importLines: string[] = [];
const fileEntries: string[] = [];
let indexHtmlVar = "";
for (let i = 0; i < allFiles.length; i++) {
const urlPath = allFiles[i]!;
const varName = `f${i}`;
const filePath = toImportSpecifier(buildDir, join(distWebDir, urlPath.slice(1)));
importLines.push(`import ${varName} from "./${filePath}" with { type: "file" };`);
if (urlPath === "/index.html") {
indexHtmlVar = varName;
} else {
fileEntries.push(` "${urlPath}": Bun.file(${varName}),`);
}
}
if (!indexHtmlVar) {
console.error("index.html not found in dist/web/");
process.exit(1);
}
const staticAssetsTs = [
`import type { StaticAssets } from "../src/server/static";`,
"",
...importLines,
"",
`export const staticAssets: StaticAssets = {`,
` files: {`,
...fileEntries,
` },`,
` indexHtml: Bun.file(${indexHtmlVar}),`,
`};`,
"",
].join("\n");
await writeFile(join(buildDir, "static-assets.ts"), staticAssetsTs);
const serverEntryTs = [
`import { bootstrap } from "../src/server/bootstrap";`,
`import { readRuntimeConfig } from "../src/server/config";`,
`import { staticAssets } from "./static-assets";`,
"",
`const APP_VERSION = "${version}" as const;`,
"",
`async function main() {`,
` const { configPath } = readRuntimeConfig();`,
` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`,
`}`,
"",
`void main().catch((error) => {`,
` console.error("启动失败:", error instanceof Error ? error.message : error);`,
` process.exit(1);`,
`});`,
"",
].join("\n");
await writeFile(join(buildDir, "server-entry.ts"), serverEntryTs);
}
async function scanDir(dir: string, prefix: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const paths: string[] = [];
for (const entry of entries) {
const fullPath = join(dir, entry.name);
const urlPath = `${prefix}${entry.name}`;
if (entry.isDirectory()) {
paths.push(...(await scanDir(fullPath, `${urlPath}/`)));
} else {
paths.push(urlPath);
}
}
return paths;
}
function toImportSpecifier(fromDir: string, targetPath: string) {
return relative(fromDir, targetPath).split(sep).join("/");
}
async function viteBuild() {
console.log("Step 1/3: Vite build...");
const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], {
cwd: projectRoot,
stderr: "inherit",
stdout: "inherit",
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
console.error("Vite build failed");
process.exit(1);
}
}
await build(); await build();

View File

@@ -8,6 +8,7 @@ const dirs: Array<{ desc: string; path: string }> = [
{ desc: "Bun 构建缓存", path: ".build" }, { desc: "Bun 构建缓存", path: ".build" },
{ desc: "Playwright 测试报告", path: "playwright-report" }, { desc: "Playwright 测试报告", path: "playwright-report" },
{ desc: "测试结果", path: "test-results" }, { desc: "测试结果", path: "test-results" },
{ desc: "发布产物", path: "dist/release" },
]; ];
const filePatterns: Array<{ desc: string; glob: string }> = [{ desc: "Bun 构建临时文件", glob: ".*.bun-build" }]; const filePatterns: Array<{ desc: string; glob: string }> = [{ desc: "Bun 构建临时文件", glob: ".*.bun-build" }];

196
scripts/release.ts Normal file
View File

@@ -0,0 +1,196 @@
import { createHash } from "node:crypto";
import { mkdir, rm, stat } from "node:fs/promises";
import { join, relative } from "node:path";
import { createGzip } from "node:zlib";
import tar from "tar-stream";
import { buildDir, cleanup, codeGeneration, projectRoot, viteBuild } from "./build-common";
const releaseDir = join(projectRoot, "dist/release");
const binariesDir = join(releaseDir, "binaries");
const packagesDir = join(releaseDir, "packages");
export interface ReleaseTarget {
arch: string;
bunTarget: string;
displayName: string;
os: string;
}
export const ALL_TARGETS: ReleaseTarget[] = [
{ arch: "x64", bunTarget: "bun-linux-x64", displayName: "Linux x64 (glibc)", os: "linux" },
{ arch: "arm64", bunTarget: "bun-linux-arm64", displayName: "Linux ARM64 (glibc)", os: "linux" },
{ arch: "x64-musl", bunTarget: "bun-linux-x64-musl", displayName: "Linux x64 (musl)", os: "linux" },
{ arch: "arm64-musl", bunTarget: "bun-linux-arm64-musl", displayName: "Linux ARM64 (musl)", os: "linux" },
{ arch: "x64", bunTarget: "bun-windows-x64", displayName: "Windows x64", os: "windows" },
{ arch: "x64", bunTarget: "bun-darwin-x64", displayName: "macOS x64 (Intel)", os: "darwin" },
{ arch: "arm64", bunTarget: "bun-darwin-arm64", displayName: "macOS ARM64 (Apple Silicon)", os: "darwin" },
];
export function archiveName(target: ReleaseTarget, version: string): string {
return `dial-server_${version}_${target.os}_${target.arch}.tar.gz`;
}
export function checksumName(target: ReleaseTarget, version: string): string {
return `${archiveName(target, version)}.sha256`;
}
export async function compileTarget(target: ReleaseTarget, version: string): Promise<string> {
const outfile = join(binariesDir, execName(target, version));
console.log(` 编译 ${target.displayName}...`);
const result = await Bun.build({
compile: {
autoloadBunfig: true,
autoloadDotenv: true,
outfile,
target: target.bunTarget as Bun.Build.CompileTarget,
},
entrypoints: [join(buildDir, "server-entry.ts")],
minify: true,
});
if (!result.success) {
console.error(` 编译失败 (${target.displayName}):`, result.logs);
process.exit(1);
}
return outfile;
}
export async function computeChecksum(archivePath: string): Promise<string> {
const content = await Bun.file(archivePath).arrayBuffer();
const hash = createHash("sha256").update(Buffer.from(content)).digest("hex");
const filename = relative(packagesDir, archivePath);
const checksumPath = `${archivePath}.sha256`;
const checksumContent = `${hash} ${filename}\n`;
await Bun.write(checksumPath, checksumContent);
return checksumPath;
}
export function execName(target: ReleaseTarget, version: string): string {
const suffix = target.os === "windows" ? ".exe" : "";
return `dial-server-${version}-${target.os}-${target.arch}${suffix}`;
}
export async function packageTarget(target: ReleaseTarget, version: string, binaryPath: string): Promise<string> {
const archivePath = join(packagesDir, archiveName(target, version));
const prefix = `dial-server_${version}_${target.os}_${target.arch}`;
const binaryName = target.os === "windows" ? "dial-server.exe" : "dial-server";
const binaryContent = await Bun.file(binaryPath).arrayBuffer();
const probesContent = await Bun.file(join(projectRoot, "probes.example.yaml")).arrayBuffer();
const licenseContent = await Bun.file(join(projectRoot, "LICENSE")).arrayBuffer();
const pack = tar.pack();
pack.entry(
{ mode: 0o755, name: `${prefix}/${binaryName}`, size: binaryContent.byteLength },
Buffer.from(binaryContent),
);
pack.entry(
{ mode: 0o644, name: `${prefix}/probes.example.yaml`, size: probesContent.byteLength },
Buffer.from(probesContent),
);
pack.entry({ mode: 0o644, name: `${prefix}/LICENSE`, size: licenseContent.byteLength }, Buffer.from(licenseContent));
pack.finalize();
const gzip = createGzip();
const chunks: Buffer[] = [];
await new Promise<void>((resolve, reject) => {
pack.pipe(gzip);
gzip.on("data", (chunk: Buffer) => chunks.push(chunk));
gzip.on("end", resolve);
gzip.on("error", reject);
});
await Bun.write(archivePath, Buffer.concat(chunks));
return archivePath;
}
export function parseTargets(args: string[]): ReleaseTarget[] {
const targetIndex = args.indexOf("--target");
if (targetIndex === -1 || targetIndex === args.length - 1) {
return ALL_TARGETS;
}
const targetValues = args[targetIndex + 1]!.split(",");
const targets: ReleaseTarget[] = [];
for (const value of targetValues) {
const bunTarget = `bun-${value.trim()}`;
const found = ALL_TARGETS.find((t) => t.bunTarget === bunTarget);
if (!found) {
const available = ALL_TARGETS.map((t) => t.bunTarget.replace(/^bun-/, "")).join(", ");
console.error(`无效的 target: ${value.trim()}`);
console.error(`可用的 target 值: ${available}`);
process.exit(1);
}
targets.push(found);
}
return targets;
}
export async function printReport(binaries: string[], archives: string[]): Promise<void> {
console.log("\n=== Release 报告 ===\n");
console.log("裸二进制:");
for (const binary of binaries) {
const size = (await stat(binary)).size;
const mb = (size / 1024 / 1024).toFixed(1);
console.log(` ${relative(projectRoot, binary)} (${mb} MB)`);
}
console.log("\n压缩包:");
for (const archive of archives) {
const size = (await stat(archive)).size;
const mb = (size / 1024 / 1024).toFixed(1);
console.log(` ${relative(projectRoot, archive)} (${mb} MB)`);
}
}
async function release() {
const targets = parseTargets(process.argv);
console.log(`Release 目标: ${targets.map((t) => t.displayName).join(", ")}\n`);
try {
await viteBuild();
const version = await codeGeneration();
console.log(`\n版本: ${version}`);
console.log(`编译 ${targets.length} 个目标...\n`);
await rm(releaseDir, { force: true, recursive: true });
await mkdir(binariesDir, { recursive: true });
await mkdir(packagesDir, { recursive: true });
const binaries: string[] = [];
for (const target of targets) {
const binaryPath = await compileTarget(target, version);
binaries.push(binaryPath);
}
const archives: string[] = [];
for (let i = 0; i < targets.length; i++) {
const target = targets[i]!;
const binaryPath = binaries[i]!;
console.log(` 打包 ${target.displayName}...`);
const archivePath = await packageTarget(target, version, binaryPath);
await computeChecksum(archivePath);
archives.push(archivePath);
}
await cleanup();
await printReport(binaries, archives);
console.log("\nRelease 完成!");
} catch (error) {
await cleanup();
console.error("Release 失败:", error);
process.exit(1);
}
}
if (import.meta.main) {
await release();
}

View File

@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, sep } from "node:path";
import { scanDir, toImportSpecifier } from "../../scripts/build-common";
describe("scanDir", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = join(tmpdir(), `build-common-test-${Date.now()}`);
await mkdir(tempDir, { recursive: true });
});
afterEach(async () => {
await rm(tempDir, { force: true, recursive: true });
});
test("扫描空目录", async () => {
const files = await scanDir(tempDir, "/");
expect(files).toEqual([]);
});
test("扫描单文件", async () => {
await writeFile(join(tempDir, "index.html"), "<html></html>");
const files = await scanDir(tempDir, "/");
expect(files).toEqual(["/index.html"]);
});
test("扫描嵌套目录", async () => {
await mkdir(join(tempDir, "assets"), { recursive: true });
await writeFile(join(tempDir, "index.html"), "");
await writeFile(join(tempDir, "assets", "style.css"), "");
await writeFile(join(tempDir, "assets", "app.js"), "");
const files = await scanDir(tempDir, "/");
expect(files.sort()).toEqual(["/assets/app.js", "/assets/style.css", "/index.html"]);
});
test("扫描多层嵌套", async () => {
await mkdir(join(tempDir, "a", "b"), { recursive: true });
await writeFile(join(tempDir, "a", "b", "deep.txt"), "");
const files = await scanDir(tempDir, "/");
expect(files).toEqual(["/a/b/deep.txt"]);
});
test("自定义前缀", async () => {
await writeFile(join(tempDir, "file.txt"), "");
const files = await scanDir(tempDir, "/static/");
expect(files).toEqual(["/static/file.txt"]);
});
});
describe("toImportSpecifier", () => {
test("同目录文件生成正确的相对路径", () => {
const result = toImportSpecifier("/project/.build", "/project/.build/file.txt");
expect(result).toBe("file.txt");
});
test("子目录文件生成正确的相对路径", () => {
const result = toImportSpecifier("/project/.build", "/project/.build/sub/file.txt");
expect(result).toBe("sub/file.txt");
});
test("Windows 路径分隔符转换为正斜杠", () => {
const fromDir = ["C:", "project", ".build"].join(sep);
const targetPath = ["C:", "project", "dist", "web", "assets", "app.js"].join(sep);
const result = toImportSpecifier(fromDir, targetPath);
expect(result).toBe("../dist/web/assets/app.js");
expect(result).not.toContain(sep);
});
});

View File

@@ -0,0 +1,220 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import {
ALL_TARGETS,
archiveName,
checksumName,
computeChecksum,
execName,
packageTarget,
parseTargets,
} from "../../scripts/release";
describe("parseTargets", () => {
test("默认返回全部目标", () => {
const targets = parseTargets([]);
expect(targets).toEqual(ALL_TARGETS);
});
test("解析单一目标", () => {
const targets = parseTargets(["--target", "linux-x64"]);
expect(targets).toHaveLength(1);
expect(targets[0]!.bunTarget).toBe("bun-linux-x64");
expect(targets[0]!.os).toBe("linux");
expect(targets[0]!.arch).toBe("x64");
});
test("解析多个逗号分隔目标", () => {
const targets = parseTargets(["--target", "linux-x64,darwin-arm64,windows-x64"]);
expect(targets).toHaveLength(3);
expect(targets[0]!.bunTarget).toBe("bun-linux-x64");
expect(targets[1]!.bunTarget).toBe("bun-darwin-arm64");
expect(targets[2]!.bunTarget).toBe("bun-windows-x64");
});
test("解析 musl 变体", () => {
const targets = parseTargets(["--target", "linux-x64-musl"]);
expect(targets).toHaveLength(1);
expect(targets[0]!.bunTarget).toBe("bun-linux-x64-musl");
expect(targets[0]!.os).toBe("linux");
expect(targets[0]!.arch).toBe("x64-musl");
});
test("无效 target 导致进程退出", () => {
const exitCalls: number[] = [];
const exitSpy = spyOn(process, "exit").mockImplementation(((code: number) => {
exitCalls.push(code);
}) as typeof process.exit);
parseTargets(["--target", "invalid-target"]);
exitSpy.mockRestore();
expect(exitCalls).toEqual([1]);
});
});
describe("execName", () => {
const linuxTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-linux-x64")!;
const windowsTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-windows-x64")!;
const muslTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-linux-x64-musl")!;
test("Linux x64 可执行文件命名", () => {
expect(execName(linuxTarget, "0.1.0")).toBe("dial-server-0.1.0-linux-x64");
});
test("Windows 可执行文件命名带 .exe 后缀", () => {
expect(execName(windowsTarget, "0.1.0")).toBe("dial-server-0.1.0-windows-x64.exe");
});
test("musl 变体命名", () => {
expect(execName(muslTarget, "0.1.0")).toBe("dial-server-0.1.0-linux-x64-musl");
});
test("版本号正确嵌入", () => {
expect(execName(linuxTarget, "1.2.3")).toBe("dial-server-1.2.3-linux-x64");
});
});
describe("archiveName", () => {
const linuxTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-linux-x64")!;
const windowsTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-windows-x64")!;
test("Linux 压缩包命名", () => {
expect(archiveName(linuxTarget, "0.1.0")).toBe("dial-server_0.1.0_linux_x64.tar.gz");
});
test("Windows 压缩包命名", () => {
expect(archiveName(windowsTarget, "0.1.0")).toBe("dial-server_0.1.0_windows_x64.tar.gz");
});
});
describe("checksumName", () => {
const linuxTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-linux-x64")!;
test("校验和文件命名", () => {
expect(checksumName(linuxTarget, "0.1.0")).toBe("dial-server_0.1.0_linux_x64.tar.gz.sha256");
});
});
describe("computeChecksum", () => {
const projectRoot = fileURLToPath(new URL("../../", import.meta.url));
const packagesDir = join(projectRoot, "dist/release/packages");
beforeEach(async () => {
await mkdir(packagesDir, { recursive: true });
});
afterEach(async () => {
await rm(packagesDir, { force: true, recursive: true });
});
test("生成正确格式的 sha256 文件", async () => {
const archivePath = join(packagesDir, "dial-server_0.1.0_linux_x64.tar.gz");
const content = Buffer.from("test archive content");
await Bun.write(archivePath, content);
const checksumPath = await computeChecksum(archivePath);
const checksumContent = await Bun.file(checksumPath).text();
const parts = checksumContent.split(" ");
expect(parts).toHaveLength(2);
expect(parts[0]).toMatch(/^[0-9a-f]{64}$/);
expect(parts[1]).toBe("dial-server_0.1.0_linux_x64.tar.gz\n");
});
test("校验和与文件内容一致", async () => {
const archivePath = join(packagesDir, "test.tar.gz");
const content = Buffer.from("hello world");
await Bun.write(archivePath, content);
const checksumPath = await computeChecksum(archivePath);
const checksumContent = await Bun.file(checksumPath).text();
const hash = checksumContent.split(" ")[0]!;
const expectedHash = Bun.CryptoHasher.hash("sha256", content, "hex");
expect(hash).toBe(expectedHash);
});
});
describe("packageTarget", () => {
const projectRoot = fileURLToPath(new URL("../../", import.meta.url));
const packagesDir = join(projectRoot, "dist/release/packages");
beforeEach(async () => {
await mkdir(packagesDir, { recursive: true });
});
afterEach(async () => {
await rm(packagesDir, { force: true, recursive: true });
});
test("Linux 压缩包包含正确文件、目录前缀和权限位", async () => {
const linuxTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-linux-x64")!;
const binaryPath = join(packagesDir, "test-binary");
await Bun.write(binaryPath, "binary content");
const archivePath = await packageTarget(linuxTarget, "0.1.0", binaryPath);
const archiveContent = await Bun.file(archivePath).arrayBuffer();
const tar = await import("tar-stream");
const zlib = await import("node:zlib");
const extract = tar.extract();
const entries: Array<{ mode: number; name: string }> = [];
await new Promise<void>((resolve, reject) => {
const gunzip = zlib.createGunzip();
gunzip.write(Buffer.from(archiveContent));
gunzip.end();
gunzip.pipe(extract);
extract.on("entry", (header: { mode?: number; name: string }, _stream: unknown, next: () => void) => {
entries.push({ mode: header.mode ?? 0, name: header.name });
next();
});
extract.on("finish", resolve);
extract.on("error", reject);
});
const names = entries.map((e) => e.name);
expect(names).toContain("dial-server_0.1.0_linux_x64/dial-server");
expect(names).toContain("dial-server_0.1.0_linux_x64/probes.example.yaml");
expect(names).toContain("dial-server_0.1.0_linux_x64/LICENSE");
const binaryEntry = entries.find((e) => e.name.endsWith("/dial-server"))!;
expect(binaryEntry.mode).toBe(0o755);
const probesEntry = entries.find((e) => e.name.endsWith("probes.example.yaml"))!;
expect(probesEntry.mode).toBe(0o644);
});
test("Windows 压缩包内可执行文件名为 dial-server.exe", async () => {
const windowsTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-windows-x64")!;
const binaryPath = join(packagesDir, "test-binary.exe");
await Bun.write(binaryPath, "binary content");
const archivePath = await packageTarget(windowsTarget, "0.1.0", binaryPath);
const archiveContent = await Bun.file(archivePath).arrayBuffer();
const tar = await import("tar-stream");
const zlib = await import("node:zlib");
const extract = tar.extract();
const names: string[] = [];
await new Promise<void>((resolve, reject) => {
const gunzip = zlib.createGunzip();
gunzip.write(Buffer.from(archiveContent));
gunzip.end();
gunzip.pipe(extract);
extract.on("entry", (header: { name: string }, _stream: unknown, next: () => void) => {
names.push(header.name);
next();
});
extract.on("finish", resolve);
extract.on("error", reject);
});
expect(names).toContain("dial-server_0.1.0_windows_x64/dial-server.exe");
});
});

View File

@@ -104,6 +104,17 @@ describe("loadConfig", () => {
expect((error as Error).message).toContain(message); expect((error as Error).message).toContain(message);
} }
async function expectConfigLoadError(configPath: string, message: string): Promise<void> {
let error: unknown;
try {
await loadConfig(configPath);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(message);
}
test("解析最简 HTTP 配置", async () => { test("解析最简 HTTP 配置", async () => {
const configPath = join(tempDir, "minimal-http.yaml"); const configPath = join(tempDir, "minimal-http.yaml");
await writeFile( await writeFile(
@@ -327,8 +338,7 @@ targets:
url: "http://example.com" url: "http://example.com"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "name 不能为空白");
await expect(loadConfig(configPath)).rejects.toThrow("name 不能为空白");
}); });
test("name 仅包含空白字符抛出错误", async () => { test("name 仅包含空白字符抛出错误", async () => {
@@ -343,8 +353,7 @@ targets:
url: "http://example.com" url: "http://example.com"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "name 不能为空白");
await expect(loadConfig(configPath)).rejects.toThrow("name 不能为空白");
}); });
test("description 显式 null 保留为 null", async () => { test("description 显式 null 保留为 null", async () => {
@@ -449,8 +458,7 @@ targets:
maxRedirects: "\${max_redirects}" maxRedirects: "\${max_redirects}"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "maxRedirects");
await expect(loadConfig(configPath)).rejects.toThrow("maxRedirects");
}); });
test("变量替换后通过 schema 校验", async () => { test("变量替换后通过 schema 校验", async () => {
@@ -491,8 +499,7 @@ targets:
url: "\${MISSING_BASE_URL}/health" url: "\${MISSING_BASE_URL}/health"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "未定义的变量");
await expect(loadConfig(configPath)).rejects.toThrow("未定义的变量");
}); });
test("绝对 dataDir 保持不变", async () => { test("绝对 dataDir 保持不变", async () => {
@@ -546,8 +553,7 @@ targets:
}); });
test("配置文件不存在抛出错误", async () => { test("配置文件不存在抛出错误", async () => {
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError("/nonexistent/file.yaml", "配置文件不存在");
await expect(loadConfig("/nonexistent/file.yaml")).rejects.toThrow("配置文件不存在");
}); });
test("target 缺少 id 抛出错误", async () => { test("target 缺少 id 抛出错误", async () => {
@@ -560,8 +566,7 @@ targets:
url: "http://example.com" url: "http://example.com"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "缺少 id 字段");
await expect(loadConfig(configPath)).rejects.toThrow("缺少 id 字段");
}); });
test("target 缺少 type 抛出错误", async () => { test("target 缺少 type 抛出错误", async () => {
@@ -575,8 +580,7 @@ targets:
url: "http://example.com" url: "http://example.com"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "缺少 type 字段");
await expect(loadConfig(configPath)).rejects.toThrow("缺少 type 字段");
}); });
test("HTTP target 缺少 url 抛出错误", async () => { test("HTTP target 缺少 url 抛出错误", async () => {
@@ -590,8 +594,7 @@ targets:
http: {} http: {}
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "缺少 http.url 字段");
await expect(loadConfig(configPath)).rejects.toThrow("缺少 http.url 字段");
}); });
test("HTTP target 缺少 http 分组抛出清晰错误", async () => { test("HTTP target 缺少 http 分组抛出清晰错误", async () => {
@@ -604,8 +607,7 @@ targets:
type: http type: http
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "缺少 http.url 字段");
await expect(loadConfig(configPath)).rejects.toThrow("缺少 http.url 字段");
}); });
test("HTTP target ignoreSSL 非布尔值抛出错误", async () => { test("HTTP target ignoreSSL 非布尔值抛出错误", async () => {
@@ -621,8 +623,7 @@ targets:
ignoreSSL: "true" ignoreSSL: "true"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "http.ignoreSSL 类型不合法");
await expect(loadConfig(configPath)).rejects.toThrow("http.ignoreSSL 类型不合法");
}); });
test("HTTP target maxRedirects 非负整数校验", async () => { test("HTTP target maxRedirects 非负整数校验", async () => {
@@ -638,8 +639,7 @@ targets:
maxRedirects: 1.5 maxRedirects: 1.5
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "http.maxRedirects 类型不合法");
await expect(loadConfig(configPath)).rejects.toThrow("http.maxRedirects 类型不合法");
}); });
test("HTTP target status 模式非法抛出错误", async () => { test("HTTP target status 模式非法抛出错误", async () => {
@@ -656,8 +656,7 @@ targets:
status: ["abc"] status: ["abc"]
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "status 模式");
await expect(loadConfig(configPath)).rejects.toThrow("status 模式");
}); });
test("cmd target 缺少 exec 抛出错误", async () => { test("cmd target 缺少 exec 抛出错误", async () => {
@@ -671,8 +670,7 @@ targets:
cmd: {} cmd: {}
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "缺少 cmd.exec 字段");
await expect(loadConfig(configPath)).rejects.toThrow("缺少 cmd.exec 字段");
}); });
test("非法 target type 抛出错误", async () => { test("非法 target type 抛出错误", async () => {
@@ -685,8 +683,7 @@ targets:
type: dns type: dns
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "不支持的 type");
await expect(loadConfig(configPath)).rejects.toThrow("不支持的 type");
}); });
test("target id 重复抛出错误", async () => { test("target id 重复抛出错误", async () => {
@@ -706,8 +703,7 @@ targets:
url: "http://b.com" url: "http://b.com"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "target id 重复");
await expect(loadConfig(configPath)).rejects.toThrow("target id 重复");
}); });
test("target id 为空字符串抛出错误", async () => { test("target id 为空字符串抛出错误", async () => {
@@ -721,8 +717,7 @@ targets:
url: "http://example.com" url: "http://example.com"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "缺少 id 字段");
await expect(loadConfig(configPath)).rejects.toThrow("缺少 id 字段");
}); });
test("target id 命名不合法抛出错误", async () => { test("target id 命名不合法抛出错误", async () => {
@@ -736,8 +731,7 @@ targets:
url: "http://example.com" url: "http://example.com"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "id 不符合命名规则");
await expect(loadConfig(configPath)).rejects.toThrow("id 不符合命名规则");
}); });
test("target id 包含下划线和连字符通过", async () => { test("target id 包含下划线和连字符通过", async () => {
@@ -758,8 +752,7 @@ targets:
test("targets 为空数组抛出错误", async () => { test("targets 为空数组抛出错误", async () => {
const configPath = join(tempDir, "empty-targets.yaml"); const configPath = join(tempDir, "empty-targets.yaml");
await writeFile(configPath, `targets: []`); await writeFile(configPath, `targets: []`);
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "至少一个 target");
await expect(loadConfig(configPath)).rejects.toThrow("至少一个 target");
}); });
test("无效端口号抛出错误", async () => { test("无效端口号抛出错误", async () => {
@@ -776,8 +769,7 @@ targets:
url: "http://a.com" url: "http://a.com"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "server.port 数值范围不合法");
await expect(loadConfig(configPath)).rejects.toThrow("server.port 数值范围不合法");
}); });
test("非法 maxConcurrentChecks 抛出错误", async () => { test("非法 maxConcurrentChecks 抛出错误", async () => {
@@ -794,8 +786,7 @@ targets:
url: "http://a.com" url: "http://a.com"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "runtime.maxConcurrentChecks 数值范围不合法");
await expect(loadConfig(configPath)).rejects.toThrow("runtime.maxConcurrentChecks 数值范围不合法");
}); });
test("非法 size 格式抛出错误", async () => { test("非法 size 格式抛出错误", async () => {
@@ -813,8 +804,7 @@ targets:
url: "http://a.com" url: "http://a.com"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "无效的 size 格式");
await expect(loadConfig(configPath)).rejects.toThrow("无效的 size 格式");
}); });
test("非法 interval 格式抛出错误", async () => { test("非法 interval 格式抛出错误", async () => {
@@ -830,8 +820,7 @@ targets:
url: "http://a.com" url: "http://a.com"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "无效的时长格式");
await expect(loadConfig(configPath)).rejects.toThrow("无效的时长格式");
}); });
test("解析 expect 配置", async () => { test("解析 expect 配置", async () => {
@@ -1011,8 +1000,7 @@ targets:
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "group 必须为字符串");
await expect(loadConfig(configPath)).rejects.toThrow("group 必须为字符串");
}); });
test("HTTP headers 非字符串值抛出错误", async () => { test("HTTP headers 非字符串值抛出错误", async () => {
@@ -1029,8 +1017,7 @@ targets:
X-Custom: 123 X-Custom: 123
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "http.headers");
await expect(loadConfig(configPath)).rejects.toThrow("http.headers");
}); });
test("HTTP body 非字符串抛出错误", async () => { test("HTTP body 非字符串抛出错误", async () => {
@@ -1046,8 +1033,7 @@ targets:
body: 123 body: 123
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "http.body 类型不合法");
await expect(loadConfig(configPath)).rejects.toThrow("http.body 类型不合法");
}); });
test("maxBodyBytes 负数抛出错误", async () => { test("maxBodyBytes 负数抛出错误", async () => {
@@ -1063,8 +1049,7 @@ targets:
maxBodyBytes: -1 maxBodyBytes: -1
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "非负安全整数");
await expect(loadConfig(configPath)).rejects.toThrow("非负安全整数");
}); });
test("maxBodyBytes 非整数抛出错误", async () => { test("maxBodyBytes 非整数抛出错误", async () => {
@@ -1080,8 +1065,7 @@ targets:
maxBodyBytes: 1.5 maxBodyBytes: 1.5
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "非负安全整数");
await expect(loadConfig(configPath)).rejects.toThrow("非负安全整数");
}); });
test("expect.status 数字不在 100-599 范围抛出错误", async () => { test("expect.status 数字不在 100-599 范围抛出错误", async () => {
@@ -1098,8 +1082,7 @@ targets:
status: [999] status: [999]
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "100-599");
await expect(loadConfig(configPath)).rejects.toThrow("100-599");
}); });
test("expect.status 范围 6xx 抛出错误", async () => { test("expect.status 范围 6xx 抛出错误", async () => {
@@ -1116,8 +1099,7 @@ targets:
status: ["6xx"] status: ["6xx"]
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "5xx");
await expect(loadConfig(configPath)).rejects.toThrow("5xx");
}); });
test("expect.durationMs 对象简写抛出错误", async () => { test("expect.durationMs 对象简写抛出错误", async () => {
@@ -1135,8 +1117,7 @@ targets:
foo: "bar" foo: "bar"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "expect.durationMs.foo 是未知 matcher");
await expect(loadConfig(configPath)).rejects.toThrow("expect.durationMs.foo 是未知 matcher");
}); });
test("expect.body 非数组抛出错误", async () => { test("expect.body 非数组抛出错误", async () => {
@@ -1153,8 +1134,7 @@ targets:
body: "not-array" body: "not-array"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "expect.body 必须为数组");
await expect(loadConfig(configPath)).rejects.toThrow("expect.body 必须为数组");
}); });
test("body rule 缺少支持字段抛出错误", async () => { test("body rule 缺少支持字段抛出错误", async () => {
@@ -1172,8 +1152,7 @@ targets:
- foo: "bar" - foo: "bar"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "foo 是未知字段");
await expect(loadConfig(configPath)).rejects.toThrow("foo 是未知字段");
}); });
test("body rule 使用 match 字段(非支持)抛出错误", async () => { test("body rule 使用 match 字段(非支持)抛出错误", async () => {
@@ -1191,8 +1170,7 @@ targets:
- match: "ok" - match: "ok"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "match 是未知字段");
await expect(loadConfig(configPath)).rejects.toThrow("match 是未知字段");
}); });
test("body rule 直接 matcher 混入 extractor 抛出错误", async () => { test("body rule 直接 matcher 混入 extractor 抛出错误", async () => {
@@ -1212,8 +1190,7 @@ targets:
path: "$.status" path: "$.status"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "直接 matcher 不能与 extractor 混用");
await expect(loadConfig(configPath)).rejects.toThrow("直接 matcher 不能与 extractor 混用");
}); });
test("body regex 非法正则抛出错误", async () => { test("body regex 非法正则抛出错误", async () => {
@@ -1231,8 +1208,7 @@ targets:
- regex: "[invalid" - regex: "[invalid"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "regex 正则不合法");
await expect(loadConfig(configPath)).rejects.toThrow("regex 正则不合法");
}); });
test("body json path 不以 $. 开头抛出错误", async () => { test("body json path 不以 $. 开头抛出错误", async () => {
@@ -1252,8 +1228,7 @@ targets:
equals: "ok" equals: "ok"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "json.path");
await expect(loadConfig(configPath)).rejects.toThrow("json.path");
}); });
test("body css selector 为空抛出错误", async () => { test("body css selector 为空抛出错误", async () => {
@@ -1272,8 +1247,7 @@ targets:
selector: "" selector: ""
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "css.selector 必须为非空字符串");
await expect(loadConfig(configPath)).rejects.toThrow("css.selector 必须为非空字符串");
}); });
test("旧 match matcher 抛出错误", async () => { test("旧 match matcher 抛出错误", async () => {
@@ -1292,8 +1266,7 @@ targets:
match: "[invalid" match: "[invalid"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "match 是未知 matcher");
await expect(loadConfig(configPath)).rejects.toThrow("match 是未知 matcher");
}); });
test("operator gte 非数字抛出错误", async () => { test("operator gte 非数字抛出错误", async () => {
@@ -1313,8 +1286,7 @@ targets:
gte: "abc" gte: "abc"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "gte 必须为有限数字");
await expect(loadConfig(configPath)).rejects.toThrow("gte 必须为有限数字");
}); });
test("operator exists 非布尔值抛出错误", async () => { test("operator exists 非布尔值抛出错误", async () => {
@@ -1334,8 +1306,7 @@ targets:
exists: "yes" exists: "yes"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "exists 必须为布尔值");
await expect(loadConfig(configPath)).rejects.toThrow("exists 必须为布尔值");
}); });
test("未知字段导致启动失败", async () => { test("未知字段导致启动失败", async () => {
@@ -1357,8 +1328,7 @@ targets:
note: "ignored" note: "ignored"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "unknownHttpField 是未知字段");
await expect(loadConfig(configPath)).rejects.toThrow("unknownHttpField 是未知字段");
}); });
test("xpath path 非空字符串校验", async () => { test("xpath path 非空字符串校验", async () => {
@@ -1377,8 +1347,7 @@ targets:
path: "" path: ""
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "xpath.path 必须为非空字符串");
await expect(loadConfig(configPath)).rejects.toThrow("xpath.path 必须为非空字符串");
}); });
test("expect headers 非对象抛出错误", async () => { test("expect headers 非对象抛出错误", async () => {
@@ -1395,8 +1364,7 @@ targets:
headers: "invalid" headers: "invalid"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "expect.headers 类型不合法");
await expect(loadConfig(configPath)).rejects.toThrow("expect.headers 类型不合法");
}); });
test("HTTP method 小写输入失败", async () => { test("HTTP method 小写输入失败", async () => {
@@ -1760,8 +1728,7 @@ targets:
url: "http://example.com" url: "http://example.com"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "description");
await expect(loadConfig(configPath)).rejects.toThrow("description");
}); });
test("description 超过 500 字符抛出错误", async () => { test("description 超过 500 字符抛出错误", async () => {
@@ -1776,8 +1743,7 @@ targets:
url: "http://example.com" url: "http://example.com"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "description");
await expect(loadConfig(configPath)).rejects.toThrow("description");
}); });
test("变量替换后 description 超长抛出错误", async () => { test("变量替换后 description 超长抛出错误", async () => {
@@ -1794,8 +1760,7 @@ targets:
url: "http://example.com" url: "http://example.com"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "description");
await expect(loadConfig(configPath)).rejects.toThrow("description");
}); });
test("id 超过 30 字符抛出错误", async () => { test("id 超过 30 字符抛出错误", async () => {
@@ -1809,8 +1774,7 @@ targets:
url: "http://example.com" url: "http://example.com"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "id");
await expect(loadConfig(configPath)).rejects.toThrow("id");
}); });
test("name 超过 30 字符抛出错误", async () => { test("name 超过 30 字符抛出错误", async () => {
@@ -1825,8 +1789,7 @@ targets:
url: "http://example.com" url: "http://example.com"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable await expectConfigLoadError(configPath, "name");
await expect(loadConfig(configPath)).rejects.toThrow("name");
}); });
test("解析最简 tcp 配置", async () => { test("解析最简 tcp 配置", async () => {

View File

@@ -4,7 +4,6 @@
* 组件测试使用各自的 test-utils.tsx * 组件测试使用各自的 test-utils.tsx
*/ */
/* eslint-disable @typescript-eslint/no-empty-function */
// Set up jsdom for ALL tests (both backend and frontend) // Set up jsdom for ALL tests (both backend and frontend)
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
@@ -34,8 +33,10 @@ const nodeProto = dom.window.Node.prototype;
const elementProto = dom.window.Element.prototype; const elementProto = dom.window.Element.prototype;
const htmlElementProto = dom.window.HTMLElement.prototype; const htmlElementProto = dom.window.HTMLElement.prototype;
const attachEventFn = () => {}; const noop = () => undefined;
const detachEventFn = () => {};
const attachEventFn = noop;
const detachEventFn = noop;
Object.defineProperty(nodeProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true }); Object.defineProperty(nodeProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
Object.defineProperty(nodeProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true }); Object.defineProperty(nodeProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
@@ -46,27 +47,53 @@ Object.defineProperty(htmlElementProto, "detachEvent", { configurable: true, val
// Other polyfills // Other polyfills
globalThis.ResizeObserver = class { globalThis.ResizeObserver = class {
disconnect() {} disconnect() {
observe() {} return undefined;
unobserve() {} }
observe() {
return undefined;
}
unobserve() {
return undefined;
}
}; };
globalThis.MutationObserver = class { globalThis.MutationObserver = class {
disconnect() {} disconnect() {
observe() {} return undefined;
}
observe() {
return undefined;
}
takeRecords() { takeRecords() {
return []; return [];
} }
unobserve() {}
unobserve() {
return undefined;
}
}; };
globalThis.IntersectionObserver = class { globalThis.IntersectionObserver = class {
disconnect() {} disconnect() {
observe() {} return undefined;
}
observe() {
return undefined;
}
takeRecords() { takeRecords() {
return []; return [];
} }
unobserve() {}
unobserve() {
return undefined;
}
} as unknown as typeof IntersectionObserver; } as unknown as typeof IntersectionObserver;
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(cb, 16); globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(cb, 16);
@@ -74,24 +101,24 @@ globalThis.cancelAnimationFrame = (id: number) => clearTimeout(id);
Object.defineProperty(dom.window, "matchMedia", { Object.defineProperty(dom.window, "matchMedia", {
value: (query: string) => ({ value: (query: string) => ({
addEventListener: () => {}, addEventListener: noop,
addListener: () => {}, addListener: noop,
dispatchEvent: () => true, dispatchEvent: () => true,
matches: false, matches: false,
media: query, media: query,
onchange: null, onchange: null,
removeEventListener: () => {}, removeEventListener: noop,
removeListener: () => {}, removeListener: noop,
}), }),
writable: true, writable: true,
}); });
dom.window.Element.prototype.scrollTo = () => {}; dom.window.Element.prototype.scrollTo = noop;
dom.window.Element.prototype.scrollIntoView = () => {}; dom.window.Element.prototype.scrollIntoView = noop;
Object.defineProperty(dom.window, "customElements", { Object.defineProperty(dom.window, "customElements", {
value: { value: {
define: () => {}, define: noop,
get: () => undefined, get: () => undefined,
}, },
writable: true, writable: true,

View File

@@ -1,7 +1,3 @@
/* eslint-disable @typescript-eslint/no-require-imports */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import "../../../tests/web/test-utils"; import "../../../tests/web/test-utils";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
@@ -9,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
import { App } from "../../../src/web/app"; import { App } from "../../../src/web/app";
import { THEME_MEDIA_QUERY, THEME_PREFERENCE_STORAGE_KEY } from "../../../src/web/hooks/use-theme-preference"; import { THEME_MEDIA_QUERY, THEME_PREFERENCE_STORAGE_KEY } from "../../../src/web/hooks/use-theme-preference";
function createDashboardResult(overrides = {}) { function createDashboardResult(overrides: Record<string, unknown> = {}) {
return { return {
data: { data: {
summary: { summary: {
@@ -35,6 +31,40 @@ function createDashboardResult(overrides = {}) {
}; };
} }
const useDashboardMock = vi.fn(() => createDashboardResult());
const useMetaMock = vi.fn(() => ({
data: { checkerTypes: ["http", "cmd"] as string[], version: "0.1.0" },
}));
const useTargetDetailMock = vi.fn(() => ({
activeTab: "overview",
closeDrawer: vi.fn(),
handlePageChange: vi.fn(),
handleTabChange: vi.fn(),
handleTimeChange: vi.fn(),
historyData: {
items: [],
page: 1,
pageSize: 20,
total: 0,
},
historyLoading: false,
metricsData: null,
metricsLoading: false,
openDrawer: vi.fn(),
selectedTarget: null,
timeFrom: "",
timeTo: "",
}));
void vi.mock("../../../src/web/hooks/use-queries", () => ({
useDashboard: useDashboardMock,
useMeta: useMetaMock,
}));
void vi.mock("../../../src/web/hooks/use-target-detail", () => ({
useTargetDetail: useTargetDetailMock,
}));
function installMatchMedia(initialMatches: boolean) { function installMatchMedia(initialMatches: boolean) {
const originalMatchMedia = window.matchMedia; const originalMatchMedia = window.matchMedia;
let matches = initialMatches; let matches = initialMatches;
@@ -65,43 +95,11 @@ function installMatchMedia(initialMatches: boolean) {
}; };
} }
// Mock hooks
void vi.mock("../../../src/web/hooks/use-queries", () => ({
useDashboard: vi.fn(() => createDashboardResult()),
useMeta: vi.fn(() => ({
data: { checkerTypes: ["http", "cmd"], version: "0.1.0" },
})),
}));
void vi.mock("../../../src/web/hooks/use-target-detail", () => ({
useTargetDetail: vi.fn(() => ({
activeTab: "overview",
closeDrawer: vi.fn(),
handlePageChange: vi.fn(),
handleTabChange: vi.fn(),
handleTimeChange: vi.fn(),
historyData: {
items: [],
page: 1,
pageSize: 20,
total: 0,
},
historyLoading: false,
metricsData: null,
metricsLoading: false,
openDrawer: vi.fn(),
selectedTarget: null,
timeFrom: "",
timeTo: "",
})),
}));
describe("App", () => { describe("App", () => {
let matchMediaController: ReturnType<typeof installMatchMedia>; let matchMediaController: ReturnType<typeof installMatchMedia>;
beforeEach(() => { beforeEach(() => {
const { useDashboard } = require("../../../src/web/hooks/use-queries"); useDashboardMock.mockReturnValue(createDashboardResult());
useDashboard.mockReturnValue(createDashboardResult());
window.localStorage.clear(); window.localStorage.clear();
document.documentElement.removeAttribute("theme-mode"); document.documentElement.removeAttribute("theme-mode");
matchMediaController = installMatchMedia(false); matchMediaController = installMatchMedia(false);
@@ -117,8 +115,7 @@ describe("App", () => {
}); });
test("loading 状态不崩溃", () => { test("loading 状态不崩溃", () => {
const { useDashboard } = require("../../../src/web/hooks/use-queries"); useDashboardMock.mockReturnValue(
useDashboard.mockReturnValue(
createDashboardResult({ createDashboardResult({
data: null, data: null,
dataUpdatedAt: 0, dataUpdatedAt: 0,
@@ -134,12 +131,11 @@ describe("App", () => {
}); });
test("错误状态不崩溃", () => { test("错误状态不崩溃", () => {
const { useDashboard } = require("../../../src/web/hooks/use-queries"); useDashboardMock.mockReturnValue(
useDashboard.mockReturnValue(
createDashboardResult({ createDashboardResult({
data: null, data: null,
dataUpdatedAt: 0, dataUpdatedAt: 0,
error: { message: "Network error" }, error: new Error("Network error"),
isFetching: false, isFetching: false,
isLoading: false, isLoading: false,
refetch: vi.fn(), refetch: vi.fn(),
@@ -151,8 +147,7 @@ describe("App", () => {
}); });
test("有数据状态不崩溃", () => { test("有数据状态不崩溃", () => {
const { useDashboard } = require("../../../src/web/hooks/use-queries"); useDashboardMock.mockReturnValue(
useDashboard.mockReturnValue(
createDashboardResult({ createDashboardResult({
data: { data: {
summary: { summary: {
@@ -215,9 +210,8 @@ describe("App", () => {
}); });
test("缺失版本时不展示版本占位", () => { test("缺失版本时不展示版本占位", () => {
const { useMeta } = require("../../../src/web/hooks/use-queries"); useMetaMock.mockReturnValue({
useMeta.mockReturnValue({ data: { checkerTypes: ["http", "cmd"], version: undefined as unknown as string },
data: { checkerTypes: ["http", "cmd"] },
}); });
render(<App />); render(<App />);
@@ -225,8 +219,7 @@ describe("App", () => {
}); });
test("复用 useMeta 查询结果", () => { test("复用 useMeta 查询结果", () => {
const { useMeta } = require("../../../src/web/hooks/use-queries");
render(<App />); render(<App />);
expect(useMeta).toHaveBeenCalled(); expect(useMetaMock).toHaveBeenCalled();
}); });
}); });