Compare commits

...

2 Commits

Author SHA1 Message Date
2ea4bd4410 test: 测试体系全面优化,修复 Windows SQLite EBUSY 和前端产品缺陷
测试基础设施
- 统一 SQLite 测试 DB/临时目录 helper(tests/helpers.ts),支持 Windows EBUSY 重试清理
- 测试库使用 PRAGMA journal_mode=DELETE 避免 WAL 句柄延迟
- 路由 handler 测试改用 createMigratedMemoryTestDatabase 避免 File DB 锁
- SQLite 聚焦 --rerun-each=20 全部通过(720 pass)

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

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

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

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

文档同步
- 更新后端/前端/开发文档测试规范、质量门禁和 helper 使用说明
2026-05-29 00:45:21 +08:00
6cb378d7cb feat: Admin/Workbench 双入口架构
- 抽取 ConsoleShell 共享外壳(Layout/Header/Sider/主题切换/侧边栏折叠)
- Sidebar 纯化为接受 menuItems prop 的展示组件
- Admin 管理台:/ 总览 + /projects 项目管理
- Workbench 工作台:/workbench/:projectId 项目作用域
- WorkbenchProjectGate 入口守卫(loading/error/archived/不存在拦截)
- ProjectContext 提供当前项目上下文
- 项目管理表格 active 行增加'进入工作台'按钮
- 项目名称 trim 后最多 10 字符(前后端一致)
- Workbench 总览页展示项目 Descriptions
- Header 区分:管理台显示副标题,工作台显示项目名 + 返回管理台按钮
- 28/28 前端测试通过
- 文档更新:frontend.md ConsoleShell 规范、usage.md 双入口说明
2026-05-28 22:33:03 +08:00
48 changed files with 1914 additions and 722 deletions

View File

