Files
Alfred/tests/helpers.ts
lanyuanxiaoyao 2ea4bd4410 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 使用说明
2026-05-29 00:45:21 +08:00

183 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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++) {
try {
await rm(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);
}
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;
}