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

@@ -41,12 +41,13 @@
代码变更必须按影响范围执行验证。
| 变更类型 | 必跑命令 |
| -------------------------- | --------------------------------------------------------- |
| 常规代码变更 | `bun run check` |
| 构建、部署、前后端集成变更 | `bun run verify` |
| 配置 schema 变化 | `bun run schema``bun run schema:check``bun run check` |
| 仅文档变更 | 检查链接、索引和文档归属一致性 |
| 变更类型 | 必跑命令 |
| -------------------------- | ------------------------------------------------------------- |
| 常规代码变更 | `bun run check` |
| 构建、部署、前后端集成变更 | `bun run verify` |
| 配置 schema 变化 | `bun run schema``bun run schema:check``bun run check` |
| SQLite 测试基础设施变化 | 相关单文件测试 + SQLite 聚焦 `--rerun-each` + `bun run check` |
| 仅文档变更 | 检查链接、索引和文档归属一致性 |
正式提交或影响构建产物时优先运行 `bun run verify`。如果因环境限制无法执行完整验证,必须在收尾说明中记录未执行项和原因。

View File

@@ -119,6 +119,13 @@ bun run version:set # 显式设置版本号
| 配置 schema | schema 导出、合法/非法配置 |
| helpers/middleware | 单元测试 |
后端测试约定:
- API 路由集成测试必须通过真实 `startServer` 覆盖路由注册、HTTP method、fallback、响应 header 和核心错误路径。
- SQLite 相关测试必须复用 `tests/helpers.ts` 中的测试数据库 helper不要在测试文件内分散实现临时目录清理或直接裸用 `rmSync(dir, { recursive: true })`
- DAO 和路由边界测试应优先使用真实 migration 初始化测试库,只有 migration 执行器单测可以使用最小 fake migration。
- logger/bootstrap 中预期的 fallback 输出必须在测试中捕获并断言,正常通过的测试不应污染 stdout/stderr。
## 更新触发条件
修改后端 API、共享类型、配置契约、日志模块、版本管理或后端测试规范时必须更新本文档。

View File

