test: 测试体系全面优化,修复 Windows SQLite EBUSY 和前端产品缺陷

测试基础设施
- 统一 SQLite 测试 DB/临时目录 helper(tests/helpers.ts),支持 Windows EBUSY 重试清理
- 测试库使用 PRAGMA journal_mode=DELETE 避免 WAL 句柄延迟
- 路由 handler 测试改用 createMigratedMemoryTestDatabase 避免 File DB 锁
- SQLite 聚焦 --rerun-each=20 全部通过(720 pass)

后端测试补强
- 新增 tests/server/app.test.ts 真实 startServer 集成测试
- 覆盖 /api/meta、项目 CRUD、错误路径、静态 fallback、安全 header
- bootstrap/logger 测试捕获预期输出,消除测试噪音

前端测试补强
- 移除 .ant-* 内部类名依赖,改为角色/文本/导航/请求契约断言
- 项目页补充搜索、Tab 切换、表单、表格操作、错误反馈行为测试
- 新增 hooks(use-theme-preference、use-sidebar-collapsed、use-projects)纯逻辑测试
- 新增 ErrorBoundary 错误展示和刷新按钮测试
- 新增搜索清空行为测试
- 测试 setup 过滤 antd/rc-trigger NaN height warning

产品修复(测试暴露)
- 修复 ProjectToolbar 搜索框无法输入(新增 draftKeyword 状态)
- 加固 ProjectFormModal 表单字段同步(useEffect 替代不可靠的 afterOpenChange)
- 清理 ProjectFormModal 冗余 afterOpenChange 同步逻辑

重构与合规
- ProjectContext 拆分为三文件满足 React Fast Refresh 规则
- use-projects.ts 导出内部 helper 函数供测试验证
- scripts/build.ts 提取纯生成函数供测试使用,修复构建步骤日志编号
- 修复 build 测试覆盖真实生成逻辑

文档同步
- 更新后端/前端/开发文档测试规范、质量门禁和 helper 使用说明
This commit is contained in:
2026-05-29 00:45:21 +08:00
parent 6cb378d7cb
commit 2ea4bd4410
31 changed files with 1417 additions and 723 deletions

View File

@@ -1,4 +1,130 @@
import Database from "bun:sqlite";
import { mkdirSync, rmSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { MigrationRecord } from "../src/server/db/load-migrations";
import { createDatabase } from "../src/server/db/connection";
import { loadMigrationsFromDir } from "../src/server/db/load-migrations";
import { runMigrations } from "../src/server/db/migrate";
import { createMemoryLogger } from "../src/server/logger";
const RETRYABLE_RM_CODES = new Set(["EBUSY", "ENOTEMPTY", "EPERM"]);
export interface TestDatabaseHandle {
cleanup: () => void;
close: () => void;
db: Database;
dir: string;
logger: ReturnType<typeof createMemoryLogger>;
}
export function closeSqliteForTest(db: Database): void {
try {
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
} catch {
// 关闭前的 checkpoint 是尽力操作;失败时仍继续 close避免掩盖原测试断言。
}
try {
db.exec("PRAGMA journal_mode = DELETE");
} catch {
// Windows 上 WAL/SHM 句柄释放可能延迟,切回 DELETE 失败时仍继续关闭连接。
}
db.close();
forceBunGc();
sleepSync(100);
}
export function configureSqliteForTest(db: Database): void {
// 生产库使用 WAL测试库切回 DELETE避免 Windows 上 WAL/SHM 句柄延迟导致清理 EBUSY。
db.exec("PRAGMA journal_mode = DELETE");
}
export function createMigratedMemoryTestDatabase(prefix: string): TestDatabaseHandle {
const dir = makeTempDir(prefix);
const logger = createMemoryLogger();
const db = new Database(":memory:");
configureSqliteForTest(db);
let closed = false;
runMigrations(db, loadMigrationsFromDir(), dir, logger);
const close = () => {
if (closed) return;
closeSqliteForTest(db);
closed = true;
};
return {
cleanup: () => {
close();
rmRetrySync(dir);
},
close,
db,
dir,
logger,
};
}
export function createMigratedTestDatabase(prefix: string): TestDatabaseHandle {
return createTestDatabase(prefix, loadMigrationsFromDir());
}
export function createTestDatabase(prefix: string, migrations: MigrationRecord[] = []): TestDatabaseHandle {
const dir = makeTempDir(prefix);
const logger = createMemoryLogger();
const db = createDatabase(dir, logger);
configureSqliteForTest(db);
let closed = false;
if (migrations.length > 0) {
runMigrations(db, migrations, dir, logger);
}
const close = () => {
if (closed) return;
closeSqliteForTest(db);
closed = true;
};
return {
cleanup: () => {
close();
rmRetrySync(dir);
},
close,
db,
dir,
logger,
};
}
export function makeTempDir(prefix: string): string {
const dir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
return dir;
}
export function openTestDatabase(dir: string): Pick<TestDatabaseHandle, "close" | "db" | "logger"> {
const logger = createMemoryLogger();
const db = createDatabase(dir, logger);
configureSqliteForTest(db);
let closed = false;
return {
close: () => {
if (closed) return;
closeSqliteForTest(db);
closed = true;
},
db,
logger,
};
}
export async function rmRetry(dir: string, retries = 10, delayMs = 500) {
for (let i = 0; i < retries; i++) {
@@ -6,8 +132,51 @@ export async function rmRetry(dir: string, retries = 10, delayMs = 500) {
await rm(dir, { force: true, recursive: true });
return;
} catch (e) {
if (i === retries - 1) throw e;
const code = getErrorCode(e);
if (i === retries - 1 || (code && !RETRYABLE_RM_CODES.has(code))) {
throw withCleanupMessage(e, dir, i + 1);
}
await new Promise((r) => setTimeout(r, delayMs));
}
}
}
export function rmRetrySync(dir: string, retries = 300, delayMs = 50): void {
for (let i = 0; i < retries; i++) {
try {
forceBunGc();
rmSync(dir, { force: true, recursive: true });
return;
} catch (e) {
const code = getErrorCode(e);
if (i === retries - 1 || (code && !RETRYABLE_RM_CODES.has(code))) {
throw withCleanupMessage(e, dir, i + 1);
}
sleepSync(delayMs);
}
}
}
function forceBunGc(): void {
const bun = (globalThis as typeof globalThis & { Bun?: { gc?: (force?: boolean) => void } }).Bun;
bun?.gc?.(true);
}
function getErrorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error
? String((error as { code?: unknown }).code)
: undefined;
}
function sleepSync(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function withCleanupMessage(error: unknown, dir: string, attempts: number): Error {
if (!(error instanceof Error)) {
return new Error(`测试临时目录清理失败:${dir},已重试 ${attempts} 次,原始错误:${String(error)}`);
}
error.message = `测试临时目录清理失败:${dir},已重试 ${attempts} 次。${error.message}`;
return error;
}