@mandujs/core 0.22.1 → 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 +1 -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
package/src/perf/hmr-markers.ts
CHANGED
|
@@ -175,6 +175,18 @@ export const HMR_PERF = {
|
|
|
175
175
|
* prewarm before the user hits a file save. */
|
|
176
176
|
JIT_PREWARM: "boot:jit-prewarm",
|
|
177
177
|
|
|
178
|
+
/** Phase 11 C — Deep-path JIT prewarm extension. Phase 7.3 A closed the
|
|
179
|
+
* first-iter gap from 41 ms to 25 ms by prewarming the React hot set;
|
|
180
|
+
* R0.3 diagnostics traced the remaining ~15 ms to the
|
|
181
|
+
* `registerManifestHandlers` deep-path (cli `util/handlers` +
|
|
182
|
+
* `util/bun` bundledImport + `@mandujs/core/bundler/safe-build`
|
|
183
|
+
* internals) which only execute on the FIRST SSR reload. This marker
|
|
184
|
+
* measures the settling time of the deep-import Promise — still
|
|
185
|
+
* fire-and-forget, still NOT on the critical path, so values are
|
|
186
|
+
* informational only. Target: first-iter ≤ 15 ms (hard) / ≤ 20 ms
|
|
187
|
+
* (soft). See `packages/cli/src/util/jit-prewarm.ts`. */
|
|
188
|
+
JIT_PREWARM_DEEP: "boot:jit-prewarm-deep",
|
|
189
|
+
|
|
178
190
|
/** API route handler reload (`handleAPIChange`) — `.route.ts` /
|
|
179
191
|
* `.route.tsx` change. Symmetric to `SSR_HANDLER_RELOAD` for page /
|
|
180
192
|
* layout reloads. Phase 7.2 §7.4 flagged that `handleAPIChange` was
|
package/src/runtime/server.ts
CHANGED
|
@@ -449,6 +449,13 @@ export interface ServerRegistrySettings {
|
|
|
449
449
|
cacheStore?: CacheStore;
|
|
450
450
|
/** Internal management token for local runtime control */
|
|
451
451
|
managementToken?: string;
|
|
452
|
+
/**
|
|
453
|
+
* Edge runtime flag — disables filesystem-dependent features (static file
|
|
454
|
+
* serving, Kitchen dashboard, image optimization, SSG fallback loaders).
|
|
455
|
+
* Set by `@mandujs/edge` adapters (Cloudflare Workers, Deno Deploy, Vercel Edge).
|
|
456
|
+
* Default: false (Bun/Node runtime with full FS access).
|
|
457
|
+
*/
|
|
458
|
+
edge?: boolean;
|
|
452
459
|
}
|
|
453
460
|
|
|
454
461
|
export class ServerRegistry {
|
|
@@ -2408,18 +2415,23 @@ async function handleRequestInternal(
|
|
|
2408
2415
|
}
|
|
2409
2416
|
|
|
2410
2417
|
// 1. 정적 파일 서빙 시도 (최우선)
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2418
|
+
// Edge runtimes (Cloudflare Workers, etc.) have no filesystem — skip and
|
|
2419
|
+
// let the platform's asset pipeline (Wrangler [assets], Vercel _static, …)
|
|
2420
|
+
// handle static routing instead.
|
|
2421
|
+
if (!settings.edge) {
|
|
2422
|
+
const staticFileResult = await serveStaticFile(pathname, settings, req);
|
|
2423
|
+
if (staticFileResult.handled) {
|
|
2424
|
+
const staticResponse = staticFileResult.response!;
|
|
2425
|
+
if (settings.cors && isCorsRequest(req)) {
|
|
2426
|
+
const corsOptions: CorsOptions = typeof settings.cors === 'object' ? settings.cors : {};
|
|
2427
|
+
return ok(applyCorsToResponse(staticResponse, req, corsOptions));
|
|
2428
|
+
}
|
|
2429
|
+
return ok(staticResponse);
|
|
2417
2430
|
}
|
|
2418
|
-
return ok(staticResponse);
|
|
2419
2431
|
}
|
|
2420
2432
|
|
|
2421
2433
|
// 1.5. Image optimization handler (/_mandu/image)
|
|
2422
|
-
if (pathname === "/_mandu/image") {
|
|
2434
|
+
if (!settings.edge && pathname === "/_mandu/image") {
|
|
2423
2435
|
const imageResponse = await handleImageRequest(req, settings.rootDir, settings.publicDir);
|
|
2424
2436
|
if (imageResponse) return ok(imageResponse);
|
|
2425
2437
|
}
|
|
@@ -2769,6 +2781,119 @@ export const pageLoaders = defaultRegistry.pageLoaders;
|
|
|
2769
2781
|
export const pageHandlers = defaultRegistry.pageHandlers;
|
|
2770
2782
|
export const routeComponents = defaultRegistry.routeComponents;
|
|
2771
2783
|
|
|
2784
|
+
// ========== Runtime-Neutral Fetch Handler Factory ==========
|
|
2785
|
+
|
|
2786
|
+
/**
|
|
2787
|
+
* Options for {@link createAppFetchHandler}. Subset of {@link ServerOptions}
|
|
2788
|
+
* that makes sense in edge/serverless runtimes — no listen/port/hmr fields.
|
|
2789
|
+
*/
|
|
2790
|
+
export interface AppFetchHandlerOptions {
|
|
2791
|
+
/** Project root (used for module path validation). Required. */
|
|
2792
|
+
rootDir: string;
|
|
2793
|
+
/** Bundle manifest (Island hydration). Optional in pure-SSR apps. */
|
|
2794
|
+
bundleManifest?: BundleManifest;
|
|
2795
|
+
/** CORS config — `true` allows all origins, object for fine-grained rules. */
|
|
2796
|
+
cors?: boolean | CorsOptions;
|
|
2797
|
+
/** Streaming SSR toggle. Default: `false`. */
|
|
2798
|
+
streaming?: boolean;
|
|
2799
|
+
/** Rate limit policy. Memory-backed; edge runtimes should prefer durable stores. */
|
|
2800
|
+
rateLimit?: boolean | RateLimitOptions;
|
|
2801
|
+
/**
|
|
2802
|
+
* CSS link injection target for SSR. Typically `"/.mandu/client/globals.css"`
|
|
2803
|
+
* when Tailwind is in use. `false` disables injection.
|
|
2804
|
+
*/
|
|
2805
|
+
cssPath?: string | false;
|
|
2806
|
+
/** Custom registry override (defaults to the global registry). */
|
|
2807
|
+
registry?: ServerRegistry;
|
|
2808
|
+
/**
|
|
2809
|
+
* Mark this handler as edge-hosted. Skips filesystem-dependent features
|
|
2810
|
+
* (static file serving, Kitchen dashboard, image optimization). Set to
|
|
2811
|
+
* `true` by `@mandujs/edge` adapters.
|
|
2812
|
+
*/
|
|
2813
|
+
edge?: boolean;
|
|
2814
|
+
/**
|
|
2815
|
+
* Optional global middleware function. When omitted, the handler does not
|
|
2816
|
+
* attempt to auto-load `middleware.ts` from disk (important for edge
|
|
2817
|
+
* bundles where FS is unavailable). Adapters should pass pre-compiled
|
|
2818
|
+
* middleware at build time.
|
|
2819
|
+
*/
|
|
2820
|
+
middleware?: {
|
|
2821
|
+
fn: MiddlewareFn;
|
|
2822
|
+
config?: MiddlewareConfig | null;
|
|
2823
|
+
};
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
/**
|
|
2827
|
+
* Build a runtime-neutral `fetch(req) → Promise<Response>` handler from a
|
|
2828
|
+
* routes manifest. Reuses the same request pipeline as `startServer()`
|
|
2829
|
+
* (CORS, middleware, router, SSR, API handlers) but without binding to
|
|
2830
|
+
* `Bun.serve`. Suitable for Cloudflare Workers, Deno Deploy, Vercel Edge,
|
|
2831
|
+
* Netlify Edge, and any other Web-Fetch host.
|
|
2832
|
+
*
|
|
2833
|
+
* Handler registration (`registerApiHandler`, `registerPageHandler`, …) must
|
|
2834
|
+
* happen *before* calling this factory — same contract as `startServer`.
|
|
2835
|
+
*
|
|
2836
|
+
* @example
|
|
2837
|
+
* ```ts
|
|
2838
|
+
* // Cloudflare Workers entry
|
|
2839
|
+
* import { createAppFetchHandler } from "@mandujs/core";
|
|
2840
|
+
* import manifest from "./.mandu/routes.manifest.json";
|
|
2841
|
+
* import "./.mandu/edge-workers/register.js"; // populates registries
|
|
2842
|
+
*
|
|
2843
|
+
* const fetch = createAppFetchHandler(manifest, {
|
|
2844
|
+
* rootDir: "/",
|
|
2845
|
+
* edge: true,
|
|
2846
|
+
* cssPath: false,
|
|
2847
|
+
* });
|
|
2848
|
+
*
|
|
2849
|
+
* export default { fetch };
|
|
2850
|
+
* ```
|
|
2851
|
+
*/
|
|
2852
|
+
export function createAppFetchHandler(
|
|
2853
|
+
manifest: RoutesManifest,
|
|
2854
|
+
options: AppFetchHandlerOptions
|
|
2855
|
+
): (req: Request) => Promise<Response> {
|
|
2856
|
+
const {
|
|
2857
|
+
rootDir,
|
|
2858
|
+
bundleManifest,
|
|
2859
|
+
cors = false,
|
|
2860
|
+
streaming = false,
|
|
2861
|
+
rateLimit = false,
|
|
2862
|
+
cssPath = false,
|
|
2863
|
+
registry = defaultRegistry,
|
|
2864
|
+
edge = false,
|
|
2865
|
+
middleware,
|
|
2866
|
+
} = options;
|
|
2867
|
+
|
|
2868
|
+
const corsOptions: CorsOptions | false = cors === true ? {} : cors;
|
|
2869
|
+
const rateLimitOptions = normalizeRateLimitOptions(rateLimit);
|
|
2870
|
+
|
|
2871
|
+
registry.settings = {
|
|
2872
|
+
isDev: false,
|
|
2873
|
+
bundleManifest,
|
|
2874
|
+
rootDir,
|
|
2875
|
+
publicDir: "public",
|
|
2876
|
+
cors: corsOptions,
|
|
2877
|
+
streaming,
|
|
2878
|
+
rateLimit: rateLimitOptions,
|
|
2879
|
+
cssPath,
|
|
2880
|
+
edge,
|
|
2881
|
+
};
|
|
2882
|
+
|
|
2883
|
+
registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
|
|
2884
|
+
|
|
2885
|
+
const router = new Router(manifest.routes);
|
|
2886
|
+
|
|
2887
|
+
return createFetchHandler({
|
|
2888
|
+
router,
|
|
2889
|
+
registry,
|
|
2890
|
+
corsOptions,
|
|
2891
|
+
middlewareFn: middleware?.fn ?? null,
|
|
2892
|
+
middlewareConfig: middleware?.config ?? null,
|
|
2893
|
+
handleRequest,
|
|
2894
|
+
});
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2772
2897
|
// ========== Rate Limiting Public API ==========
|
|
2773
2898
|
|
|
2774
2899
|
/**
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/testing/db
|
|
3
|
+
*
|
|
4
|
+
* In-memory / file-backed SQLite fixture for integration tests.
|
|
5
|
+
*
|
|
6
|
+
* Wraps `@mandujs/core/db` so callers do not have to learn Bun.SQL's URL
|
|
7
|
+
* conventions just to stand up a throwaway database. The default is
|
|
8
|
+
* `sqlite::memory:` — fully isolated, survives exactly one test, zero
|
|
9
|
+
* filesystem footprint.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { createTestDb } from "@mandujs/core/testing";
|
|
13
|
+
*
|
|
14
|
+
* const db = await createTestDb({
|
|
15
|
+
* schema: `
|
|
16
|
+
* CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL);
|
|
17
|
+
* CREATE INDEX users_email ON users(email);
|
|
18
|
+
* `,
|
|
19
|
+
* });
|
|
20
|
+
* afterEach(async () => await db.close());
|
|
21
|
+
*
|
|
22
|
+
* await db.db`INSERT INTO users (id, email) VALUES (${"u1"}, ${"a@b.c"})`;
|
|
23
|
+
* const rows = await db.db<{ id: string; email: string }>`SELECT * FROM users`;
|
|
24
|
+
* expect(rows).toHaveLength(1);
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* ## Design
|
|
28
|
+
*
|
|
29
|
+
* - **Isolation first**: each call to `createTestDb()` returns a fresh
|
|
30
|
+
* `Db` handle. SQLite in-memory dbs are scoped to the connection, so
|
|
31
|
+
* there is no cross-fixture leakage.
|
|
32
|
+
* - **DDL delivered as plain SQL**: callers pass schema as a string (or an
|
|
33
|
+
* array of statements). No dependency on the resource migration runner —
|
|
34
|
+
* that's a Phase 12.3 concern.
|
|
35
|
+
* - **Transaction helper**: `transaction(fn)` is just a re-export of the
|
|
36
|
+
* underlying `Db.transaction` — convenient to avoid threading `db.db.*`.
|
|
37
|
+
* - **Async-dispose**: `using db = await createTestDb(...)` works via
|
|
38
|
+
* `Symbol.asyncDispose` (ES2023 Explicit Resource Management). Pair with
|
|
39
|
+
* Bun.test's per-test cleanup for maximum terseness.
|
|
40
|
+
*
|
|
41
|
+
* ## SQLite caveats
|
|
42
|
+
*
|
|
43
|
+
* Bun.SQL's SQLite adapter requires non-null columns to be typed.
|
|
44
|
+
* Tests that want rich schemas should use `TEXT`, `INTEGER`, `REAL`,
|
|
45
|
+
* `BLOB`. Higher-level typed schemas come with the Phase 12.3 resource
|
|
46
|
+
* migration fixture.
|
|
47
|
+
*
|
|
48
|
+
* @module testing/db
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { createDb, type Db } from "../db/index";
|
|
52
|
+
|
|
53
|
+
/** Options for {@link createTestDb}. */
|
|
54
|
+
export interface CreateTestDbOptions {
|
|
55
|
+
/**
|
|
56
|
+
* Connection URL. Default: `"sqlite::memory:"`.
|
|
57
|
+
*
|
|
58
|
+
* Any `sqlite:` URL is accepted — `sqlite:./fixture.db` for a file-backed
|
|
59
|
+
* fixture that survives across fixture instances, for example. Non-sqlite
|
|
60
|
+
* providers are accepted but not recommended for unit tests (you lose
|
|
61
|
+
* isolation across fixtures).
|
|
62
|
+
*/
|
|
63
|
+
url?: string;
|
|
64
|
+
/**
|
|
65
|
+
* DDL to apply on open. Accepts a multi-statement SQL string or an array
|
|
66
|
+
* of pre-split statements. Statements are run sequentially — if any
|
|
67
|
+
* fails, subsequent ones are skipped and the error re-throws.
|
|
68
|
+
*/
|
|
69
|
+
schema?: string | string[];
|
|
70
|
+
/** Optional seed block to run after `schema` — convenient for row-level setup. */
|
|
71
|
+
seed?: (db: Db) => Promise<void> | void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Handle returned by {@link createTestDb}. */
|
|
75
|
+
export interface TestDb {
|
|
76
|
+
/** The underlying Db handle — use as a tagged-template query function. */
|
|
77
|
+
readonly db: Db;
|
|
78
|
+
/** Re-export of `db.transaction`. */
|
|
79
|
+
transaction: Db["transaction"];
|
|
80
|
+
/**
|
|
81
|
+
* Apply additional DDL after the fixture has been created. Useful when the
|
|
82
|
+
* schema depends on per-test parameters (e.g., random suffixes to avoid
|
|
83
|
+
* SQLite's reserved-words).
|
|
84
|
+
*/
|
|
85
|
+
apply(ddl: string | string[]): Promise<void>;
|
|
86
|
+
/** Idempotent cleanup — safe to call multiple times. */
|
|
87
|
+
close(): Promise<void>;
|
|
88
|
+
/** `using db = await createTestDb(...)` support. */
|
|
89
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Split a multi-statement SQL string into individual statements. */
|
|
93
|
+
function splitSql(source: string): string[] {
|
|
94
|
+
// Naïve splitter: works for vanilla DDL without embedded `;` inside quoted
|
|
95
|
+
// strings — the realistic shape of test fixtures. A full lexer lives in
|
|
96
|
+
// `db/migrations/runner.ts`; we intentionally do not re-use it here to
|
|
97
|
+
// avoid coupling the testing fixture to migration-runner internals.
|
|
98
|
+
return source
|
|
99
|
+
.split(";")
|
|
100
|
+
.map((s) => s.trim())
|
|
101
|
+
.filter((s) => s.length > 0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function applyDdl(db: Db, ddl: string | string[]): Promise<void> {
|
|
105
|
+
const statements = Array.isArray(ddl) ? ddl : splitSql(ddl);
|
|
106
|
+
for (const stmt of statements) {
|
|
107
|
+
// Bun.SQL's tagged-template form does not support raw DDL composition —
|
|
108
|
+
// but a 1-argument template with no placeholders is safe. The value
|
|
109
|
+
// inside `strings.raw[0]` is the literal SQL, never an interpolated value.
|
|
110
|
+
const raw = stmt.trim();
|
|
111
|
+
if (raw.length === 0) continue;
|
|
112
|
+
const strings = Object.assign([raw], { raw: [raw] }) as TemplateStringsArray;
|
|
113
|
+
await db(strings);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Boot a fixture-scoped database handle.
|
|
119
|
+
*
|
|
120
|
+
* The default URL (`sqlite::memory:`) creates a per-connection in-memory
|
|
121
|
+
* database — ideal for per-test isolation.
|
|
122
|
+
*
|
|
123
|
+
* @throws if `url` is non-empty but Bun.SQL rejects it on the first query.
|
|
124
|
+
* Validation is lazy — construction never throws on unreachable targets.
|
|
125
|
+
*/
|
|
126
|
+
export async function createTestDb(
|
|
127
|
+
options: CreateTestDbOptions = {},
|
|
128
|
+
): Promise<TestDb> {
|
|
129
|
+
const url = options.url ?? "sqlite::memory:";
|
|
130
|
+
const db = createDb({ url });
|
|
131
|
+
|
|
132
|
+
if (options.schema !== undefined) {
|
|
133
|
+
await applyDdl(db, options.schema);
|
|
134
|
+
}
|
|
135
|
+
if (options.seed) {
|
|
136
|
+
await options.seed(db);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let closed = false;
|
|
140
|
+
const close = async (): Promise<void> => {
|
|
141
|
+
if (closed) return;
|
|
142
|
+
closed = true;
|
|
143
|
+
await db.close();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
db,
|
|
148
|
+
transaction: db.transaction.bind(db),
|
|
149
|
+
async apply(ddl) {
|
|
150
|
+
await applyDdl(db, ddl);
|
|
151
|
+
},
|
|
152
|
+
close,
|
|
153
|
+
async [Symbol.asyncDispose]() {
|
|
154
|
+
await close();
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
package/src/testing/index.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mandu Testing Utilities
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* The `@mandujs/core/testing` barrel. Everything a test file needs:
|
|
5
|
+
*
|
|
6
|
+
* - Filling-level stubs (`testFilling`, `createTestRequest`, `createTestContext`)
|
|
7
|
+
* - Manifest / island factories (`createTestManifest`, `createTestIsland`)
|
|
8
|
+
* - MCP fixtures (`createMockMcpContext`)
|
|
9
|
+
* - **Phase 12.1** HTTP/session/db/mock fixtures:
|
|
10
|
+
* - `createTestServer` — ephemeral-port in-process Bun.serve
|
|
11
|
+
* - `createTestSession` — pre-signed session cookie (no login roundtrip)
|
|
12
|
+
* - `createTestDb` — in-memory SQLite fixture
|
|
13
|
+
* - `mockMail`, `mockStorage` — dependency-injectable I/O mocks
|
|
14
|
+
*
|
|
15
|
+
* All fixtures produced by this module support idempotent `close()`/`clear()`
|
|
16
|
+
* and, where applicable, `Symbol.asyncDispose` / `Symbol.dispose` for the
|
|
17
|
+
* ES2023 `using` syntax. Prefer those over hand-rolled afterEach chains —
|
|
18
|
+
* they stay correct even when a test throws mid-setup.
|
|
4
19
|
*/
|
|
5
20
|
|
|
6
21
|
import path from "path";
|
|
@@ -245,3 +260,46 @@ export function createMockMcpContext(options: {
|
|
|
245
260
|
readManifest: async () => manifest,
|
|
246
261
|
};
|
|
247
262
|
}
|
|
263
|
+
|
|
264
|
+
// ========== Phase 12.1 — Integration fixtures ==========
|
|
265
|
+
|
|
266
|
+
export {
|
|
267
|
+
createTestServer,
|
|
268
|
+
type CreateTestServerOptions,
|
|
269
|
+
type TestServer,
|
|
270
|
+
} from "./server";
|
|
271
|
+
|
|
272
|
+
export {
|
|
273
|
+
createTestSession,
|
|
274
|
+
readSession,
|
|
275
|
+
extractCookieValuePair,
|
|
276
|
+
type CreateTestSessionOptions,
|
|
277
|
+
type TestSession,
|
|
278
|
+
} from "./session";
|
|
279
|
+
|
|
280
|
+
export {
|
|
281
|
+
createTestDb,
|
|
282
|
+
type CreateTestDbOptions,
|
|
283
|
+
type TestDb,
|
|
284
|
+
} from "./db";
|
|
285
|
+
|
|
286
|
+
export {
|
|
287
|
+
mockMail,
|
|
288
|
+
mockStorage,
|
|
289
|
+
type MockMail,
|
|
290
|
+
type MockStorage,
|
|
291
|
+
type MockStoredObject,
|
|
292
|
+
} from "./mocks";
|
|
293
|
+
|
|
294
|
+
// ========== Phase 12.3 — Snapshot assertions ==========
|
|
295
|
+
|
|
296
|
+
export {
|
|
297
|
+
matchSnapshot,
|
|
298
|
+
toMatchSnapshot,
|
|
299
|
+
stableStringify,
|
|
300
|
+
scrubVolatile,
|
|
301
|
+
deriveSnapshotPath,
|
|
302
|
+
isUpdateMode,
|
|
303
|
+
type SnapshotOptions,
|
|
304
|
+
type SnapshotResult,
|
|
305
|
+
} from "./snapshot";
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/testing/mocks
|
|
3
|
+
*
|
|
4
|
+
* Swap-in mocks for the I/O primitives tests typically stub:
|
|
5
|
+
*
|
|
6
|
+
* - `mockMail()` → the production `MemoryEmailSender` (Phase 5 email
|
|
7
|
+
* primitive), wrapped so tests get a uniform async-dispose cleanup helper.
|
|
8
|
+
* - `mockStorage()` → an in-memory `S3Client`-shaped handle satisfying the
|
|
9
|
+
* public `@mandujs/core/storage/s3` interface without booting `Bun.S3Client`.
|
|
10
|
+
*
|
|
11
|
+
* Both return objects that are **drop-in replacements** for their production
|
|
12
|
+
* counterparts. Pass them to the handler/service under test via whatever
|
|
13
|
+
* dependency-injection path your code already uses — there is no magic here.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { mockMail, mockStorage } from "@mandujs/core/testing";
|
|
17
|
+
*
|
|
18
|
+
* const mail = mockMail();
|
|
19
|
+
* const storage = mockStorage();
|
|
20
|
+
*
|
|
21
|
+
* await sendWelcomeEmail({ mail }, "u@x.com");
|
|
22
|
+
* expect(mail.sent[0].subject).toBe("Welcome");
|
|
23
|
+
*
|
|
24
|
+
* await uploadAvatar({ storage }, buffer);
|
|
25
|
+
* expect(await storage.exists("u/avatar.png")).toBe(true);
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @module testing/mocks
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
createMemoryEmailSender,
|
|
33
|
+
type EmailMessage,
|
|
34
|
+
type MemoryEmailSender,
|
|
35
|
+
} from "../email/index";
|
|
36
|
+
import { getContentType, type S3Client, type S3UploadOptions, type S3PresignOptions } from "../storage/s3/index";
|
|
37
|
+
|
|
38
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
39
|
+
// Email mock
|
|
40
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
41
|
+
|
|
42
|
+
/** Handle returned by {@link mockMail}. Extends the production sender 1:1. */
|
|
43
|
+
export interface MockMail extends MemoryEmailSender {
|
|
44
|
+
/**
|
|
45
|
+
* Find the most recently sent message whose recipient matches `to`
|
|
46
|
+
* (single-address equality). Returns `undefined` if none matched.
|
|
47
|
+
*
|
|
48
|
+
* Convenience shortcut around a reverse scan of `sent` — the common
|
|
49
|
+
* assertion in verification / password-reset tests.
|
|
50
|
+
*/
|
|
51
|
+
lastTo(to: string): (EmailMessage & { id: string; sentAt: number }) | undefined;
|
|
52
|
+
/** `using mail = mockMail()` — clears the outbox on exit. */
|
|
53
|
+
[Symbol.dispose](): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create an in-process email sender. Backing store is a plain array —
|
|
58
|
+
* read via `mail.sent`, clear between cases with `mail.clear()`.
|
|
59
|
+
*
|
|
60
|
+
* This is a thin wrapper around the production `createMemoryEmailSender`
|
|
61
|
+
* so tests do not depend on an implementation detail of the email package.
|
|
62
|
+
*/
|
|
63
|
+
export function mockMail(): MockMail {
|
|
64
|
+
const inner = createMemoryEmailSender();
|
|
65
|
+
|
|
66
|
+
// MemoryEmailSender's `sent` is declared readonly at the type level; the
|
|
67
|
+
// runtime object exposes .push under the covers. We add the convenience
|
|
68
|
+
// helper without widening the surface.
|
|
69
|
+
//
|
|
70
|
+
// Reverse-iterating by index (vs. `.findLast`) avoids dependency on the
|
|
71
|
+
// ES2023 `Array.prototype.findLast` lib type — older tsconfig targets
|
|
72
|
+
// (`"lib": ["ES2022"]`) do not declare it. The runtime has it regardless.
|
|
73
|
+
return Object.assign(inner, {
|
|
74
|
+
lastTo(
|
|
75
|
+
to: string,
|
|
76
|
+
): (EmailMessage & { id: string; sentAt: number }) | undefined {
|
|
77
|
+
for (let i = inner.sent.length - 1; i >= 0; i--) {
|
|
78
|
+
const m = inner.sent[i];
|
|
79
|
+
if (Array.isArray(m.to)) {
|
|
80
|
+
if (m.to.includes(to)) return m;
|
|
81
|
+
} else if (m.to === to) {
|
|
82
|
+
return m;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
},
|
|
87
|
+
[Symbol.dispose]() {
|
|
88
|
+
inner.clear();
|
|
89
|
+
},
|
|
90
|
+
}) as MockMail;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
94
|
+
// Storage (S3-compatible) mock
|
|
95
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
96
|
+
|
|
97
|
+
/** Stored blob + metadata for assertions. */
|
|
98
|
+
export interface MockStoredObject {
|
|
99
|
+
readonly body: Uint8Array;
|
|
100
|
+
readonly contentType: string;
|
|
101
|
+
readonly acl?: "private" | "public-read";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Handle returned by {@link mockStorage}. Extends `S3Client` with test-only affordances. */
|
|
105
|
+
export interface MockStorage extends S3Client {
|
|
106
|
+
/** Every key currently present. */
|
|
107
|
+
keys(): string[];
|
|
108
|
+
/** Raw access to a stored object. Returns `undefined` for missing keys. */
|
|
109
|
+
peek(key: string): MockStoredObject | undefined;
|
|
110
|
+
/** Wipe all stored objects. */
|
|
111
|
+
clear(): void;
|
|
112
|
+
/** `using s = mockStorage()` — clears the store on exit. */
|
|
113
|
+
[Symbol.dispose](): void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Convert the production `S3Client` body types into a normalized Uint8Array
|
|
118
|
+
* so `peek()` returns a stable type regardless of what callers uploaded.
|
|
119
|
+
*/
|
|
120
|
+
function normalizeBody(body: Blob | ArrayBuffer | Uint8Array): Promise<Uint8Array> {
|
|
121
|
+
if (body instanceof Uint8Array) return Promise.resolve(new Uint8Array(body));
|
|
122
|
+
if (body instanceof ArrayBuffer) return Promise.resolve(new Uint8Array(body));
|
|
123
|
+
// Blob → ArrayBuffer → Uint8Array.
|
|
124
|
+
return body.arrayBuffer().then((ab) => new Uint8Array(ab));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create an in-memory S3-compatible storage client. Satisfies the full
|
|
129
|
+
* `S3Client` interface — handlers written against the production API can
|
|
130
|
+
* be called with this mock unchanged.
|
|
131
|
+
*
|
|
132
|
+
* Presigned URLs are synthesized as opaque `mandu-mock://bucket/<key>`
|
|
133
|
+
* strings. They are not de-serializable back into real uploads — only used
|
|
134
|
+
* for identity assertions (presign returned? key matches?).
|
|
135
|
+
*/
|
|
136
|
+
export function mockStorage(options?: { bucket?: string }): MockStorage {
|
|
137
|
+
const bucket = options?.bucket ?? "mandu-test-bucket";
|
|
138
|
+
const store = new Map<string, MockStoredObject>();
|
|
139
|
+
|
|
140
|
+
async function upload(
|
|
141
|
+
body: Blob | ArrayBuffer | Uint8Array,
|
|
142
|
+
opts: S3UploadOptions,
|
|
143
|
+
): Promise<string> {
|
|
144
|
+
if (!opts.key) {
|
|
145
|
+
throw new TypeError(
|
|
146
|
+
"[testing/mocks] mockStorage.upload: 'key' is required.",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const contentType = opts.contentType ?? getContentType(opts.key);
|
|
150
|
+
const bytes = await normalizeBody(body);
|
|
151
|
+
store.set(opts.key, { body: bytes, contentType, acl: opts.acl });
|
|
152
|
+
return `mandu-mock://${bucket}/${opts.key}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function presign(opts: S3PresignOptions): Promise<string> {
|
|
156
|
+
if (!opts.key) {
|
|
157
|
+
throw new TypeError(
|
|
158
|
+
"[testing/mocks] mockStorage.presign: 'key' is required.",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const method = opts.method ?? "PUT";
|
|
162
|
+
const expiresIn = opts.expiresIn ?? 900;
|
|
163
|
+
return `mandu-mock://${bucket}/${opts.key}?method=${method}&expires=${expiresIn}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function deleteObject(key: string): Promise<void> {
|
|
167
|
+
store.delete(key);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function getReadable(key: string): Promise<ReadableStream> {
|
|
171
|
+
const obj = store.get(key);
|
|
172
|
+
if (!obj) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`[testing/mocks] mockStorage.getReadable: key not found: ${JSON.stringify(key)}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return new ReadableStream({
|
|
178
|
+
start(controller) {
|
|
179
|
+
controller.enqueue(obj.body);
|
|
180
|
+
controller.close();
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function exists(key: string): Promise<boolean> {
|
|
186
|
+
return store.has(key);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const handle: MockStorage = {
|
|
190
|
+
upload,
|
|
191
|
+
presign,
|
|
192
|
+
delete: deleteObject,
|
|
193
|
+
getReadable,
|
|
194
|
+
exists,
|
|
195
|
+
keys: () => [...store.keys()],
|
|
196
|
+
peek: (key) => store.get(key),
|
|
197
|
+
clear: () => store.clear(),
|
|
198
|
+
[Symbol.dispose]() {
|
|
199
|
+
store.clear();
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
return handle;
|
|
203
|
+
}
|