@@ -157,6 +157,9 @@ Workbench 项目上下文通过 `ProjectContext` 提供,在 `WorkbenchProjectG
- 组件测试环境由 tests/setup.ts 和 bunfig.toml preload 提供
- 断言优先基于用户可见文本、role、按钮和交互结果不依赖 `.ant-*` 内部类名。
- 对 antd 组件只断言本项目传入的可观察行为或配置结果,避免把 antd 内部 DOM 结构当作稳定契约。
- fetch mock、路由、QueryClientProvider 等系统边界优先复用 tests/web/test-utils.tsx避免在每个测试文件重复安装 `window.fetch`
- 项目页这类数据驱动页面至少覆盖请求 URL/query、method/body、成功后的用户可见结果以及关键错误路径或失败后状态。
- ErrorBoundary、hooks 纯逻辑和 fetch request helper 应使用单元测试覆盖异常回退,页面测试只保留真实用户路径。
## 更新触发条件

View File

@@ -11,6 +11,67 @@ const buildDir = join(projectRoot, ".build");
const executablePath = join(projectRoot, `dist/${APP.name}`);
const packageJsonPath = join(projectRoot, "package.json");
export function createMigrationsDataSource(records: Array<{ checksum: string; id: string; sql: string }>): string {
return [
`import type { MigrationRecord } from "../src/server/db/load-migrations";`,
``,
`export const MIGRATIONS: MigrationRecord[] = [`,
...records.map(
(r) =>
` { id: ${JSON.stringify(r.id)}, sql: ${JSON.stringify(r.sql)}, checksum: ${JSON.stringify(r.checksum)} },`,
),
`];`,
``,
].join("\n");
}
export function createServerEntrySource(version: string): string {
return [
`import { bootstrap } from "../src/server/bootstrap";`,
`import { parseRuntimeArgs } from "../src/server/config";`,
`import { createConsoleFallback } from "../src/server/logger";`,
`import { MIGRATIONS } from "./migrations-data";`,
`import { staticAssets } from "./static-assets";`,
"",
`const APP_VERSION = "${version}" as const;`,
"",
`async function main() {`,
` const { configPath } = parseRuntimeArgs();`,
` await bootstrap({ configPath, migrations: MIGRATIONS, mode: "production", staticAssets, version: APP_VERSION });`,
`}`,
"",
`void main().catch((error) => {`,
` createConsoleFallback().fatal(\`启动失败: \${error instanceof Error ? error.message : String(error)}\`);`,
` process.exit(1);`,
`});`,
"",
].join("\n");
}
export function createStaticAssetsSource({
fileEntries,
importLines,
indexHtmlVar,
}: {
fileEntries: string[];
importLines: string[];
indexHtmlVar: string;
}): string {
return [
`import type { StaticAssets } from "../src/server/static";`,
"",
...importLines,
"",
`export const staticAssets: StaticAssets = {`,
` files: {`,
...fileEntries,
` },`,
` indexHtml: Bun.file(${indexHtmlVar}),`,
`};`,
"",
].join("\n");
}
async function build() {
try {
await viteBuild();
@@ -98,42 +159,11 @@ async function codeGeneration() {
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");
const staticAssetsTs = createStaticAssetsSource({ fileEntries, importLines, indexHtmlVar });
await writeFile(join(buildDir, "static-assets.ts"), staticAssetsTs);
const serverEntryTs = [
`import { bootstrap } from "../src/server/bootstrap";`,
`import { parseRuntimeArgs } from "../src/server/config";`,
`import { createConsoleFallback } from "../src/server/logger";`,
`import { MIGRATIONS } from "./migrations-data";`,
`import { staticAssets } from "./static-assets";`,
"",
`const APP_VERSION = "${version}" as const;`,
"",
`async function main() {`,
` const { configPath } = parseRuntimeArgs();`,
` await bootstrap({ configPath, migrations: MIGRATIONS, mode: "production", staticAssets, version: APP_VERSION });`,
`}`,
"",
`void main().catch((error) => {`,
` createConsoleFallback().fatal(\`启动失败: \${error instanceof Error ? error.message : String(error)}\`);`,
` process.exit(1);`,
`});`,
"",
].join("\n");
const serverEntryTs = createServerEntrySource(version);
await writeFile(join(buildDir, "server-entry.ts"), serverEntryTs);
}
@@ -159,17 +189,7 @@ async function generateMigrationsData() {
return { checksum, id, sql: sql.trim() };
});
const lines = [
`import type { MigrationRecord } from "../src/server/db/load-migrations";`,
``,
`export const MIGRATIONS: MigrationRecord[] = [`,
...records.map(
(r) =>
` { id: ${JSON.stringify(r.id)}, sql: ${JSON.stringify(r.sql)}, checksum: ${JSON.stringify(r.checksum)} },`,
),
`];`,
``,
].join("\n");
const lines = createMigrationsDataSource(records);
await writeFile(join(buildDir, "migrations-data.ts"), lines);
console.log(`Embedded ${records.length} migration(s)`);
@@ -195,7 +215,7 @@ function toImportSpecifier(fromDir: string, targetPath: string) {
}
async function viteBuild() {
console.log("Step 1/3: Vite build...");
console.log("Step 1/4: Vite build...");
const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], {
cwd: projectRoot,
stderr: "inherit",
@@ -208,4 +228,6 @@ async function viteBuild() {
}
}
await build();
if (import.meta.main) {
await build();
}

View File

@@ -1,17 +1,9 @@
import { createContext, type ReactNode, useContext } from "react";
import { type ReactNode } from "react";
import type { Project } from "../../../shared/api";
const ProjectContext = createContext<null | Project>(null);
import { ProjectContext } from "./ProjectContextValue";
export function ProjectProvider({ children, project }: { children: ReactNode; project: Project }) {
return <ProjectContext.Provider value={project}>{children}</ProjectContext.Provider>;
}
export function useCurrentProject(): Project {
const project = useContext(ProjectContext);
if (!project) {
throw new Error("useCurrentProject 必须在 Workbench 项目上下文内使用");
}
return project;
}

View File

@@ -0,0 +1,5 @@
import { createContext } from "react";
import type { Project } from "../../../shared/api";
export const ProjectContext = createContext<null | Project>(null);

View File

@@ -5,8 +5,9 @@ import { useNavigate } from "react-router";
import type { Project } from "../../../shared/api";
import { ConsoleShell } from "../../components/ConsoleShell/ConsoleShell";
import { ProjectProvider, useCurrentProject } from "./ProjectContext";
import { ProjectProvider } from "./ProjectContext";
import { getWorkbenchMenuItems } from "./routes";
import { useCurrentProject } from "./useCurrentProject";
interface WorkbenchConsoleLayoutProps {
project: Project;

View File

@@ -0,0 +1,13 @@
import { useContext } from "react";
import type { Project } from "../../../shared/api";
import { ProjectContext } from "./ProjectContextValue";
export function useCurrentProject(): Project {
const project = useContext(ProjectContext);
if (!project) {
throw new Error("useCurrentProject 必须在 Workbench 项目上下文内使用");
}
return project;
}

View File

@@ -11,6 +11,93 @@ import type {
const PROJECTS_KEY = ["projects"] as const;
export async function archiveProject(id: string): Promise<Project> {
const response = await fetch(`/api/projects/${id}/archive`, { method: "POST" });
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
const data = (await response.json()) as ProjectResponse;
return data.project;
}
export async function createProject(data: CreateProjectRequest): Promise<Project> {
const response = await fetch("/api/projects", {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
method: "POST",
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
const result = (await response.json()) as ProjectResponse;
return result.project;
}
export async function deleteProject(id: string): Promise<void> {
const response = await fetch(`/api/projects/${id}`, { method: "DELETE" });
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
}
export async function fetchProject(id: string): Promise<Project> {
const response = await fetch(`/api/projects/${id}`);
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
const data = (await response.json()) as ProjectResponse;
return data.project;
}
export async function fetchProjectList(params: {
keyword?: string;
page?: number;
pageSize?: number;
status?: ProjectStatus;
}): Promise<ProjectListResponse> {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set("page", String(params.page));
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
if (params.keyword) searchParams.set("keyword", params.keyword);
if (params.status) searchParams.set("status", params.status);
const qs = searchParams.toString();
const url = `/api/projects${qs ? `?${qs}` : ""}`;
const response = await fetch(url);
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
return response.json() as Promise<ProjectListResponse>;
}
export async function restoreProject(id: string): Promise<Project> {
const response = await fetch(`/api/projects/${id}/restore`, { method: "POST" });
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
const data = (await response.json()) as ProjectResponse;
return data.project;
}
export async function updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
const response = await fetch(`/api/projects/${id}`, {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
method: "PATCH",
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
const result = (await response.json()) as ProjectResponse;
return result.project;
}
export function useArchiveProject() {
const queryClient = useQueryClient();
return useMutation({
@@ -75,90 +162,3 @@ export function useUpdateProject() {
},
});
}
async function archiveProject(id: string): Promise<Project> {
const response = await fetch(`/api/projects/${id}/archive`, { method: "POST" });
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
const data = (await response.json()) as ProjectResponse;
return data.project;
}
async function createProject(data: CreateProjectRequest): Promise<Project> {
const response = await fetch("/api/projects", {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
method: "POST",
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
const result = (await response.json()) as ProjectResponse;
return result.project;
}
async function deleteProject(id: string): Promise<void> {
const response = await fetch(`/api/projects/${id}`, { method: "DELETE" });
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
}
async function fetchProject(id: string): Promise<Project> {
const response = await fetch(`/api/projects/${id}`);
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
const data = (await response.json()) as ProjectResponse;
return data.project;
}
async function fetchProjectList(params: {
keyword?: string;
page?: number;
pageSize?: number;
status?: ProjectStatus;
}): Promise<ProjectListResponse> {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set("page", String(params.page));
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
if (params.keyword) searchParams.set("keyword", params.keyword);
if (params.status) searchParams.set("status", params.status);
const qs = searchParams.toString();
const url = `/api/projects${qs ? `?${qs}` : ""}`;
const response = await fetch(url);
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
return response.json() as Promise<ProjectListResponse>;
}
async function restoreProject(id: string): Promise<Project> {
const response = await fetch(`/api/projects/${id}/restore`, { method: "POST" });
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
const data = (await response.json()) as ProjectResponse;
return data.project;
}
async function updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
const response = await fetch(`/api/projects/${id}`, {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
method: "PATCH",
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
const result = (await response.json()) as ProjectResponse;
return result.project;
}

View File

@@ -1,4 +1,5 @@
import { App as AntApp, Form, Input, Modal } from "antd";
import { useEffect } from "react";
import type { CreateProjectRequest, Project, UpdateProjectRequest } from "../../../../shared/api";
@@ -29,6 +30,15 @@ export function ProjectFormModal({
const { message } = AntApp.useApp();
const [form] = Form.useForm<FormValues>();
useEffect(() => {
if (!open) return;
if (editingProject) {
form.setFieldsValue({ description: editingProject.description, name: editingProject.name });
} else {
form.resetFields();
}
}, [editingProject, form, open]);
const handleFinish = async (values: FormValues) => {
try {
if (editingProject) {
@@ -52,15 +62,6 @@ export function ProjectFormModal({
return (
<Modal
afterOpenChange={(visible) => {
if (visible) {
if (editingProject) {
form.setFieldsValue({ description: editingProject.description, name: editingProject.name });
} else {
form.resetFields();
}
}
}}
confirmLoading={submitting}
destroyOnHidden
okText="确定"

View File

@@ -1,5 +1,6 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button, Flex, Input, Tabs } from "antd";
import { useState } from "react";
import type { ProjectStatus } from "../../../../shared/api";
@@ -25,6 +26,8 @@ export function ProjectToolbar({
onTabChange,
openCreateDialog,
}: ProjectToolbarProps) {
const [draftKeyword, setDraftKeyword] = useState(keyword);
return (
<Flex align="center" gap="var(--ant-margin-lg)" justify="space-between" wrap="wrap">
<Tabs activeKey={activeTab} items={STATUS_TAB_ITEMS} onChange={onTabChange} />
@@ -32,10 +35,14 @@ export function ProjectToolbar({
<Input.Search
allowClear
enterButton="搜索"
onClear={onSearchClear}
onSearch={onSearch}
onChange={(event) => setDraftKeyword(event.target.value)}
onClear={() => {
setDraftKeyword("");
onSearchClear();
}}
onSearch={(value) => onSearch(value)}
placeholder="搜索项目名称或描述"
value={keyword}
value={draftKeyword}
/>
{activeTab === "active" && (
<Button icon={<PlusOutlined />} onClick={openCreateDialog} type="primary">

View File

@@ -1,6 +1,6 @@
import { Card, Descriptions, Space, Typography } from "antd";
import { useCurrentProject } from "../../consoles/workbench/ProjectContext";
import { useCurrentProject } from "../../consoles/workbench/useCurrentProject";
export function WorkbenchOverviewPage() {
const project = useCurrentProject();

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;
}

View File

@@ -1,48 +1,55 @@
import { describe, expect, test } from "bun:test";
import { validateVersion } from "../../scripts/bump-version-logic";
import { createMigrationsDataSource, createServerEntrySource, createStaticAssetsSource } from "../../scripts/build";
describe("build 版本注入", () => {
test("validateVersion 接受有效版本", () => {
expect(() => validateVersion("0.1.0")).not.toThrow();
expect(() => validateVersion("1.2.3")).not.toThrow();
});
test("validateVersion 拒绝无效版本", () => {
expect(() => validateVersion("invalid")).toThrow();
expect(() => validateVersion("1.0.0-beta.1")).toThrow();
});
test("生成的 server-entry 包含版本字面量", () => {
test("生成的 server-entry 包含真实 bootstrap 参数", () => {
const version = "0.1.0";
const serverEntryTs = [
`import { bootstrap } from "../src/server/bootstrap";`,
`import { parseRuntimeArgs } from "../src/server/config";`,
`import { staticAssets } from "./static-assets";`,
"",
`const APP_VERSION = "${version}" as const;`,
"",
`async function main() {`,
` const { configPath } = parseRuntimeArgs();`,
` 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");
const serverEntryTs = createServerEntrySource(version);
expect(serverEntryTs).toContain(`const APP_VERSION = "${version}"`);
expect(serverEntryTs).toContain("version: APP_VERSION");
expect(serverEntryTs).toContain(`import { MIGRATIONS } from "./migrations-data";`);
expect(serverEntryTs).toContain(`import { staticAssets } from "./static-assets";`);
expect(serverEntryTs).toContain(
`bootstrap({ configPath, migrations: MIGRATIONS, mode: "production", staticAssets, version: APP_VERSION })`,
);
expect(serverEntryTs).toContain("createConsoleFallback().fatal");
});
test("版本字面量不依赖外部 package.json", () => {
const serverEntryTs = [`const APP_VERSION = "0.1.0" as const;`].join("\n");
test("生成的 static-assets 使用 indexHtml 与文件映射", () => {
const staticAssetsTs = createStaticAssetsSource({
fileEntries: [` "/assets/app.js": Bun.file(f1),`],
importLines: [
`import f0 from "./../dist/web/index.html" with { type: "file" };`,
`import f1 from "./../dist/web/assets/app.js" with { type: "file" };`,
],
indexHtmlVar: "f0",
});
expect(serverEntryTs).not.toContain("package.json");
expect(serverEntryTs).not.toContain("Bun.file");
expect(serverEntryTs).toContain('"0.1.0"');
expect(staticAssetsTs).toContain(`export const staticAssets`);
expect(staticAssetsTs).toContain(`"/assets/app.js": Bun.file(f1)`);
expect(staticAssetsTs).toContain(`indexHtml: Bun.file(f0)`);
});
test("生成的 migrations-data 嵌入真实记录形状", () => {
const migrationsTs = createMigrationsDataSource([
{ checksum: "abc123", id: "0000_initial", sql: "CREATE TABLE projects (id TEXT);" },
]);
expect(migrationsTs).toContain(`import type { MigrationRecord }`);
expect(migrationsTs).toContain(`export const MIGRATIONS: MigrationRecord[]`);
expect(migrationsTs).toContain(`id: "0000_initial"`);
expect(migrationsTs).toContain(`checksum: "abc123"`);
expect(migrationsTs).toContain(`CREATE TABLE projects`);
});
test("构建步骤编号保持 1/4 到 4/4 一致", async () => {
const source = await Bun.file(new URL("../../scripts/build.ts", import.meta.url)).text();
expect(source).toContain(`Step 1/4: Vite build`);
expect(source).toContain(`Step 2/4: Code generation`);
expect(source).toContain(`Step 3/4: Generating static assets`);
expect(source).toContain(`Step 4/4: Bun compile`);
expect(source).not.toContain(`Step 1/3`);
});
});

169
tests/server/app.test.ts Normal file
View File

@@ -0,0 +1,169 @@
import { describe, expect, test } from "bun:test";
import type { StaticAssets } from "../../src/server/static";
import { startServer } from "../../src/server/server";
import { createMigratedTestDatabase } from "../helpers";
function createStaticAssets(): StaticAssets {
return {
files: {
"/assets/app.js": new Blob(["console.log('app')"], { type: "text/javascript" }),
},
indexHtml: new Blob(["<!doctype html><main>Alfred</main>"], { type: "text/html" }),
};
}
function createTestApp() {
const handle = createMigratedTestDatabase("app-test");
const server = startServer({
config: { host: "127.0.0.1", port: 0 },
db: handle.db,
logger: handle.logger,
mode: "production",
staticAssets: createStaticAssets(),
version: "9.8.7-test",
});
return {
baseUrl: server.url.toString().replace(/\/$/, ""),
close: () => {
void server.stop(true);
handle.cleanup();
},
};
}
describe("后端应用集成", () => {
test("/api/meta 返回服务元信息和生产安全 header", async () => {
const app = createTestApp();
try {
const res = await fetch(`${app.baseUrl}/api/meta`);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/json; charset=utf-8");
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(res.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
const body = (await res.json()) as { ok: boolean; service: string; timestamp: string; version: string };
expect(body.ok).toBe(true);
expect(body.service).toBe("alfred");
expect(body.version).toBe("9.8.7-test");
expect(Number.isNaN(Date.parse(body.timestamp))).toBe(false);
} finally {
app.close();
}
});
test("未知 API 返回 JSON 404", async () => {
const app = createTestApp();
try {
const res = await fetch(`${app.baseUrl}/api/missing`);
expect(res.status).toBe(404);
expect(res.headers.get("Content-Type")).toBe("application/json; charset=utf-8");
expect(await res.json()).toEqual({ error: "API route not found", status: 404 });
} finally {
app.close();
}
});
test("静态资源和 SPA fallback 由真实服务返回", async () => {
const app = createTestApp();
try {
const assetRes = await fetch(`${app.baseUrl}/assets/app.js`);
expect(assetRes.status).toBe(200);
expect(assetRes.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
expect(await assetRes.text()).toBe("console.log('app')");
const fallbackRes = await fetch(`${app.baseUrl}/projects`);
expect(fallbackRes.status).toBe(200);
expect(fallbackRes.headers.get("Cache-Control")).toBe("no-cache");
expect(await fallbackRes.text()).toContain("Alfred");
} finally {
app.close();
}
});
test("项目 API 覆盖创建、列表、详情、更新、归档、恢复和删除", async () => {
const app = createTestApp();
try {
const createdRes = await fetch(`${app.baseUrl}/api/projects`, {
body: JSON.stringify({ description: "集成测试", name: "集成项目" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
expect(createdRes.status).toBe(201);
const createdBody = (await createdRes.json()) as { project: { id: string; name: string; status: string } };
const id = createdBody.project.id;
expect(createdBody.project.name).toBe("集成项目");
const listRes = await fetch(`${app.baseUrl}/api/projects?keyword=集成&page=1&pageSize=20`);
expect(listRes.status).toBe(200);
const listBody = (await listRes.json()) as { items: Array<{ id: string }>; total: number };
expect(listBody.total).toBe(1);
expect(listBody.items[0]!.id).toBe(id);
const getRes = await fetch(`${app.baseUrl}/api/projects/${id}`);
expect(getRes.status).toBe(200);
const updateRes = await fetch(`${app.baseUrl}/api/projects/${id}`, {
body: JSON.stringify({ name: "更新项目" }),
headers: { "Content-Type": "application/json" },
method: "PATCH",
});
expect(updateRes.status).toBe(200);
expect(((await updateRes.json()) as { project: { name: string } }).project.name).toBe("更新项目");
const archiveRes = await fetch(`${app.baseUrl}/api/projects/${id}/archive`, { method: "POST" });
expect(archiveRes.status).toBe(200);
expect(((await archiveRes.json()) as { project: { status: string } }).project.status).toBe("archived");
const restoreRes = await fetch(`${app.baseUrl}/api/projects/${id}/restore`, { method: "POST" });
expect(restoreRes.status).toBe(200);
expect(((await restoreRes.json()) as { project: { status: string } }).project.status).toBe("active");
await fetch(`${app.baseUrl}/api/projects/${id}/archive`, { method: "POST" });
const deleteRes = await fetch(`${app.baseUrl}/api/projects/${id}`, { method: "DELETE" });
expect(deleteRes.status).toBe(204);
} finally {
app.close();
}
});
test("项目 API 覆盖关键错误路径", async () => {
const app = createTestApp();
try {
const invalidIdRes = await fetch(`${app.baseUrl}/api/projects/-bad`);
expect(invalidIdRes.status).toBe(400);
const createOnce = await fetch(`${app.baseUrl}/api/projects`, {
body: JSON.stringify({ name: "错误项目" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const id = ((await createOnce.json()) as { project: { id: string } }).project.id;
const duplicateRes = await fetch(`${app.baseUrl}/api/projects`, {
body: JSON.stringify({ name: "错误项目" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
expect(duplicateRes.status).toBe(409);
const deleteActiveRes = await fetch(`${app.baseUrl}/api/projects/${id}`, { method: "DELETE" });
expect(deleteActiveRes.status).toBe(409);
await fetch(`${app.baseUrl}/api/projects/${id}/archive`, { method: "POST" });
const updateArchivedRes = await fetch(`${app.baseUrl}/api/projects/${id}`, {
body: JSON.stringify({ name: "不可改" }),
headers: { "Content-Type": "application/json" },
method: "PATCH",
});
expect(updateArchivedRes.status).toBe(409);
const missingRes = await fetch(`${app.baseUrl}/api/projects/00000000-0000-4000-8000-000000000000`);
expect(missingRes.status).toBe(404);
} finally {
app.close();
}
});
});

View File

@@ -11,6 +11,22 @@ import type { StartServerOptions } from "../../src/server/server";
import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstrap";
import { createMemoryLogger } from "../../src/server/logger";
async function captureConsoleError(callback: () => Promise<void>): Promise<string[]> {
const originalError = console.error;
const errors: string[] = [];
console.error = (...args: unknown[]) => {
errors.push(args.map(String).join(" "));
};
try {
await callback();
} finally {
console.error = originalError;
}
return errors;
}
function makeTempConfig(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
const base = join(tmpdir(), `bootstrap-test-${Date.now()}`);
mkdirSync(base, { recursive: true });
@@ -106,13 +122,17 @@ describe("bootstrap", () => {
},
};
try {
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
} catch {
// expected - exit threw
}
const errors = await captureConsoleError(async () => {
try {
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
} catch {
// expected - exit threw
}
});
expect(exitCode).toBe(1);
expect(errors).toContain("日志初始化失败: pino import failed");
expect(errors).toContain("启动失败: exit called");
});
test("启动失败时调用 logger.fatal 并 flush", async () => {

View File

@@ -1,23 +1,16 @@
import { describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { createDatabase } from "../../../src/server/db/connection";
import { createMemoryLogger } from "../../../src/server/logger";
function makeTempDir(): string {
const dir = join(tmpdir(), `db-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
return dir;
}
import { closeSqliteForTest, createTestDatabase, makeTempDir, rmRetrySync } from "../../helpers";
describe("数据库初始化", () => {
test("创建数据库文件并设置 PRAGMA", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
const dir = makeTempDir("db-test");
const db = createDatabase(dir, createMemoryLogger());
try {
const db = createDatabase(dir, logger);
const journalMode = db.query("PRAGMA journal_mode").get() as { journal_mode: string };
expect(journalMode.journal_mode).toBe("wal");
@@ -28,37 +21,34 @@ describe("数据库初始化", () => {
const timeoutResult = db.query("PRAGMA busy_timeout").get() as Record<string, unknown>;
expect(timeoutResult).not.toBeNull();
db.close();
closeSqliteForTest(db);
} finally {
rmSync(dir, { force: true, recursive: true });
rmRetrySync(dir);
}
});
test("在空数据目录中创建 alfred.db", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
const handle = createTestDatabase("db-test");
try {
const db = createDatabase(dir, logger);
db.close();
handle.close();
expect(existsSync(join(dir, "alfred.db"))).toBe(true);
expect(existsSync(join(handle.dir, "alfred.db"))).toBe(true);
} finally {
rmSync(dir, { force: true, recursive: true });
handle.cleanup();
}
});
test("数据库连接可执行查询", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
const handle = createTestDatabase("db-test");
try {
const db = createDatabase(dir, logger);
const { db } = handle;
db.exec("CREATE TABLE test (id TEXT PRIMARY KEY)");
db.exec("INSERT INTO test (id) VALUES ('1')");
const row = db.query("SELECT id FROM test WHERE id = '1'").get() as { id: string };
expect(row.id).toBe("1");
db.close();
handle.close();
} finally {
rmSync(dir, { force: true, recursive: true });
handle.cleanup();
}
});
});

View File

@@ -1,19 +1,11 @@
import { describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import type { MigrationRecord } from "../../../src/server/db/load-migrations";
import { createDatabase } from "../../../src/server/db/connection";
import { runMigrations } from "../../../src/server/db/migrate";
import { createMemoryLogger } from "../../../src/server/logger";
function makeTempDir(): string {
const dir = join(tmpdir(), `migration-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
return dir;
}
import { createTestDatabase, openTestDatabase } from "../../helpers";
const MIGRATION_001: MigrationRecord = {
checksum: "fake-checksum-001",
@@ -33,10 +25,9 @@ const MIGRATION_002: MigrationRecord = {
describe("migration 执行器", () => {
test("应用待执行 migration 并记录", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
const handle = createTestDatabase("migration-test");
try {
const db = createDatabase(dir, logger);
const { db, dir, logger } = handle;
runMigrations(db, [MIGRATION_001], dir, logger);
const rows = db.query("SELECT id, checksum FROM schema_migrations").all() as Array<{
@@ -48,33 +39,31 @@ describe("migration 执行器", () => {
expect(rows[0]!.checksum).toBe("fake-checksum-001");
db.exec("INSERT INTO test_table (id, name) VALUES ('1', 'test')");
db.close();
handle.close();
} finally {
rmSync(dir, { force: true, recursive: true });
handle.cleanup();
}
});
test("跳过已应用的 migration", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
const handle = createTestDatabase("migration-test");
try {
const db = createDatabase(dir, logger);
const { db, dir, logger } = handle;
runMigrations(db, [MIGRATION_001], dir, logger);
runMigrations(db, [MIGRATION_001], dir, logger);
const rows = db.query("SELECT id FROM schema_migrations").all() as Array<{ id: string }>;
expect(rows.length).toBe(1);
db.close();
handle.close();
} finally {
rmSync(dir, { force: true, recursive: true });
handle.cleanup();
}
});
test("按顺序应用多个 migration", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
const handle = createTestDatabase("migration-test");
try {
const db = createDatabase(dir, logger);
const { db, dir, logger } = handle;
runMigrations(db, [MIGRATION_001, MIGRATION_002], dir, logger);
const rows = db.query("SELECT id FROM schema_migrations ORDER BY id").all() as Array<{ id: string }>;
@@ -83,41 +72,39 @@ describe("migration 执行器", () => {
expect(rows[1]!.id).toBe("0002_add_desc");
db.exec("INSERT INTO test_table (id, name, description) VALUES ('1', 'test', 'desc')");
db.close();
handle.close();
} finally {
rmSync(dir, { force: true, recursive: true });
handle.cleanup();
}
});
test("无待执行 migration 时不做变更", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
const handle = createTestDatabase("migration-test");
try {
const db = createDatabase(dir, logger);
const { db, dir, logger } = handle;
runMigrations(db, [], dir, logger);
const tableExists = db
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'")
.get();
expect(tableExists).toBeNull();
db.close();
handle.close();
} finally {
rmSync(dir, { force: true, recursive: true });
handle.cleanup();
}
});
test("执行 migration 前创建备份", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
const handle = createTestDatabase("migration-test");
try {
const db = createDatabase(dir, logger);
const { db, dir } = handle;
db.exec("CREATE TABLE existing (id TEXT)");
db.exec("INSERT INTO existing (id) VALUES ('x')");
db.close();
handle.close();
const db2 = createDatabase(dir, logger);
runMigrations(db2, [MIGRATION_001], dir, logger);
db2.close();
const reopened = openTestDatabase(dir);
runMigrations(reopened.db, [MIGRATION_001], dir, reopened.logger);
reopened.close();
const backupsDir = join(dir, "backups");
expect(existsSync(backupsDir)).toBe(true);
@@ -125,29 +112,28 @@ describe("migration 执行器", () => {
expect(backupFiles.length).toBe(1);
expect(backupFiles[0]!).toMatch(/^alfred-.*\.db$/);
} finally {
rmSync(dir, { force: true, recursive: true });
handle.cleanup();
}
});
test("失败的 migration 不留下部分记录", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
const handle = createTestDatabase("migration-test");
const BAD_MIGRATION: MigrationRecord = {
checksum: "bad",
id: "0003_bad",
sql: "INVALID SQL STATEMENT;",
};
try {
const db = createDatabase(dir, logger);
const { db, dir, logger } = handle;
expect(() => {
runMigrations(db, [MIGRATION_001, BAD_MIGRATION], dir, logger);
}).toThrow();
const rows = db.query("SELECT id FROM schema_migrations").all() as Array<{ id: string }>;
expect(rows.length).toBe(0);
db.close();
handle.close();
} finally {
rmSync(dir, { force: true, recursive: true });
handle.cleanup();
}
});
});

View File

@@ -1,12 +1,7 @@
import type Database from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDatabase } from "../../../src/server/db/connection";
import { runMigrations } from "../../../src/server/db/migrate";
import {
archiveProject,
createProject,
@@ -16,44 +11,21 @@ import {
restoreProject,
updateProject,
} from "../../../src/server/db/projects";
import { createMemoryLogger } from "../../../src/server/logger";
import { createMigratedTestDatabase } from "../../helpers";
const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
archived_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL
);
`;
function makeTempDir(): string {
const dir = join(tmpdir(), `projects-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
return dir;
}
function setupDb(dir: string): Database {
const logger = createMemoryLogger();
const db = createDatabase(dir, logger);
runMigrations(db, [{ checksum: "init", id: "001_init", sql: MIGRATION_SQL }], dir, logger);
return db;
function withProjectsDb(callback: (db: Database) => void): void {
const handle = createMigratedTestDatabase("projects-test");
try {
callback(handle.db);
handle.close();
} finally {
handle.cleanup();
}
}
describe("项目数据访问层", () => {
test("创建项目", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
withProjectsDb((db) => {
const result = createProject(db, { description: "测试描述", name: "测试项目" });
expect("error" in result).toBe(false);
expect((result as { project: unknown }).project).toBeDefined();
@@ -66,43 +38,28 @@ describe("项目数据访问层", () => {
expect(row.name).toBe("测试项目");
expect(row.description).toBe("测试描述");
expect(row.status).toBe("active");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("项目名称全局唯一(含归档项目)", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
withProjectsDb((db) => {
createProject(db, { name: "唯一名称" });
const result2 = createProject(db, { name: "唯一名称" });
expect("error" in result2).toBe(true);
expect((result2 as unknown as { error: string }).error).toContain("已存在");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("trim 后名称为空时创建失败", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
withProjectsDb((db) => {
const result = createProject(db, { name: " " });
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("不能为空");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("列表查询(分页和关键字)", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
withProjectsDb((db) => {
createProject(db, { description: "descA", name: "项目A" });
createProject(db, { description: "descB", name: "项目B" });
createProject(db, { name: "其他" });
@@ -117,16 +74,11 @@ describe("项目数据访问层", () => {
const result3 = listProjects(db, { page: 1, pageSize: 1 });
expect(result3.total).toBe(3);
expect(result3.items.length).toBe(1);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("获取项目详情", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
withProjectsDb((db) => {
const created = createProject(db, { description: "详情", name: "详情项目" });
const id = (created as { project: { id: string } }).project.id;
@@ -134,29 +86,19 @@ describe("项目数据访问层", () => {
expect("error" in result).toBe(false);
const projectResult = result as { project: { description: string } };
expect(projectResult.project.description).toBe("详情");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("获取不存在的项目返回 404 错误", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
withProjectsDb((db) => {
const result = getProject(db, "nonexistent");
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(404);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("更新项目名称和描述", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
withProjectsDb((db) => {
const created = createProject(db, { name: "原名" });
const id = (created as { project: { id: string } }).project.id;
@@ -166,16 +108,11 @@ describe("项目数据访问层", () => {
const updated = result as { project: { description: string; name: string } };
expect(updated.project.name).toBe("新名");
expect(updated.project.description).toBe("新描述");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("更新已归档项目失败", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
withProjectsDb((db) => {
const created = createProject(db, { name: "待归档" });
const id = (created as { project: { id: string } }).project.id;
archiveProject(db, id);
@@ -183,16 +120,11 @@ describe("项目数据访问层", () => {
const result = updateProject(db, id, { name: "新名称" });
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("归档项目", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
withProjectsDb((db) => {
const created = createProject(db, { name: "待归档" });
const id = (created as { project: { id: string } }).project.id;
@@ -209,32 +141,22 @@ describe("项目数据访问层", () => {
};
expect(row.status).toBe("archived");
expect(row.archived_at).not.toBeNull();
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("对已归档项目重复归档失败", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
withProjectsDb((db) => {
const created = createProject(db, { name: "测试" });
const id = (created as { project: { id: string } }).project.id;
archiveProject(db, id);
const result = archiveProject(db, id);
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("恢复已归档项目", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
withProjectsDb((db) => {
const created = createProject(db, { name: "恢复测试" });
const id = (created as { project: { id: string } }).project.id;
archiveProject(db, id);
@@ -245,31 +167,21 @@ describe("项目数据访问层", () => {
const restored = (result as { project: { archivedAt: null | string; status: string } }).project;
expect(restored.status).toBe("active");
expect(restored.archivedAt).toBeNull();
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("恢复 active 项目失败", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
withProjectsDb((db) => {
const created = createProject(db, { name: "活跃项目" });
const id = (created as { project: { id: string } }).project.id;
const result = restoreProject(db, id);
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("永久删除已归档项目", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
withProjectsDb((db) => {
const created = createProject(db, { name: "删除测试" });
const id = (created as { project: { id: string } }).project.id;
archiveProject(db, id);
@@ -279,84 +191,56 @@ describe("项目数据访问层", () => {
const after = getProject(db, id);
expect("error" in after).toBe(true);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("删除 active 项目失败", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
withProjectsDb((db) => {
const created = createProject(db, { name: "活跃项目" });
const id = (created as { project: { id: string } }).project.id;
const result = deleteProject(db, id);
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("创建项目名称超过 10 个字符失败", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const result = createProject(db, { name: "这是一个很长的名字" });
withProjectsDb((db) => {
const result = createProject(db, { name: "这是一个非常非常长的名字" });
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("不能超过 10 个字符");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("创建项目名称刚好 10 个字符成功", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
withProjectsDb((db) => {
const result = createProject(db, { name: "一二三四五六七八九十" });
expect("error" in result).toBe(false);
const project = (result as { project: { name: string } }).project;
expect(project.name).toBe("一二三四五六七八九十");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("更新项目名称超过 10 个字符失败", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
withProjectsDb((db) => {
const created = createProject(db, { name: "短名" });
const id = (created as { project: { id: string } }).project.id;
const result = updateProject(db, id, { name: "这是一个长的名字" });
const result = updateProject(db, id, { name: "这是一个非常非常长的名字" });
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("不能超过 10 个字符");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("更新项目名称 trim 后为空失败", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
withProjectsDb((db) => {
const created = createProject(db, { name: "原名" });
const id = (created as { project: { id: string } }).project.id;
const result = updateProject(db, id, { name: " " });
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("不能为空");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
});

View File

@@ -4,6 +4,35 @@ import type { Logger } from "../../src/server/logger";
import { createConsoleFallback, createMemoryLogger, createNoopLogger, REDACT_PATHS } from "../../src/server/logger";
function captureConsole(callback: () => void): { errors: string[]; logs: string[]; warns: string[] } {
const originalError = console.error;
const originalLog = console.log;
const originalWarn = console.warn;
const errors: string[] = [];
const logs: string[] = [];
const warns: string[] = [];
console.error = (...args: unknown[]) => {
errors.push(args.map(String).join(" "));
};
console.log = (...args: unknown[]) => {
logs.push(args.map(String).join(" "));
};
console.warn = (...args: unknown[]) => {
warns.push(args.map(String).join(" "));
};
try {
callback();
} finally {
console.error = originalError;
console.log = originalLog;
console.warn = originalWarn;
}
return { errors, logs, warns };
}
describe("NoopLogger", () => {
test("所有方法不抛异常", () => {
const logger = createNoopLogger();
@@ -59,17 +88,33 @@ describe("MemoryLogger", () => {
describe("ConsoleFallbackLogger", () => {
test("不抛异常", () => {
const logger = createConsoleFallback();
logger.trace("trace");
logger.debug("debug");
logger.info("info");
logger.warn("warn");
logger.error("error");
logger.fatal("fatal");
logger.flush();
const child = logger.child({ component: "test" });
const child = captureConsole(() => {
const logger = createConsoleFallback();
logger.trace("trace");
logger.debug("debug");
logger.info("info");
logger.warn("warn");
logger.error("error");
logger.fatal("fatal");
logger.flush();
return logger.child({ component: "test" });
});
expect(child).toBeDefined();
});
test("按等级写入对应 console 通道", () => {
const output = captureConsole(() => {
const logger = createConsoleFallback();
logger.info("info");
logger.warn("warn");
logger.error("error");
logger.fatal("fatal");
});
expect(output.logs).toContain("info");
expect(output.warns).toContain("warn");
expect(output.errors).toEqual(["error", "fatal"]);
});
});
describe("Logger 接口契约", () => {
@@ -94,7 +139,7 @@ describe("Logger 接口契约", () => {
});
test("ConsoleFallbackLogger 满足 Logger 接口", () => {
expect(() => assertLogger(createConsoleFallback())).not.toThrow();
expect(() => captureConsole(() => assertLogger(createConsoleFallback()))).not.toThrow();
});
});

View File

@@ -1,36 +1,13 @@
import type Database from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Project, RuntimeMode } from "../../../src/shared/api";
import { createDatabase } from "../../../src/server/db/connection";
import { runMigrations } from "../../../src/server/db/migrate";
import { createMemoryLogger } from "../../../src/server/logger";
import { createMigratedMemoryTestDatabase } from "../../helpers";
const MODE: RuntimeMode = "test";
const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
archived_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL
);
`;
async function archiveProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleArchiveProject: h } = await import("../../../src/server/routes/projects/archive");
return h(req, db, MODE);
@@ -63,24 +40,11 @@ async function listProjectsViaHandler(req: Request, db: Database): Promise<Respo
return h(req, db, MODE);
}
function makeTempDir(): string {
const dir = join(tmpdir(), `route-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
return dir;
}
async function restoreProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleRestoreProject: h } = await import("../../../src/server/routes/projects/restore");
return h(req, db, MODE);
}
function setupDb(dir: string): Database {
const logger = createMemoryLogger();
const db = createDatabase(dir, logger);
runMigrations(db, [{ checksum: "init", id: "001_init", sql: MIGRATION_SQL }], dir, logger);
return db;
}
async function updateProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleUpdateProject: h } = await import("../../../src/server/routes/projects/update");
return h(req, db, MODE);
@@ -89,11 +53,19 @@ async function updateProjectViaHandler(req: Request, db: Database): Promise<Resp
// Need db/projects for setup
import { archiveProject, createProject, getProject } from "../../../src/server/db/projects";
async function withRouteDb(callback: (db: Database) => Promise<void>): Promise<void> {
const handle = createMigratedMemoryTestDatabase("route-test");
try {
await callback(handle.db);
handle.close();
} finally {
handle.cleanup();
}
}
describe("项目 API 路由", () => {
test("POST /api/projects 创建项目", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
await withRouteDb(async (db) => {
const req = new Request("http://localhost/api/projects", {
body: JSON.stringify({ description: "路由测试", name: "路由项目" }),
headers: { "Content-Type": "application/json" },
@@ -103,16 +75,11 @@ describe("项目 API 路由", () => {
expect(res.status).toBe(201);
const body = (await res.json()) as { project: Project };
expect(body.project.name).toBe("路由项目");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("GET /api/projects 列表查询", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
await withRouteDb(async (db) => {
createTestProject(db, "A项目");
createTestProject(db, "B项目");
@@ -122,16 +89,11 @@ describe("项目 API 路由", () => {
const body = (await res.json()) as { items: Project[]; total: number };
expect(body.total).toBe(2);
expect(body.items.length).toBe(2);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("GET /api/projects/:id 获取详情", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
await withRouteDb(async (db) => {
const project = createTestProject(db, "详情路由");
const req = new Request(`http://localhost/api/projects/${project.id}`);
@@ -139,16 +101,11 @@ describe("项目 API 路由", () => {
expect(res.status).toBe(200);
const body = (await res.json()) as { project: Project };
expect(body.project.name).toBe("详情路由");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("PATCH /api/projects/:id 更新项目", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
await withRouteDb(async (db) => {
const project = createTestProject(db, "更新路由");
const req = new Request(`http://localhost/api/projects/${project.id}`, {
@@ -160,16 +117,11 @@ describe("项目 API 路由", () => {
expect(res.status).toBe(200);
const body = (await res.json()) as { project: Project };
expect(body.project.name).toBe("已更新");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("POST /api/projects/:id/archive 归档项目", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
await withRouteDb(async (db) => {
const project = createTestProject(db, "归档路由");
const req = new Request(`http://localhost/api/projects/${project.id}/archive`, { method: "POST" });
@@ -177,16 +129,11 @@ describe("项目 API 路由", () => {
expect(res.status).toBe(200);
const body = (await res.json()) as { project: Project };
expect(body.project.status).toBe("archived");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("POST /api/projects/:id/restore 恢复项目", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
await withRouteDb(async (db) => {
const project = createTestProject(db, "恢复路由");
archiveProject(db, project.id);
@@ -195,16 +142,11 @@ describe("项目 API 路由", () => {
expect(res.status).toBe(200);
const body = (await res.json()) as { project: Project };
expect(body.project.status).toBe("active");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("DELETE /api/projects/:id 永久删除已归档项目", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
await withRouteDb(async (db) => {
const project = createTestProject(db, "删除路由");
archiveProject(db, project.id);
@@ -214,16 +156,11 @@ describe("项目 API 路由", () => {
const after = getProject(db, project.id);
expect("error" in after).toBe(true);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("创建同名项目返回 409", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
await withRouteDb(async (db) => {
const req1 = new Request("http://localhost/api/projects", {
body: JSON.stringify({ name: "重复名" }),
headers: { "Content-Type": "application/json" },
@@ -238,24 +175,16 @@ describe("项目 API 路由", () => {
});
const res = await createProjectViaHandler(req2, db);
expect(res.status).toBe(409);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
test("删除 active 项目返回 409", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
await withRouteDb(async (db) => {
const project = createTestProject(db, "活项目");
const req = new Request(`http://localhost/api/projects/${project.id}`, { method: "DELETE" });
const res = await deleteProjectViaHandler(req, db);
expect(res.status).toBe(409);
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});
});

View File

@@ -17,8 +17,13 @@ globalThis.document = dom.window.document;
globalThis.window = dom.window as unknown as typeof globalThis & Window;
globalThis.navigator = dom.window.navigator;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.HTMLBodyElement = dom.window.HTMLBodyElement;
globalThis.HTMLHtmlElement = dom.window.HTMLHtmlElement;
globalThis.Element = dom.window.Element;
globalThis.getComputedStyle = dom.window.getComputedStyle;
globalThis.getComputedStyle = (element: Element, pseudoElt?: null | string) => {
// jsdom 不支持伪元素计算样式antd/rc-trigger 会传入伪元素参数,测试中退回普通样式即可。
return dom.window.getComputedStyle(element, pseudoElt ? undefined : pseudoElt);
};
// Ensure document.body exists
if (!globalThis.document.body) {
@@ -44,6 +49,32 @@ Object.defineProperty(elementProto, "detachEvent", { configurable: true, value:
Object.defineProperty(htmlElementProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
Object.defineProperty(htmlElementProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
// 抑制 antd/rc-trigger 在 jsdom 中产生的 NaN height warning
const originalStderrWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk: string | Uint8Array, encodingOrCb?: unknown, cb?: unknown) => {
const str = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString();
if (str.includes("NaN") && str.includes("height") && str.includes("css style property")) return true;
return originalStderrWrite(
chunk,
encodingOrCb as Parameters<typeof process.stderr.write>[1],
cb as Parameters<typeof process.stderr.write>[2],
);
};
const originalConsoleError = console.error;
console.error = (...args: unknown[]) => {
const message = args.map(String).join(" ");
if (message.includes("NaN") && message.includes("height") && message.includes("css style property")) return;
originalConsoleError(...args);
};
const originalConsoleWarn = console.warn;
console.warn = (...args: unknown[]) => {
const message = args.map(String).join(" ");
if (message.includes("NaN") && message.includes("height") && message.includes("css style property")) return;
originalConsoleWarn(...args);
};
// Other polyfills
globalThis.ResizeObserver = class {
disconnect() {}

View File

@@ -4,77 +4,27 @@ import { createElement } from "react";
import { APP } from "../../src/shared/app";
import { App } from "../../src/web/app";
import { renderWithProviders } from "./test-utils";
import { installFetchMock, mockMetaResponse, renderWithProviders } from "./test-utils";
describe("App", () => {
test("渲染 Layout 骨架和品牌名", () => {
window.fetch = (() => {
return new Response(
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
{
headers: { "Content-Type": "application/json" },
status: 200,
},
);
}) as unknown as typeof fetch;
test("渲染管理台入口、品牌和主题切换项", () => {
installFetchMock(() => mockMetaResponse());
renderWithProviders(createElement(App));
expect(screen.getByText(APP.title)).not.toBeNull();
expect(screen.getByText("管理台")).not.toBeNull();
expect(screen.getByText("系统")).not.toBeNull();
expect(screen.getByText("明亮")).not.toBeNull();
expect(screen.getByText("黑暗")).not.toBeNull();
});
test("渲染 Admin 侧边栏菜单项", () => {
window.fetch = (() => {
return new Response(
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
{
headers: { "Content-Type": "application/json" },
status: 200,
},
);
}) as unknown as typeof fetch;
test("渲染 Admin 导航菜单项", () => {
installFetchMock(() => mockMetaResponse());
renderWithProviders(createElement(App));
expect(screen.getAllByText("总览").length).toBeGreaterThan(0);
expect(screen.getAllByText("项目管理").length).toBeGreaterThan(0);
});
test("Sider 渲染侧边栏菜单", () => {
window.fetch = (() => {
return new Response(
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
{
headers: { "Content-Type": "application/json" },
status: 200,
},
);
}) as unknown as typeof fetch;
renderWithProviders(createElement(App));
const sider = document.querySelector(".ant-layout-sider");
expect(sider).not.toBeNull();
const menu = document.querySelector(".ant-menu");
expect(menu).not.toBeNull();
});
test("Admin header 显示管理台标题", () => {
window.fetch = (() => {
return new Response(
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
{
headers: { "Content-Type": "application/json" },
status: 200,
},
);
}) as unknown as typeof fetch;
renderWithProviders(createElement(App));
expect(screen.getByText("管理台")).not.toBeNull();
});
});

View File

@@ -0,0 +1,62 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { App as AntApp, ConfigProvider } from "antd";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { ErrorBoundary } from "../../../src/web/components/ErrorBoundary";
function BrokenChild(): never {
throw new Error("render failed");
}
function captureConsoleError(callback: () => void): string[] {
const originalError = console.error;
const errors: string[] = [];
console.error = (...args: unknown[]) => {
errors.push(args.map(String).join(" "));
};
try {
callback();
} finally {
console.error = originalError;
}
return errors;
}
describe("ErrorBoundary", () => {
test("子组件渲染失败后展示错误结果并隔离 console.error", () => {
const errors = captureConsoleError(() => {
render(
createElement(
ConfigProvider,
null,
createElement(AntApp, null, createElement(ErrorBoundary, null, createElement(BrokenChild))),
),
);
});
expect(screen.getByText("渲染错误")).not.toBeNull();
expect(screen.getByText("页面渲染出现异常,请刷新重试")).not.toBeNull();
expect(screen.getByRole("button", { name: "刷新页面" })).not.toBeNull();
expect(errors.some((line) => line.includes("渲染错误:"))).toBe(true);
});
test("点击刷新页面按钮不会破坏错误兜底界面", () => {
captureConsoleError(() => {
render(
createElement(
ConfigProvider,
null,
createElement(AntApp, null, createElement(ErrorBoundary, null, createElement(BrokenChild))),
),
);
});
expect(() => {
captureConsoleError(() => fireEvent.click(screen.getByRole("button", { name: "刷新页面" })));
}).not.toThrow();
expect(screen.getByText("渲染错误")).not.toBeNull();
});
});

View File

@@ -1,11 +1,17 @@
import { screen } from "@testing-library/react";
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { useLocation } from "react-router";
import { Sidebar } from "../../../../src/web/components/Sidebar";
import { ADMIN_MENU_ITEMS } from "../../../../src/web/consoles/admin/menu";
import { renderWithProviders } from "../../test-utils";
function LocationProbe() {
const location = useLocation();
return <span>{location.pathname}</span>;
}
describe("Sidebar", () => {
test("渲染 Admin 菜单项", () => {
renderWithProviders(createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }));
@@ -14,23 +20,21 @@ describe("Sidebar", () => {
expect(screen.getByText("项目管理")).not.toBeNull();
});
test("项目管理菜单项导航到 /projects", () => {
renderWithProviders(createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }), {
initialRoute: "/projects",
});
test("点击项目管理菜单项导航到 /projects", () => {
renderWithProviders(
createElement("div", null, createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }), createElement(LocationProbe)),
);
const activeItem = document.querySelector(".ant-menu-item-selected");
expect(activeItem).not.toBeNull();
expect(activeItem?.textContent).toContain("项目管理");
fireEvent.click(screen.getByText("项目管理"));
expect(screen.getByText("当前路径:/projects")).not.toBeNull();
});
test("高亮当前路由对应的总览菜单项", () => {
test("当前路由仍展示对应菜单项", () => {
renderWithProviders(createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }), {
initialRoute: "/",
});
const activeItem = document.querySelector(".ant-menu-item-selected");
expect(activeItem).not.toBeNull();
expect(activeItem?.textContent).toContain("总览");
expect(screen.getByText("总览")).not.toBeNull();
});
});

View File

@@ -0,0 +1,88 @@
import { describe, expect, test } from "bun:test";
import {
archiveProject,
createProject,
deleteProject,
fetchProject,
fetchProjectList,
restoreProject,
updateProject,
} from "../../../src/web/hooks/use-projects";
import { installFetchMock, jsonResponse } from "../test-utils";
const PROJECT = {
archivedAt: null,
createdAt: "2024-01-01T00:00:00.000Z",
description: "描述",
id: "p1",
name: "项目",
status: "active" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
};
async function expectRejectsWithMessage(action: () => Promise<unknown>, message: string) {
try {
await action();
throw new Error("expected rejection");
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe(message);
}
}
function jsonBody(body: BodyInit | null | undefined): unknown {
return JSON.parse(typeof body === "string" ? body : "{}");
}
describe("use-projects request helpers", () => {
test("fetchProjectList 按协议拼接 query 参数", async () => {
const calls = installFetchMock(() => jsonResponse({ items: [PROJECT], page: 2, pageSize: 10, total: 1 }));
const result = await fetchProjectList({ keyword: "项目", page: 2, pageSize: 10, status: "active" });
expect(result.items).toHaveLength(1);
expect(calls[0]?.method).toBe("GET");
expect(calls[0]?.url).toBe("/api/projects?page=2&pageSize=10&keyword=%E9%A1%B9%E7%9B%AE&status=active");
});
test("创建、更新、归档、恢复和删除使用正确 method 与 body", async () => {
const calls = installFetchMock((call) => {
if (call.method === "DELETE") return new Response(null, { status: 204 });
return jsonResponse(
{ project: PROJECT },
{ status: call.method === "POST" && call.url === "/api/projects" ? 201 : 200 },
);
});
await createProject({ description: "描述", name: "项目" });
await updateProject("p1", { name: "新项目" });
await archiveProject("p1");
await restoreProject("p1");
await deleteProject("p1");
await fetchProject("p1");
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
"POST /api/projects",
"PATCH /api/projects/p1",
"POST /api/projects/p1/archive",
"POST /api/projects/p1/restore",
"DELETE /api/projects/p1",
"GET /api/projects/p1",
]);
expect(jsonBody(calls[0]?.body)).toEqual({ description: "描述", name: "项目" });
expect(jsonBody(calls[1]?.body)).toEqual({ name: "新项目" });
});
test("错误响应优先使用后端 error 字段", async () => {
installFetchMock(() => jsonResponse({ error: "项目名称已存在" }, { status: 409 }));
await expectRejectsWithMessage(() => createProject({ name: "重复项目" }), "项目名称已存在");
});
test("非 JSON 错误响应回退到 HTTP 状态", async () => {
installFetchMock(() => new Response("broken", { status: 500 }));
await expectRejectsWithMessage(() => fetchProject("p-missing"), "HTTP 500");
});
});

View File

@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import {
parseSidebarCollapsed,
readSidebarCollapsed,
SIDEBAR_COLLAPSED_STORAGE_KEY,
writeSidebarCollapsed,
} from "../../../src/web/hooks/use-sidebar-collapsed";
function createStorage(initial?: string): Storage {
const values = new Map<string, string>();
if (initial !== undefined) values.set(SIDEBAR_COLLAPSED_STORAGE_KEY, initial);
return {
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => Array.from(values.keys())[index] ?? null,
get length() {
return values.size;
},
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
};
}
describe("sidebar collapsed 纯逻辑", () => {
test("仅字符串 true 解析为折叠", () => {
expect(parseSidebarCollapsed("true")).toBe(true);
expect(parseSidebarCollapsed("false")).toBe(false);
expect(parseSidebarCollapsed(true)).toBe(false);
expect(parseSidebarCollapsed(null)).toBe(false);
});
test("读取和写入 localStorage", () => {
const storage = createStorage("true");
expect(readSidebarCollapsed(storage)).toBe(true);
writeSidebarCollapsed(false, storage);
expect(storage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe("false");
});
test("storage 异常时回退且不抛错", () => {
const brokenStorage = {
getItem: () => {
throw new Error("blocked");
},
setItem: () => {
throw new Error("blocked");
},
} as unknown as Storage;
expect(readSidebarCollapsed(brokenStorage)).toBe(false);
expect(() => writeSidebarCollapsed(true, brokenStorage)).not.toThrow();
});
});

View File

@@ -0,0 +1,77 @@
import { describe, expect, test } from "bun:test";
import {
getSystemPrefersDark,
parseThemePreference,
readThemePreference,
resolveEffectiveTheme,
THEME_MEDIA_QUERY,
THEME_PREFERENCE_STORAGE_KEY,
writeThemePreference,
} from "../../../src/web/hooks/use-theme-preference";
function createStorage(initial?: string): Storage {
const values = new Map<string, string>();
if (initial !== undefined) values.set(THEME_PREFERENCE_STORAGE_KEY, initial);
return {
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => Array.from(values.keys())[index] ?? null,
get length() {
return values.size;
},
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
};
}
describe("theme preference 纯逻辑", () => {
test("解析合法和非法主题偏好", () => {
expect(parseThemePreference("dark")).toBe("dark");
expect(parseThemePreference("light")).toBe("light");
expect(parseThemePreference("system")).toBe("system");
expect(parseThemePreference("unknown")).toBe("system");
});
test("读取和写入 localStorage", () => {
const storage = createStorage("dark");
expect(readThemePreference(storage)).toBe("dark");
writeThemePreference("light", storage);
expect(storage.getItem(THEME_PREFERENCE_STORAGE_KEY)).toBe("light");
});
test("storage 异常时回退且不抛错", () => {
const brokenStorage = {
getItem: () => {
throw new Error("blocked");
},
setItem: () => {
throw new Error("blocked");
},
} as unknown as Storage;
expect(readThemePreference(brokenStorage)).toBe("system");
expect(() => writeThemePreference("dark", brokenStorage)).not.toThrow();
});
test("解析系统主题和异常回退", () => {
const matchMedia = ((query: string) => {
expect(query).toBe(THEME_MEDIA_QUERY);
return { matches: true };
}) as Window["matchMedia"];
const brokenMatchMedia = (() => {
throw new Error("blocked");
}) as Window["matchMedia"];
expect(getSystemPrefersDark(matchMedia)).toBe(true);
expect(getSystemPrefersDark(brokenMatchMedia)).toBe(false);
});
test("根据系统主题解析实际主题", () => {
expect(resolveEffectiveTheme("dark", false)).toBe("dark");
expect(resolveEffectiveTheme("light", true)).toBe("light");
expect(resolveEffectiveTheme("system", true)).toBe("dark");
expect(resolveEffectiveTheme("system", false)).toBe("light");
});
});

View File

@@ -1,10 +1,16 @@
import { screen } from "@testing-library/react";
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { useLocation } from "react-router";
import { NotFoundPage } from "../../../src/web/pages/404";
import { renderWithProviders } from "../test-utils";
function LocationProbe() {
const location = useLocation();
return <span>{location.pathname}</span>;
}
describe("NotFoundPage", () => {
test("渲染 404 页面", () => {
renderWithProviders(createElement(NotFoundPage));
@@ -14,10 +20,14 @@ describe("NotFoundPage", () => {
expect(screen.getByRole("button", { name: "返回首页" })).not.toBeNull();
});
test("返回首页按钮存在且可点击", () => {
renderWithProviders(createElement(NotFoundPage));
test("点击返回首页按钮导航到首页", () => {
renderWithProviders(createElement("div", null, createElement(NotFoundPage), createElement(LocationProbe)), {
initialRoute: "/missing",
});
const button = screen.getByRole("button", { name: "返回首页" });
expect(button).not.toBeNull();
fireEvent.click(button);
expect(screen.getByText("当前路径:/")).not.toBeNull();
});
});

View File

@@ -1,114 +1,278 @@
import { screen, waitFor } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, mock, test } from "bun:test";
import { createElement } from "react";
import { useLocation } from "react-router";
import type { Project } from "../../../src/shared/api";
import { App } from "../../../src/web/app";
import { renderWithProviders } from "../test-utils";
import { ProjectFormModal } from "../../../src/web/pages/projects/components/ProjectFormModal";
import { ProjectTable } from "../../../src/web/pages/projects/components/ProjectTable";
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
const ACTIVE_PROJECT = {
const ACTIVE_PROJECT: Project = {
archivedAt: null,
createdAt: "2024-01-01T00:00:00.000Z",
description: "",
description: "活跃描述",
id: "p1",
name: "活跃项目",
status: "active",
updatedAt: "2024-01-01T00:00:00.000Z",
};
const ARCHIVED_PROJECT = {
const ARCHIVED_PROJECT: Project = {
archivedAt: "2024-01-02T00:00:00.000Z",
createdAt: "2024-01-01T00:00:00.000Z",
description: "",
description: "归档描述",
id: "p2",
name: "归档项目",
status: "archived",
updatedAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-02T00:00:00.000Z",
};
function createMockHandler(projectList?: unknown[]) {
const handler = (input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : typeof input === "string" ? input : input.toString();
if (url.includes("/api/meta")) {
return new Response(
JSON.stringify({
ok: true,
service: "test-app",
timestamp: new Date().toISOString(),
version: "0.1.0",
}),
{ headers: { "Content-Type": "application/json" }, status: 200 },
);
}
if (url.includes("/api/projects")) {
const items = projectList ?? [];
return new Response(JSON.stringify({ items, page: 1, pageSize: 10, total: items.length }), {
headers: { "Content-Type": "application/json" },
status: 200,
function clickLatestConfirmButton() {
const buttons = screen.getAllByRole("button", { name: /OK|确\s*定/ });
fireEvent.click(buttons[buttons.length - 1]!);
}
function createProjectFetchMock(initialProjects: Project[] = [ACTIVE_PROJECT, ARCHIVED_PROJECT]) {
let projects = [...initialProjects];
return installFetchMock((call) => {
if (call.url.includes("/api/meta")) return mockMetaResponse();
const url = new URL(call.url, "http://localhost");
if (url.pathname === "/api/projects" && call.method === "GET") {
const status = url.searchParams.get("status");
const keyword = url.searchParams.get("keyword") ?? "";
const items = projects.filter((project) => {
const statusMatched = !status || project.status === status;
const keywordMatched = !keyword || `${project.name}${project.description}`.includes(keyword);
return statusMatched && keywordMatched;
});
return jsonResponse({ items, page: 1, pageSize: 20, total: items.length });
}
return new Response(JSON.stringify({ error: "Not Found" }), {
status: 404,
});
};
const mocked = handler as unknown as typeof fetch;
globalThis.fetch = mocked;
window.fetch = mocked;
if (url.pathname === "/api/projects" && call.method === "POST") {
const data = jsonBody(call.body) as { description?: string; name: string };
const created: Project = {
archivedAt: null,
createdAt: "2024-01-03T00:00:00.000Z",
description: data.description ?? "",
id: "p-created",
name: data.name,
status: "active",
updatedAt: "2024-01-03T00:00:00.000Z",
};
projects = [created, ...projects];
return jsonResponse({ project: created }, { status: 201 });
}
const projectId = /^\/api\/projects\/([^/]+)(?:\/(archive|restore))?$/.exec(url.pathname);
if (projectId) {
const [, id, action] = projectId;
const project = projects.find((item) => item.id === id);
if (!project) return jsonResponse({ error: "项目不存在", status: 404 }, { status: 404 });
if (call.method === "PATCH") {
const data = jsonBody(call.body) as { description?: string; name?: string };
const updated = { ...project, ...data, updatedAt: "2024-01-04T00:00:00.000Z" };
projects = projects.map((item) => (item.id === id ? updated : item));
return jsonResponse({ project: updated });
}
if (call.method === "POST" && action === "archive") {
const archived = { ...project, archivedAt: "2024-01-04T00:00:00.000Z", status: "archived" as const };
projects = projects.map((item) => (item.id === id ? archived : item));
return jsonResponse({ project: archived });
}
if (call.method === "POST" && action === "restore") {
const restored = { ...project, archivedAt: null, status: "active" as const };
projects = projects.map((item) => (item.id === id ? restored : item));
return jsonResponse({ project: restored });
}
if (call.method === "DELETE") {
projects = projects.filter((item) => item.id !== id);
return new Response(null, { status: 204 });
}
}
return jsonResponse({ error: "Not Found" }, { status: 404 });
});
}
function jsonBody(body: BodyInit | null | undefined): unknown {
return JSON.parse(typeof body === "string" ? body : "{}");
}
function LocationProbe() {
const location = useLocation();
return createElement("span", null, `当前路径:${location.pathname}`);
}
describe("ProjectsPage", () => {
test("渲染 Tab、搜索框、新建按钮和表格", async () => {
createMockHandler();
test("渲染项目管理入口并按状态请求项目列表", async () => {
const calls = createProjectFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await waitFor(
() => {
expect(screen.getByText("进行中")).not.toBeNull();
},
{ timeout: 10000 },
);
await waitFor(() => {
expect(screen.getByText("活跃项目")).not.toBeNull();
});
expect(screen.getByText("已归档")).not.toBeNull();
expect(screen.getByText("新建项目")).not.toBeNull();
expect(screen.getByRole("button", { name: /新建项目/ })).not.toBeNull();
expect(screen.getByPlaceholderText("搜索项目名称或描述")).not.toBeNull();
expect(calls.some((call) => call.url.includes("status=active"))).toBe(true);
});
test("新建按钮点击打开弹窗", async () => {
createMockHandler();
test("搜索和切换 Tab 会更新请求参数与用户可见结果", async () => {
const calls = createProjectFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
await waitFor(
() => {
expect(screen.getByText("进行中")).not.toBeNull();
},
{ timeout: 10000 },
);
fireEvent.change(screen.getByPlaceholderText("搜索项目名称或描述"), { target: { value: "归档" } });
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
fireEvent.click(screen.getByText("已归档"));
const createBtn = screen.getByRole("button", { name: /新建项目/ });
createBtn.click();
await waitFor(() => expect(screen.getByText("归档项目")).not.toBeNull());
await waitFor(
() => {
expect(document.body.querySelector(".ant-modal")).not.toBeNull();
},
{ timeout: 10000 },
);
expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true);
expect(calls.some((call) => call.url.includes("status=archived"))).toBe(true);
});
test("active 项目行显示'进入工作台'archived 行不显示", async () => {
createMockHandler([ACTIVE_PROJECT, ARCHIVED_PROJECT]);
test("清空搜索条件复位请求参数并重新展示全部项目", async () => {
const calls = createProjectFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
await waitFor(
() => {
expect(screen.queryByText("活跃项目")).not.toBeNull();
},
{ timeout: 10000 },
fireEvent.change(screen.getByPlaceholderText("搜索项目名称或描述"), { target: { value: "归档" } });
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
fireEvent.change(screen.getByPlaceholderText("搜索项目名称或描述"), { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
});
test("新建项目提交请求 body 并显示创建结果", async () => {
const calls = createProjectFetchMock([]);
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await waitFor(() => expect(screen.getByRole("button", { name: /新建项目/ })).not.toBeNull());
fireEvent.click(screen.getByRole("button", { name: /新建项目/ }));
await waitFor(() => expect(screen.getAllByText("新建项目").length).toBeGreaterThan(1));
await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "新增项目" } });
fireEvent.change(screen.getByPlaceholderText("请输入项目描述"), { target: { value: "新增描述" } });
clickLatestConfirmButton();
await waitFor(() => expect(screen.getByText("新增项目")).not.toBeNull());
const createCall = calls.find((call) => call.url.endsWith("/api/projects") && call.method === "POST");
expect(createCall).toBeDefined();
expect(jsonBody(createCall?.body)).toEqual({ description: "新增描述", name: "新增项目" });
});
test("编辑项目表单只提交变更字段", async () => {
const updateCalls: unknown[] = [];
const onUpdate = mock((args: unknown) => {
updateCalls.push(args);
return Promise.resolve();
});
renderWithProviders(
createElement(ProjectFormModal, {
editingProject: ACTIVE_PROJECT,
onCancel: () => undefined,
onCreate: () => Promise.resolve(),
onOpenChange: () => undefined,
onUpdate,
open: true,
submitting: false,
}),
);
const enterBtns = screen.getAllByText("进入工作台");
expect(enterBtns.length).toBe(1);
await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "编辑项目" } });
clickLatestConfirmButton();
const archivedRow = screen.getByText("归档项目").closest("tr");
expect(archivedRow?.textContent).not.toContain("进入工作台");
await waitFor(() => expect(onUpdate).toHaveBeenCalled());
expect(updateCalls[0]).toEqual({ data: { name: "编辑项目" }, id: "p1" });
});
test("项目表单校验失败不会提交,接口失败时保留弹窗", async () => {
const onCreate = mock(() => Promise.reject(new Error("创建失败")));
const onOpenChange = mock(() => undefined);
renderWithProviders(
createElement(ProjectFormModal, {
editingProject: null,
onCancel: () => undefined,
onCreate,
onOpenChange,
onUpdate: () => Promise.resolve(),
open: true,
submitting: false,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull());
clickLatestConfirmButton();
expect(onCreate).not.toHaveBeenCalled();
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "失败项目" } });
clickLatestConfirmButton();
await waitFor(() => expect(onCreate).toHaveBeenCalled());
expect(onOpenChange).not.toHaveBeenCalledWith(false);
expect(screen.getByText("新建项目")).not.toBeNull();
});
test("项目表格操作触发导航和行级动作", async () => {
const onArchive = mock(() => Promise.resolve());
const onDelete = mock(() => Promise.resolve());
const onRestore = mock(() => Promise.resolve());
renderWithProviders(
createElement(
"div",
null,
createElement(LocationProbe),
createElement(ProjectTable, {
data: { items: [ACTIVE_PROJECT, ARCHIVED_PROJECT], page: 1, pageSize: 20, total: 2 },
loading: false,
onArchive,
onDelete,
onEdit: () => undefined,
onPageChange: () => undefined,
onRestore,
page: 1,
pageSize: 20,
}),
),
);
fireEvent.click(screen.getByRole("button", { name: /进入工作台/ }));
expect(screen.getByText("当前路径:/workbench/p1")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: /归档/ }));
await waitFor(() => expect(screen.getByText("确认归档此项目?")).not.toBeNull());
clickLatestConfirmButton();
await waitFor(() => expect(onArchive).toHaveBeenCalledWith("p1"));
fireEvent.click(screen.getByRole("button", { name: /恢复/ }));
await waitFor(() => expect(screen.getByText("确认恢复此项目?")).not.toBeNull());
clickLatestConfirmButton();
await waitFor(() => expect(onRestore).toHaveBeenCalledWith("p2"));
fireEvent.click(screen.getByRole("button", { name: /删除/ }));
await waitFor(() => expect(screen.getByText("确认永久删除此项目?")).not.toBeNull());
clickLatestConfirmButton();
await waitFor(() => expect(onDelete).toHaveBeenCalledWith("p2"));
});
});

View File

@@ -19,10 +19,48 @@ void mock.module("recharts", () => ({
YAxis: () => null,
}));
export interface FetchMockCall {
body?: BodyInit | null;
method: string;
url: string;
}
export interface RenderWithProvidersOptions {
initialRoute?: string;
}
export function installFetchMock(handler: (call: FetchMockCall) => Promise<Response> | Response): FetchMockCall[] {
const calls: FetchMockCall[] = [];
const mocked = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : undefined;
const url = request?.url ?? (typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
const call: FetchMockCall = {
body: init?.body ?? null,
method: init?.method ?? request?.method ?? "GET",
url,
};
calls.push(call);
return handler(call);
}) as typeof fetch;
globalThis.fetch = mocked;
window.fetch = mocked;
return calls;
}
export function jsonResponse(body: unknown, init?: ResponseInit): Response {
const headers = new Headers(init?.headers);
if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
return new Response(JSON.stringify(body), {
headers,
status: init?.status ?? 200,
});
}
export function mockMetaResponse(): Response {
return jsonResponse({ ok: true, service: "test-app", timestamp: "2024-01-01T00:00:00.000Z", version: "0.1.0" });
}
export function renderWithProviders(ui: React.ReactElement, options?: RenderWithProvidersOptions) {
const queryClient = createTestQueryClient();
const initialRoute = options?.initialRoute ?? "/";
@@ -58,38 +96,3 @@ function createTestQueryClient() {
},
});
}
// Custom test helpers (替代 jest-dom matchers)
export const testHelpers = {
toBeInTheDocument: (element: Element | null) => {
const pass = element !== null && document.contains(element);
return {
message: () => (pass ? "Expected element not to be in document" : "Expected element to be in document"),
pass,
};
},
toHaveAttribute: (element: Element | null, attr: string, value?: string) => {
const pass = value === undefined ? (element?.hasAttribute(attr) ?? false) : element?.getAttribute(attr) === value;
return {
message: () =>
pass ? `Expected element not to have attribute "${attr}"` : `Expected element to have attribute "${attr}"`,
pass,
};
},
toHaveClass: (element: Element | null, className: string) => {
const pass = element?.classList.contains(className) ?? false;
return {
message: () =>
pass ? `Expected element not to have class "${className}"` : `Expected element to have class "${className}"`,
pass,
};
},
toHaveTextContent: (element: Element | null, text: RegExp | string) => {
const content = element?.textContent ?? "";
const pass = element !== null && (typeof text === "string" ? content.includes(text) : text.test(content));
return {
message: () => (pass ? `Expected element not to have text "${text}"` : `Expected element to have text "${text}"`),
pass,
};
},
};