@mauroandre/velojs 0.0.22 → 0.0.24

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 CHANGED
@@ -1188,6 +1188,60 @@ Hooks (`useParams`, `useQuery`, `usePathname`, `Loader`, `useLoader`) access thi
1188
1188
 
1189
1189
  ---
1190
1190
 
1191
+ ## Testing
1192
+
1193
+ VeloJS ships a backend testing toolkit at `@mauroandre/velojs/testing`. Spin up the app in memory, fire HTTP requests against the registered handlers, subscribe to event streams. No socket, no browser, no fragile mocks of framework internals.
1194
+
1195
+ ```typescript
1196
+ import { createTestApp } from "@mauroandre/velojs/testing";
1197
+ import { routes } from "../app/routes.js";
1198
+ import { stream_progress } from "../app/Deploy.js";
1199
+ import { action_startDeploy } from "../app/Deploy.js";
1200
+
1201
+ const app = await createTestApp({
1202
+ routes,
1203
+ bootstrap: async () => { await connect(process.env.MONGO_URI!); },
1204
+ getSessionCookie: async ({ user }) => ({ session: await sign(user) }),
1205
+ });
1206
+
1207
+ // HTTP
1208
+ const res = await app.get("/api/health");
1209
+
1210
+ // Convention helpers — pass the function, framework resolves the URL
1211
+ const data = await app.loader(homeLoader, { params: { id: "abc" } });
1212
+ await app.action(action_startDeploy, { body: { appId } });
1213
+
1214
+ // Streams — TestSubscription with snapshot, next(), close, etc
1215
+ const sub = await app.subscribe(stream_progress, { channel: appId });
1216
+ expect(sub.status).toBe(200);
1217
+ const event = await sub.next({ timeoutMs: 2000 });
1218
+
1219
+ // Auth — sub-client with cookies bound automatically
1220
+ const asAlice = app.as({ user: alice });
1221
+ await asAlice.subscribe(stream_progress, { channel: appId });
1222
+
1223
+ await app.close();
1224
+ ```
1225
+
1226
+ **Prerequisite:** `vitest.config.ts` must include `veloPlugin()` so action/loader/stream metadata is injected.
1227
+
1228
+ | API | Purpose |
1229
+ |-----|---------|
1230
+ | `createTestApp(options)` | Build isolated app with bootstrap + auth callback |
1231
+ | `app.get/post/put/patch/delete` | HTTP requests (cookies, headers, query, JSON/FormData body) |
1232
+ | `app.action(fn, opts)` | Invoke `action_*` by function reference |
1233
+ | `app.loader(fn, opts)` | Invoke `loader` and unwrap response data |
1234
+ | `app.subscribe(stream, opts)` | Subscribe to a stream; returns `TestSubscription` with `next/nextN/snapshot/close/closed` |
1235
+ | `app.as({ user })` | Sub-client with cookies bound to a user |
1236
+ | `app.sessionCookies({ user })` | Build cookies via `getSessionCookie` |
1237
+ | `app.mockContext(opts)` | Escape hatch — partial Hono Context for direct invocation |
1238
+ | `app.reset()` | Clear stream buffers/listeners between tests |
1239
+ | `app.close()` | Tear down everything (zero open handles guaranteed) |
1240
+
1241
+ See [Testing docs](https://github.com/mauro-andre/velojs/blob/dev/site/docs/16-testing.md) for the full guide with patterns, isolation, and FAQ.
1242
+
1243
+ ---
1244
+
1191
1245
  ## Subpath Exports
1192
1246
 
1193
1247
  | Import | Contents |
@@ -1197,6 +1251,7 @@ Hooks (`useParams`, `useQuery`, `usePathname`, `Loader`, `useLoader`) access thi
1197
1251
  | `@mauroandre/velojs/client` | `startClient` |
1198
1252
  | `@mauroandre/velojs/hooks` | `Loader`, `useLoader`, `useEventStream`, `useParams`, `useQuery`, `useNavigate`, `usePathname`, `touch` |
1199
1253
  | `@mauroandre/velojs/events` | `createEventStream`, `poll`, `EventStream`, `EventStreamConfig`, `EmitFn`, `EmitOptions`, `SourceFn`, `PerChannelSourceFn`, `ChannelResolver` (also re-exported from root) |
1254
+ | `@mauroandre/velojs/testing` | `createTestApp`, `TestApp`, `TestResponse`, `TestSubscription`, `CreateTestAppOptions`, `MockContextOptions` |
1200
1255
  | `@mauroandre/velojs/cookie` | `getCookie`, `setCookie`, `deleteCookie`, `getSignedCookie`, `setSignedCookie` |
1201
1256
  | `@mauroandre/velojs/factory` | `createMiddleware`, `createFactory` |
1202
1257
  | `@mauroandre/velojs/vite` | `veloPlugin` |
@@ -0,0 +1,38 @@
1
+ /**
2
+ * App context — scoped via a swappable global slot so multiple test apps
3
+ * can coexist without polluting each other's pending route registrations.
4
+ *
5
+ * Production runtime uses a single default context implicitly. Tests create
6
+ * isolated contexts via `withAppContext()` which swaps the slot for the
7
+ * duration of the call.
8
+ *
9
+ * NOTE: this module is client-safe (no `node:async_hooks` import). Vitest runs
10
+ * each test file in its own worker, so a simple swappable slot is sufficient
11
+ * for parallelism between files. Within a single file, tests run sequentially
12
+ * unless `describe.concurrent`/`it.concurrent` is used.
13
+ */
14
+ import type { Hono } from "hono";
15
+ type RouteRegisterFn = (app: Hono) => void | Promise<void>;
16
+ type StreamRouteRegisterFn = (app: Hono) => void;
17
+ type ServerCallbackFn = (server: any) => void;
18
+ export interface AppContext {
19
+ pendingRoutes: RouteRegisterFn[];
20
+ pendingStreamRoutes: StreamRouteRegisterFn[];
21
+ serverCallbacks: ServerCallbackFn[];
22
+ disposers: Set<() => void>;
23
+ }
24
+ export declare function getAppContext(): AppContext;
25
+ /**
26
+ * Run a function within an isolated app context (for tests).
27
+ * Restores the previous context after the function resolves/rejects.
28
+ */
29
+ export declare function withAppContext<T>(ctx: AppContext, fn: () => T | Promise<T>): Promise<T>;
30
+ /**
31
+ * Create a new isolated context. Inherits a SNAPSHOT of the default context
32
+ * so any addRoutes/onServer/createEventStream calls that happened at top-level
33
+ * import time are captured.
34
+ */
35
+ export declare function createIsolatedContext(): AppContext;
36
+ export declare function registerDisposer(dispose: () => void): void;
37
+ export declare function unregisterDisposer(dispose: () => void): void;
38
+ export {};
@@ -0,0 +1,43 @@
1
+ function o() {
2
+ return {
3
+ pendingRoutes: [],
4
+ pendingStreamRoutes: [],
5
+ serverCallbacks: [],
6
+ disposers: /* @__PURE__ */ new Set()
7
+ };
8
+ }
9
+ const n = o();
10
+ let e = n;
11
+ function i() {
12
+ return e;
13
+ }
14
+ async function p(t, s) {
15
+ const r = e;
16
+ e = t;
17
+ try {
18
+ return await s();
19
+ } finally {
20
+ e = r;
21
+ }
22
+ }
23
+ function u() {
24
+ return {
25
+ pendingRoutes: [...n.pendingRoutes],
26
+ pendingStreamRoutes: [...n.pendingStreamRoutes],
27
+ serverCallbacks: [...n.serverCallbacks],
28
+ disposers: /* @__PURE__ */ new Set()
29
+ };
30
+ }
31
+ function a(t) {
32
+ e.disposers.add(t);
33
+ }
34
+ function d(t) {
35
+ e.disposers.delete(t);
36
+ }
37
+ export {
38
+ u as createIsolatedContext,
39
+ i as getAppContext,
40
+ a as registerDisposer,
41
+ d as unregisterDisposer,
42
+ p as withAppContext
43
+ };
package/dist/events.d.ts CHANGED
@@ -221,6 +221,8 @@ export interface EventStream<TEvent, TSnapshot = TEvent> {
221
221
  __onConnect(channelKey: string, listener: StreamListener<TEvent>): void;
222
222
  /** Internal: source lifecycle hook */
223
223
  __onDisconnect(channelKey: string, listener: StreamListener<TEvent>): void;
224
+ /** @internal Test-only: reset all transient state (buffers, listeners, controllers, timers). */
225
+ __reset(): void;
224
226
  }
225
227
  /** Internal listener — receives events and a close signal. */
226
228
  interface StreamListener<TEvent> {
@@ -228,6 +230,8 @@ interface StreamListener<TEvent> {
228
230
  close(): void;
229
231
  }
230
232
  export declare function flushPendingStreamRoutes(app: Hono): void;
233
+ /** @internal Used by `@mauroandre/velojs/testing` to reset all streams. */
234
+ export declare function getRegisteredStreams(): ReadonlySet<EventStream<unknown, unknown>>;
231
235
  export declare function createEventStream<TEvent, TSnapshot = TEvent>(config?: EventStreamConfig<TEvent, TSnapshot>): EventStream<TEvent, TSnapshot>;
232
236
  /**
233
237
  * Creates a `source` function that runs `tick` every `intervalMs` while subscribed.
package/dist/events.js CHANGED
@@ -1,7 +1,12 @@
1
- const C = [];
2
- function R(t) {
3
- for (const a of C) a(t);
4
- C.length = 0;
1
+ import { getAppContext as A } from "./app-context.js";
2
+ const M = 2e4, R = 300 * 1e3, v = "";
3
+ function x(t) {
4
+ const o = A().pendingStreamRoutes.splice(0);
5
+ for (const c of o) c(t);
6
+ }
7
+ const I = /* @__PURE__ */ new Set();
8
+ function y() {
9
+ return I;
5
10
  }
6
11
  function L(t = {}) {
7
12
  if (t.broadcast && t.channel && console.warn(
@@ -10,103 +15,116 @@ function L(t = {}) {
10
15
  throw new Error(
11
16
  "[velojs] createEventStream: `source` and `perChannelSource` are mutually exclusive. Use `source` for stream-wide producers or `perChannelSource` for per-channel resources, not both."
12
17
  );
13
- const a = /* @__PURE__ */ new Map(), s = /* @__PURE__ */ new Map(), l = /* @__PURE__ */ new Set(), d = /* @__PURE__ */ new Map();
14
- let u = null, p = 0;
15
- const c = /* @__PURE__ */ new Map(), E = () => {
16
- t.source && (u || (u = new AbortController(), Promise.resolve(
17
- t.source(_, { abortSignal: u.signal })
18
+ const a = /* @__PURE__ */ new Map(), o = /* @__PURE__ */ new Map(), c = /* @__PURE__ */ new Set(), f = /* @__PURE__ */ new Map();
19
+ let l = null, w = 0;
20
+ const i = /* @__PURE__ */ new Map(), b = () => {
21
+ t.source && (l || (l = new AbortController(), Promise.resolve(
22
+ t.source(p, { abortSignal: l.signal })
18
23
  ).catch((e) => {
19
24
  console.error("[velojs] event stream source threw:", e);
20
25
  })));
21
- }, v = () => {
22
- p > 0 || u && (u.abort(), u = null);
23
- }, i = (e) => {
24
- if (!t.perChannelSource || c.has(e)) return;
25
- const o = new AbortController();
26
- c.set(e, o);
27
- const n = (r, S) => {
26
+ }, m = () => {
27
+ w > 0 || l && (l.abort(), l = null);
28
+ }, u = (e) => {
29
+ if (!t.perChannelSource || i.has(e)) return;
30
+ const n = new AbortController();
31
+ i.set(e, n);
32
+ const s = (r, S) => {
28
33
  h.emit(e, r, S);
29
34
  };
30
35
  Promise.resolve(
31
- t.perChannelSource(e, n, { abortSignal: o.signal })
36
+ t.perChannelSource(e, s, { abortSignal: n.signal })
32
37
  ).catch((r) => {
33
38
  console.error(
34
39
  `[velojs] perChannelSource threw for channel "${e}":`,
35
40
  r
36
41
  );
37
42
  });
38
- }, f = (e) => {
39
- const o = c.get(e);
40
- o && (o.abort(), c.delete(e));
43
+ }, d = (e) => {
44
+ const n = i.get(e);
45
+ n && (n.abort(), i.delete(e));
41
46
  };
42
- function _(e, o, n) {
43
- let r, S, b;
44
- if (!!t.broadcast ? (r = "", S = e, b = o) : (r = e, S = o, b = n), l.has(r)) {
47
+ function p(e, n, s) {
48
+ let r, S, E;
49
+ if (!!t.broadcast ? (r = v, S = e, E = n) : (r = e, S = n, E = s), c.has(r)) {
45
50
  console.warn(
46
51
  `[velojs] emit() called on closed channel "${r}" — ignored. Reopen the channel by emitting to a fresh ID, or remove the close() call.`
47
52
  );
48
53
  return;
49
54
  }
50
- if (b?.snapshot) {
51
- const w = s.get(r) ?? [];
52
- w.push(S);
55
+ if (E?.snapshot) {
56
+ const _ = o.get(r) ?? [];
57
+ _.push(S);
53
58
  const T = t.bufferSize ?? 1 / 0;
54
- w.length > T && w.splice(0, w.length - T), s.set(r, w);
59
+ _.length > T && _.splice(0, _.length - T), o.set(r, _);
55
60
  }
56
- const m = a.get(r);
57
- if (m)
58
- for (const w of m) w(S);
61
+ const C = a.get(r);
62
+ if (C)
63
+ for (const _ of C) _(S);
59
64
  }
60
- function A(e = "") {
61
- if (l.has(e)) return;
62
- l.add(e);
63
- const o = a.get(e);
64
- if (o)
65
- for (const S of o) S.close();
66
- const n = t.retainMs ?? 3e5, r = d.get(e);
67
- r && clearTimeout(r), d.set(
65
+ function g(e = v) {
66
+ if (c.has(e)) return;
67
+ c.add(e);
68
+ const n = a.get(e);
69
+ if (n)
70
+ for (const S of n) S.close();
71
+ const s = t.retainMs ?? R, r = f.get(e);
72
+ r && clearTimeout(r), f.set(
68
73
  e,
69
74
  setTimeout(() => {
70
- s.delete(e), d.delete(e), l.delete(e);
71
- }, n)
75
+ o.delete(e), f.delete(e), c.delete(e);
76
+ }, s)
72
77
  );
73
78
  }
74
79
  const h = {
75
- emit: _,
76
- close: A,
80
+ emit: p,
81
+ close: g,
77
82
  __isVeloEventStream: !0,
78
83
  __config: t,
79
84
  __listeners: a,
80
- __buffers: s,
85
+ __buffers: o,
81
86
  __path: t.path,
82
- __onConnect(e, o) {
83
- let n = a.get(e);
84
- const r = !n || n.size === 0;
85
- n || (n = /* @__PURE__ */ new Set(), a.set(e, n)), n.add(o), p++, E(), r && i(e);
87
+ __onConnect(e, n) {
88
+ let s = a.get(e);
89
+ const r = !s || s.size === 0;
90
+ s || (s = /* @__PURE__ */ new Set(), a.set(e, s)), s.add(n), w++, b(), r && u(e);
91
+ },
92
+ __onDisconnect(e, n) {
93
+ const s = a.get(e);
94
+ s && (s.delete(n), s.size === 0 && (a.delete(e), d(e)), w--, m());
86
95
  },
87
- __onDisconnect(e, o) {
88
- const n = a.get(e);
89
- n && (n.delete(o), n.size === 0 && (a.delete(e), f(e)), p--, v());
96
+ __reset() {
97
+ for (const e of a.values())
98
+ for (const n of e)
99
+ try {
100
+ n.close();
101
+ } catch {
102
+ }
103
+ a.clear(), o.clear(), c.clear();
104
+ for (const e of f.values()) clearTimeout(e);
105
+ f.clear(), l && (l.abort(), l = null);
106
+ for (const e of i.values()) e.abort();
107
+ i.clear(), w = 0;
90
108
  }
91
109
  };
92
- return t.path && C.push((e) => {
93
- N(e, t.path, h, t.middlewares ?? []);
94
- }), h;
110
+ return t.path && A().pendingStreamRoutes.push((e) => {
111
+ D(e, t.path, h, t.middlewares ?? []);
112
+ }), I.add(h), h;
95
113
  }
96
- function M(t) {
97
- return async (a, { abortSignal: s }) => {
98
- for (; !s.aborted; ) {
114
+ function B(t) {
115
+ return async (a, { abortSignal: o }) => {
116
+ for (; !o.aborted; ) {
99
117
  try {
100
118
  await t.tick(a);
101
- } catch (l) {
102
- console.error("[velojs] poll tick threw:", l);
119
+ } catch (c) {
120
+ console.error("[velojs] poll tick threw:", c);
103
121
  }
104
- await new Promise((l) => {
105
- const d = setTimeout(l, t.intervalMs);
106
- s.addEventListener(
122
+ await new Promise((c) => {
123
+ const f = setTimeout(c, t.intervalMs);
124
+ o.addEventListener(
107
125
  "abort",
108
126
  () => {
109
- clearTimeout(d), l();
127
+ clearTimeout(f), c();
110
128
  },
111
129
  { once: !0 }
112
130
  );
@@ -114,67 +132,68 @@ function M(t) {
114
132
  }
115
133
  };
116
134
  }
117
- function g(t) {
118
- return t.req.query("channel") ?? "";
135
+ function j(t) {
136
+ return t.req.query("channel") ?? v;
119
137
  }
120
- function N(t, a, s, l = []) {
121
- const d = async (u) => {
122
- const { streamSSE: p } = await import("hono/streaming");
123
- let c;
124
- if (s.__config.broadcast)
125
- c = "";
138
+ function D(t, a, o, c = []) {
139
+ const f = async (l) => {
140
+ const { streamSSE: w } = await import("hono/streaming");
141
+ let i;
142
+ if (o.__config.broadcast)
143
+ i = v;
126
144
  else {
127
- const i = s.__config.channel ?? g;
128
- let f;
145
+ const u = o.__config.channel ?? j;
146
+ let d;
129
147
  try {
130
- f = await i(u);
131
- } catch (_) {
132
- return console.error("[velojs] channel resolver threw:", _), u.json({ error: "internal" }, 500);
148
+ d = await u(l);
149
+ } catch (p) {
150
+ return console.error("[velojs] channel resolver threw:", p), l.json({ error: "internal" }, 500);
133
151
  }
134
- if (f == null)
135
- return u.json({ error: "forbidden" }, 403);
136
- c = f;
152
+ if (d == null)
153
+ return l.json({ error: "forbidden" }, 403);
154
+ i = d;
137
155
  }
138
- const E = s.__config.heartbeatMs, v = E === !1 ? 0 : E ?? 2e4;
139
- return p(u, async (i) => {
140
- const f = s.__buffers.get(c);
141
- if (f && f.length > 0 && await i.writeSSE({
156
+ const b = o.__config.heartbeatMs, m = b === !1 ? 0 : b ?? M;
157
+ return w(l, async (u) => {
158
+ const d = o.__buffers.get(i);
159
+ if (d && d.length > 0 && await u.writeSSE({
142
160
  event: "snapshot",
143
- data: JSON.stringify(f)
144
- }), s.__config.snapshot) {
145
- const r = await s.__config.snapshot(
146
- c === "" ? void 0 : c
161
+ data: JSON.stringify(d)
162
+ }), o.__config.snapshot) {
163
+ const r = await o.__config.snapshot(
164
+ i === v ? void 0 : i
147
165
  );
148
- r !== void 0 && await i.writeSSE({
166
+ r !== void 0 && await u.writeSSE({
149
167
  event: "snapshot",
150
168
  data: JSON.stringify(r)
151
169
  });
152
170
  }
153
- let _;
154
- const A = new Promise((r) => {
155
- _ = r;
171
+ let p;
172
+ const g = new Promise((r) => {
173
+ p = r;
156
174
  });
157
175
  let h = !1, e = null;
158
- const o = () => {
159
- h || (h = !0, e && clearInterval(e), s.__onDisconnect(c, n), _());
160
- }, n = ((r) => {
161
- h || (i.writeSSE({ data: JSON.stringify(r) }).catch(() => {
162
- }), s.__config.closeOn?.(r) && (i.close(), o()));
176
+ const n = () => {
177
+ h || (h = !0, e && clearInterval(e), o.__onDisconnect(i, s), p());
178
+ }, s = ((r) => {
179
+ h || (u.writeSSE({ data: JSON.stringify(r) }).catch(() => {
180
+ }), o.__config.closeOn?.(r) && (u.close(), n()));
163
181
  });
164
- n.close = () => {
165
- h || (i.writeSSE({ event: "close", data: "" }).catch(() => {
166
- }), i.close(), o());
167
- }, s.__onConnect(c, n), e = v > 0 ? setInterval(() => {
168
- h || i.writeSSE({ event: "heartbeat", data: "" }).catch(() => {
182
+ s.close = () => {
183
+ h || (u.writeSSE({ event: "close", data: "" }).catch(() => {
184
+ }), u.close(), n());
185
+ }, o.__onConnect(i, s), e = m > 0 ? setInterval(() => {
186
+ h || u.writeSSE({ event: "heartbeat", data: "" }).catch(() => {
169
187
  });
170
- }, v) : null, i.onAbort(o), await A;
188
+ }, m) : null, u.onAbort(n), await g;
171
189
  });
172
190
  };
173
- l.length > 0 ? t.on(["GET"], [a], ...l, d) : t.on(["GET"], [a], d);
191
+ c.length > 0 ? t.on(["GET"], [a], ...c, f) : t.on(["GET"], [a], f);
174
192
  }
175
193
  export {
176
194
  L as createEventStream,
177
- R as flushPendingStreamRoutes,
178
- M as poll,
179
- N as registerStreamHandler
195
+ x as flushPendingStreamRoutes,
196
+ y as getRegisteredStreams,
197
+ B as poll,
198
+ D as registerStreamHandler
180
199
  };