@finesoft/front 0.1.75 → 0.1.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +2 -411
  2. package/dist/browser.d.mts +2 -0
  3. package/dist/browser.mjs +1 -0
  4. package/dist/index.d.mts +2 -1248
  5. package/dist/index.mjs +54 -3557
  6. package/dist/server-data-DGbiKzMS.d.mts +1249 -0
  7. package/dist/start-app-BdXBCcor.mjs +2 -0
  8. package/docs/01-getting-started.md +230 -0
  9. package/docs/02-routing-and-controllers.md +197 -0
  10. package/docs/03-middleware.md +214 -0
  11. package/docs/04-rendering-and-hydration.md +271 -0
  12. package/docs/05-i18n.md +243 -0
  13. package/docs/06-http-client.md +286 -0
  14. package/docs/07-di-container.md +264 -0
  15. package/docs/08-observability.md +290 -0
  16. package/docs/09-server-and-deployment.md +242 -0
  17. package/docs/10-features-platform-pwa.md +238 -0
  18. package/docs/README.md +72 -0
  19. package/docs/advanced/custom-action-handler.md +248 -0
  20. package/docs/advanced/custom-adapter.md +264 -0
  21. package/docs/advanced/custom-event-recorder.md +318 -0
  22. package/docs/advanced/inline-proxy-codegen.md +200 -0
  23. package/docs/advanced/multi-tenant-scopes.md +330 -0
  24. package/docs/engineering/ci-release-flow.md +244 -0
  25. package/docs/engineering/project-structure.md +296 -0
  26. package/docs/engineering/testing.md +317 -0
  27. package/docs/pitfalls/container-scope-leak.md +215 -0
  28. package/docs/pitfalls/i18n-bundle-size.md +182 -0
  29. package/docs/pitfalls/proxy-binary-payloads.md +133 -0
  30. package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
  31. package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
  32. package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
  33. package/docs/zh/01-getting-started.md +230 -0
  34. package/docs/zh/02-routing-and-controllers.md +197 -0
  35. package/docs/zh/03-middleware.md +214 -0
  36. package/docs/zh/04-rendering-and-hydration.md +271 -0
  37. package/docs/zh/05-i18n.md +243 -0
  38. package/docs/zh/06-http-client.md +286 -0
  39. package/docs/zh/07-di-container.md +264 -0
  40. package/docs/zh/08-observability.md +287 -0
  41. package/docs/zh/09-server-and-deployment.md +242 -0
  42. package/docs/zh/10-features-platform-pwa.md +238 -0
  43. package/docs/zh/README.md +72 -0
  44. package/docs/zh/advanced/custom-action-handler.md +248 -0
  45. package/docs/zh/advanced/custom-adapter.md +264 -0
  46. package/docs/zh/advanced/custom-event-recorder.md +318 -0
  47. package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
  48. package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
  49. package/docs/zh/engineering/ci-release-flow.md +244 -0
  50. package/docs/zh/engineering/project-structure.md +296 -0
  51. package/docs/zh/engineering/testing.md +317 -0
  52. package/docs/zh/pitfalls/container-scope-leak.md +215 -0
  53. package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
  54. package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
  55. package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
  56. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
  57. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
  58. package/package.json +12 -3
