@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.
- package/README.md +2 -411
- package/dist/browser.d.mts +2 -0
- package/dist/browser.mjs +1 -0
- package/dist/index.d.mts +2 -1248
- package/dist/index.mjs +54 -3557
- package/dist/server-data-DGbiKzMS.d.mts +1249 -0
- package/dist/start-app-BdXBCcor.mjs +2 -0
- package/docs/01-getting-started.md +230 -0
- package/docs/02-routing-and-controllers.md +197 -0
- package/docs/03-middleware.md +214 -0
- package/docs/04-rendering-and-hydration.md +271 -0
- package/docs/05-i18n.md +243 -0
- package/docs/06-http-client.md +286 -0
- package/docs/07-di-container.md +264 -0
- package/docs/08-observability.md +290 -0
- package/docs/09-server-and-deployment.md +242 -0
- package/docs/10-features-platform-pwa.md +238 -0
- package/docs/README.md +72 -0
- package/docs/advanced/custom-action-handler.md +248 -0
- package/docs/advanced/custom-adapter.md +264 -0
- package/docs/advanced/custom-event-recorder.md +318 -0
- package/docs/advanced/inline-proxy-codegen.md +200 -0
- package/docs/advanced/multi-tenant-scopes.md +330 -0
- package/docs/engineering/ci-release-flow.md +244 -0
- package/docs/engineering/project-structure.md +296 -0
- package/docs/engineering/testing.md +317 -0
- package/docs/pitfalls/container-scope-leak.md +215 -0
- package/docs/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/docs/zh/01-getting-started.md +230 -0
- package/docs/zh/02-routing-and-controllers.md +197 -0
- package/docs/zh/03-middleware.md +214 -0
- package/docs/zh/04-rendering-and-hydration.md +271 -0
- package/docs/zh/05-i18n.md +243 -0
- package/docs/zh/06-http-client.md +286 -0
- package/docs/zh/07-di-container.md +264 -0
- package/docs/zh/08-observability.md +287 -0
- package/docs/zh/09-server-and-deployment.md +242 -0
- package/docs/zh/10-features-platform-pwa.md +238 -0
- package/docs/zh/README.md +72 -0
- package/docs/zh/advanced/custom-action-handler.md +248 -0
- package/docs/zh/advanced/custom-adapter.md +264 -0
- package/docs/zh/advanced/custom-event-recorder.md +318 -0
- package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
- package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
- package/docs/zh/engineering/ci-release-flow.md +244 -0
- package/docs/zh/engineering/project-structure.md +296 -0
- package/docs/zh/engineering/testing.md +317 -0
- package/docs/zh/pitfalls/container-scope-leak.md +215 -0
- package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
- 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
|