diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4fc33ea..d8d3b44 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1157,6 +1157,15 @@ bun run check # 一键运行 schema:check + typecheck + lint + test | `eslint-plugin-import` | 导入路径验证、循环依赖检测、重复导入合并 | | `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 配置 配置文件:`.prettierrc.json`,通过 `eslint-plugin-prettier` 集成为 ESLint 规则(`lint` 命令同时检查格式),也可通过 `format` 命令独立运行。 diff --git a/openspec/specs/code-quality-gates/spec.md b/openspec/specs/code-quality-gates/spec.md index be309db..a7c90f2 100644 --- a/openspec/specs/code-quality-gates/spec.md +++ b/openspec/specs/code-quality-gates/spec.md @@ -124,3 +124,26 @@ #### Scenario: 完整验证失败 - **WHEN** `verify` 中任一阶段失败 - **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` 指令的情况下通过 diff --git a/tests/scripts/release.test.ts b/tests/scripts/release.test.ts index 37753a7..1097abf 100644 --- a/tests/scripts/release.test.ts +++ b/tests/scripts/release.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +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"; @@ -45,17 +45,12 @@ describe("parseTargets", () => { test("无效 target 导致进程退出", () => { const exitCalls: number[] = []; - // eslint-disable-next-line @typescript-eslint/unbound-method - const originalExit = process.exit; - process.exit = ((code: number) => { + const exitSpy = spyOn(process, "exit").mockImplementation(((code: number) => { exitCalls.push(code); - }) as never; + }) as typeof process.exit); - try { - parseTargets(["--target", "invalid-target"]); - } finally { - process.exit = originalExit; - } + parseTargets(["--target", "invalid-target"]); + exitSpy.mockRestore(); expect(exitCalls).toEqual([1]); }); diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index 395d77e..701c3e8 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -104,6 +104,17 @@ describe("loadConfig", () => { expect((error as Error).message).toContain(message); } + async function expectConfigLoadError(configPath: string, message: string): Promise { + 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 () => { const configPath = join(tempDir, "minimal-http.yaml"); await writeFile( @@ -327,8 +338,7 @@ targets: url: "http://example.com" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("name 不能为空白"); + await expectConfigLoadError(configPath, "name 不能为空白"); }); test("name 仅包含空白字符抛出错误", async () => { @@ -343,8 +353,7 @@ targets: url: "http://example.com" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("name 不能为空白"); + await expectConfigLoadError(configPath, "name 不能为空白"); }); test("description 显式 null 保留为 null", async () => { @@ -449,8 +458,7 @@ targets: maxRedirects: "\${max_redirects}" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("maxRedirects"); + await expectConfigLoadError(configPath, "maxRedirects"); }); test("变量替换后通过 schema 校验", async () => { @@ -491,8 +499,7 @@ targets: url: "\${MISSING_BASE_URL}/health" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("未定义的变量"); + await expectConfigLoadError(configPath, "未定义的变量"); }); test("绝对 dataDir 保持不变", async () => { @@ -546,8 +553,7 @@ targets: }); test("配置文件不存在抛出错误", async () => { - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig("/nonexistent/file.yaml")).rejects.toThrow("配置文件不存在"); + await expectConfigLoadError("/nonexistent/file.yaml", "配置文件不存在"); }); test("target 缺少 id 抛出错误", async () => { @@ -560,8 +566,7 @@ targets: url: "http://example.com" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("缺少 id 字段"); + await expectConfigLoadError(configPath, "缺少 id 字段"); }); test("target 缺少 type 抛出错误", async () => { @@ -575,8 +580,7 @@ targets: url: "http://example.com" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("缺少 type 字段"); + await expectConfigLoadError(configPath, "缺少 type 字段"); }); test("HTTP target 缺少 url 抛出错误", async () => { @@ -590,8 +594,7 @@ targets: http: {} `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("缺少 http.url 字段"); + await expectConfigLoadError(configPath, "缺少 http.url 字段"); }); test("HTTP target 缺少 http 分组抛出清晰错误", async () => { @@ -604,8 +607,7 @@ targets: type: http `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("缺少 http.url 字段"); + await expectConfigLoadError(configPath, "缺少 http.url 字段"); }); test("HTTP target ignoreSSL 非布尔值抛出错误", async () => { @@ -621,8 +623,7 @@ targets: ignoreSSL: "true" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("http.ignoreSSL 类型不合法"); + await expectConfigLoadError(configPath, "http.ignoreSSL 类型不合法"); }); test("HTTP target maxRedirects 非负整数校验", async () => { @@ -638,8 +639,7 @@ targets: maxRedirects: 1.5 `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("http.maxRedirects 类型不合法"); + await expectConfigLoadError(configPath, "http.maxRedirects 类型不合法"); }); test("HTTP target status 模式非法抛出错误", async () => { @@ -656,8 +656,7 @@ targets: status: ["abc"] `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("status 模式"); + await expectConfigLoadError(configPath, "status 模式"); }); test("cmd target 缺少 exec 抛出错误", async () => { @@ -671,8 +670,7 @@ targets: cmd: {} `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("缺少 cmd.exec 字段"); + await expectConfigLoadError(configPath, "缺少 cmd.exec 字段"); }); test("非法 target type 抛出错误", async () => { @@ -685,8 +683,7 @@ targets: type: dns `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("不支持的 type"); + await expectConfigLoadError(configPath, "不支持的 type"); }); test("target id 重复抛出错误", async () => { @@ -706,8 +703,7 @@ targets: url: "http://b.com" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("target id 重复"); + await expectConfigLoadError(configPath, "target id 重复"); }); test("target id 为空字符串抛出错误", async () => { @@ -721,8 +717,7 @@ targets: url: "http://example.com" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("缺少 id 字段"); + await expectConfigLoadError(configPath, "缺少 id 字段"); }); test("target id 命名不合法抛出错误", async () => { @@ -736,8 +731,7 @@ targets: url: "http://example.com" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("id 不符合命名规则"); + await expectConfigLoadError(configPath, "id 不符合命名规则"); }); test("target id 包含下划线和连字符通过", async () => { @@ -758,8 +752,7 @@ targets: test("targets 为空数组抛出错误", async () => { const configPath = join(tempDir, "empty-targets.yaml"); await writeFile(configPath, `targets: []`); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("至少一个 target"); + await expectConfigLoadError(configPath, "至少一个 target"); }); test("无效端口号抛出错误", async () => { @@ -776,8 +769,7 @@ targets: url: "http://a.com" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("server.port 数值范围不合法"); + await expectConfigLoadError(configPath, "server.port 数值范围不合法"); }); test("非法 maxConcurrentChecks 抛出错误", async () => { @@ -794,8 +786,7 @@ targets: url: "http://a.com" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("runtime.maxConcurrentChecks 数值范围不合法"); + await expectConfigLoadError(configPath, "runtime.maxConcurrentChecks 数值范围不合法"); }); test("非法 size 格式抛出错误", async () => { @@ -813,8 +804,7 @@ targets: url: "http://a.com" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("无效的 size 格式"); + await expectConfigLoadError(configPath, "无效的 size 格式"); }); test("非法 interval 格式抛出错误", async () => { @@ -830,8 +820,7 @@ targets: url: "http://a.com" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("无效的时长格式"); + await expectConfigLoadError(configPath, "无效的时长格式"); }); test("解析 expect 配置", async () => { @@ -1011,8 +1000,7 @@ targets: `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("group 必须为字符串"); + await expectConfigLoadError(configPath, "group 必须为字符串"); }); test("HTTP headers 非字符串值抛出错误", async () => { @@ -1029,8 +1017,7 @@ targets: X-Custom: 123 `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("http.headers"); + await expectConfigLoadError(configPath, "http.headers"); }); test("HTTP body 非字符串抛出错误", async () => { @@ -1046,8 +1033,7 @@ targets: body: 123 `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("http.body 类型不合法"); + await expectConfigLoadError(configPath, "http.body 类型不合法"); }); test("maxBodyBytes 负数抛出错误", async () => { @@ -1063,8 +1049,7 @@ targets: maxBodyBytes: -1 `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("非负安全整数"); + await expectConfigLoadError(configPath, "非负安全整数"); }); test("maxBodyBytes 非整数抛出错误", async () => { @@ -1080,8 +1065,7 @@ targets: maxBodyBytes: 1.5 `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("非负安全整数"); + await expectConfigLoadError(configPath, "非负安全整数"); }); test("expect.status 数字不在 100-599 范围抛出错误", async () => { @@ -1098,8 +1082,7 @@ targets: status: [999] `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("100-599"); + await expectConfigLoadError(configPath, "100-599"); }); test("expect.status 范围 6xx 抛出错误", async () => { @@ -1116,8 +1099,7 @@ targets: status: ["6xx"] `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("5xx"); + await expectConfigLoadError(configPath, "5xx"); }); test("expect.durationMs 对象简写抛出错误", async () => { @@ -1135,8 +1117,7 @@ targets: foo: "bar" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("expect.durationMs.foo 是未知 matcher"); + await expectConfigLoadError(configPath, "expect.durationMs.foo 是未知 matcher"); }); test("expect.body 非数组抛出错误", async () => { @@ -1153,8 +1134,7 @@ targets: body: "not-array" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("expect.body 必须为数组"); + await expectConfigLoadError(configPath, "expect.body 必须为数组"); }); test("body rule 缺少支持字段抛出错误", async () => { @@ -1172,8 +1152,7 @@ targets: - foo: "bar" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("foo 是未知字段"); + await expectConfigLoadError(configPath, "foo 是未知字段"); }); test("body rule 使用 match 字段(非支持)抛出错误", async () => { @@ -1191,8 +1170,7 @@ targets: - match: "ok" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("match 是未知字段"); + await expectConfigLoadError(configPath, "match 是未知字段"); }); test("body rule 直接 matcher 混入 extractor 抛出错误", async () => { @@ -1212,8 +1190,7 @@ targets: path: "$.status" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("直接 matcher 不能与 extractor 混用"); + await expectConfigLoadError(configPath, "直接 matcher 不能与 extractor 混用"); }); test("body regex 非法正则抛出错误", async () => { @@ -1231,8 +1208,7 @@ targets: - regex: "[invalid" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("regex 正则不合法"); + await expectConfigLoadError(configPath, "regex 正则不合法"); }); test("body json path 不以 $. 开头抛出错误", async () => { @@ -1252,8 +1228,7 @@ targets: equals: "ok" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("json.path"); + await expectConfigLoadError(configPath, "json.path"); }); test("body css selector 为空抛出错误", async () => { @@ -1272,8 +1247,7 @@ targets: selector: "" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("css.selector 必须为非空字符串"); + await expectConfigLoadError(configPath, "css.selector 必须为非空字符串"); }); test("旧 match matcher 抛出错误", async () => { @@ -1292,8 +1266,7 @@ targets: match: "[invalid" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("match 是未知 matcher"); + await expectConfigLoadError(configPath, "match 是未知 matcher"); }); test("operator gte 非数字抛出错误", async () => { @@ -1313,8 +1286,7 @@ targets: gte: "abc" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("gte 必须为有限数字"); + await expectConfigLoadError(configPath, "gte 必须为有限数字"); }); test("operator exists 非布尔值抛出错误", async () => { @@ -1334,8 +1306,7 @@ targets: exists: "yes" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("exists 必须为布尔值"); + await expectConfigLoadError(configPath, "exists 必须为布尔值"); }); test("未知字段导致启动失败", async () => { @@ -1357,8 +1328,7 @@ targets: note: "ignored" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("unknownHttpField 是未知字段"); + await expectConfigLoadError(configPath, "unknownHttpField 是未知字段"); }); test("xpath path 非空字符串校验", async () => { @@ -1377,8 +1347,7 @@ targets: path: "" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("xpath.path 必须为非空字符串"); + await expectConfigLoadError(configPath, "xpath.path 必须为非空字符串"); }); test("expect headers 非对象抛出错误", async () => { @@ -1395,8 +1364,7 @@ targets: headers: "invalid" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("expect.headers 类型不合法"); + await expectConfigLoadError(configPath, "expect.headers 类型不合法"); }); test("HTTP method 小写输入失败", async () => { @@ -1760,8 +1728,7 @@ targets: url: "http://example.com" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("description"); + await expectConfigLoadError(configPath, "description"); }); test("description 超过 500 字符抛出错误", async () => { @@ -1776,8 +1743,7 @@ targets: url: "http://example.com" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("description"); + await expectConfigLoadError(configPath, "description"); }); test("变量替换后 description 超长抛出错误", async () => { @@ -1794,8 +1760,7 @@ targets: url: "http://example.com" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("description"); + await expectConfigLoadError(configPath, "description"); }); test("id 超过 30 字符抛出错误", async () => { @@ -1809,8 +1774,7 @@ targets: url: "http://example.com" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("id"); + await expectConfigLoadError(configPath, "id"); }); test("name 超过 30 字符抛出错误", async () => { @@ -1825,8 +1789,7 @@ targets: url: "http://example.com" `, ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("name"); + await expectConfigLoadError(configPath, "name"); }); test("解析最简 tcp 配置", async () => { diff --git a/tests/setup.ts b/tests/setup.ts index a68cc7a..472391e 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -4,7 +4,6 @@ * 组件测试使用各自的 test-utils.tsx */ -/* eslint-disable @typescript-eslint/no-empty-function */ // Set up jsdom for ALL tests (both backend and frontend) import { JSDOM } from "jsdom"; @@ -34,8 +33,10 @@ const nodeProto = dom.window.Node.prototype; const elementProto = dom.window.Element.prototype; const htmlElementProto = dom.window.HTMLElement.prototype; -const attachEventFn = () => {}; -const detachEventFn = () => {}; +const noop = () => undefined; + +const attachEventFn = noop; +const detachEventFn = noop; Object.defineProperty(nodeProto, "attachEvent", { configurable: true, value: attachEventFn, 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 globalThis.ResizeObserver = class { - disconnect() {} - observe() {} - unobserve() {} + disconnect() { + return undefined; + } + + observe() { + return undefined; + } + + unobserve() { + return undefined; + } }; globalThis.MutationObserver = class { - disconnect() {} - observe() {} + disconnect() { + return undefined; + } + + observe() { + return undefined; + } + takeRecords() { return []; } - unobserve() {} + + unobserve() { + return undefined; + } }; globalThis.IntersectionObserver = class { - disconnect() {} - observe() {} + disconnect() { + return undefined; + } + + observe() { + return undefined; + } + takeRecords() { return []; } - unobserve() {} + + unobserve() { + return undefined; + } } as unknown as typeof IntersectionObserver; globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(cb, 16); @@ -74,24 +101,24 @@ globalThis.cancelAnimationFrame = (id: number) => clearTimeout(id); Object.defineProperty(dom.window, "matchMedia", { value: (query: string) => ({ - addEventListener: () => {}, - addListener: () => {}, + addEventListener: noop, + addListener: noop, dispatchEvent: () => true, matches: false, media: query, onchange: null, - removeEventListener: () => {}, - removeListener: () => {}, + removeEventListener: noop, + removeListener: noop, }), writable: true, }); -dom.window.Element.prototype.scrollTo = () => {}; -dom.window.Element.prototype.scrollIntoView = () => {}; +dom.window.Element.prototype.scrollTo = noop; +dom.window.Element.prototype.scrollIntoView = noop; Object.defineProperty(dom.window, "customElements", { value: { - define: () => {}, + define: noop, get: () => undefined, }, writable: true, diff --git a/tests/web/components/App.test.tsx b/tests/web/components/App.test.tsx index 3bfda48..b32f0ba 100644 --- a/tests/web/components/App.test.tsx +++ b/tests/web/components/App.test.tsx @@ -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 { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; 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 { THEME_MEDIA_QUERY, THEME_PREFERENCE_STORAGE_KEY } from "../../../src/web/hooks/use-theme-preference"; -function createDashboardResult(overrides = {}) { +function createDashboardResult(overrides: Record = {}) { return { data: { 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) { const originalMatchMedia = window.matchMedia; 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", () => { let matchMediaController: ReturnType; beforeEach(() => { - const { useDashboard } = require("../../../src/web/hooks/use-queries"); - useDashboard.mockReturnValue(createDashboardResult()); + useDashboardMock.mockReturnValue(createDashboardResult()); window.localStorage.clear(); document.documentElement.removeAttribute("theme-mode"); matchMediaController = installMatchMedia(false); @@ -117,8 +115,7 @@ describe("App", () => { }); test("loading 状态不崩溃", () => { - const { useDashboard } = require("../../../src/web/hooks/use-queries"); - useDashboard.mockReturnValue( + useDashboardMock.mockReturnValue( createDashboardResult({ data: null, dataUpdatedAt: 0, @@ -134,12 +131,11 @@ describe("App", () => { }); test("错误状态不崩溃", () => { - const { useDashboard } = require("../../../src/web/hooks/use-queries"); - useDashboard.mockReturnValue( + useDashboardMock.mockReturnValue( createDashboardResult({ data: null, dataUpdatedAt: 0, - error: { message: "Network error" }, + error: new Error("Network error"), isFetching: false, isLoading: false, refetch: vi.fn(), @@ -151,8 +147,7 @@ describe("App", () => { }); test("有数据状态不崩溃", () => { - const { useDashboard } = require("../../../src/web/hooks/use-queries"); - useDashboard.mockReturnValue( + useDashboardMock.mockReturnValue( createDashboardResult({ data: { summary: { @@ -215,9 +210,8 @@ describe("App", () => { }); test("缺失版本时不展示版本占位", () => { - const { useMeta } = require("../../../src/web/hooks/use-queries"); - useMeta.mockReturnValue({ - data: { checkerTypes: ["http", "cmd"] }, + useMetaMock.mockReturnValue({ + data: { checkerTypes: ["http", "cmd"], version: undefined as unknown as string }, }); render(); @@ -225,8 +219,7 @@ describe("App", () => { }); test("复用 useMeta 查询结果", () => { - const { useMeta } = require("../../../src/web/hooks/use-queries"); render(); - expect(useMeta).toHaveBeenCalled(); + expect(useMetaMock).toHaveBeenCalled(); }); });