@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
+ # Engineering: project structure
2
+
3
+ Recommended layout for apps past the scaffolded starter. The shape that works for ~20 routes and ~10 engineers without major reorganization.
4
+
5
+ ## Layout
6
+
7
+ ```
8
+ my-app/
9
+ ├── src/
10
+ │ ├── bootstrap.ts # routes + DI setup (shared SSR + CSR)
11
+ │ ├── main.ts # browser entry
12
+ │ ├── ssr.ts # SSR entry
13
+ │ ├── App.vue # root component
14
+ │ │
15
+ │ ├── routes/ # route definitions, grouped by domain
16
+ │ │ ├── home.ts
17
+ │ │ ├── product.ts
18
+ │ │ ├── checkout.ts
19
+ │ │ └── admin.ts
20
+ │ │
21
+ │ ├── controllers/ # controllers, one file per intent
22
+ │ │ ├── home.ts
23
+ │ │ ├── product.ts
24
+ │ │ └── checkout.ts
25
+ │ │
26
+ │ ├── views/ # view components, mirror controllers
27
+ │ │ ├── Home.vue
28
+ │ │ ├── Product.vue
29
+ │ │ └── Checkout.vue
30
+ │ │
31
+ │ ├── lib/
32
+ │ │ ├── api/ # HttpClient subclasses
33
+ │ │ │ ├── user.ts
34
+ │ │ │ └── product.ts
35
+ │ │ ├── guards/ # reusable middleware
36
+ │ │ │ ├── auth.ts
37
+ │ │ │ ├── locale.ts
38
+ │ │ │ └── analytics.ts
39
+ │ │ ├── di/
40
+ │ │ │ ├── keys.ts # APP_KEYS const map
41
+ │ │ │ └── register.ts # central registration
42
+ │ │ ├── i18n/
43
+ │ │ │ └── translator.ts # Translator factory
44
+ │ │ └── pages/
45
+ │ │ └── types.ts # Page union type
46
+ │ │
47
+ │ ├── locales/
48
+ │ │ ├── en-US.json
49
+ │ │ ├── zh-Hans.json
50
+ │ │ └── ja-JP.json
51
+ │ │
52
+ │ └── env.ts # env var parsing + validation
53
+
54
+ ├── public/ # static assets, served at /
55
+ ├── index.html
56
+ ├── vite.config.ts
57
+ ├── package.json
58
+ └── tsconfig.json
59
+ ```
60
+
61
+ ## Why this shape
62
+
63
+ ### `routes/` separate from `controllers/`
64
+
65
+ A route is **where** an intent is exposed (URL pattern, guards, render mode). A controller is **what** an intent does. Splitting them lets you:
66
+
67
+ - Reuse a controller across multiple URLs without route definitions cluttering its file
68
+ - Find "what URLs does my app serve" by reading one folder
69
+ - Find "what does intent X compute" by reading one folder
70
+
71
+ ### `controllers/` mirrors `views/`
72
+
73
+ One file per intent on both sides. The intent id, controller file, and view file share the same name. Finding the rendering code for `/products/:id` becomes mechanical.
74
+
75
+ ### `lib/` for everything cross-cutting
76
+
77
+ Anything that isn't a route, controller, or view. The framework's `bootstrap()` reaches into `lib/di/register.ts` for the heavy registration; controllers reach into `lib/api/*` for clients; guards live in `lib/guards/`.
78
+
79
+ ### `env.ts` at the top of `src/`
80
+
81
+ Parse and validate environment variables once, in one file. Use [zod](https://zod.dev/) or hand-rolled checks. Re-export typed constants everywhere else.
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
+ This makes "what env vars does this app need" trivially discoverable, and the build fails fast on missing values instead of crashing at request time.
99
+
100
+ ## `bootstrap.ts` shape
101
+
102
+ Keep `bootstrap.ts` thin — it should orchestrate, not contain logic:
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
+ Each `*Routes` function calls `defineRoutes(framework, [...])` with its own routes. Adding a new route group is one import + one call.
124
+
125
+ ## Per-domain route file
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
+ ## Central DI registration
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
+ Centralizing keeps the wiring inspectable. Adding a service is a single edit, not a search across the project.
181
+
182
+ ## Typed DI keys
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
+ Then in any 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
+ A typo in `APP_KEYS.USER_PAI` is a compile error. A typo in `"userPai"` is a runtime error.
208
+
209
+ ## Page type 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
+ Use a discriminated union with a `kind` field. The view layer's root component switches on `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
+ The discriminant narrows the type inside each branch — view components get a fully typed `page` prop without casts.
238
+
239
+ ## Splitting the SSR and browser entries
240
+
241
+ Keep `ssr.ts` and `main.ts` thin. They differ only in:
242
+
243
+ - `ssr.ts` calls `createSSRRender` and exports `render` + `serializeServerData`
244
+ - `main.ts` calls `startBrowserApp` and mounts the view layer
245
+
246
+ Everything else — routes, controllers, DI, i18n — is shared via `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
+ ## When to break this layout
284
+
285
+ The shape above works through ~50 routes. Past that, consider:
286
+
287
+ - **Per-feature folders** (`src/features/checkout/{routes,controllers,views,api}.ts`) for very large apps. Each feature is independently understandable.
288
+ - **Lazy-loaded route bundles** with `import()` inside the route definition. The Vite plugin splits them automatically.
289
+ - **Workspace packages** if multiple apps share the same controllers / API clients. Move shared code to `packages/shared` and import from there.
290
+
291
+ Don't pre-emptively reorganize. The flat `routes/` + `controllers/` shape is fine well into the hundreds of files.
292
+
293
+ ## See also
294
+
295
+ - [Engineering: testing](./testing.md) — how to test against this structure
296
+ - [DI container](../07-di-container.md) — registration patterns
@@ -0,0 +1,317 @@
1
+ # Engineering: testing
2
+
3
+ The framework is built to be tested. Routes, controllers, and middleware all run through the same dispatch path on server and browser, so a single test exercises both worlds.
4
+
5
+ ## What to test
6
+
7
+ | Subject | What to assert | Layer |
8
+ | -------------- | ------------------------------------------------------------------------------------------------ | ----------- |
9
+ | Controllers | Given params + scoped container, the page produced is correct. | unit |
10
+ | Guards | Given a `NavigationContext`, the result is `next` / `redirect` / `rewrite` / `deny` as expected. | unit |
11
+ | Routes | URL → expected intent + render mode. | unit |
12
+ | Full request | URL → final HTML / status code through the full pipeline. | integration |
13
+ | Proxy / server | Hono routes return the right responses for synthetic requests. | integration |
14
+
15
+ ## Vitest setup
16
+
17
+ The repo uses Vite+. Always import from `vite-plus/test`:
18
+
19
+ ```ts
20
+ import { describe, expect, test, vi, beforeEach, afterEach } from "vite-plus/test";
21
+ ```
22
+
23
+ Run tests with:
24
+
25
+ ```bash
26
+ vp test # all
27
+ vp test path/to/file.test.ts # one file
28
+ vp test -t "name match" # filter by test name
29
+ vp test --coverage # with coverage
30
+ ```
31
+
32
+ ## Testing a 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
+ Key idea: **build a `Container` per test, register only what the controller needs.** Don't pull in a real `Framework` — you'd be testing the framework, not your controller.
76
+
77
+ ## Testing a guard
78
+
79
+ Guards take a `NavigationContext` and return a `MiddlewareResult`. Build a fake context inline:
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
+ The factory function (`makeCtx`) is the pattern — keep it co-located with the guard, parameterize the bits the test actually cares about.
119
+
120
+ ## Testing routes
121
+
122
+ To assert URL → intent mapping:
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
+ This catches route regressions during refactors — a renamed intent shows up as a failing test, not a 404 in production.
152
+
153
+ ## Testing the full request pipeline
154
+
155
+ For SSR end-to-end tests, exercise `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
+ This is the highest-value test layer — it exercises routing, middleware, controllers, and rendering together.
198
+
199
+ ## Mocking the network
200
+
201
+ `HttpClient` uses `fetch` directly. Stub it via `vi.stubGlobal`:
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
+ For tests with many fetches, build a small registry:
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
+ This makes "what does my test expect to be fetched" readable at a glance.
251
+
252
+ ## Disposing scopes in tests
253
+
254
+ If your test creates a scope, dispose it in `afterEach`:
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 isolates tests by default, but disposing exposes leaks if the scope had `destroy()`-able resources (recorders, etc.).
272
+
273
+ ## Testing middleware with `rewrite`
274
+
275
+ `rewrite` in `beforeLoad` recurses through the router. Test both the rewrite signal and the resolved final route:
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
+ // The user-visible URL stays unchanged
283
+ expect(result.status).toBe(200);
284
+
285
+ // But the rendered controller was for /products/42 — assert via the rendered HTML
286
+ expect(result.html).toContain("Widget"); // product 42's name
287
+ });
288
+ ```
289
+
290
+ For `afterLoad` rewrites (canonicalization), assert the `Content-Location` header:
291
+
292
+ ```ts
293
+ const result = await render("/page?utm=x");
294
+ expect(result.headers["Content-Location"]).toBe("/page");
295
+ ```
296
+
297
+ ## Coverage targets
298
+
299
+ The framework itself targets >95% on `core` and >85% on `server`. For application code, aim for:
300
+
301
+ - **Controllers**: 100% of `execute()` happy paths + at least one `fallback()` test.
302
+ - **Guards**: every branch (pass / redirect / deny).
303
+ - **Routes**: at least one assertion per route group that the URLs resolve as expected.
304
+
305
+ Don't chase 100% on view components — those test the view layer, not the framework. Test the page-shape contracts the controllers produce instead.
306
+
307
+ ## Speed
308
+
309
+ Vitest with vite-plus is fast — ~1ms per test for unit, ~10ms for integration. If you see slower:
310
+
311
+ - Avoid creating a full `Framework` in tight loops; build a `Container` directly.
312
+ - Mock heavy `bootstrap()` calls in unit tests.
313
+ - Use `vi.useFakeTimers()` for tests that wait on `setTimeout` (retry logic, debouncing).
314
+
315
+ ## See also
316
+
317
+ - [Testing the proxy](../09-server-and-deployment.md#proxy-routes) — the framework's own tests at `packages/server/test/proxy.test.ts` are good references