@mandujs/core 0.22.1 → 0.24.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.
@@ -9,6 +9,21 @@ import { PORTS, TIMEOUTS } from "../constants";
9
9
  import { escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./escape";
10
10
  import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
11
11
  import { generateFastRefreshPreamble } from "../bundler/dev";
12
+ import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
13
+
14
+ /**
15
+ * Issue #192 — `@view-transition` at-rule block.
16
+ * Inert in browsers without CSS View Transitions (Firefox, Safari < 18.0):
17
+ * the at-rule is simply ignored, so there is no regression. Supporting
18
+ * browsers (Chrome/Edge ≥ 111, Safari 18.2+) play the default crossfade
19
+ * between cross-document navigations.
20
+ *
21
+ * `navigation: auto` is the only value we need — selective transitions
22
+ * are a per-route concern reserved for a future `transitions` config
23
+ * sub-block.
24
+ */
25
+ const VIEW_TRANSITION_STYLE_TAG =
26
+ "<style>@view-transition{navigation:auto}</style>";
12
27
 
13
28
  // Re-export streaming SSR utilities
14
29
  export {
@@ -69,6 +84,45 @@ export interface SSROptions {
69
84
  * manifest entry — prod builds never emit the preamble at all.
70
85
  */
71
86
  cspNonce?: string | boolean;
87
+ /**
88
+ * Issue #192 — emit `<style>@view-transition{navigation:auto}</style>`
89
+ * into `<head>`. Supported browsers (Chrome/Edge ≥ 111, Safari 18.2+)
90
+ * show a default crossfade between cross-document navigations; others
91
+ * ignore the at-rule (no regression).
92
+ *
93
+ * Default: `true`. Pass `false` to suppress the injection — typically
94
+ * wired from `ManduConfig.transitions`.
95
+ */
96
+ transitions?: boolean;
97
+ /**
98
+ * Issue #192 — emit the ~500-byte hover prefetch helper (`<script>`)
99
+ * into `<head>`. Listens for `mouseover` on same-origin `<a href="/...">`
100
+ * anchors and issues `<link rel="prefetch" as="document">` once per
101
+ * unique target. Individual links can opt out via `data-no-prefetch`.
102
+ *
103
+ * Default: `true`. Pass `false` to suppress — typically wired from
104
+ * `ManduConfig.prefetch`.
105
+ */
106
+ prefetch?: boolean;
107
+ /**
108
+ * Issue #191 — control dev-mode injection of the `_devtools.js` bundle
109
+ * (~1.15 MB React dev runtime + Mandu Kitchen panel).
110
+ *
111
+ * Three states:
112
+ * - `true` → force inject regardless of islands (explicit opt-in —
113
+ * use this for SSR-only projects that still want the
114
+ * Kitchen panel for local debugging).
115
+ * - `false` → force skip regardless of islands (explicit opt-out —
116
+ * disables Kitchen even for island projects).
117
+ * - unset → default. Inject iff the page renders at least one
118
+ * hydratable island. Pure-SSR pages (no islands) download
119
+ * zero devtools bytes.
120
+ *
121
+ * Wired from `ManduConfig.dev.devtools`. Only takes effect in dev mode
122
+ * (production builds omit the `_devtools.js` output entirely, so this
123
+ * flag is a no-op in prod regardless of value).
124
+ */
125
+ devtools?: boolean;
72
126
  }
73
127
 
74
128
  let projectRenderToString: ((element: ReactElement) => string) | null | undefined;
@@ -394,6 +448,9 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
394
448
  routePattern,
395
449
  cssPath,
396
450
  islandPreWrapped,
451
+ transitions = true,
452
+ prefetch = true,
453
+ devtools,
397
454
  } = options;
398
455
 
399
456
  // CSS 링크 태그 생성
@@ -403,6 +460,20 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
403
460
  ? `<link rel="stylesheet" href="${escapeHtmlAttr(`${cssPath}${isDev ? `?t=${Date.now()}` : ""}`)}">`
404
461
  : "";
405
462
 
463
+ // Issue #192 — Smooth navigation primitives.
464
+ // `transitions`: CSS `@view-transition { navigation: auto }` — inert in
465
+ // non-supporting browsers (Firefox, older Safari), crossfade in
466
+ // Chrome/Edge ≥ 111 and Safari 18.2+. Zero layout impact, ~70 bytes.
467
+ // `prefetch`: ~500-byte IIFE that listens for `mouseover` on internal
468
+ // `<a href="/...">` anchors and issues `<link rel="prefetch">`. Honors
469
+ // per-link `data-no-prefetch` opt-out.
470
+ // Position: immediately after `cssLinkTag` so that (a) the at-rule
471
+ // parses alongside the user stylesheet, and (b) both blocks precede
472
+ // user-owned `headTags` / `collectedHeadTags`, letting users override
473
+ // or cancel with a later inline style. False disables each independently.
474
+ const viewTransitionTag = transitions !== false ? VIEW_TRANSITION_STYLE_TAG : "";
475
+ const prefetchScriptTag = prefetch !== false ? PREFETCH_HELPER_SCRIPT : "";
476
+
406
477
  // useHead/useSeoMeta SSR 수집
407
478
  let collectedHeadTags = "";
408
479
  let headReset: (() => void) | undefined;
@@ -492,10 +563,14 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
492
563
  ? generateFastRefreshPreambleTag(isDev, bundleManifest, resolvedCspNonce)
493
564
  : "";
494
565
 
495
- // DevTools 번들 로드 (개발 모드)
566
+ // Issue #191 — DevTools 번들 (~1.15 MB) 주입 결정.
567
+ // - 기본: island 이 하나라도 있을 때만 주입. Pure-SSR 페이지는 0 bytes 다운로드.
568
+ // - `devtools === true` → 강제 주입 (SSR-only 프로젝트에서 Kitchen panel 원할 때)
569
+ // - `devtools === false` → 강제 스킵 (island 프로젝트에서도 Kitchen 비활성화)
570
+ // Cache-bust 은 `manifest.buildTime` 우선, 없으면 `Date.now()`.
496
571
  let devtoolsScript = "";
497
- if (isDev) {
498
- devtoolsScript = generateDevtoolsScript();
572
+ if (isDev && shouldInjectDevtools(devtools, bundleManifest)) {
573
+ devtoolsScript = generateDevtoolsScript(bundleManifest);
499
574
  }
500
575
 
501
576
  // #179: body 내 <link> 태그를 <head>로 호이스팅
@@ -516,6 +591,8 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
516
591
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
517
592
  <title>${escapeHtmlText(title)}</title>
518
593
  ${cssLinkTag}
594
+ ${viewTransitionTag}
595
+ ${prefetchScriptTag}
519
596
  ${hoistedLinkTags}
520
597
  ${headTags}
521
598
  ${collectedHeadTags}
@@ -729,13 +806,75 @@ window.__MANDU_HMR_PORT__ = ${hmrPort};
729
806
  }
730
807
 
731
808
  /**
732
- * DevTools 번들 로드 스크립트 생성 (개발 모드 전용)
733
- * _devtools.js 번들이 자체적으로 initManduKitchen() 호출
809
+ * Issue #191 Determine whether the dev-only `_devtools.js` bundle
810
+ * (~1.15 MB React dev runtime + Kitchen panel) should be injected
811
+ * into the HTML response.
812
+ *
813
+ * Decision table (`devtools` option × manifest shape):
814
+ *
815
+ * | `devtools` | hasIslands | inject? | rationale |
816
+ * |-------------|------------|---------|-----------------------------|
817
+ * | `true` | any | YES | explicit opt-in |
818
+ * | `false` | any | NO | explicit opt-out |
819
+ * | `undefined` | true | YES | default, hydration runtime |
820
+ * | `undefined` | false | NO | pure-SSR — save 1.15 MB |
821
+ * | `undefined` | no manifest| NO | nothing to hydrate anyway |
822
+ *
823
+ * `hasIslands` is derived from the existing manifest shape rather than
824
+ * a new field, so no bundler-side change is required:
825
+ * - `manifest.islands` is populated only when per-island code
826
+ * splitting produced at least one bundle (build.ts:1654).
827
+ * - `manifest.bundles` entries exist only for routes where
828
+ * `needsHydration()` is true (build.ts:70 filter).
829
+ * Either non-empty ⇒ some route on this server hydrates ⇒ devtools useful.
830
+ *
831
+ * @internal Exported via `_testOnly_shouldInjectDevtools` below so
832
+ * `tests/runtime/devtools-inject.test.ts` can table-test the matrix
833
+ * without mounting React.
834
+ */
835
+ function shouldInjectDevtools(
836
+ devtools: boolean | undefined,
837
+ manifest: BundleManifest | undefined,
838
+ ): boolean {
839
+ // Explicit overrides take absolute precedence.
840
+ if (devtools === true) return true;
841
+ if (devtools === false) return false;
842
+
843
+ // Default behavior: inject only when there is at least one island.
844
+ if (!manifest) return false;
845
+ const hasIslandsMap =
846
+ manifest.islands && Object.keys(manifest.islands).length > 0;
847
+ const hasBundles =
848
+ manifest.bundles && Object.keys(manifest.bundles).length > 0;
849
+ return Boolean(hasIslandsMap || hasBundles);
850
+ }
851
+
852
+ /**
853
+ * Issue #191 — DevTools 번들 로드 스크립트 생성 (개발 모드 전용).
854
+ *
855
+ * `_devtools.js` 번들이 자체적으로 `initManduKitchen()` 을 호출한다.
856
+ *
857
+ * Cache-bust: `?v=${manifest.buildTime}` 을 우선 사용하고, manifest 가 없으면
858
+ * `?t=${Date.now()}` 로 fallback. `buildTime` 은 빌드별로 고정이라 브라우저
859
+ * 캐시 효율이 좋지만, 동일 빌드 안에서 HMR 이 발생하더라도 devtools 번들은
860
+ * dev 서버의 static 응답이 `Cache-Control: no-cache, no-store, must-revalidate`
861
+ * (server.ts:1104) 를 보내므로 stale 위험이 없다.
862
+ *
863
+ * @internal Exported via `_testOnly_generateDevtoolsScript` below so tests
864
+ * can verify the URL shape without needing a full render.
734
865
  */
735
- function generateDevtoolsScript(): string {
736
- return `<script type="module" src="/.mandu/client/_devtools.js"></script>`;
866
+ function generateDevtoolsScript(manifest?: BundleManifest): string {
867
+ const cacheBust = manifest?.buildTime
868
+ ? `?v=${encodeURIComponent(manifest.buildTime)}`
869
+ : `?t=${Date.now()}`;
870
+ return `<script type="module" src="/.mandu/client/_devtools.js${cacheBust}"></script>`;
737
871
  }
738
872
 
873
+ /** @internal test helper — exposed only so unit tests can inspect the decision. */
874
+ export const _testOnly_shouldInjectDevtools = shouldInjectDevtools;
875
+ /** @internal test helper — exposed only so unit tests can inspect the script tag. */
876
+ export const _testOnly_generateDevtoolsScript = generateDevtoolsScript;
877
+
739
878
  export function createHTMLResponse(
740
879
  html: string,
741
880
  status: number = 200,
@@ -24,6 +24,16 @@ import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
24
24
  import { getRenderToString } from "./react-renderer";
25
25
  import { mark, measure } from "../perf";
26
26
  import { generateFastRefreshPreamble } from "../bundler/dev";
27
+ import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
28
+
29
+ /**
30
+ * Issue #192 — `@view-transition` at-rule, mirror of the constant in
31
+ * `./ssr.ts`. Duplicated here to keep the streaming path self-contained
32
+ * without a cross-module runtime import cycle (ssr.ts already re-exports
33
+ * streaming-ssr.ts). Both constants MUST stay byte-identical.
34
+ */
35
+ const VIEW_TRANSITION_STYLE_TAG =
36
+ "<style>@view-transition{navigation:auto}</style>";
27
37
 
28
38
  // ========== Types ==========
29
39
 
@@ -154,8 +164,67 @@ export interface StreamingSSROptions {
154
164
  * Dev + hydration + populated `shared.fastRefresh` only.
155
165
  */
156
166
  cspNonce?: string | boolean;
167
+ /**
168
+ * Issue #192 — emit `<style>@view-transition{navigation:auto}</style>`
169
+ * into the streaming shell `<head>`. Mirrors `SSROptions.transitions`.
170
+ * Default: `true`.
171
+ */
172
+ transitions?: boolean;
173
+ /**
174
+ * Issue #192 — emit the ~500-byte hover prefetch helper `<script>`
175
+ * into the streaming shell `<head>`. Mirrors `SSROptions.prefetch`.
176
+ * Default: `true`.
177
+ */
178
+ prefetch?: boolean;
179
+ /**
180
+ * Issue #191 — control dev-mode injection of the `_devtools.js`
181
+ * bundle. Mirrors `SSROptions.devtools`:
182
+ * - `true` → force inject (explicit opt-in)
183
+ * - `false` → force skip (explicit opt-out)
184
+ * - `undefined` → default; inject iff the manifest has islands.
185
+ */
186
+ devtools?: boolean;
157
187
  }
158
188
 
189
+ /**
190
+ * Issue #191 — Streaming-SSR mirror of `ssr.ts:shouldInjectDevtools`.
191
+ * Kept in sync manually (tiny pure function, not worth a cross-module
192
+ * runtime import — the ssr.ts → streaming-ssr.ts re-export direction
193
+ * means a circular import here would force a refactor of the whole
194
+ * module graph). The unit test suite table-tests both implementations
195
+ * against the same matrix so drift is caught at CI time.
196
+ */
197
+ function shouldInjectDevtoolsStreaming(
198
+ devtools: boolean | undefined,
199
+ manifest: BundleManifest | undefined,
200
+ ): boolean {
201
+ if (devtools === true) return true;
202
+ if (devtools === false) return false;
203
+ if (!manifest) return false;
204
+ const hasIslandsMap =
205
+ manifest.islands && Object.keys(manifest.islands).length > 0;
206
+ const hasBundles =
207
+ manifest.bundles && Object.keys(manifest.bundles).length > 0;
208
+ return Boolean(hasIslandsMap || hasBundles);
209
+ }
210
+
211
+ /**
212
+ * Issue #191 — Streaming-SSR mirror of `ssr.ts:generateDevtoolsScript`.
213
+ * Emits `<script type="module" src="/.mandu/client/_devtools.js?v=BUILD">`
214
+ * with a `buildTime`-keyed cache-bust (or `Date.now()` fallback).
215
+ */
216
+ function generateStreamingDevtoolsScript(manifest: BundleManifest | undefined): string {
217
+ const cacheBust = manifest?.buildTime
218
+ ? `?v=${encodeURIComponent(manifest.buildTime)}`
219
+ : `?t=${Date.now()}`;
220
+ return `<script type="module" src="/.mandu/client/_devtools.js${cacheBust}"></script>`;
221
+ }
222
+
223
+ /** @internal — surface for the shared test suite in tests/runtime/devtools-inject.test.ts. */
224
+ export const _testOnly_shouldInjectDevtoolsStreaming = shouldInjectDevtoolsStreaming;
225
+ /** @internal — surface for the shared test suite in tests/runtime/devtools-inject.test.ts. */
226
+ export const _testOnly_generateStreamingDevtoolsScript = generateStreamingDevtoolsScript;
227
+
159
228
  export interface StreamingLoaderResult<T = unknown> {
160
229
  /** 즉시 로드할 Critical 데이터 */
161
230
  critical?: T;
@@ -462,6 +531,8 @@ function generateHTMLShell(options: StreamingSSROptions): string {
462
531
  hydration,
463
532
  cssPath,
464
533
  isDev = false,
534
+ transitions = true,
535
+ prefetch = true,
465
536
  } = options;
466
537
 
467
538
  // CSS 링크 태그 생성
@@ -471,6 +542,14 @@ function generateHTMLShell(options: StreamingSSROptions): string {
471
542
  ? `<link rel="stylesheet" href="${escapeHtmlAttr(`${cssPath}${isDev ? `?t=${Date.now()}` : ""}`)}">`
472
543
  : "";
473
544
 
545
+ // Issue #192 — Smooth navigation primitives. Mirror of the block in
546
+ // `ssr.ts::renderToHTML`; see that call-site for the full rationale.
547
+ // Positioned right after the user stylesheet so the at-rule parses
548
+ // alongside it, and before user `headTags` so users can override with
549
+ // an inline style later in the document order.
550
+ const viewTransitionTag = transitions !== false ? VIEW_TRANSITION_STYLE_TAG : "";
551
+ const prefetchScriptTag = prefetch !== false ? PREFETCH_HELPER_SCRIPT : "";
552
+
474
553
  // Island wrapper (hydration이 필요한 경우)
475
554
  const needsHydration = hydration && hydration.strategy !== "none" && routeId && bundleManifest;
476
555
 
@@ -549,6 +628,8 @@ function generateHTMLShell(options: StreamingSSROptions): string {
549
628
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
550
629
  <title>${escapeHtmlText(title)}</title>
551
630
  ${cssLinkTag}
631
+ ${viewTransitionTag}
632
+ ${prefetchScriptTag}
552
633
  ${loadingStyles}
553
634
  ${importMapScript}
554
635
  ${headTags}
@@ -572,6 +653,7 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
572
653
  hmrPort,
573
654
  enableClientRouter = false,
574
655
  hydration,
656
+ devtools,
575
657
  } = options;
576
658
 
577
659
  const scripts: string[] = [];
@@ -658,9 +740,13 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
658
740
  scripts.push(generateHMRScript(hmrPort));
659
741
  }
660
742
 
661
- // 11. DevTools 번들 로드 (개발 모드)
662
- if (isDev) {
663
- scripts.push(`<script type="module" src="/.mandu/client/_devtools.js"></script>`);
743
+ // 11. Issue #191 — DevTools 번들 (~1.15 MB) 주입 결정.
744
+ // - 기본: manifest 에 island/bundle 이 있을 때만 주입 (pure-SSR 페이지는 스킵).
745
+ // - `devtools === true` → 강제 주입 (SSR-only 프로젝트에서 Kitchen 원할 때).
746
+ // - `devtools === false` → 강제 스킵.
747
+ // - Cache-bust (`?v=buildTime`) 로 HMR 후 stale 방지.
748
+ if (isDev && shouldInjectDevtoolsStreaming(devtools, bundleManifest)) {
749
+ scripts.push(generateStreamingDevtoolsScript(bundleManifest));
664
750
  }
665
751
 
666
752
  // Island wrapper 닫기 (hydration이 필요한 경우)
@@ -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
+ }
@@ -1,6 +1,21 @@
1
1
  /**
2
2
  * Mandu Testing Utilities
3
- * 서버 없이 라우트/filling 단위 테스트
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";