diff --git a/docs/development/README.md b/docs/development/README.md index 91ce610..721e1d2 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -41,12 +41,13 @@ 代码变更必须按影响范围执行验证。 -| 变更类型 | 必跑命令 | -| -------------------------- | --------------------------------------------------------- | -| 常规代码变更 | `bun run check` | -| 构建、部署、前后端集成变更 | `bun run verify` | -| 配置 schema 变化 | `bun run schema`、`bun run schema:check`、`bun run check` | -| 仅文档变更 | 检查链接、索引和文档归属一致性 | +| 变更类型 | 必跑命令 | +| -------------------------- | ------------------------------------------------------------- | +| 常规代码变更 | `bun run check` | +| 构建、部署、前后端集成变更 | `bun run verify` | +| 配置 schema 变化 | `bun run schema`、`bun run schema:check`、`bun run check` | +| SQLite 测试基础设施变化 | 相关单文件测试 + SQLite 聚焦 `--rerun-each` + `bun run check` | +| 仅文档变更 | 检查链接、索引和文档归属一致性 | 正式提交或影响构建产物时优先运行 `bun run verify`。如果因环境限制无法执行完整验证,必须在收尾说明中记录未执行项和原因。 diff --git a/docs/development/backend.md b/docs/development/backend.md index 4969851..0f3ce52 100644 --- a/docs/development/backend.md +++ b/docs/development/backend.md @@ -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、共享类型、配置契约、日志模块、版本管理或后端测试规范时,必须更新本文档。 diff --git a/docs/development/frontend.md b/docs/development/frontend.md index e52ee5e..d0e7284 100644 --- a/docs/development/frontend.md +++ b/docs/development/frontend.md @@ -157,6 +157,9 @@ Workbench 项目上下文通过 `ProjectContext` 提供,在 `WorkbenchProjectG - 组件测试环境由 tests/setup.ts 和 bunfig.toml preload 提供 - 断言优先基于用户可见文本、role、按钮和交互结果,不依赖 `.ant-*` 内部类名。 - 对 antd 组件只断言本项目传入的可观察行为或配置结果,避免把 antd 内部 DOM 结构当作稳定契约。 +- fetch mock、路由、QueryClientProvider 等系统边界优先复用 tests/web/test-utils.tsx,避免在每个测试文件重复安装 `window.fetch`。 +- 项目页这类数据驱动页面至少覆盖请求 URL/query、method/body、成功后的用户可见结果,以及关键错误路径或失败后状态。 +- ErrorBoundary、hooks 纯逻辑和 fetch request helper 应使用单元测试覆盖异常回退,页面测试只保留真实用户路径。 ## 更新触发条件 diff --git a/scripts/build.ts b/scripts/build.ts index 24e825d..c0269e1 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -11,6 +11,67 @@ const buildDir = join(projectRoot, ".build"); const executablePath = join(projectRoot, `dist/${APP.name}`); const packageJsonPath = join(projectRoot, "package.json"); +export function createMigrationsDataSource(records: Array<{ checksum: string; id: string; sql: string }>): string { + return [ + `import type { MigrationRecord } from "../src/server/db/load-migrations";`, + ``, + `export const MIGRATIONS: MigrationRecord[] = [`, + ...records.map( + (r) => + ` { id: ${JSON.stringify(r.id)}, sql: ${JSON.stringify(r.sql)}, checksum: ${JSON.stringify(r.checksum)} },`, + ), + `];`, + ``, + ].join("\n"); +} + +export function createServerEntrySource(version: string): string { + return [ + `import { bootstrap } from "../src/server/bootstrap";`, + `import { parseRuntimeArgs } from "../src/server/config";`, + `import { createConsoleFallback } from "../src/server/logger";`, + `import { MIGRATIONS } from "./migrations-data";`, + `import { staticAssets } from "./static-assets";`, + "", + `const APP_VERSION = "${version}" as const;`, + "", + `async function main() {`, + ` const { configPath } = parseRuntimeArgs();`, + ` await bootstrap({ configPath, migrations: MIGRATIONS, mode: "production", staticAssets, version: APP_VERSION });`, + `}`, + "", + `void main().catch((error) => {`, + ` createConsoleFallback().fatal(\`启动失败: \${error instanceof Error ? error.message : String(error)}\`);`, + ` process.exit(1);`, + `});`, + "", + ].join("\n"); +} + +export function createStaticAssetsSource({ + fileEntries, + importLines, + indexHtmlVar, +}: { + fileEntries: string[]; + importLines: string[]; + indexHtmlVar: string; +}): string { + return [ + `import type { StaticAssets } from "../src/server/static";`, + "", + ...importLines, + "", + `export const staticAssets: StaticAssets = {`, + ` files: {`, + ...fileEntries, + ` },`, + ` indexHtml: Bun.file(${indexHtmlVar}),`, + `};`, + "", + ].join("\n"); +} + async function build() { try { await viteBuild(); @@ -98,42 +159,11 @@ async function codeGeneration() { process.exit(1); } - const staticAssetsTs = [ - `import type { StaticAssets } from "../src/server/static";`, - "", - ...importLines, - "", - `export const staticAssets: StaticAssets = {`, - ` files: {`, - ...fileEntries, - ` },`, - ` indexHtml: Bun.file(${indexHtmlVar}),`, - `};`, - "", - ].join("\n"); + const staticAssetsTs = createStaticAssetsSource({ fileEntries, importLines, indexHtmlVar }); await writeFile(join(buildDir, "static-assets.ts"), staticAssetsTs); - const serverEntryTs = [ - `import { bootstrap } from "../src/server/bootstrap";`, - `import { parseRuntimeArgs } from "../src/server/config";`, - `import { createConsoleFallback } from "../src/server/logger";`, - `import { MIGRATIONS } from "./migrations-data";`, - `import { staticAssets } from "./static-assets";`, - "", - `const APP_VERSION = "${version}" as const;`, - "", - `async function main() {`, - ` const { configPath } = parseRuntimeArgs();`, - ` await bootstrap({ configPath, migrations: MIGRATIONS, mode: "production", staticAssets, version: APP_VERSION });`, - `}`, - "", - `void main().catch((error) => {`, - ` createConsoleFallback().fatal(\`启动失败: \${error instanceof Error ? error.message : String(error)}\`);`, - ` process.exit(1);`, - `});`, - "", - ].join("\n"); + const serverEntryTs = createServerEntrySource(version); await writeFile(join(buildDir, "server-entry.ts"), serverEntryTs); } @@ -159,17 +189,7 @@ async function generateMigrationsData() { return { checksum, id, sql: sql.trim() }; }); - const lines = [ - `import type { MigrationRecord } from "../src/server/db/load-migrations";`, - ``, - `export const MIGRATIONS: MigrationRecord[] = [`, - ...records.map( - (r) => - ` { id: ${JSON.stringify(r.id)}, sql: ${JSON.stringify(r.sql)}, checksum: ${JSON.stringify(r.checksum)} },`, - ), - `];`, - ``, - ].join("\n"); + const lines = createMigrationsDataSource(records); await writeFile(join(buildDir, "migrations-data.ts"), lines); console.log(`Embedded ${records.length} migration(s)`); @@ -195,7 +215,7 @@ function toImportSpecifier(fromDir: string, targetPath: string) { } async function viteBuild() { - console.log("Step 1/3: Vite build..."); + console.log("Step 1/4: Vite build..."); const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], { cwd: projectRoot, stderr: "inherit", @@ -208,4 +228,6 @@ async function viteBuild() { } } -await build(); +if (import.meta.main) { + await build(); +} diff --git a/src/web/consoles/workbench/ProjectContext.tsx b/src/web/consoles/workbench/ProjectContext.tsx index c8fd182..cc4b09e 100644 --- a/src/web/consoles/workbench/ProjectContext.tsx +++ b/src/web/consoles/workbench/ProjectContext.tsx @@ -1,17 +1,9 @@ -import { createContext, type ReactNode, useContext } from "react"; +import { type ReactNode } from "react"; import type { Project } from "../../../shared/api"; -const ProjectContext = createContext(null); +import { ProjectContext } from "./ProjectContextValue"; export function ProjectProvider({ children, project }: { children: ReactNode; project: Project }) { return {children}; } - -export function useCurrentProject(): Project { - const project = useContext(ProjectContext); - if (!project) { - throw new Error("useCurrentProject 必须在 Workbench 项目上下文内使用"); - } - return project; -} diff --git a/src/web/consoles/workbench/ProjectContextValue.ts b/src/web/consoles/workbench/ProjectContextValue.ts new file mode 100644 index 0000000..9bf9edb --- /dev/null +++ b/src/web/consoles/workbench/ProjectContextValue.ts @@ -0,0 +1,5 @@ +import { createContext } from "react"; + +import type { Project } from "../../../shared/api"; + +export const ProjectContext = createContext(null); diff --git a/src/web/consoles/workbench/WorkbenchConsoleLayout.tsx b/src/web/consoles/workbench/WorkbenchConsoleLayout.tsx index c40d26f..de804ae 100644 --- a/src/web/consoles/workbench/WorkbenchConsoleLayout.tsx +++ b/src/web/consoles/workbench/WorkbenchConsoleLayout.tsx @@ -5,8 +5,9 @@ import { useNavigate } from "react-router"; import type { Project } from "../../../shared/api"; import { ConsoleShell } from "../../components/ConsoleShell/ConsoleShell"; -import { ProjectProvider, useCurrentProject } from "./ProjectContext"; +import { ProjectProvider } from "./ProjectContext"; import { getWorkbenchMenuItems } from "./routes"; +import { useCurrentProject } from "./useCurrentProject"; interface WorkbenchConsoleLayoutProps { project: Project; diff --git a/src/web/consoles/workbench/useCurrentProject.ts b/src/web/consoles/workbench/useCurrentProject.ts new file mode 100644 index 0000000..7021dc6 --- /dev/null +++ b/src/web/consoles/workbench/useCurrentProject.ts @@ -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; +} diff --git a/src/web/hooks/use-projects.ts b/src/web/hooks/use-projects.ts index a07acd1..6da9121 100644 --- a/src/web/hooks/use-projects.ts +++ b/src/web/hooks/use-projects.ts @@ -11,6 +11,93 @@ import type { const PROJECTS_KEY = ["projects"] as const; +export async function archiveProject(id: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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; +} + +export async function restoreProject(id: string): Promise { + 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 { + 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 { - 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 { - 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 { - 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 { - 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 { - 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; -} - -async function restoreProject(id: string): Promise { - 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 { - 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; -} diff --git a/src/web/pages/projects/components/ProjectFormModal.tsx b/src/web/pages/projects/components/ProjectFormModal.tsx index 511c5e8..2fe6984 100644 --- a/src/web/pages/projects/components/ProjectFormModal.tsx +++ b/src/web/pages/projects/components/ProjectFormModal.tsx @@ -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(); + 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 ( { - if (visible) { - if (editingProject) { - form.setFieldsValue({ description: editingProject.description, name: editingProject.name }); - } else { - form.resetFields(); - } - } - }} confirmLoading={submitting} destroyOnHidden okText="确定" diff --git a/src/web/pages/projects/components/ProjectToolbar.tsx b/src/web/pages/projects/components/ProjectToolbar.tsx index 8a14ea2..1121e41 100644 --- a/src/web/pages/projects/components/ProjectToolbar.tsx +++ b/src/web/pages/projects/components/ProjectToolbar.tsx @@ -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 ( @@ -32,10 +35,14 @@ export function ProjectToolbar({ setDraftKeyword(event.target.value)} + onClear={() => { + setDraftKeyword(""); + onSearchClear(); + }} + onSearch={(value) => onSearch(value)} placeholder="搜索项目名称或描述" - value={keyword} + value={draftKeyword} /> {activeTab === "active" && (