测试基础设施 - 统一 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 使用说明
183 lines
4.9 KiB
TypeScript
183 lines
4.9 KiB
TypeScript
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;
|
||
}
|