@mandujs/core 0.22.0 → 0.23.0
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/package.json +8 -1
- package/src/config/mandu.ts +172 -110
- package/src/config/validate.ts +357 -290
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -0
- package/src/desktop/__tests__/window.test.ts +79 -3
- package/src/desktop/webview-fallback.ts +583 -0
- package/src/desktop/window.ts +527 -492
- package/src/perf/hmr-markers.ts +12 -0
- package/src/runtime/server.ts +133 -8
- package/src/testing/db.ts +157 -0
- package/src/testing/index.ts +59 -1
- package/src/testing/mocks.ts +203 -0
- package/src/testing/server.ts +196 -0
- package/src/testing/session.ts +190 -0
- package/src/testing/snapshot.ts +444 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/testing/server
|
|
3
|
+
*
|
|
4
|
+
* In-process server fixture for integration tests.
|
|
5
|
+
*
|
|
6
|
+
* Wraps `startServer()` with an ephemeral port (`0`), a per-test
|
|
7
|
+
* `ServerRegistry` (so parallel tests cannot stomp each other's handlers),
|
|
8
|
+
* and a scoped `fetch` helper so callers can write:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { createTestServer } from "@mandujs/core/testing";
|
|
12
|
+
*
|
|
13
|
+
* const server = await createTestServer(manifest, {
|
|
14
|
+
* registerHandlers(reg) {
|
|
15
|
+
* reg.registerApiHandler("api/health", async () => Response.json({ ok: true }));
|
|
16
|
+
* },
|
|
17
|
+
* });
|
|
18
|
+
* afterAll(() => server.close());
|
|
19
|
+
*
|
|
20
|
+
* const res = await server.fetch("/api/health");
|
|
21
|
+
* expect(res.status).toBe(200);
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* ## Design
|
|
25
|
+
*
|
|
26
|
+
* - **Registry isolation** — the fixture always creates its own
|
|
27
|
+
* `ServerRegistry` (via `createServerRegistry()`), never touches the default
|
|
28
|
+
* global. That lets several fixtures run concurrently in the same test
|
|
29
|
+
* process without handler collisions.
|
|
30
|
+
* - **Port zero** — Bun picks an OS-assigned ephemeral port; the fixture
|
|
31
|
+
* exposes the resolved port and a pre-computed `baseUrl` so tests never
|
|
32
|
+
* hard-code `:3000`.
|
|
33
|
+
* - **Scoped `fetch`** — accepts either an absolute URL or a root-relative
|
|
34
|
+
* path; the latter is joined to `baseUrl`. This matches the ergonomics
|
|
35
|
+
* of `supertest` / Remix `createRoutesStub` without taking a dep on them.
|
|
36
|
+
* - **Cleanup contract** — `close()` is idempotent; the returned handle is
|
|
37
|
+
* also `asyncDispose`-compatible so `using server = await createTestServer(...)`
|
|
38
|
+
* works under Bun's Explicit Resource Management.
|
|
39
|
+
*
|
|
40
|
+
* @module testing/server
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import type { RoutesManifest } from "../spec/schema";
|
|
44
|
+
import {
|
|
45
|
+
startServer,
|
|
46
|
+
createServerRegistry,
|
|
47
|
+
type ManduServer,
|
|
48
|
+
type ServerOptions,
|
|
49
|
+
type ServerRegistry,
|
|
50
|
+
} from "../runtime/server";
|
|
51
|
+
|
|
52
|
+
/** Options accepted by {@link createTestServer}. */
|
|
53
|
+
export interface CreateTestServerOptions {
|
|
54
|
+
/**
|
|
55
|
+
* Optional registration callback. The fixture creates a fresh
|
|
56
|
+
* `ServerRegistry` and passes it here so tests can attach API handlers,
|
|
57
|
+
* page loaders, layouts, and so on synchronously before the server starts
|
|
58
|
+
* listening. Skip when the manifest's handlers are resolved elsewhere.
|
|
59
|
+
*/
|
|
60
|
+
registerHandlers?: (registry: ServerRegistry) => void | Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Host to bind. Default: `"127.0.0.1"` (explicit IPv4 so Windows CI boxes
|
|
63
|
+
* do not fall through to `::1` and break `fetch` default resolution).
|
|
64
|
+
*/
|
|
65
|
+
hostname?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Extra options forwarded to `startServer()`. The fixture overrides
|
|
68
|
+
* `port`, `registry`, and `isDev` — those keys are ignored if present.
|
|
69
|
+
*/
|
|
70
|
+
serverOptions?: Omit<ServerOptions, "port" | "registry" | "isDev" | "hostname">;
|
|
71
|
+
/** Set to `true` to boot the server in dev mode (HMR, kitchen, etc.). Default: `false`. */
|
|
72
|
+
isDev?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The fixture handle returned by {@link createTestServer}. */
|
|
76
|
+
export interface TestServer {
|
|
77
|
+
/** The underlying `ManduServer` (access `server.server.port` if you need Bun handles). */
|
|
78
|
+
readonly server: ManduServer;
|
|
79
|
+
/** The registry the fixture created. Use this to register handlers *after* startup. */
|
|
80
|
+
readonly registry: ServerRegistry;
|
|
81
|
+
/** Resolved port (non-zero once `await`ed). */
|
|
82
|
+
readonly port: number;
|
|
83
|
+
/** `http://<host>:<port>` with no trailing slash. */
|
|
84
|
+
readonly baseUrl: string;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Scoped fetch — accepts a path (`"/api/foo"`) or absolute URL
|
|
88
|
+
* (`"http://other/..."`). Path-style inputs are joined to `baseUrl`.
|
|
89
|
+
*
|
|
90
|
+
* ```ts
|
|
91
|
+
* await server.fetch("/api/health")
|
|
92
|
+
* await server.fetch("/api/login", { method: "POST", body: JSON.stringify({...}) })
|
|
93
|
+
* await server.fetch(new Request("http://localhost/anything"))
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Idempotent teardown. Stops the underlying `Bun.serve` instance and
|
|
100
|
+
* clears the registry so no handler leaks into the next fixture.
|
|
101
|
+
*/
|
|
102
|
+
close(): void;
|
|
103
|
+
|
|
104
|
+
/** Explicit Resource Management support: `using server = await createTestServer(...)`. */
|
|
105
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Boot an in-process test server on an ephemeral port.
|
|
110
|
+
*
|
|
111
|
+
* Returns once `Bun.serve` is listening — no polling required; Bun binds
|
|
112
|
+
* synchronously before `fetch` becomes ready.
|
|
113
|
+
*
|
|
114
|
+
* @throws if `manifest` is missing required fields (handler resolution errors
|
|
115
|
+
* surface on the first `fetch`, not here).
|
|
116
|
+
*/
|
|
117
|
+
export async function createTestServer(
|
|
118
|
+
manifest: RoutesManifest,
|
|
119
|
+
options: CreateTestServerOptions = {},
|
|
120
|
+
): Promise<TestServer> {
|
|
121
|
+
const registry = createServerRegistry();
|
|
122
|
+
|
|
123
|
+
if (options.registerHandlers) {
|
|
124
|
+
await options.registerHandlers(registry);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const hostname = options.hostname ?? "127.0.0.1";
|
|
128
|
+
const server = startServer(manifest, {
|
|
129
|
+
...(options.serverOptions ?? {}),
|
|
130
|
+
port: 0,
|
|
131
|
+
hostname,
|
|
132
|
+
isDev: options.isDev ?? false,
|
|
133
|
+
registry,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Bun.serve always resolves the port synchronously before returning the
|
|
137
|
+
// handle — `undefined` would only happen on a fully-closed server, which
|
|
138
|
+
// is impossible here because we just created it. Assert + narrow so
|
|
139
|
+
// consumers see a plain `number`.
|
|
140
|
+
if (typeof server.server.port !== "number") {
|
|
141
|
+
throw new Error(
|
|
142
|
+
"[testing/server] startServer() returned without a bound port. This is a framework-level bug — please report.",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const port: number = server.server.port;
|
|
146
|
+
const baseUrl = `http://${hostname}:${port}`;
|
|
147
|
+
|
|
148
|
+
let closed = false;
|
|
149
|
+
const close = (): void => {
|
|
150
|
+
if (closed) return;
|
|
151
|
+
closed = true;
|
|
152
|
+
try {
|
|
153
|
+
server.stop();
|
|
154
|
+
} catch {
|
|
155
|
+
// stop() is best-effort — swallow so afterEach doesn't mask real test failures.
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const fetchImpl = async (
|
|
160
|
+
input: string | URL | Request,
|
|
161
|
+
init?: RequestInit,
|
|
162
|
+
): Promise<Response> => {
|
|
163
|
+
if (closed) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
"[testing/server] fetch() called after close() — did an afterAll run before this test?",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Resolve path-style inputs against baseUrl. Absolute URLs and Request
|
|
170
|
+
// objects pass through untouched so callers keep full control.
|
|
171
|
+
if (typeof input === "string") {
|
|
172
|
+
const resolved = input.startsWith("http://") || input.startsWith("https://")
|
|
173
|
+
? input
|
|
174
|
+
: `${baseUrl}${input.startsWith("/") ? "" : "/"}${input}`;
|
|
175
|
+
return fetch(resolved, init);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (input instanceof URL) {
|
|
179
|
+
return fetch(input, init);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return fetch(input, init);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
server,
|
|
187
|
+
registry,
|
|
188
|
+
port,
|
|
189
|
+
baseUrl,
|
|
190
|
+
fetch: fetchImpl,
|
|
191
|
+
close,
|
|
192
|
+
async [Symbol.asyncDispose]() {
|
|
193
|
+
close();
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/testing/session
|
|
3
|
+
*
|
|
4
|
+
* Pre-baked login state for integration tests.
|
|
5
|
+
*
|
|
6
|
+
* Most test scenarios start with "given a logged-in user, when...". This
|
|
7
|
+
* module returns a ready-to-use set of request headers (specifically,
|
|
8
|
+
* a `Cookie` header bearing a signed session payload) without going
|
|
9
|
+
* through the live `/login` flow — which would couple the test to the
|
|
10
|
+
* app's password, CSRF, and rate-limit policies.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { createTestSession } from "@mandujs/core/testing";
|
|
14
|
+
*
|
|
15
|
+
* const authed = await createTestSession({ userId: "u_42", roles: ["admin"] });
|
|
16
|
+
*
|
|
17
|
+
* const res = await server.fetch("/dashboard", {
|
|
18
|
+
* headers: authed.headers,
|
|
19
|
+
* });
|
|
20
|
+
* expect(res.status).toBe(200);
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* ## Contract
|
|
24
|
+
*
|
|
25
|
+
* - **Storage compatibility**: the cookie produced here is consumable by the
|
|
26
|
+
* same `createCookieSessionStorage()` the app uses in production, as long
|
|
27
|
+
* as the shared `secret` matches. Tests typically pass the app's real
|
|
28
|
+
* storage instance via `{ storage }`; otherwise the fixture spins up a
|
|
29
|
+
* fresh one with a deterministic test secret.
|
|
30
|
+
* - **No network**: everything happens in-process — no HTTP roundtrip, no
|
|
31
|
+
* CSRF token generation, no rate-limit counters incremented.
|
|
32
|
+
* - **Fully typed**: `userId` is required, `extras` carries arbitrary
|
|
33
|
+
* JSON-serializable payload. Cookie attributes (name, path, secure) come
|
|
34
|
+
* from the provided storage's options.
|
|
35
|
+
*
|
|
36
|
+
* @module testing/session
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
Session,
|
|
41
|
+
createCookieSessionStorage,
|
|
42
|
+
type SessionStorage,
|
|
43
|
+
type CookieSessionOptions,
|
|
44
|
+
} from "../filling/session";
|
|
45
|
+
import { CookieManager } from "../filling/context";
|
|
46
|
+
|
|
47
|
+
/** Key under which login helpers persist the user id. Mirrors `auth/login.ts`. */
|
|
48
|
+
const USER_ID_KEY = "userId";
|
|
49
|
+
/** Key under which login helpers persist the login timestamp. Mirrors `auth/login.ts`. */
|
|
50
|
+
const LOGGED_AT_KEY = "loginAt";
|
|
51
|
+
|
|
52
|
+
/** Default session secret used when the caller does not pass a storage instance. */
|
|
53
|
+
const DEFAULT_TEST_SECRET = "mandu-test-secret-do-not-use-in-production";
|
|
54
|
+
|
|
55
|
+
/** Options for {@link createTestSession}. */
|
|
56
|
+
export interface CreateTestSessionOptions {
|
|
57
|
+
/** User id persisted in the session. Required. */
|
|
58
|
+
userId: string;
|
|
59
|
+
/** Arbitrary additional session data (JSON-serializable). */
|
|
60
|
+
extras?: Record<string, unknown>;
|
|
61
|
+
/**
|
|
62
|
+
* Login timestamp to persist. Default: `Date.now()` at call time.
|
|
63
|
+
* Useful for age-sensitive flows (e.g., re-auth prompts).
|
|
64
|
+
*/
|
|
65
|
+
loggedAt?: number;
|
|
66
|
+
/**
|
|
67
|
+
* Storage instance to commit against. When omitted, the fixture spins up
|
|
68
|
+
* a fresh in-memory cookie-backed storage with {@link DEFAULT_TEST_SECRET}.
|
|
69
|
+
* Pass your app's real storage (same `secret`) to make the cookie
|
|
70
|
+
* round-trippable by the server under test.
|
|
71
|
+
*/
|
|
72
|
+
storage?: SessionStorage;
|
|
73
|
+
/**
|
|
74
|
+
* Overrides for the fixture-created storage. Ignored when `storage` is
|
|
75
|
+
* passed directly.
|
|
76
|
+
*/
|
|
77
|
+
cookieOptions?: Partial<CookieSessionOptions["cookie"]>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The return shape. */
|
|
81
|
+
export interface TestSession {
|
|
82
|
+
/** Raw `Set-Cookie` header string produced by committing the session. */
|
|
83
|
+
readonly setCookie: string;
|
|
84
|
+
/** `Cookie: ...` value suitable for outbound requests. */
|
|
85
|
+
readonly cookieHeader: string;
|
|
86
|
+
/** Headers object with `Cookie` pre-set — spread into `fetch()` directly. */
|
|
87
|
+
readonly headers: Record<string, string>;
|
|
88
|
+
/** The storage instance used to commit — re-use for subsequent setup steps. */
|
|
89
|
+
readonly storage: SessionStorage;
|
|
90
|
+
/** The `Session` instance that was committed (read-only snapshot view). */
|
|
91
|
+
readonly session: Session;
|
|
92
|
+
/** Convenience: the `userId` the fixture was created for. */
|
|
93
|
+
readonly userId: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build a ready-to-use authenticated session.
|
|
98
|
+
*
|
|
99
|
+
* **Why not just POST `/login`?** Because the login endpoint can be behind
|
|
100
|
+
* CSRF, rate limit, captcha, 2FA, etc. The testing need is to assert
|
|
101
|
+
* behaviour *given* a logged-in user — not to re-validate the login path.
|
|
102
|
+
* For login-path tests, call the route directly with `server.fetch("/login", …)`.
|
|
103
|
+
*/
|
|
104
|
+
export async function createTestSession(
|
|
105
|
+
options: CreateTestSessionOptions,
|
|
106
|
+
): Promise<TestSession> {
|
|
107
|
+
const { userId, extras, loggedAt, cookieOptions } = options;
|
|
108
|
+
|
|
109
|
+
if (typeof userId !== "string" || userId.length === 0) {
|
|
110
|
+
throw new TypeError(
|
|
111
|
+
"[testing/session] createTestSession: 'userId' must be a non-empty string.",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const storage =
|
|
116
|
+
options.storage ??
|
|
117
|
+
createCookieSessionStorage({
|
|
118
|
+
cookie: {
|
|
119
|
+
name: cookieOptions?.name ?? "__session",
|
|
120
|
+
secrets: cookieOptions?.secrets ?? [DEFAULT_TEST_SECRET],
|
|
121
|
+
httpOnly: cookieOptions?.httpOnly ?? true,
|
|
122
|
+
// Tests run over plain HTTP — forcing Secure would make the cookie
|
|
123
|
+
// invisible to the server under test. Explicit `false` overrides the
|
|
124
|
+
// production-env default in `createCookieSessionStorage`.
|
|
125
|
+
secure: cookieOptions?.secure ?? false,
|
|
126
|
+
sameSite: cookieOptions?.sameSite ?? "lax",
|
|
127
|
+
maxAge: cookieOptions?.maxAge ?? 86_400,
|
|
128
|
+
path: cookieOptions?.path ?? "/",
|
|
129
|
+
domain: cookieOptions?.domain,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const session = new Session();
|
|
134
|
+
session.set(USER_ID_KEY, userId);
|
|
135
|
+
session.set(LOGGED_AT_KEY, typeof loggedAt === "number" ? loggedAt : Date.now());
|
|
136
|
+
|
|
137
|
+
if (extras) {
|
|
138
|
+
for (const [key, value] of Object.entries(extras)) {
|
|
139
|
+
session.set(key, value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const setCookie = await storage.commitSession(session);
|
|
144
|
+
const cookieHeader = extractCookieValuePair(setCookie);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
setCookie,
|
|
148
|
+
cookieHeader,
|
|
149
|
+
headers: { Cookie: cookieHeader },
|
|
150
|
+
storage,
|
|
151
|
+
session,
|
|
152
|
+
userId,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Given a Set-Cookie string, return the `name=value` segment suitable for
|
|
158
|
+
* an outgoing `Cookie` request header. Attribute fields (Path, Max-Age,
|
|
159
|
+
* HttpOnly, SameSite, ...) are stripped.
|
|
160
|
+
*
|
|
161
|
+
* The value MUST NOT be URL-decoded — the server-side cookie parser expects
|
|
162
|
+
* the same encoding that was produced by `commitSession`.
|
|
163
|
+
*
|
|
164
|
+
* Exported so tests that build their own Set-Cookie (custom storage) can
|
|
165
|
+
* share the parsing logic.
|
|
166
|
+
*/
|
|
167
|
+
export function extractCookieValuePair(setCookie: string): string {
|
|
168
|
+
const end = setCookie.indexOf(";");
|
|
169
|
+
return end === -1 ? setCookie : setCookie.slice(0, end);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Read the session that `storage.getSession()` would yield for the given
|
|
174
|
+
* `Cookie:` header. Useful in tests that install their own middleware and
|
|
175
|
+
* want to assert what the handler would see.
|
|
176
|
+
*
|
|
177
|
+
* Thin wrapper that constructs a throwaway `Request` (the form the
|
|
178
|
+
* production `CookieManager` expects) and delegates to `getSession` —
|
|
179
|
+
* so tests never have to thread the plumbing themselves.
|
|
180
|
+
*/
|
|
181
|
+
export async function readSession(
|
|
182
|
+
storage: SessionStorage,
|
|
183
|
+
cookieHeader: string,
|
|
184
|
+
): Promise<Session> {
|
|
185
|
+
const request = new Request("http://localhost/__testing/readSession", {
|
|
186
|
+
headers: { cookie: cookieHeader },
|
|
187
|
+
});
|
|
188
|
+
const cookies = new CookieManager(request);
|
|
189
|
+
return storage.getSession(cookies);
|
|
190
|
+
}
|