@mauroandre/velojs 0.0.37 → 0.0.39
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 +19 -9
- package/agent/AGENTS.md +143 -0
- package/agent/skills/velojs-actions/SKILL.md +192 -0
- package/agent/skills/velojs-components/SKILL.md +99 -0
- package/agent/skills/velojs-endpoints/SKILL.md +187 -0
- package/agent/skills/velojs-event-streams/SKILL.md +705 -0
- package/agent/skills/velojs-getting-started/SKILL.md +191 -0
- package/agent/skills/velojs-hooks/SKILL.md +103 -0
- package/agent/skills/velojs-link-component/SKILL.md +67 -0
- package/agent/skills/velojs-loaders/SKILL.md +134 -0
- package/agent/skills/velojs-middlewares/SKILL.md +143 -0
- package/agent/skills/velojs-production-deploy/SKILL.md +96 -0
- package/agent/skills/velojs-project-graph/SKILL.md +97 -0
- package/agent/skills/velojs-routes/SKILL.md +209 -0
- package/agent/skills/velojs-scripts-component/SKILL.md +62 -0
- package/agent/skills/velojs-server-api/SKILL.md +155 -0
- package/agent/skills/velojs-sockets/SKILL.md +303 -0
- package/agent/skills/velojs-static-generation/SKILL.md +148 -0
- package/agent/skills/velojs-testing/SKILL.md +472 -0
- package/agent/skills/velojs-type-reference/SKILL.md +142 -0
- package/agent/skills/velojs-vite-plugin/SKILL.md +71 -0
- package/dist/cli.js +42 -31
- package/dist/graph.d.ts +37 -0
- package/dist/graph.js +202 -0
- package/dist/init.js +16 -7
- package/dist/vite.js +279 -248
- package/package.json +6 -2
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: velojs-testing
|
|
3
|
+
description: "Writing tests with `createTestApp` from `@mauroandre/velojs/testing`: HTTP requests, actions, loaders, auth, SSE subscriptions, sockets. Use when writing or fixing a test for a VeloJS app."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Testing
|
|
7
|
+
|
|
8
|
+
VeloJS ships a backend testing toolkit at `@mauroandre/velojs/testing`. It spins up your app in memory, fires HTTP requests against the registered handlers, subscribes to event streams, and asserts on the result. No socket. No browser. No fragile mocks of the framework internals.
|
|
9
|
+
|
|
10
|
+
You test the whole stack — middleware, auth, routing, conventions, serialization — through the API your real users hit.
|
|
11
|
+
|
|
12
|
+
## Why a built-in toolkit?
|
|
13
|
+
|
|
14
|
+
Writing integration tests for a VeloJS app without this toolkit means one of:
|
|
15
|
+
- Spinning up a real HTTP server, opening sockets, racing on ports
|
|
16
|
+
- Calling `loader`/`action_*`/`stream_*` functions directly and mocking the Hono `Context` by hand
|
|
17
|
+
- Reaching into the database to assert state, bypassing services
|
|
18
|
+
|
|
19
|
+
All three pull tests away from the API your code actually exposes. The toolkit closes that gap.
|
|
20
|
+
|
|
21
|
+
## Prerequisite — Vitest must load the VeloJS Vite plugin
|
|
22
|
+
|
|
23
|
+
The toolkit relies on metadata that the VeloJS Vite plugin injects at build time (`metadata.moduleId`, `metadata.fullPath`, stream `__path`). For tests to see this metadata, your `vitest.config.ts` must include `veloPlugin()`:
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
// vitest.config.ts
|
|
27
|
+
import { defineConfig } from "vitest/config";
|
|
28
|
+
import { veloPlugin } from "@mauroandre/velojs/vite";
|
|
29
|
+
|
|
30
|
+
export default defineConfig({
|
|
31
|
+
plugins: [veloPlugin()],
|
|
32
|
+
test: {
|
|
33
|
+
// your test config
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
If you have a `vite.config.ts` with the plugin already, you can extend it with `mergeConfig`. Without this step, `.action(fn)`, `.loader(fn)`, and `.subscribe(stream)` won't be able to resolve URLs.
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { createTestApp } from "@mauroandre/velojs/testing";
|
|
44
|
+
import routes from "../app/routes.js";
|
|
45
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
46
|
+
|
|
47
|
+
describe("API", () => {
|
|
48
|
+
let app: Awaited<ReturnType<typeof createTestApp>>;
|
|
49
|
+
|
|
50
|
+
beforeAll(async () => {
|
|
51
|
+
app = await createTestApp({
|
|
52
|
+
routes,
|
|
53
|
+
bootstrap: async () => {
|
|
54
|
+
await connect(process.env.MONGO_URI!);
|
|
55
|
+
},
|
|
56
|
+
getSessionCookie: async ({ user }) => ({
|
|
57
|
+
session: await createToken(user),
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterAll(async () => await app.close());
|
|
63
|
+
|
|
64
|
+
it("returns 200 for health check", async () => {
|
|
65
|
+
const res = await app.get("/api/health");
|
|
66
|
+
expect(res.status).toBe(200);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
That's the whole shape. Below is each piece in detail.
|
|
72
|
+
|
|
73
|
+
## createTestApp
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
const app = await createTestApp({
|
|
77
|
+
routes,
|
|
78
|
+
bootstrap?: async () => { ... },
|
|
79
|
+
getSessionCookie?: async ({ user }) => ({ ... }),
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
| Option | Purpose |
|
|
84
|
+
|--------|---------|
|
|
85
|
+
| `routes` | Required. Your app's `routes` array — typically `import routes from "./app/routes.js"` (it is the **default** export of `routes.tsx`) |
|
|
86
|
+
| `bootstrap` | Runs once before the app is created. Use for DB connections, index creation, anything `app/server.tsx` does at startup. `addRoutes()` and `onServer()` calls here are **scoped to this app only** |
|
|
87
|
+
| `getSessionCookie` | Maps a user object to cookies the test client will attach. Required for `app.as(user)` and `app.sessionCookies(user)` |
|
|
88
|
+
|
|
89
|
+
### app.close()
|
|
90
|
+
|
|
91
|
+
Tears down everything: heartbeats, retention timers, listeners, callbacks. After `close()`, Vitest must report **zero open handles**. Always call it in `afterAll`.
|
|
92
|
+
|
|
93
|
+
### app.reset()
|
|
94
|
+
|
|
95
|
+
Zeros transient state without re-importing routes or re-running `bootstrap`:
|
|
96
|
+
- All event stream buffers (`{ snapshot: true }` accumulator)
|
|
97
|
+
- All listeners (residual subscribers)
|
|
98
|
+
- All closed-channel sets
|
|
99
|
+
- All `perChannelSource` AbortControllers
|
|
100
|
+
|
|
101
|
+
Call it in `beforeEach` to keep tests isolated:
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
beforeEach(async () => {
|
|
105
|
+
await app.reset();
|
|
106
|
+
await cleanTestDb(); // your responsibility
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## HTTP client
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
const res = await app.get("/api/repos", {
|
|
114
|
+
cookies: { session: "jwt" },
|
|
115
|
+
headers: { "X-Trace": "abc" },
|
|
116
|
+
query: { filter: "active" },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await app.post("/api/payments/webhook", {
|
|
120
|
+
body: { txid: "abc" }, // auto JSON-serialized + Content-Type set
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await app.post("/api/upload", {
|
|
124
|
+
body: formData, // FormData passed as-is
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Methods: `.get`, `.post`, `.put`, `.patch`, `.delete`.
|
|
129
|
+
|
|
130
|
+
These methods hit everything registered under your `routes` — **pages, actions, streams, and endpoints**. A webhook declared as `{ path: "/api/github/webhook", method: "POST", handler: githubWebhook }` in `routes.tsx` is exercised exactly like any other POST route:
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
const res = await app.post("/api/github/webhook", {
|
|
134
|
+
headers: { "x-hub-signature-256": sig },
|
|
135
|
+
body: { action: "push" },
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
See [06-endpoints.md](/docs/endpoints) for declaring endpoints.
|
|
140
|
+
|
|
141
|
+
`TestResponse` mirrors the Fetch `Response`:
|
|
142
|
+
|
|
143
|
+
| Field | Description |
|
|
144
|
+
|-------|-------------|
|
|
145
|
+
| `status` | HTTP status |
|
|
146
|
+
| `headers` | Plain object. **Keys are lowercased** (Fetch `Headers` normalization) — use `res.headers["x-trace"]`, not `res.headers["X-Trace"]` |
|
|
147
|
+
| `cookies` | Set-Cookie headers parsed into a record |
|
|
148
|
+
| `json<T>()` | Parse body as JSON |
|
|
149
|
+
| `text()` | Body as text |
|
|
150
|
+
| `blob()` | Body as Blob |
|
|
151
|
+
| `raw` | The underlying Response |
|
|
152
|
+
|
|
153
|
+
### Cookies and query strings
|
|
154
|
+
|
|
155
|
+
Pass `cookies: { name: value }` and the toolkit serializes them into the `Cookie` header. Query strings accept arrays:
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
await app.get("/api/posts", { query: { tag: ["preact", "ssr"] } });
|
|
159
|
+
// → /api/posts?tag=preact&tag=ssr
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Auth — getSessionCookie and .as
|
|
163
|
+
|
|
164
|
+
Most authenticated tests look like:
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
const cookies = await app.sessionCookies({ user: alice });
|
|
168
|
+
const res = await app.get("/api/me", { cookies });
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
But that gets repetitive. Use `app.as({ user })` to scope a sub-client:
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
const asAlice = app.as({ user: alice });
|
|
175
|
+
await asAlice.get("/api/me");
|
|
176
|
+
await asAlice.action(action_save, { body: {...} });
|
|
177
|
+
await asAlice.subscribe(stream_progress, { channel: appId });
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Every method of the sub-client carries Alice's cookies automatically.
|
|
181
|
+
|
|
182
|
+
If `getSessionCookie` was not provided, `.as()` throws a clear error. The framework doesn't know your app's session format — you provide it.
|
|
183
|
+
|
|
184
|
+
## Conventions — .action(fn) and .loader(fn)
|
|
185
|
+
|
|
186
|
+
For VeloJS conventions, you don't need to know the URL. Pass the imported function:
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
import { action_setBackup } from "../app/stacks/Backup.js";
|
|
190
|
+
|
|
191
|
+
const res = await app.action(action_setBackup, {
|
|
192
|
+
body: { appId, schedule: "daily" },
|
|
193
|
+
cookies: { session: jwt },
|
|
194
|
+
});
|
|
195
|
+
expect(res.status).toBe(200);
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
import { loader as homeLoader } from "../app/Home.js";
|
|
200
|
+
|
|
201
|
+
const data = await app.loader(homeLoader, {
|
|
202
|
+
cookies: { session: jwt },
|
|
203
|
+
params: { id: "abc" },
|
|
204
|
+
});
|
|
205
|
+
// `data` is the loader's return value, unwrapped from the HTTP response
|
|
206
|
+
expect(data.user.name).toBe("Alice");
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### .loader unwrapping rules
|
|
210
|
+
|
|
211
|
+
| Loader behavior | Returned by `.loader()` |
|
|
212
|
+
|-----------------|------------------------|
|
|
213
|
+
| Returned a value | The value directly (unwrapped) |
|
|
214
|
+
| Redirected (`c.redirect(...)`) | `TestResponse` for status inspection |
|
|
215
|
+
| Set a non-2xx status (`c.status(403)`) | `TestResponse` for status inspection |
|
|
216
|
+
| Threw an exception | `TestResponse` with status 500 — **not** re-thrown |
|
|
217
|
+
|
|
218
|
+
A loader runs inside a Hono handler, and Hono catches a throw and turns it into a 500 response — so `.loader()` resolves either way. Assert on `res.status`; `await expect(app.loader(fn)).rejects.toThrow()` can never pass.
|
|
219
|
+
|
|
220
|
+
If the function isn't found in the route tree, `.action`/`.loader` throws a clear error pointing at the most likely cause (Vitest config missing the VeloJS plugin).
|
|
221
|
+
|
|
222
|
+
### Fallback — string URL
|
|
223
|
+
|
|
224
|
+
If you need to bypass resolution, pass a URL string:
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
await app.action("/_action/Login/login", { body: {...} });
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Streams — .subscribe(stream)
|
|
231
|
+
|
|
232
|
+
Subscribing to a stream returns a `TestSubscription`:
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
const sub = await app.as({ user: alice }).subscribe(stream_progress, {
|
|
236
|
+
channel: deployId,
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
| Field/method | Description |
|
|
241
|
+
|--------------|-------------|
|
|
242
|
+
| `sub.status` | HTTP status of the initial response (200, 403, etc) |
|
|
243
|
+
| `sub.snapshot` | Snapshot from the server (buffer or callback). `null` if none |
|
|
244
|
+
| `sub.events` | All events received since connect, in order |
|
|
245
|
+
| `sub.closed` | `true` after server signals deliberate close |
|
|
246
|
+
| `sub.next({ timeoutMs })` | Wait for next event. **Rejects on timeout** (no silent undefined) |
|
|
247
|
+
| `sub.nextN(n, { timeoutMs })` | Wait for N events. Total timeout is shared across them |
|
|
248
|
+
| `sub.close()` | Force disconnect — triggers `perChannelSource` `abortSignal` server-side |
|
|
249
|
+
|
|
250
|
+
### Asserting cross-user isolation
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
it("user B cannot subscribe to user A's app", async () => {
|
|
254
|
+
const sub = await app.as({ user: userB }).subscribe(stream_appLogs, {
|
|
255
|
+
channel: userAsAppId,
|
|
256
|
+
});
|
|
257
|
+
expect(sub.status).toBe(403);
|
|
258
|
+
expect(sub.events).toEqual([]);
|
|
259
|
+
});
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Asserting snapshot replay on reconnect
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
it("F5 mid-deploy shows current state immediately", async () => {
|
|
266
|
+
const subA = await app.subscribe(stream_progress, { channel: appId });
|
|
267
|
+
await app.action(action_startDeploy, { body: { appId } });
|
|
268
|
+
await subA.next({ timeoutMs: 2000 });
|
|
269
|
+
await subA.close();
|
|
270
|
+
|
|
271
|
+
// Reconnect — buffer/snapshot still available within retainMs
|
|
272
|
+
const subB = await app.subscribe(stream_progress, { channel: appId });
|
|
273
|
+
expect(subB.snapshot?.[0]).toMatchObject({ status: "running" });
|
|
274
|
+
});
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Asserting perChannelSource lifecycle
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
it("perChannelSource aborts when the last subscriber leaves", async () => {
|
|
281
|
+
const a = await app.subscribe(stream_logs, { channel: "session" });
|
|
282
|
+
const b = await app.subscribe(stream_logs, { channel: "session" });
|
|
283
|
+
|
|
284
|
+
await a.close();
|
|
285
|
+
// Source still running — b is still subscribed
|
|
286
|
+
expect(activeSshConnections.get("session")).toBeDefined();
|
|
287
|
+
|
|
288
|
+
await b.close();
|
|
289
|
+
// Source aborted
|
|
290
|
+
expect(activeSshConnections.get("session")).toBeUndefined();
|
|
291
|
+
});
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## Sockets — `.socket(handler)`
|
|
295
|
+
|
|
296
|
+
`app.socket(handlerOrStubOrPath, opts)` opens an **in-memory** WebSocket session against a `socket_*` handler — no real TCP, no upgrade handshake. The handler runs directly with a mock Context.
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
import { socket_terminal } from "../app/workers/WorkerTerminal.js";
|
|
300
|
+
|
|
301
|
+
const ws = await app.socket(socket_terminal, {
|
|
302
|
+
user: { id: "alice" },
|
|
303
|
+
params: { workerId: "w42" },
|
|
304
|
+
channel: "w42",
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Send frames client → server
|
|
308
|
+
ws.send({ type: "data", data: "ls\n" });
|
|
309
|
+
|
|
310
|
+
// Receive frames server → client
|
|
311
|
+
const reply = await ws.next({ timeoutMs: 500 });
|
|
312
|
+
expect(JSON.parse(reply as string)).toMatchObject({ type: "data" });
|
|
313
|
+
|
|
314
|
+
// Consume a batch
|
|
315
|
+
const batch = await ws.nextN(3, { timeoutMs: 1000 });
|
|
316
|
+
|
|
317
|
+
// Close from the client — fires abortSignal inside the handler
|
|
318
|
+
await ws.close();
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
| Field / method | Description |
|
|
322
|
+
|---|---|
|
|
323
|
+
| `ws.send(msg)` | Send a frame. `string` / `Uint8Array` pass through; objects → `JSON.stringify`. |
|
|
324
|
+
| `ws.next({ timeoutMs })` | Wait for the next server-sent frame (cursor advances). |
|
|
325
|
+
| `ws.nextN(n, { timeoutMs })` | Collect `n` consecutive frames (total timeout, not per-frame). |
|
|
326
|
+
| `ws.messages` | All frames received so far. |
|
|
327
|
+
| `ws.closed` | `true` when the handler finished or `close()` was called. |
|
|
328
|
+
| `ws.done` | Promise that resolves when the handler finishes. |
|
|
329
|
+
| `ws.close(code?, reason?)` | Client-initiated close. Aborts `abortSignal` inside the handler. |
|
|
330
|
+
|
|
331
|
+
### What `app.socket()` does NOT do
|
|
332
|
+
|
|
333
|
+
- **Middleware does not run.** If your socket handler reads `c.get("user")`, pass `user` via the option:
|
|
334
|
+
```typescript
|
|
335
|
+
const ws = await app.socket(handler, { user: await buildUserForTest(...) });
|
|
336
|
+
```
|
|
337
|
+
Test middleware correctness separately through a regular endpoint or page that exercises the same middleware.
|
|
338
|
+
- **No real HTTP upgrade.** Binary frames (`Uint8Array`) pass through the queue, but the adapter is a buffered channel, not a socket.
|
|
339
|
+
|
|
340
|
+
### Accepting multiple input shapes
|
|
341
|
+
|
|
342
|
+
- **Function**: the server-imported `socket_*` handler.
|
|
343
|
+
`app.socket(socket_terminal, ...)`
|
|
344
|
+
- **Path string**: resolve by route path.
|
|
345
|
+
`app.socket("/_socket/workers/WorkerTerminal/terminal", ...)`
|
|
346
|
+
- **Stub**: the client stub `{ __path }` produced by the Vite plugin.
|
|
347
|
+
`app.socket(terminalStub, ...)`
|
|
348
|
+
|
|
349
|
+
`app.close()` aborts every open socket session on the app — no leaked handlers between tests.
|
|
350
|
+
|
|
351
|
+
## mockContext — escape hatch
|
|
352
|
+
|
|
353
|
+
Last resort. Build a partial Hono `Context` for direct invocation of background jobs or internal helpers that aren't routes:
|
|
354
|
+
|
|
355
|
+
```typescript
|
|
356
|
+
const c = app.mockContext({
|
|
357
|
+
user: { id, email, role },
|
|
358
|
+
params: { id: "abc" },
|
|
359
|
+
query: { filter: "active" },
|
|
360
|
+
body: { foo: "bar" },
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
await someJob({ c });
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Middlewares **don't run** with `mockContext`. That's the trade-off — direct invocation skips the stack. Use `.action`/`.loader`/`.subscribe` whenever possible; reach for `mockContext` only for code that isn't behind a route.
|
|
367
|
+
|
|
368
|
+
## Putting it together — full example
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
import { createTestApp } from "@mauroandre/velojs/testing";
|
|
372
|
+
import routes from "../app/routes.js";
|
|
373
|
+
import { stream_backup } from "../app/layouts/AdminLayout.js";
|
|
374
|
+
import { action_setBackup, action_backupNow } from "../app/stacks/apps/AppEdit.js";
|
|
375
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
|
376
|
+
|
|
377
|
+
describe("Backup — stream + auth + ownership", () => {
|
|
378
|
+
let app: Awaited<ReturnType<typeof createTestApp>>;
|
|
379
|
+
let userA: any, userB: any, appId: string;
|
|
380
|
+
|
|
381
|
+
beforeAll(async () => {
|
|
382
|
+
app = await createTestApp({
|
|
383
|
+
routes,
|
|
384
|
+
bootstrap: async () => {
|
|
385
|
+
const { connect } = await import("@mauroandre/zodmongo");
|
|
386
|
+
await connect(process.env.MONGO_URI!, "podcubo_test");
|
|
387
|
+
},
|
|
388
|
+
getSessionCookie: async ({ user }) => {
|
|
389
|
+
const { createToken } = await import("../app/modules/user/user.service.js");
|
|
390
|
+
return { session: await createToken({ user }) };
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
afterAll(async () => await app.close());
|
|
396
|
+
|
|
397
|
+
beforeEach(async () => {
|
|
398
|
+
await app.reset();
|
|
399
|
+
await cleanTestDb();
|
|
400
|
+
userA = await factory.user({ email: "a@x.com" });
|
|
401
|
+
userB = await factory.user({ email: "b@x.com" });
|
|
402
|
+
appId = await factory.dbApp(userA, "postgresql");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("backup events reach the app owner", async () => {
|
|
406
|
+
const sub = await app.as({ user: userA }).subscribe(stream_backup, {
|
|
407
|
+
channel: appId,
|
|
408
|
+
});
|
|
409
|
+
expect(sub.status).toBe(200);
|
|
410
|
+
|
|
411
|
+
await app.as({ user: userA }).action(action_setBackup, {
|
|
412
|
+
body: { appId, schedule: "daily" },
|
|
413
|
+
});
|
|
414
|
+
await app.as({ user: userA }).action(action_backupNow, { body: { appId } });
|
|
415
|
+
|
|
416
|
+
const event = await sub.next({ timeoutMs: 2000 });
|
|
417
|
+
expect(event).toMatchObject({ kind: "backup", status: "running" });
|
|
418
|
+
await sub.close();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("user B cannot subscribe to user A's app stream", async () => {
|
|
422
|
+
const sub = await app.as({ user: userB }).subscribe(stream_backup, {
|
|
423
|
+
channel: appId,
|
|
424
|
+
});
|
|
425
|
+
expect(sub.status).toBe(403);
|
|
426
|
+
expect(sub.events).toEqual([]);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
A single test exercises: channel resolver with auth, middleware chain, cookie parsing, action invocation, service → stream emit, snapshot persistence, reconnection. **No mocks of framework internals.** Real DB. Stack complete.
|
|
432
|
+
|
|
433
|
+
## Patterns that are NOT in scope
|
|
434
|
+
|
|
435
|
+
The toolkit covers backend testing (HTTP, streams, server-side functions). It **does not** cover:
|
|
436
|
+
|
|
437
|
+
- **Driver / service mocking** — use `vi.mock(path, factory)` from Vitest. The toolkit doesn't try to abstract this since Vitest already does it well.
|
|
438
|
+
- **Frontend testing** — use Preact Testing Library or Playwright. These are well-served by external tools; VeloJS doesn't duplicate them.
|
|
439
|
+
- **End-to-end with browser** — use Playwright with `velojs start`.
|
|
440
|
+
|
|
441
|
+
## FAQ
|
|
442
|
+
|
|
443
|
+
### Can I run tests in parallel?
|
|
444
|
+
|
|
445
|
+
Yes. Each test file runs in its own Vitest worker, and the toolkit isolates state per `createTestApp` instance. Within a file, `it.concurrent` works as long as each test has its own app or you avoid shared streams across concurrent tests.
|
|
446
|
+
|
|
447
|
+
### Does `app.close()` really clean up everything?
|
|
448
|
+
|
|
449
|
+
Yes — that's an invariant, not a feature. Heartbeats, retention timers, source AbortControllers, listeners — all go away. If Vitest reports open handles after `close()`, that's a bug in the toolkit.
|
|
450
|
+
|
|
451
|
+
### What about `addRoutes` and `onServer` called at top-level import?
|
|
452
|
+
|
|
453
|
+
If your `app/server.tsx` calls `addRoutes(...)` at the top level, those calls happen at import time, before any `createTestApp`. The toolkit captures a snapshot of the default context when each `createTestApp` is created, so top-level registrations are included.
|
|
454
|
+
|
|
455
|
+
If your test needs **additional** route registration scoped to that test, do it inside `bootstrap`:
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
const app = await createTestApp({
|
|
459
|
+
routes,
|
|
460
|
+
bootstrap: async () => {
|
|
461
|
+
addRoutes((a) => a.get("/test-only", (c) => c.json({ ok: true })));
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### How do I test a stream without a real perChannelSource resource?
|
|
467
|
+
|
|
468
|
+
Mock the resource (e.g., the SSH client) with `vi.mock`, then subscribe normally. The framework's lifecycle (subscribe → start → unsubscribe → abort) runs as in production; only the underlying resource is mocked.
|
|
469
|
+
|
|
470
|
+
### Why no top-level await on createTestApp?
|
|
471
|
+
|
|
472
|
+
Top-level await works in Vitest, but the explicit `beforeAll` is clearer and lets you call `app.close()` symmetrically in `afterAll`.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: velojs-type-reference
|
|
3
|
+
description: "Exact TypeScript signatures for route and config types: `AppRoutes`, `RouteNode`, `RouteModule`, `LoaderArgs`, `ActionArgs`, `Metadata`, `EndpointHandler`, `VeloConfig`, plus the package subpath exports. Use when you need a precise type or aren't sure which subpath exports a symbol."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Type Reference
|
|
7
|
+
|
|
8
|
+
This page lists all the TypeScript types and interfaces exported by VeloJS, organized by import path.
|
|
9
|
+
|
|
10
|
+
## Subpath exports
|
|
11
|
+
|
|
12
|
+
| Import | What you get |
|
|
13
|
+
|--------|-------------|
|
|
14
|
+
| `@mauroandre/velojs` | Types (`AppRoutes`, `RouteNode`, `RouteModule`, `ActionArgs`, `LoaderArgs`, `Metadata`, `HTTPMethod`, `EndpointHandler`, `EndpointHandlerArgs`, `VeloConfig`), `Scripts`, `Link`, `defineConfig`, `createEventStream`, `poll` |
|
|
15
|
+
| `@mauroandre/velojs/server` | `startServer`, `createApp`, `addRoutes`, `onServer`, `serverDataStorage` |
|
|
16
|
+
| `@mauroandre/velojs/client` | `startClient` |
|
|
17
|
+
| `@mauroandre/velojs/hooks` | `Loader`, `useLoader`, `useParams`, `useQuery`, `useNavigate`, `usePathname`, `touch`, `useEventStream`, `useSocket` |
|
|
18
|
+
| `@mauroandre/velojs/events` | `createEventStream`, `poll` |
|
|
19
|
+
| `@mauroandre/velojs/sockets` | `parseJson` |
|
|
20
|
+
| `@mauroandre/velojs/testing` | `createTestApp`, `TestApp`, `TestResponse`, `TestSubscription`, `TestSocketSession` |
|
|
21
|
+
| `@mauroandre/velojs/cookie` | `getCookie`, `setCookie`, `deleteCookie`, `getSignedCookie`, `setSignedCookie` |
|
|
22
|
+
| `@mauroandre/velojs/factory` | `createMiddleware`, `createFactory` |
|
|
23
|
+
| `@mauroandre/velojs/vite` | `veloPlugin` |
|
|
24
|
+
| `@mauroandre/velojs/config` | `defineConfig`, `VeloConfig` |
|
|
25
|
+
|
|
26
|
+
## Interfaces
|
|
27
|
+
|
|
28
|
+
### LoaderArgs
|
|
29
|
+
|
|
30
|
+
Passed to every `loader` function. Contains the URL parameters, query string, and Hono context:
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
interface LoaderArgs {
|
|
34
|
+
params: Record<string, string>; // URL params like :id
|
|
35
|
+
query: Record<string, string>; // Query string like ?page=2
|
|
36
|
+
c: Context; // Hono request context
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### ActionArgs
|
|
41
|
+
|
|
42
|
+
Passed to every `action_*` function. The generic type `TBody` defines the shape of the data sent from the client:
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
interface ActionArgs<TBody = unknown> {
|
|
46
|
+
body: TBody; // Data from the client
|
|
47
|
+
params?: Record<string, string>; // URL params (server only)
|
|
48
|
+
query?: Record<string, string>; // Query string (server only)
|
|
49
|
+
c?: Context; // Hono context (server only)
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Metadata
|
|
54
|
+
|
|
55
|
+
Automatically injected into each module by the Vite plugin. Contains the module identifier and resolved paths:
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
interface Metadata {
|
|
59
|
+
moduleId: string; // e.g., "admin/Users"
|
|
60
|
+
fullPath?: string; // e.g., "/admin/users"
|
|
61
|
+
path?: string; // e.g., "/users"
|
|
62
|
+
[key: string]: unknown; // your own metadata (title, etc)
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### RouteModule
|
|
67
|
+
|
|
68
|
+
The shape of a route module (what `import * as Module` gives you):
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
interface RouteModule {
|
|
72
|
+
Component: ComponentType<any>;
|
|
73
|
+
loader?: (args: LoaderArgs) => Promise<any>;
|
|
74
|
+
/** Dynamic routes to prerender with `velojs build --static`. */
|
|
75
|
+
staticPaths?: () => Promise<Record<string, string>[]>;
|
|
76
|
+
metadata?: Metadata;
|
|
77
|
+
[key: `action_${string}`]: (args: ActionArgs<any>) => Promise<any>;
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### RouteNode
|
|
82
|
+
|
|
83
|
+
A single node in the route tree defined in `routes.tsx`:
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
interface RouteNode {
|
|
87
|
+
path?: string;
|
|
88
|
+
/** Optional: endpoint-only and grouping nodes have no module. */
|
|
89
|
+
module?: RouteModule;
|
|
90
|
+
/** Endpoint nodes — see the Endpoints doc. */
|
|
91
|
+
method?: HTTPMethod;
|
|
92
|
+
handler?: EndpointHandler;
|
|
93
|
+
children?: RouteNode[];
|
|
94
|
+
middlewares?: MiddlewareHandler[];
|
|
95
|
+
isRoot?: boolean;
|
|
96
|
+
/** HTTP status for the page (default 200). A bare `path: "*"` catch-all
|
|
97
|
+
* defaults to 404 and is served via Hono's notFound hook. */
|
|
98
|
+
statusCode?: number;
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### HTTPMethod
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
type HTTPMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### EndpointHandlerArgs / EndpointHandler
|
|
109
|
+
|
|
110
|
+
Passed to an endpoint node's `handler`. Unlike a loader, it returns a `Response`:
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
interface EndpointHandlerArgs {
|
|
114
|
+
c: Context;
|
|
115
|
+
params: Record<string, string>;
|
|
116
|
+
query: Record<string, string>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type EndpointHandler = (args: EndpointHandlerArgs) => Response | Promise<Response>;
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### AppRoutes
|
|
123
|
+
|
|
124
|
+
The type of the default export from `routes.tsx`:
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
type AppRoutes = RouteNode[];
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### VeloConfig
|
|
131
|
+
|
|
132
|
+
Configuration options for `veloPlugin()`:
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
interface VeloConfig {
|
|
136
|
+
appDirectory?: string; // default: "./app"
|
|
137
|
+
routesFile?: string; // default: "routes.tsx"
|
|
138
|
+
serverInit?: string; // default: "server.tsx"
|
|
139
|
+
clientInit?: string; // default: "client.tsx"
|
|
140
|
+
port?: number; // default: 3000 (the PORT env always wins)
|
|
141
|
+
}
|
|
142
|
+
```
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: velojs-vite-plugin
|
|
3
|
+
description: "The `veloPlugin` build pipeline: the 9 AST transforms that inject metadata, stub actions, strip loaders/middlewares and prune imports; plugin options and virtual entries. Use when configuring `vite.config.ts` or debugging why transformed code behaves unexpectedly."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Vite Plugin
|
|
7
|
+
|
|
8
|
+
The `veloPlugin()` is the core of VeloJS. It's a single function that returns 6 Vite plugins working together to handle routing, SSR, code transforms, and more.
|
|
9
|
+
|
|
10
|
+
## The 6 plugins
|
|
11
|
+
|
|
12
|
+
| Plugin | What it does |
|
|
13
|
+
|--------|-------------|
|
|
14
|
+
| `velo:config` | Sets up build config for client/server modes, Preact aliases, and environment defines |
|
|
15
|
+
| `velo:transform` | Performs AST transforms on your code (the most important plugin) |
|
|
16
|
+
| `velo:static-url` | Rewrites CSS `url(/path)` to use `STATIC_BASE_URL` in production builds |
|
|
17
|
+
| `@preact/preset-vite` | Adds Preact JSX support |
|
|
18
|
+
| `@hono/vite-dev-server` | Runs the SSR dev server |
|
|
19
|
+
| `velo:ws-bridge` | Exposes Vite's HTTP server for WebSocket handlers during development |
|
|
20
|
+
|
|
21
|
+
## AST Transformations
|
|
22
|
+
|
|
23
|
+
The `velo:transform` plugin uses Babel to modify your code at build time. It applies 9 transformations to `.ts`/`.tsx` files inside your `appDirectory` (a file elsewhere, or a `.js`/`.jsx` one, is not transformed at all):
|
|
24
|
+
|
|
25
|
+
| # | Transform | When | What it does |
|
|
26
|
+
|---|-----------|------|-------------|
|
|
27
|
+
| 1 | Remove middlewares | Client only | Strips the `middlewares` property and any import only it referenced |
|
|
28
|
+
| 2 | Remove endpoint routes | Client only | Strips `{ method, handler }` nodes from `routes.tsx`, and their imports |
|
|
29
|
+
| 3 | Inject metadata | Server + Client | Adds `export const metadata = { moduleId, fullPath, path }` to each module |
|
|
30
|
+
| 4 | Transform loader functions | Server + Client | Injects moduleId into `useLoader()` and `Loader()` calls so they know which data to read |
|
|
31
|
+
| 5 | Transform actions for client | Client only | Replaces `action_*` bodies with `fetch()` stubs (the RPC mechanism) |
|
|
32
|
+
| 6 | Transform streams for client | Client only | Replaces `stream_*` with a `{ __path }` stub |
|
|
33
|
+
| 7 | Transform sockets for client | Client only | Replaces `socket_*` with a `{ __path }` stub |
|
|
34
|
+
| 8 | Remove loaders | Client only | Strips `export const loader` entirely from the client bundle |
|
|
35
|
+
| 9 | Prune orphaned imports | Client only | Removes imports left unreferenced by 1–8, so server-only code doesn't reach the browser |
|
|
36
|
+
|
|
37
|
+
These transforms are why VeloJS can provide such a seamless developer experience — you write one file, and the framework generates the right code for server and client automatically.
|
|
38
|
+
|
|
39
|
+
## Build process
|
|
40
|
+
|
|
41
|
+
When you run `velojs build`, two Vite builds happen in sequence:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
velojs build
|
|
45
|
+
# 1. vite build → dist/client/ (client.[hash].js, client.[hash].css + .vite/manifest.json)
|
|
46
|
+
# 2. vite build --mode server → dist/server.js
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The first build creates the client bundle (JavaScript, CSS, and assets). The second build creates the server entry point that handles SSR and API routes.
|
|
50
|
+
|
|
51
|
+
## Virtual modules
|
|
52
|
+
|
|
53
|
+
VeloJS uses Vite virtual modules to generate the server and client entry points automatically:
|
|
54
|
+
|
|
55
|
+
| Module | Purpose |
|
|
56
|
+
|--------|---------|
|
|
57
|
+
| `virtual:velo/server-entry` | Imports your `server.tsx` + routes, calls `startServer()` |
|
|
58
|
+
| `virtual:velo/client-entry` | Imports your `client.tsx` + routes, calls `startClient()` |
|
|
59
|
+
| `/__velo_client.js` | Alias for the client entry (used in dev mode) |
|
|
60
|
+
|
|
61
|
+
You never import these directly — they're generated by the plugin.
|
|
62
|
+
|
|
63
|
+
## Hot reload
|
|
64
|
+
|
|
65
|
+
When you edit `routes.tsx`, the plugin detects the change, rebuilds the path map, and triggers a full page reload. Other file changes use Vite's standard HMR (hot module replacement).
|
|
66
|
+
|
|
67
|
+
## Request isolation
|
|
68
|
+
|
|
69
|
+
VeloJS uses Node's `AsyncLocalStorage` to isolate data per request. Each SSR render runs in its own storage context, so concurrent requests never leak data between each other.
|
|
70
|
+
|
|
71
|
+
This is why hooks like `useParams()`, `useQuery()`, and `Loader()` work correctly on the server — they read from the current request's storage, not from a global variable.
|