@@ -42,10 +42,11 @@
代码变更必须按影响范围执行验证。
| 变更类型 | 必跑命令 |
| -------------------------- | --------------------------------------------------------- |
| -------------------------- | ------------------------------------------------------------- |
| 常规代码变更 | `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

@@ -120,6 +120,17 @@ token 和 CSS 变量规则:
## 运行时外壳规范
前端提供两个入口外壳,共享通用 Console Shell 组件:
- **Admin管理台**`src/web/consoles/admin/AdminConsoleLayout.tsx`,菜单配置在 `menu.tsx`,路由 `/``/projects`
- **Workbench工作台**`src/web/consoles/workbench/WorkbenchProjectGate.tsx``WorkbenchConsoleLayout.tsx`,菜单配置和路由构造在 `routes.ts`,路由 `/workbench/:projectId`
通用 Console Shell`src/web/components/ConsoleShell/ConsoleShell.tsx`)包含 Layout、Header、Sider、Content、主题切换、版本展示和侧边栏折叠状态由 Admin 和 Workbench 复用。Header 显示品牌名、版本号和控制台标题Admin 显示"管理台"Workbench 显示"工作台 · 项目名")。
Sidebar`src/web/components/Sidebar/index.tsx`)是纯展示/导航组件,通过 `menuItems` props 接收菜单配置由调用方决定菜单内容和路径。Admin 传入静态路径 `/``/projects`Workbench 通过 route builder`buildWorkbenchPath`)将相对菜单路径拼成 `/workbench/:projectId` 的子路径。
Workbench 项目上下文通过 `ProjectContext` 提供,在 `WorkbenchProjectGate` 中从 URL path param 读取 `projectId`,通过 `useProject(projectId)` 加载项目,仅 active 项目渲染工作台布局,不存在或 archived 项目显示"项目不存在或不可访问"。
- 生产入口必须启用 `ErrorBoundary`,运行时渲染异常使用 antd `Result status="500"` 或等价组件展示。
- `ReactQueryDevtools` 仅在 `import.meta.env.DEV` 条件下渲染,不进入生产渲染路径。
- 主题切换统一通过 `ConfigProvider` 的 antd theme algorithm 控制,不使用硬编码主题色。
@@ -146,6 +157,9 @@ token 和 CSS 变量规则:
- 组件测试环境由 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

@@ -33,8 +33,16 @@ bun run dev config.yaml
## 功能介绍
| 功能 | 路径 | 说明 |
| -------- | ----------- | ------------------------------------ |
| 仪表盘 | `/` | 应用总览,展示运行时元信息 |
| ---------- | ----------------------- | ---------------------------------------- |
| 总览 | `/` | Admin 管理台总览,展示运行时元信息 |
| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 |
| 工作台总览 | `/workbench/:projectId` | Workbench 工作台总览,按项目维度查看信息 |
| 用户管理 | `/users` | 页面建设中 |
| 系统设置 | `/settings` | 页面建设中 |
平台提供两个入口:
- **Admin管理台**:全局管理视角,包含总览和项目管理。默认入口,访问 `/` 即可进入。
- **Workbench工作台**:项目维度视角,通过 `/workbench/:projectId` 进入指定项目的工作台。URL 可保存为浏览器书签,下次直接进入。仅 active 状态的项目可进入工作台archived 项目不可访问。
从项目管理页面的 active 项目行可点击"进入工作台"跳转到对应项目的工作台。

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() {
}
}
if (import.meta.main) {
await build();
}

View File

@@ -27,6 +27,7 @@ export function createProject(
const db = wrap(raw);
const name = request.name.trim();
if (!name) return { error: "项目名称不能为空", status: 400 };
if (name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 };
const description = (request.description ?? "").trim();
const id = crypto.randomUUID();
@@ -142,6 +143,7 @@ export function updateProject(
const name = request.name?.trim();
if (name === "") return { error: "项目名称不能为空", status: 400 };
if (name !== undefined && name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 };
const updates: Partial<typeof projects.$inferInsert> = {
updatedAt: new Date().toISOString(),

View File

@@ -1,80 +1,13 @@
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
import { App as AntApp, ConfigProvider, Layout, Segmented, theme } from "antd";
import zhCN from "antd/locale/zh_CN";
import { useEffect } from "react";
import { APP } from "../shared/app";
import { Sidebar } from "./components/Sidebar";
import { useMeta } from "./hooks/use-meta";
import { useSidebarCollapsed } from "./hooks/use-sidebar-collapsed";
import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference";
import { AppRoutes } from "./routes";
const { Content, Header, Sider } = Layout;
const THEME_OPTIONS = [
{ label: "系统", value: "system" },
{ label: "明亮", value: "light" },
{ label: "黑暗", value: "dark" },
] as const;
export function App() {
const { effectiveTheme, preference: themePreference, setPreference: setThemePreference } = useThemePreference();
const { collapsed, setCollapsed } = useSidebarCollapsed();
const { data: meta } = useMeta();
useEffect(() => {
document.title = APP.title;
document.querySelector('meta[name="description"]')?.setAttribute("content", APP.description);
}, []);
const handleThemeChange = (value: number | string) => {
setThemePreference(value as ThemePreference);
};
const versionDisplay = meta?.version ? `v${meta.version}` : null;
const themeAlgorithm = effectiveTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm;
return (
<ConfigProvider locale={zhCN} theme={{ algorithm: themeAlgorithm }}>
<AntApp>
<Layout className="app-layout">
<Header className="app-header">
<div className="app-header-left">
<span className="app-brand-group">
<span className="app-brand">{APP.title}</span>
{versionDisplay && <span className="app-version">{versionDisplay}</span>}
</span>
</div>
<div className="app-header-right">
<Segmented
onChange={handleThemeChange}
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
value={themePreference}
/>
</div>
</Header>
<Layout>
<Sider
collapsed={collapsed}
collapsedWidth={64}
collapsible
onCollapse={(collapsed) => setCollapsed(collapsed)}
theme="light"
trigger={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
width={232}
>
<Sidebar />
</Sider>
<Layout>
<Content className="app-content">
<AppRoutes />
</Content>
</Layout>
</Layout>
</Layout>
</AntApp>
</ConfigProvider>
);
return <AppRoutes />;
}

View File

@@ -0,0 +1,5 @@
import { Outlet } from "react-router";
export function ConsoleOutlet() {
return <Outlet />;
}

View File

@@ -0,0 +1,73 @@
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
import { App as AntApp, ConfigProvider, Layout, Segmented, theme } from "antd";
import zhCN from "antd/locale/zh_CN";
import type { ConsoleShellProps } from "./types";
import { APP } from "../../../shared/app";
import { useMeta } from "../../hooks/use-meta";
import { useSidebarCollapsed } from "../../hooks/use-sidebar-collapsed";
import { useThemePreference } from "../../hooks/use-theme-preference";
import { Sidebar } from "../Sidebar";
import { ConsoleOutlet } from "./ConsoleOutlet";
const { Content, Header, Sider } = Layout;
const THEME_OPTIONS = [
{ label: "系统", value: "system" },
{ label: "明亮", value: "light" },
{ label: "黑暗", value: "dark" },
] as const;
export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProps) {
const { effectiveTheme, preference: themePreference, setPreference: setThemePreference } = useThemePreference();
const { collapsed, setCollapsed } = useSidebarCollapsed();
const { data: meta } = useMeta();
const versionDisplay = meta?.version ? `v${meta.version}` : null;
const themeAlgorithm = effectiveTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm;
return (
<ConfigProvider locale={zhCN} theme={{ algorithm: themeAlgorithm }}>
<AntApp>
<Layout className="app-layout">
<Header className="app-header">
<div className="app-header-left">
<span className="app-brand-group">
<span className="app-brand">{APP.title}</span>
{versionDisplay && <span className="app-version">{versionDisplay}</span>}
<span className="app-console-title">{title}</span>
</span>
</div>
<div className="app-header-right">
{headerExtra}
<Segmented
onChange={(value) => setThemePreference(value)}
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
value={themePreference}
/>
</div>
</Header>
<Layout>
<Sider
collapsed={collapsed}
collapsedWidth={64}
collapsible
onCollapse={(c) => setCollapsed(c)}
theme="light"
trigger={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
width={232}
>
<Sidebar menuItems={menuItems} />
</Sider>
<Layout>
<Content className="app-content">
<ConsoleOutlet />
</Content>
</Layout>
</Layout>
</Layout>
</AntApp>
</ConfigProvider>
);
}

View File

@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
import type { MenuItemConfig } from "../../menu";
export interface ConsoleShellProps {
headerExtra?: ReactNode;
menuItems: readonly MenuItemConfig[];
title: ReactNode;
}

View File

@@ -3,30 +3,34 @@ import type { MenuProps } from "antd";
import { Menu } from "antd";
import { useLocation, useNavigate } from "react-router";
import { MENU_ITEMS } from "../../menu";
import type { MenuItemConfig } from "../../menu";
type MenuItem = Required<MenuProps>["items"][number];
export function Sidebar() {
interface SidebarProps {
menuItems: readonly MenuItemConfig[];
}
export function Sidebar({ menuItems }: SidebarProps) {
const navigate = useNavigate();
const location = useLocation();
const currentPath = location.pathname;
const currentItem = MENU_ITEMS.find((item) => item.path === currentPath);
const currentItem = menuItems.find((item) => item.path === currentPath);
const selectedKeys = currentItem ? [currentItem.value] : [];
const menuItems: MenuItem[] = MENU_ITEMS.map((item) => ({
const antdMenuItems: MenuItem[] = menuItems.map((item) => ({
icon: item.icon,
key: item.value,
label: item.label,
}));
const handleMenuClick: MenuProps["onClick"] = ({ key }) => {
const item = MENU_ITEMS.find((i) => i.value === key);
const item = menuItems.find((i) => i.value === key);
if (item) {
void navigate(item.path);
}
};
return <Menu items={menuItems} mode="inline" onClick={handleMenuClick} selectedKeys={selectedKeys} />;
return <Menu items={antdMenuItems} mode="inline" onClick={handleMenuClick} selectedKeys={selectedKeys} />;
}

View File

@@ -0,0 +1,6 @@
import { ConsoleShell } from "../../components/ConsoleShell/ConsoleShell";
import { ADMIN_MENU_ITEMS } from "./menu";
export function AdminConsoleLayout() {
return <ConsoleShell menuItems={ADMIN_MENU_ITEMS} title="管理台" />;
}

View File

@@ -0,0 +1,9 @@
import { DashboardOutlined, FolderOutlined } from "@ant-design/icons";
import { createElement } from "react";
import type { MenuItemConfig } from "../../menu";
export const ADMIN_MENU_ITEMS: readonly MenuItemConfig[] = [
{ icon: createElement(DashboardOutlined), label: "总览", path: "/", value: "dashboard" },
{ icon: createElement(FolderOutlined), label: "项目管理", path: "/projects", value: "projects" },
] as const;

View File

@@ -0,0 +1,9 @@
import { type ReactNode } from "react";
import type { Project } from "../../../shared/api";
import { ProjectContext } from "./ProjectContextValue";
export function ProjectProvider({ children, project }: { children: ReactNode; project: Project }) {
return <ProjectContext.Provider value={project}>{children}</ProjectContext.Provider>;
}

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

@@ -0,0 +1,38 @@
import { HomeOutlined } from "@ant-design/icons";
import { Button } from "antd";
import { useNavigate } from "react-router";
import type { Project } from "../../../shared/api";
import { ConsoleShell } from "../../components/ConsoleShell/ConsoleShell";
import { ProjectProvider } from "./ProjectContext";
import { getWorkbenchMenuItems } from "./routes";
import { useCurrentProject } from "./useCurrentProject";
interface WorkbenchConsoleLayoutProps {
project: Project;
}
export function WorkbenchConsoleLayout({ project }: WorkbenchConsoleLayoutProps) {
const navigate = useNavigate();
const menuItems = getWorkbenchMenuItems(project.id);
return (
<ProjectProvider project={project}>
<ConsoleShell
headerExtra={
<Button icon={<HomeOutlined />} onClick={() => void navigate("/")} size="small" type="link">
</Button>
}
menuItems={menuItems}
title={<WorkbenchTitle />}
/>
</ProjectProvider>
);
}
function WorkbenchTitle() {
const project = useCurrentProject();
return <> · {project.name}</>;
}

View File

@@ -0,0 +1,47 @@
import { Alert, Button, Spin } from "antd";
import { useNavigate, useParams } from "react-router";
import { useProject } from "../../hooks/use-projects";
import { WorkbenchConsoleLayout } from "./WorkbenchConsoleLayout";
export function WorkbenchProjectGate() {
const { projectId } = useParams<{ projectId: string }>();
const navigate = useNavigate();
const { data: project, error, isLoading } = useProject(projectId ?? "");
if (!projectId) {
return <WorkbenchUnavailable onBack={() => void navigate("/")} />;
}
if (isLoading) {
return (
<div className="app-loading">
<Spin size="large" />
</div>
);
}
if (error || !project || project.status === "archived") {
return <WorkbenchUnavailable onBack={() => void navigate("/")} />;
}
return <WorkbenchConsoleLayout project={project} />;
}
function WorkbenchUnavailable({ onBack }: { onBack: () => void }) {
return (
<div className="app-unavailable">
<Alert
action={
<Button onClick={onBack} size="small" type="primary">
</Button>
}
description="请确认项目是否存在且未归档。"
showIcon
title="项目不存在或不可访问"
type="error"
/>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { DashboardOutlined } from "@ant-design/icons";
import { createElement } from "react";
import type { MenuItemConfig } from "../../menu";
export const WORKBENCH_MENU_ITEMS: readonly MenuItemConfig[] = [
{ icon: createElement(DashboardOutlined), label: "总览", path: "", value: "overview" },
] as const;
export function buildWorkbenchPath(projectId: string, relativePath = ""): string {
const base = `/workbench/${projectId}`;
if (!relativePath || relativePath === "/") return base;
const normalized = relativePath.startsWith("/") ? relativePath : `/${relativePath}`;
return `${base}${normalized}`;
}
export function getWorkbenchMenuItems(projectId: string): readonly MenuItemConfig[] {
return WORKBENCH_MENU_ITEMS.map((item) => ({
...item,
path: buildWorkbenchPath(projectId, item.path || "/"),
}));
}

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,16 +1,8 @@
import type { ReactElement } from "react";
import { DashboardOutlined, FolderOutlined } from "@ant-design/icons";
import { createElement } from "react";
export interface MenuItemConfig {
icon: ReactElement;
label: string;
path: string;
value: string;
}
export const MENU_ITEMS: readonly MenuItemConfig[] = [
{ icon: createElement(DashboardOutlined), label: "仪表盘", path: "/", value: "dashboard" },
{ icon: createElement(FolderOutlined), label: "项目管理", path: "/projects", value: "projects" },
] as const;

View File

@@ -18,8 +18,8 @@ export function DashboardPage() {
return (
<Space size="large" vertical>
<Typography.Title level={2}>使 {APP.title}</Typography.Title>
<Typography.Paragraph> /api/meta </Typography.Paragraph>
<Typography.Title level={2}></Typography.Title>
<Typography.Paragraph>使 {APP.title} /api/meta </Typography.Paragraph>
{isLoading && <Spin size="large" />}
{error && <Alert description={error.message} showIcon title="加载失败" type="error" />}
{meta && (

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="确定"
@@ -73,9 +74,9 @@ export function ProjectFormModal({
<Form.Item
label="项目名称"
name="name"
rules={[{ message: "项目名称不能为空", required: true, whitespace: true }]}
rules={[{ max: 10, message: "项目名称不能超过 10 个字符", required: true, whitespace: true }]}
>
<Input maxLength={100} placeholder="请输入项目名称" />
<Input maxLength={10} placeholder="请输入项目名称" />
</Form.Item>
<Form.Item label="项目描述" name="description">
<Input.TextArea autoSize={{ minRows: 5 }} maxLength={500} placeholder="请输入项目描述" />

View File

@@ -1,7 +1,8 @@
import type { ColumnsType } from "antd/es/table";
import { DeleteOutlined, EditOutlined, InboxOutlined, RedoOutlined } from "@ant-design/icons";
import { DeleteOutlined, EditOutlined, InboxOutlined, LoginOutlined, RedoOutlined } from "@ant-design/icons";
import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd";
import { useNavigate } from "react-router";
import type { Project, ProjectListResponse } from "../../../../shared/api";
@@ -60,6 +61,7 @@ export function ProjectTable({
pageSize,
}: ProjectTableProps) {
const { message } = AntApp.useApp();
const navigate = useNavigate();
const handleArchive = async (id: string) => {
try {
@@ -95,6 +97,14 @@ export function ProjectTable({
if (record.status === "active") {
return (
<Space size="small">
<Button
icon={<LoginOutlined />}
onClick={() => void navigate(`/workbench/${record.id}`)}
size="small"
type="link"
>
</Button>
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
</Button>
@@ -130,7 +140,7 @@ export function ProjectTable({
);
},
title: "操作",
width: 180,
width: 280,
};
return (

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

@@ -0,0 +1,22 @@
import { Card, Descriptions, Space, Typography } from "antd";
import { useCurrentProject } from "../../consoles/workbench/useCurrentProject";
export function WorkbenchOverviewPage() {
const project = useCurrentProject();
const items = [
{ children: project.name, key: "name", label: "项目名称" },
{ children: project.description || "暂无描述", key: "description", label: "项目描述" },
{ children: project.status === "active" ? "进行中" : "已归档", key: "status", label: "状态" },
];
return (
<Space size="large" vertical>
<Typography.Title level={2}></Typography.Title>
<Card>
<Descriptions column={1} items={items} title={project.name} />
</Card>
</Space>
);
}

View File

@@ -1,14 +1,22 @@
import { Route, Routes } from "react-router";
import { AdminConsoleLayout } from "./consoles/admin/AdminConsoleLayout";
import { WorkbenchProjectGate } from "./consoles/workbench/WorkbenchProjectGate";
import { NotFoundPage } from "./pages/404";
import { DashboardPage } from "./pages/dashboard";
import { ProjectsPage } from "./pages/projects";
import { WorkbenchOverviewPage } from "./pages/workbench";
export function AppRoutes() {
return (
<Routes>
<Route element={<AdminConsoleLayout />}>
<Route element={<DashboardPage />} path="/" />
<Route element={<ProjectsPage />} path="/projects" />
</Route>
<Route element={<WorkbenchProjectGate />} path="/workbench/:projectId">
<Route element={<WorkbenchOverviewPage />} path="" />
</Route>
<Route element={<NotFoundPage />} path="*" />
</Routes>
);

View File

@@ -52,3 +52,23 @@ body {
.app-content {
padding: var(--ant-padding-xl) var(--ant-padding-xl);
}
.app-console-title {
color: var(--ant-color-text-secondary);
font-size: var(--ant-font-size);
font-weight: 400;
}
.app-unavailable {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
}
.app-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
}

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", () => {
},
};
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 withProjectsDb(callback: (db: Database) => void): void {
const handle = createMigratedTestDatabase("projects-test");
try {
callback(handle.db);
handle.close();
} finally {
handle.cleanup();
}
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;
}
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,25 +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 个字符失败", () => {
withProjectsDb((db) => {
const result = createProject(db, { name: "这是一个非常非常长的名字" });
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("不能超过 10 个字符");
});
});
test("创建项目名称刚好 10 个字符成功", () => {
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("一二三四五六七八九十");
});
});
test("更新项目名称超过 10 个字符失败", () => {
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("不能超过 10 个字符");
});
});
test("更新项目名称 trim 后为空失败", () => {
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("不能为空");
});
});
});

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,6 +88,7 @@ describe("MemoryLogger", () => {
describe("ConsoleFallbackLogger", () => {
test("不抛异常", () => {
const child = captureConsole(() => {
const logger = createConsoleFallback();
logger.trace("trace");
logger.debug("debug");
@@ -67,9 +97,24 @@ describe("ConsoleFallbackLogger", () => {
logger.error("error");
logger.fatal("fatal");
logger.flush();
const child = logger.child({ component: "test" });
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

@@ -1,65 +1,30 @@
/* eslint-disable @typescript-eslint/require-await */
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
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 = (async () => {
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("渲染侧边栏菜单项", () => {
window.fetch = (async () => {
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);
expect(screen.getAllByText("项目管理").length).toBeGreaterThan(0);
});
test("Sider 渲染侧边栏菜单", () => {
window.fetch = (async () => {
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();
});
});

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,35 +1,40 @@
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";
describe("Sidebar", () => {
test("渲染菜单项", () => {
renderWithProviders(createElement(Sidebar));
function LocationProbe() {
const location = useLocation();
return <span>{location.pathname}</span>;
}
expect(screen.getByText("仪表盘")).not.toBeNull();
describe("Sidebar", () => {
test("渲染 Admin 菜单项", () => {
renderWithProviders(createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }));
expect(screen.getByText("总览")).not.toBeNull();
expect(screen.getByText("项目管理")).not.toBeNull();
});
test("项目管理菜单项导航到 /projects", () => {
renderWithProviders(createElement(Sidebar), {
initialRoute: "/projects",
test("点击项目管理菜单项导航到 /projects", () => {
renderWithProviders(
createElement("div", null, createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }), createElement(LocationProbe)),
);
fireEvent.click(screen.getByText("项目管理"));
expect(screen.getByText("当前路径:/projects")).not.toBeNull();
});
const activeItem = document.querySelector(".ant-menu-item-selected");
expect(activeItem).not.toBeNull();
expect(activeItem?.textContent).toContain("项目管理");
});
test("高亮当前路由对应的菜单项", () => {
renderWithProviders(createElement(Sidebar), {
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,39 +1,278 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { describe, expect, mock, test } from "bun:test";
import { createElement } from "react";
import { useLocation } from "react-router";
import { ProjectsPage } from "../../../src/web/pages/projects";
import { renderWithProviders } from "../test-utils";
import type { Project } from "../../../src/shared/api";
import { App } from "../../../src/web/app";
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: Project = {
archivedAt: null,
createdAt: "2024-01-01T00:00:00.000Z",
description: "活跃描述",
id: "p1",
name: "活跃项目",
status: "active",
updatedAt: "2024-01-01T00:00:00.000Z",
};
const ARCHIVED_PROJECT: Project = {
archivedAt: "2024-01-02T00:00:00.000Z",
createdAt: "2024-01-01T00:00:00.000Z",
description: "归档描述",
id: "p2",
name: "归档项目",
status: "archived",
updatedAt: "2024-01-02T00:00:00.000Z",
};
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 });
}
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 () => {
renderWithProviders(createElement(ProjectsPage));
test("渲染项目管理入口并按状态请求项目列表", async () => {
const calls = createProjectFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await waitFor(() => {
expect(screen.getByText("活跃项目")).not.toBeNull();
});
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);
});
await waitFor(
() => {
const body = document.body.textContent ?? "";
expect(body).toContain("项目名称");
},
{ timeout: 10000 },
test("搜索和切换 Tab 会更新请求参数与用户可见结果", async () => {
const calls = createProjectFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
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("已归档"));
await waitFor(() => expect(screen.getByText("归档项目")).not.toBeNull());
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("清空搜索条件复位请求参数并重新展示全部项目", async () => {
const calls = createProjectFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
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,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "编辑项目" } });
clickLatestConfirmButton();
await waitFor(() => expect(onUpdate).toHaveBeenCalled());
expect(updateCalls[0]).toEqual({ data: { name: "编辑项目" }, id: "p1" });
});
test("新建按钮点击打开弹窗", async () => {
renderWithProviders(createElement(ProjectsPage));
test("项目表单校验失败不会提交,接口失败时保留弹窗", async () => {
const onCreate = mock(() => Promise.reject(new Error("创建失败")));
const onOpenChange = mock(() => undefined);
const createBtn = screen.getByRole("button", { name: /新建项目/ });
fireEvent.click(createBtn);
await waitFor(
() => {
expect(document.body.querySelector(".ant-modal")).not.toBeNull();
},
{ timeout: 10000 },
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

@@ -0,0 +1,113 @@
import { screen, waitFor } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { App } from "../../../src/web/app";
import { ProjectProvider } from "../../../src/web/consoles/workbench/ProjectContext";
import { WorkbenchOverviewPage } from "../../../src/web/pages/workbench";
import { renderWithProviders } from "../test-utils";
const MOCK_PROJECT = {
archivedAt: null,
createdAt: "2024-01-01T00:00:00.000Z",
description: "测试项目",
id: "test-project-id",
name: "测试项目",
status: "active" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
};
function createMockHandler(overrides?: { archivedAt?: string; status?: "active" | "archived" }) {
const project = { ...MOCK_PROJECT, ...overrides };
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/${project.id}`)) {
return new Response(JSON.stringify({ project }), {
headers: { "Content-Type": "application/json" },
status: 200,
});
}
return new Response(JSON.stringify({ error: "Not Found" }), { status: 404 });
};
const mocked = handler as unknown as typeof fetch;
globalThis.fetch = mocked;
window.fetch = mocked;
}
describe("Workbench 路由", () => {
test("active 项目可进入 Workbench 并展示总览", async () => {
createMockHandler();
renderWithProviders(createElement(App), {
initialRoute: `/workbench/${MOCK_PROJECT.id}`,
});
await waitFor(
() => {
const body = document.body.textContent ?? "";
expect(body).toContain("工作台");
},
{ timeout: 10000 },
);
});
test("Workbench 显示返回管理台按钮", async () => {
createMockHandler();
renderWithProviders(createElement(App), {
initialRoute: `/workbench/${MOCK_PROJECT.id}`,
});
await waitFor(
() => {
expect(screen.getByText("返回管理台")).not.toBeNull();
},
{ timeout: 10000 },
);
});
test("不存在项目显示不可访问", async () => {
createMockHandler();
renderWithProviders(createElement(App), {
initialRoute: "/workbench/nonexistent-id",
});
await waitFor(
() => {
expect(screen.getByText("项目不存在或不可访问")).not.toBeNull();
},
{ timeout: 10000 },
);
});
test("archived 项目显示不可访问", async () => {
createMockHandler({ archivedAt: "2024-06-01T00:00:00.000Z", status: "archived" });
renderWithProviders(createElement(App), {
initialRoute: `/workbench/${MOCK_PROJECT.id}`,
});
await waitFor(
() => {
expect(screen.getByText("项目不存在或不可访问")).not.toBeNull();
},
{ timeout: 10000 },
);
});
test("Workbench 总览页标题显示'总览'", () => {
renderWithProviders(
createElement(ProjectProvider, { children: createElement(WorkbenchOverviewPage), project: MOCK_PROJECT }),
);
expect(screen.getByText("总览")).not.toBeNull();
expect(screen.getAllByText(MOCK_PROJECT.name).length).toBeGreaterThan(0);
});
});

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