@@ -0,0 +1,296 @@
1
+ # 工程实践:项目结构
2
+
3
+ 应用规模超过脚手架起点后的推荐布局。这是 ~20 路由、~10 工程师不大改就能用的形态。
4
+
5
+ ## 布局
6
+
7
+ ```
8
+ my-app/
9
+ ├── src/
10
+ │ ├── bootstrap.ts # 路由 + DI 设置(SSR + CSR 共享)
11
+ │ ├── main.ts # 浏览器入口
12
+ │ ├── ssr.ts # SSR 入口
13
+ │ ├── App.vue # 根组件
14
+ │ │
15
+ │ ├── routes/ # 路由定义,按领域分组
16
+ │ │ ├── home.ts
17
+ │ │ ├── product.ts
18
+ │ │ ├── checkout.ts
19
+ │ │ └── admin.ts
20
+ │ │
21
+ │ ├── controllers/ # Controller,每个 intent 一个文件
22
+ │ │ ├── home.ts
23
+ │ │ ├── product.ts
24
+ │ │ └── checkout.ts
25
+ │ │
26
+ │ ├── views/ # 视图组件,与 Controller 镜像对应
27
+ │ │ ├── Home.vue
28
+ │ │ ├── Product.vue
29
+ │ │ └── Checkout.vue
30
+ │ │
31
+ │ ├── lib/
32
+ │ │ ├── api/ # HttpClient 子类
33
+ │ │ │ ├── user.ts
34
+ │ │ │ └── product.ts
35
+ │ │ ├── guards/ # 可复用的中间件
36
+ │ │ │ ├── auth.ts
37
+ │ │ │ ├── locale.ts
38
+ │ │ │ └── analytics.ts
39
+ │ │ ├── di/
40
+ │ │ │ ├── keys.ts # APP_KEYS 常量 map
41
+ │ │ │ └── register.ts # 集中注册
42
+ │ │ ├── i18n/
43
+ │ │ │ └── translator.ts # Translator 工厂
44
+ │ │ └── pages/
45
+ │ │ └── types.ts # Page union 类型
46
+ │ │
47
+ │ ├── locales/
48
+ │ │ ├── en-US.json
49
+ │ │ ├── zh-Hans.json
50
+ │ │ └── ja-JP.json
51
+ │ │
52
+ │ └── env.ts # 环境变量解析 + 校验
53
+
54
+ ├── public/ # 静态资源,从 / 服务
55
+ ├── index.html
56
+ ├── vite.config.ts
57
+ ├── package.json
58
+ └── tsconfig.json
59
+ ```
60
+
61
+ ## 为什么是这个形状
62
+
63
+ ### `routes/` 与 `controllers/` 分离
64
+
65
+ 路由是**哪里**暴露一个 intent(URL 模式、守卫、渲染模式)。Controller 是 intent **做什么**。拆分让你:
66
+
67
+ - 跨多个 URL 复用 Controller,路由定义不会污染 Controller 文件
68
+ - 读一个目录就知道「应用服务哪些 URL」
69
+ - 读一个目录就知道「intent X 计算什么」
70
+
71
+ ### `controllers/` 与 `views/` 镜像
72
+
73
+ 两边每个 intent 一个文件。intent id、Controller 文件、view 文件同名。找 `/products/:id` 的渲染代码变成机械动作。
74
+
75
+ ### 横切关注点都进 `lib/`
76
+
77
+ 不是路由、Controller、view 的都进 `lib/`。框架 `bootstrap()` 通过 `lib/di/register.ts` 做重的注册;Controller 用 `lib/api/*` 里的客户端;守卫住 `lib/guards/`。
78
+
79
+ ### `env.ts` 放在 `src/` 顶层
80
+
81
+ 在一个文件里一次性解析并校验环境变量。用 [zod](https://zod.dev/) 或手写检查。在别处重新导出强类型常量。
82
+
83
+ ```ts
84
+ // src/env.ts
85
+ function requireEnv(name: string): string {
86
+ const v = process.env[name];
87
+ if (!v) throw new Error(`Missing env: ${name}`);
88
+ return v;
89
+ }
90
+
91
+ export const env = {
92
+ UPSTREAM_URL: requireEnv("UPSTREAM_URL"),
93
+ SESSION_SECRET: requireEnv("SESSION_SECRET"),
94
+ NODE_ENV: process.env.NODE_ENV ?? "development",
95
+ } as const;
96
+ ```
97
+
98
+ 让「这个应用需要什么环境变量」一目了然,构建时遇到缺失就立刻失败,而不是请求时崩。
99
+
100
+ ## `bootstrap.ts` 的形态
101
+
102
+ `bootstrap.ts` 保持薄 —— 它该是编排,不是逻辑:
103
+
104
+ ```ts
105
+ // src/bootstrap.ts
106
+ import { type Framework } from "@finesoft/front";
107
+ import { registerDependencies } from "./lib/di/register";
108
+ import { homeRoutes } from "./routes/home";
109
+ import { productRoutes } from "./routes/product";
110
+ import { checkoutRoutes } from "./routes/checkout";
111
+ import { adminRoutes } from "./routes/admin";
112
+
113
+ export function bootstrap(framework: Framework): void {
114
+ registerDependencies(framework.container);
115
+
116
+ homeRoutes(framework);
117
+ productRoutes(framework);
118
+ checkoutRoutes(framework);
119
+ adminRoutes(framework);
120
+ }
121
+ ```
122
+
123
+ 每个 `*Routes` 函数用自己的路由调 `defineRoutes(framework, [...])`。加一个新路由组就是一个 import + 一次调用。
124
+
125
+ ## 按领域的路由文件
126
+
127
+ ```ts
128
+ // src/routes/product.ts
129
+ import { defineRoutes, type Framework } from "@finesoft/front";
130
+ import { ProductController } from "../controllers/product";
131
+ import { ProductListController } from "../controllers/product-list";
132
+ import { authGuard } from "../lib/guards/auth";
133
+
134
+ export function productRoutes(framework: Framework): void {
135
+ defineRoutes(framework, [
136
+ { path: "/products", intentId: "product-list", controller: new ProductListController() },
137
+ { path: "/products/:id", intentId: "product", controller: new ProductController() },
138
+ {
139
+ path: "/products/:id/edit",
140
+ intentId: "product-edit",
141
+ controller: new ProductEditController(),
142
+ beforeLoad: [authGuard],
143
+ renderMode: "csr",
144
+ },
145
+ ]);
146
+ }
147
+ ```
148
+
149
+ ## 集中式 DI 注册
150
+
151
+ ```ts
152
+ // src/lib/di/register.ts
153
+ import { type Container, DEP_KEYS } from "@finesoft/front";
154
+ import { APP_KEYS } from "./keys";
155
+ import { UserApi } from "../api/user";
156
+ import { ProductApi } from "../api/product";
157
+ import { ConsoleLogger } from "@finesoft/front";
158
+ import { env } from "../../env";
159
+
160
+ export function registerDependencies(container: Container): void {
161
+ container.register(DEP_KEYS.LOGGER, () => new ConsoleLogger("app"));
162
+
163
+ container.register(
164
+ APP_KEYS.USER_API,
165
+ () =>
166
+ new UserApi({
167
+ baseUrl: env.UPSTREAM_URL,
168
+ }),
169
+ );
170
+ container.register(
171
+ APP_KEYS.PRODUCT_API,
172
+ () =>
173
+ new ProductApi({
174
+ baseUrl: env.UPSTREAM_URL,
175
+ }),
176
+ );
177
+ }
178
+ ```
179
+
180
+ 集中式让接线可检视。加服务是一次编辑,不是跨项目搜索。
181
+
182
+ ## 强类型 DI key
183
+
184
+ ```ts
185
+ // src/lib/di/keys.ts
186
+ export const APP_KEYS = {
187
+ USER_API: "userApi",
188
+ PRODUCT_API: "productApi",
189
+ SESSION: "session",
190
+ FEATURE_BUCKETING: "featureBucketing",
191
+ } as const;
192
+
193
+ export type AppKey = (typeof APP_KEYS)[keyof typeof APP_KEYS];
194
+ ```
195
+
196
+ 然后在任何 Controller 里:
197
+
198
+ ```ts
199
+ import { APP_KEYS } from "../lib/di/keys";
200
+
201
+ async execute(params, container) {
202
+ const api = container.resolve<UserApi>(APP_KEYS.USER_API);
203
+ // ...
204
+ }
205
+ ```
206
+
207
+ `APP_KEYS.USER_PAI` 这种拼错是编译错误。`"userPai"` 这种拼错是运行时错误。
208
+
209
+ ## Page 类型 union
210
+
211
+ ```ts
212
+ // src/lib/pages/types.ts
213
+ import type { HomePage } from "../../controllers/home";
214
+ import type { ProductPage } from "../../controllers/product";
215
+ import type { CheckoutPage } from "../../controllers/checkout";
216
+ import type { ErrorPage } from "./error";
217
+
218
+ export type Page = HomePage | ProductPage | CheckoutPage | ErrorPage;
219
+ ```
220
+
221
+ 带 `kind` 字段的可辨识 union。视图层根组件按 `page.kind` 分支:
222
+
223
+ ```vue
224
+ <script setup lang="ts">
225
+ import type { Page } from "@/lib/pages/types";
226
+ const props = defineProps<{ page: Page }>();
227
+ </script>
228
+
229
+ <template>
230
+ <Home v-if="page.kind === 'home'" :page="page" />
231
+ <Product v-else-if="page.kind === 'product'" :page="page" />
232
+ <Checkout v-else-if="page.kind === 'checkout'" :page="page" />
233
+ <Error v-else-if="page.kind === 'error'" :page="page" />
234
+ </template>
235
+ ```
236
+
237
+ discriminant 在每个分支里把类型收窄 —— view 组件拿到完整强类型 `page` prop,不用 cast。
238
+
239
+ ## 拆分 SSR 和浏览器入口
240
+
241
+ `ssr.ts` 和 `main.ts` 保持薄。两者只在:
242
+
243
+ - `ssr.ts` 调 `createSSRRender` 并 export `render` + `serializeServerData`
244
+ - `main.ts` 调 `startBrowserApp` 挂载视图层
245
+
246
+ 其他一切 —— 路由、Controller、DI、i18n —— 都通过 `bootstrap.ts` 共享。
247
+
248
+ ```ts
249
+ // src/ssr.ts
250
+ import { createSSRRender, serializeServerData } from "@finesoft/front";
251
+ import { renderToString } from "vue/server-renderer";
252
+ import { createSSRApp } from "vue";
253
+ import App from "./App.vue";
254
+ import { bootstrap } from "./bootstrap";
255
+
256
+ export const render = createSSRRender({
257
+ bootstrap,
258
+ getErrorPage: () => ({ kind: "error", title: "Server error" }),
259
+ async renderApp(page) {
260
+ const html = await renderToString(createSSRApp(App, { page }));
261
+ return { html, head: `<title>${page.title}</title>`, css: "" };
262
+ },
263
+ });
264
+
265
+ export { serializeServerData };
266
+ ```
267
+
268
+ ```ts
269
+ // src/main.ts
270
+ import { startBrowserApp } from "@finesoft/front/browser";
271
+ import { createSSRApp } from "vue";
272
+ import App from "./App.vue";
273
+ import { bootstrap } from "./bootstrap";
274
+
275
+ startBrowserApp({
276
+ bootstrap,
277
+ mount(target, { framework }) {
278
+ createSSRApp(App, { framework }).mount(target);
279
+ },
280
+ });
281
+ ```
282
+
283
+ ## 什么时候打破这个布局
284
+
285
+ 上面的形状能撑到 ~50 路由。过了之后考虑:
286
+
287
+ - **按特性的文件夹**(`src/features/checkout/{routes,controllers,views,api}.ts`)—— 大型应用。每个特性独立可理解。
288
+ - **懒加载路由 bundle**,在路由定义里 `import()`。Vite 插件自动拆分。
289
+ - **workspace 包** —— 多应用共享 Controller / API 客户端时。共享代码搬到 `packages/shared`,从那里 import。
290
+
291
+ 不要预先重组。扁平的 `routes/` + `controllers/` 形状到上百文件都没问题。
292
+
293
+ ## 参考
294
+
295
+ - [工程实践 · 测试](./testing.md) —— 怎么针对这个结构测
296
+ - [DI 容器](../07-di-container.md) —— 注册模式
@@ -0,0 +1,317 @@
1
+ # 工程实践:测试
2
+
3
+ 框架为可测试性而设计。路由、Controller、中间件在服务端和浏览器都走同一个 dispatch 路径,所以一个测试同时检验两端。
4
+
5
+ ## 测什么
6
+
7
+ | 对象 | 断言什么 | 层级 |
8
+ | -------------- | ------------------------------------------------------------------------------------ | ---- |
9
+ | Controller | 给定 params + scope 化容器,产出的 page 正确。 | 单元 |
10
+ | 守卫 | 给定 `NavigationContext`,结果是 `next` / `redirect` / `rewrite` / `deny` 的预期值。 | 单元 |
11
+ | 路由 | URL → 预期的 intent + 渲染模式。 | 单元 |
12
+ | 完整请求 | URL → 经完整管线产出的最终 HTML / 状态码。 | 集成 |
13
+ | Proxy / 服务器 | Hono 路由对合成请求返回正确的响应。 | 集成 |
14
+
15
+ ## Vitest 设置
16
+
17
+ 仓库用 Vite+。总是从 `vite-plus/test` 导入:
18
+
19
+ ```ts
20
+ import { describe, expect, test, vi, beforeEach, afterEach } from "vite-plus/test";
21
+ ```
22
+
23
+ 跑测试:
24
+
25
+ ```bash
26
+ vp test # 全部
27
+ vp test path/to/file.test.ts # 一个文件
28
+ vp test -t "name match" # 按测试名过滤
29
+ vp test --coverage # 带覆盖率
30
+ ```
31
+
32
+ ## 测一个 Controller
33
+
34
+ ```ts
35
+ // src/controllers/product.test.ts
36
+ import { afterEach, describe, expect, test, vi } from "vite-plus/test";
37
+ import { Container } from "@finesoft/front";
38
+ import { ProductController } from "./product";
39
+
40
+ describe("ProductController", () => {
41
+ afterEach(() => {
42
+ vi.restoreAllMocks();
43
+ });
44
+
45
+ test("returns product page on success", async () => {
46
+ const container = new Container();
47
+ container.register("productApi", () => ({
48
+ getById: vi.fn(async (id) => ({ name: "Widget", price: 9.99 })),
49
+ }));
50
+
51
+ const controller = new ProductController();
52
+ const page = await controller.execute({ id: "42" }, container);
53
+
54
+ expect(page).toEqual({
55
+ kind: "product",
56
+ id: "42",
57
+ name: "Widget",
58
+ price: 9.99,
59
+ });
60
+ });
61
+
62
+ test("fallback returns degraded page on api failure", () => {
63
+ const controller = new ProductController();
64
+ const page = controller.fallback({ id: "42" }, new Error("network down"));
65
+
66
+ expect(page).toMatchObject({
67
+ kind: "product",
68
+ id: "42",
69
+ name: "Not available",
70
+ });
71
+ });
72
+ });
73
+ ```
74
+
75
+ 关键点:**每个测试建一个 `Container`,只注册 Controller 真正需要的。** 别拉一个真实的 `Framework` 进来 —— 那是在测框架而不是测你的 Controller。
76
+
77
+ ## 测一个守卫
78
+
79
+ 守卫接 `NavigationContext` 返回 `MiddlewareResult`。内联构造一个假 context:
80
+
81
+ ```ts
82
+ import { afterEach, describe, expect, test, vi } from "vite-plus/test";
83
+ import { Container } from "@finesoft/front";
84
+ import { authGuard } from "./auth";
85
+
86
+ function makeCtx(overrides: Partial<{ cookie: string | null }> = {}) {
87
+ return {
88
+ url: new URL("http://app.test/admin"),
89
+ intent: { intentId: "admin", params: {} },
90
+ container: new Container(),
91
+ getCookie: vi.fn((name: string) => overrides.cookie ?? null),
92
+ getHeader: vi.fn(() => null),
93
+ isSsr: true,
94
+ };
95
+ }
96
+
97
+ describe("authGuard", () => {
98
+ test("redirects unauthenticated user to /login", () => {
99
+ const ctx = makeCtx({ cookie: null });
100
+ const result = authGuard(ctx);
101
+
102
+ expect(result).toEqual({
103
+ kind: "redirect",
104
+ url: "/login?next=%2Fadmin",
105
+ status: 302,
106
+ });
107
+ });
108
+
109
+ test("passes through when token is present", () => {
110
+ const ctx = makeCtx({ cookie: "valid-token" });
111
+ const result = authGuard(ctx);
112
+
113
+ expect(result).toEqual({ kind: "next" });
114
+ });
115
+ });
116
+ ```
117
+
118
+ 工厂函数(`makeCtx`)是套路 —— 跟守卫放一起,把测试真正关心的位参数化。
119
+
120
+ ## 测路由
121
+
122
+ 断言 URL → intent 映射:
123
+
124
+ ```ts
125
+ import { describe, expect, test } from "vite-plus/test";
126
+ import { Framework } from "@finesoft/front";
127
+ import { bootstrap } from "./bootstrap";
128
+
129
+ describe("routes", () => {
130
+ test("resolves /products/42 to product intent", () => {
131
+ const framework = Framework.create({});
132
+ bootstrap(framework);
133
+
134
+ const match = framework.router.resolve("/products/42");
135
+
136
+ expect(match).toMatchObject({
137
+ intent: { intentId: "product", params: { id: "42" } },
138
+ renderMode: "ssr",
139
+ });
140
+ });
141
+
142
+ test("returns null for unmatched URL", () => {
143
+ const framework = Framework.create({});
144
+ bootstrap(framework);
145
+
146
+ expect(framework.router.resolve("/does-not-exist")).toBeNull();
147
+ });
148
+ });
149
+ ```
150
+
151
+ 这能在重构时抓住路由回归 —— 一个改名的 intent 表现为失败的测试,而不是生产里的 404。
152
+
153
+ ## 测完整请求管线
154
+
155
+ SSR 端到端测试,调 `createSSRRender`:
156
+
157
+ ```ts
158
+ import { describe, expect, test } from "vite-plus/test";
159
+ import { createSSRRender } from "@finesoft/front";
160
+ import { bootstrap } from "./bootstrap";
161
+
162
+ describe("SSR pipeline", () => {
163
+ test("renders home page with serialized data", async () => {
164
+ const render = createSSRRender({
165
+ bootstrap,
166
+ getErrorPage: () => ({ kind: "error", title: "Error" }),
167
+ async renderApp(page) {
168
+ return {
169
+ html: `<main>${(page as any).title}</main>`,
170
+ head: "",
171
+ css: "",
172
+ };
173
+ },
174
+ });
175
+
176
+ const result = await render("/", {
177
+ template: `<!doctype html><html><head><!--head--></head><body><!--ssr--></body></html>`,
178
+ });
179
+
180
+ expect(result.status).toBe(200);
181
+ expect(result.html).toContain("<main>Welcome</main>");
182
+ expect(result.html).toContain('id="__finesoft_data__"');
183
+ });
184
+
185
+ test("returns 302 when guard redirects", async () => {
186
+ const render = createSSRRender({
187
+ /* ... */
188
+ });
189
+ const result = await render("/admin");
190
+
191
+ expect(result.status).toBe(302);
192
+ expect(result.redirectUrl).toBe("/login?next=%2Fadmin");
193
+ });
194
+ });
195
+ ```
196
+
197
+ 这是最高价值的测试层 —— 同时检验路由、中间件、Controller、渲染。
198
+
199
+ ## Mock 网络
200
+
201
+ `HttpClient` 直接用 `fetch`。通过 `vi.stubGlobal` stub:
202
+
203
+ ```ts
204
+ import { afterEach, beforeEach, test, vi, expect } from "vite-plus/test";
205
+
206
+ let fetchMock: ReturnType<typeof vi.fn>;
207
+
208
+ beforeEach(() => {
209
+ fetchMock = vi.fn();
210
+ vi.stubGlobal("fetch", fetchMock);
211
+ });
212
+
213
+ afterEach(() => {
214
+ vi.unstubAllGlobals();
215
+ });
216
+
217
+ test("UserApi.list parses JSON response", async () => {
218
+ fetchMock.mockResolvedValueOnce(
219
+ new Response(JSON.stringify([{ id: "1", name: "Alice" }]), {
220
+ status: 200,
221
+ headers: { "Content-Type": "application/json" },
222
+ }),
223
+ );
224
+
225
+ const api = new UserApi({ baseUrl: "/api" });
226
+ const users = await api.list();
227
+
228
+ expect(users).toEqual([{ id: "1", name: "Alice" }]);
229
+ expect(fetchMock).toHaveBeenCalledWith("/api/users", expect.any(Object));
230
+ });
231
+ ```
232
+
233
+ 测试里 fetch 多时建个小注册表:
234
+
235
+ ```ts
236
+ function setupFetch(routes: Record<string, () => Response>) {
237
+ fetchMock.mockImplementation(async (url: string) => {
238
+ const handler = routes[url];
239
+ if (!handler) throw new Error(`Unexpected fetch: ${url}`);
240
+ return handler();
241
+ });
242
+ }
243
+
244
+ setupFetch({
245
+ "/api/users": () => new Response(JSON.stringify(users), { status: 200 }),
246
+ "/api/products": () => new Response(JSON.stringify(products), { status: 200 }),
247
+ });
248
+ ```
249
+
250
+ 让「测试预期 fetch 什么」一眼可读。
251
+
252
+ ## 测试中 dispose scope
253
+
254
+ 测试里创建了 scope,在 `afterEach` 里 dispose:
255
+
256
+ ```ts
257
+ let scope: Container | null = null;
258
+
259
+ afterEach(() => {
260
+ scope?.dispose();
261
+ scope = null;
262
+ });
263
+
264
+ test("...", () => {
265
+ scope = framework.container.createScope();
266
+ scope.register("api", () => mockApi);
267
+ // ...
268
+ });
269
+ ```
270
+
271
+ Vitest 默认隔离测试,但 dispose 能暴露 scope 含 `destroy()` 资源(recorder 等)时的泄漏。
272
+
273
+ ## 测带 `rewrite` 的中间件
274
+
275
+ `beforeLoad` 里的 rewrite 通过 router 递归。测试时同时断言 rewrite 信号和最终解析到的路由:
276
+
277
+ ```ts
278
+ test("legacy URL rewrites to canonical", async () => {
279
+ const render = createSSRRender({ bootstrap /* ... */ });
280
+ const result = await render("/old/products/42");
281
+
282
+ // 用户可见的 URL 不变
283
+ expect(result.status).toBe(200);
284
+
285
+ // 但渲染走的 Controller 是 /products/42 的 —— 通过渲染后 HTML 断言
286
+ expect(result.html).toContain("Widget"); // product 42 的名字
287
+ });
288
+ ```
289
+
290
+ `afterLoad` rewrite(canonicalization),断言 `Content-Location` 头:
291
+
292
+ ```ts
293
+ const result = await render("/page?utm=x");
294
+ expect(result.headers["Content-Location"]).toBe("/page");
295
+ ```
296
+
297
+ ## 覆盖率目标
298
+
299
+ 框架本身在 `core` 上瞄准 >95%,`server` 上 >85%。应用代码目标:
300
+
301
+ - **Controller**:100% `execute()` 主路径 + 至少一个 `fallback()` 测试。
302
+ - **守卫**:每个分支(通过 / redirect / deny)。
303
+ - **路由**:每个路由组至少一个 URL 解析断言。
304
+
305
+ 视图组件不必追求 100% —— 那是测视图层,不是测框架。测 Controller 产出的 page 形状契约就够了。
306
+
307
+ ## 速度
308
+
309
+ vite-plus 的 Vitest 很快 —— 单元 ~1ms 一个测试,集成 ~10ms。慢的话:
310
+
311
+ - 紧密循环里别建完整 `Framework`;直接建 `Container`。
312
+ - 单元测试里 mock 重的 `bootstrap()`。
313
+ - 等 `setTimeout`(重试、debounce)的测试用 `vi.useFakeTimers()`。
314
+
315
+ ## 参考
316
+
317
+ - [测试 proxy](../09-server-and-deployment.md#proxy-路由) —— 框架自己的 proxy 测试 `packages/server/test/proxy.test.ts` 是好参考