@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.
@@ -125,4 +125,58 @@ describe("safeBuild", () => {
125
125
  expect(state.active).toBe(0);
126
126
  expect(state.queued).toBe(0);
127
127
  });
128
+
129
+ it("slot handoff — new callers cannot bypass queued waiters (regression for cap+1 race)", async () => {
130
+ // Scenario: a build completes with a waiter queued; a NEW safeBuild call
131
+ // fires on the same microtask. A prior revision decremented `active`
132
+ // before resolving the waiter, leaving a microtask-sized window where
133
+ // the new caller saw `active < max`, skipped the wait, and became the
134
+ // cap+1 concurrent build. This test launches 3*max + 1 builds, samples
135
+ // active at every slot release, and asserts the peak never exceeds max.
136
+ const { max } = _getConcurrencyState();
137
+ const N = max * 3 + 1; // always enough to trigger at least one handoff
138
+ const entries = await Promise.all(
139
+ Array.from({ length: N }, (_, i) => makeEntry(`handoff-${i}`)),
140
+ );
141
+
142
+ let peak = 0;
143
+ let samples = 0;
144
+ // Sample at microtask granularity — more aggressive than the setInterval
145
+ // sampler in the earlier test — so the cap+1 window has a realistic
146
+ // chance of being observed if the bug returned.
147
+ let stop = false;
148
+ const sample = async () => {
149
+ while (!stop) {
150
+ const { active } = _getConcurrencyState();
151
+ if (active > peak) peak = active;
152
+ samples++;
153
+ await Promise.resolve(); // yield to microtask queue
154
+ }
155
+ };
156
+ const sampler = sample();
157
+
158
+ try {
159
+ const results = await Promise.all(
160
+ entries.map((entry) =>
161
+ safeBuild({
162
+ entrypoints: [entry],
163
+ outdir: rootDir,
164
+ target: "browser",
165
+ naming: path.basename(entry, ".ts") + ".[ext]",
166
+ }),
167
+ ),
168
+ );
169
+ expect(results.every((r) => r.success)).toBe(true);
170
+ } finally {
171
+ stop = true;
172
+ await sampler;
173
+ }
174
+
175
+ expect(peak).toBeLessThanOrEqual(max);
176
+ expect(samples).toBeGreaterThan(0);
177
+
178
+ const state = _getConcurrencyState();
179
+ expect(state.active).toBe(0);
180
+ expect(state.queued).toBe(0);
181
+ });
128
182
  });
@@ -20,6 +20,15 @@
20
20
  * - In-process only. Cross-worker coordination is not this module's job;
21
21
  * per-worker throttling already prevents the observed failure modes in
22
22
  * our test matrix.
23
+ *
24
+ * Correctness — slot handoff:
25
+ * - The semaphore does slot *handoff* rather than release-then-acquire. When
26
+ * a build completes with waiters queued, the slot is transferred directly
27
+ * to the next waiter (active count never drops). A previous revision had
28
+ * a classic acquire race: `releaseSlot()` decremented `active` before the
29
+ * waiter's `active++` ran, which opened a microtask-sized window where a
30
+ * concurrent `safeBuild()` call could observe `active < max`, skip the
31
+ * wait, and join in — yielding `cap+1` concurrent builds.
23
32
  */
24
33
 
25
34
  import type { BuildConfig, BuildOutput } from "bun";
@@ -38,16 +47,36 @@ const maxConcurrent = parseMaxConcurrent();
38
47
  let active = 0;
39
48
  const waiters: Array<() => void> = [];
40
49
 
41
- function waitForSlot(): Promise<void> {
50
+ /**
51
+ * Acquire a slot. When `active < max`, increment synchronously and return.
52
+ * Otherwise, queue and await a direct handoff from a completing build —
53
+ * the completing build does NOT decrement `active`; it resolves our waiter,
54
+ * and `active` stays at `max` through the transition. This prevents a
55
+ * microtask-window race where a third caller could see `active < max` and
56
+ * skip the wait entirely.
57
+ */
58
+ function acquireSlot(): Promise<void> {
59
+ if (active < maxConcurrent) {
60
+ active++;
61
+ return Promise.resolve();
62
+ }
42
63
  return new Promise<void>((resolve) => {
43
64
  waiters.push(resolve);
44
65
  });
45
66
  }
46
67
 
68
+ /**
69
+ * Release a slot. If a waiter is queued, hand the slot off directly (keep
70
+ * `active` at `max`, resolve the waiter). Otherwise decrement `active`.
71
+ */
47
72
  function releaseSlot(): void {
48
- active--;
49
73
  const next = waiters.shift();
50
- if (next) next();
74
+ if (next) {
75
+ // Direct handoff — `active` stays at max, waiter resumes holding the slot.
76
+ next();
77
+ } else {
78
+ active--;
79
+ }
51
80
  }
52
81
 
53
82
  /**
@@ -60,10 +89,7 @@ function releaseSlot(): void {
60
89
  * coordination work.
61
90
  */
62
91
  export async function safeBuild(options: BuildConfig): Promise<BuildOutput> {
63
- if (active >= maxConcurrent) {
64
- await waitForSlot();
65
- }
66
- active++;
92
+ await acquireSlot();
67
93
  try {
68
94
  return await Bun.build(options);
69
95
  } finally {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Issue #192 — Hover prefetch helper
3
+ *
4
+ * Self-contained IIFE that watches `mouseover` events bubbling from
5
+ * internal `<a href="/...">` anchors and issues a one-shot
6
+ * `<link rel="prefetch" as="document">` per unique anchor. The browser
7
+ * cache services the subsequent full-reload navigation, so most
8
+ * above-the-fold links feel instantaneous without requiring a SPA
9
+ * runtime.
10
+ *
11
+ * Design choices (locked to keep the payload tiny — target ≤500 bytes
12
+ * minified + gzipped):
13
+ *
14
+ * 1. **WeakSet-based dedup**: we never attach per-anchor listeners;
15
+ * we use a single `document`-level capture listener and stamp
16
+ * each anchor we've seen into a `WeakSet`. Anchors removed from
17
+ * the DOM collect automatically.
18
+ *
19
+ * 2. **Scope: same-origin `/...` paths only**: we deliberately skip
20
+ * absolute URLs, `mailto:`, `tel:`, `javascript:`, hash-only
21
+ * fragments, and `#`-on-same-page links. The `a[href^="/"]`
22
+ * selector implicitly covers this and avoids a surprise
23
+ * cross-origin DNS lookup.
24
+ *
25
+ * 3. **Opt-out per-link via `data-no-prefetch`**: mirrors Next.js'
26
+ * `prefetch={false}` ergonomics while being framework-agnostic —
27
+ * works with plain `<a>` and with Mandu's `<Link>` component.
28
+ *
29
+ * 4. **`as="document"` hint**: the correct token for HTML documents
30
+ * that will be navigated to via a subsequent click. Without it
31
+ * Chrome 121+ logs a `rel=prefetch as=missing` warning.
32
+ *
33
+ * 5. **Capture phase + `passive: true`**: listening in the capture
34
+ * phase catches the event before any app-level bubble handlers
35
+ * can cancel it (defensive against apps that `stopPropagation`
36
+ * on `<a>`). `passive` ensures we can never accidentally block
37
+ * scroll.
38
+ *
39
+ * 6. **Inline, not external bundle**: the helper is under 1KB and
40
+ * emitting it as a `<script>` child (rather than
41
+ * `<script src=".../_prefetch.js">`) avoids one HTTP round-trip
42
+ * on every SSR response and keeps the module graph unchanged.
43
+ *
44
+ * The exported `PREFETCH_HELPER_SCRIPT` wraps the IIFE in a
45
+ * `<script>` tag, ready to paste into `<head>`. Callers MUST keep it
46
+ * nonce-aware if they plan to enable CSP (future work — right now the
47
+ * Fast Refresh preamble is the only inline script covered by Mandu's
48
+ * CSP header).
49
+ */
50
+
51
+ /** Inner IIFE — exposed for unit tests that want to parse the source. */
52
+ export const PREFETCH_HELPER_BODY = `(function(){var s=new WeakSet();document.addEventListener("mouseover",function(e){var t=e.target;if(!t||typeof t.closest!=="function")return;var a=t.closest("a[href^='/']");if(!a||s.has(a))return;if(a.dataset&&a.dataset.noPrefetch!==undefined)return;if(a.hasAttribute&&a.hasAttribute("download"))return;if(a.target&&a.target!=="_self")return;s.add(a);try{var l=document.createElement("link");l.rel="prefetch";l.href=a.href;l.as="document";document.head.appendChild(l);}catch(_){}},{passive:true,capture:true});})();`;
53
+
54
+ /** Ready-to-inject `<script>` tag for SSR `<head>` injection. */
55
+ export const PREFETCH_HELPER_SCRIPT = `<script>${PREFETCH_HELPER_BODY}</script>`;
@@ -5,8 +5,97 @@ import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
5
5
 
6
6
  export type GuardRuleSeverity = "error" | "warn" | "warning" | "off";
7
7
 
8
+ /**
9
+ * Test block configuration (Phase 12.1 — testing ecosystem).
10
+ *
11
+ * Shapes the CLI `mandu test` command's discovery, fixture, and reporter
12
+ * behaviour. All fields are optional; omitting the block yields sensible
13
+ * defaults that match Next.js / SvelteKit user expectations:
14
+ *
15
+ * - unit → `**\/*.test.ts` / `**\/*.test.tsx`, 30s timeout
16
+ * - integration → `tests/integration/**\/*.test.ts`, in-memory fixtures
17
+ * - e2e → reserved for Phase 12.2 (ATE integration)
18
+ * - coverage → reserved for Phase 12.3 (bun + playwright merge)
19
+ */
20
+ export interface TestUnitConfig {
21
+ /** Glob patterns for unit test files. Default: `["**\/*.test.ts", "**\/*.test.tsx"]`. */
22
+ include?: string[];
23
+ /** Glob patterns to exclude (applied after `include`). Default: `["node_modules/**", ".mandu/**", "dist/**"]`. */
24
+ exclude?: string[];
25
+ /** Per-test timeout in milliseconds. Default: `30_000` (30s). */
26
+ timeout?: number;
27
+ }
28
+
29
+ export interface TestIntegrationConfig {
30
+ /** Glob patterns for integration test files. Default: `["tests/integration/**\/*.test.ts"]`. */
31
+ include?: string[];
32
+ /** Glob patterns to exclude. Default: same as unit defaults. */
33
+ exclude?: string[];
34
+ /**
35
+ * Database URL for fixtures. Default: `"sqlite::memory:"` (in-memory SQLite).
36
+ * Accepts any Bun.sql-compatible URL — see `@mandujs/core/db` for the schema matrix.
37
+ */
38
+ dbUrl?: string;
39
+ /**
40
+ * Session storage strategy for `createTestSession`.
41
+ * - `"memory"` (default): CookieSessionStorage with ephemeral secret
42
+ * - `"sqlite"`: bun:sqlite-backed (Phase 2.5 storage, requires Phase 4)
43
+ */
44
+ sessionStore?: "memory" | "sqlite";
45
+ /** Per-test timeout. Default: `60_000` (60s — integration work is slower). */
46
+ timeout?: number;
47
+ }
48
+
49
+ export interface TestE2EConfig {
50
+ /** Reserved for Phase 12.2. Currently a typed placeholder. */
51
+ reserved?: true;
52
+ }
53
+
54
+ export interface TestCoverageConfig {
55
+ /** Minimum line coverage percentage (0-100). Reserved for Phase 12.3. */
56
+ lines?: number;
57
+ /** Minimum branch coverage percentage (0-100). Reserved for Phase 12.3. */
58
+ branches?: number;
59
+ }
60
+
61
+ export interface TestConfig {
62
+ unit?: TestUnitConfig;
63
+ integration?: TestIntegrationConfig;
64
+ e2e?: TestE2EConfig;
65
+ coverage?: TestCoverageConfig;
66
+ }
67
+
8
68
  export interface ManduConfig {
9
69
  adapter?: ManduAdapter;
70
+ /**
71
+ * Issue #192 — Enable CSS View Transitions for cross-document
72
+ * navigations. When `true` (default) Mandu injects
73
+ * `<style>@view-transition { navigation: auto; }</style>` into the SSR
74
+ * `<head>`, which lets supporting browsers (Chrome/Edge ≥ 111) play a
75
+ * crossfade between the outgoing and incoming pages. Non-supporting
76
+ * browsers ignore the at-rule and fall back to the classic
77
+ * full-reload — zero regression.
78
+ *
79
+ * Set to `false` to opt out entirely (e.g. if your app ships a
80
+ * hand-rolled navigation animation or a conflicting CSS rule).
81
+ *
82
+ * Default: `true`.
83
+ */
84
+ transitions?: boolean;
85
+ /**
86
+ * Issue #192 — Enable hover-based link prefetch. When `true` (default)
87
+ * Mandu injects a ~500-byte inline script that listens for `mouseover`
88
+ * events on internal links (`<a href="/...">`) and issues a
89
+ * `<link rel="prefetch">` for each unique target. The browser's HTTP
90
+ * cache services the subsequent navigation, removing most of the TTFB
91
+ * for above-the-fold links.
92
+ *
93
+ * Per-link opt-out: add `data-no-prefetch` to an `<a>` tag to skip it.
94
+ * Global opt-out: set this field to `false`.
95
+ *
96
+ * Default: `true`.
97
+ */
98
+ prefetch?: boolean;
10
99
  server?: {
11
100
  port?: number;
12
101
  hostname?: string;
@@ -47,6 +136,21 @@ export interface ManduConfig {
47
136
  watchDirs?: string[];
48
137
  /** Observability SQLite 영구 저장 (기본: true) */
49
138
  observability?: boolean;
139
+ /**
140
+ * Issue #191 — Dev-only `_devtools.js` (~1.15 MB React dev runtime +
141
+ * Mandu Kitchen panel) injection override.
142
+ *
143
+ * - `true` → force inject on every page (SSR-only projects that
144
+ * still want the Kitchen panel in dev).
145
+ * - `false` → force skip on every page (Kitchen-off dev loop).
146
+ * - `undefined` → default. Inject iff the page's bundle manifest
147
+ * has at least one island. Pure-SSR pages download
148
+ * zero devtools bytes.
149
+ *
150
+ * Production builds never emit `_devtools.js`, so this flag is
151
+ * a no-op in prod regardless of value.
152
+ */
153
+ devtools?: boolean;
50
154
  };
51
155
  fsRoutes?: {
52
156
  routesDir?: string;
@@ -59,6 +163,8 @@ export interface ManduConfig {
59
163
  defaultTitle?: string;
60
164
  titleTemplate?: string;
61
165
  };
166
+ /** Phase 12.1 — `mandu test` configuration block. */
167
+ test?: TestConfig;
62
168
  plugins?: ManduPlugin[];
63
169
  hooks?: Partial<ManduHooks>;
64
170
  }
@@ -41,7 +41,9 @@ function strictWithWarnings<T extends z.ZodRawShape>(
41
41
  const ServerConfigSchema = z
42
42
  .object({
43
43
  port: z.number().min(1).max(65535).default(3000),
44
- hostname: z.string().default("localhost"),
44
+ // Default 0.0.0.0 so IPv4 `localhost` resolution (Windows default) succeeds.
45
+ // Users may pin "::1" or "127.0.0.1" explicitly. See issue #190.
46
+ hostname: z.string().default("0.0.0.0"),
45
47
  cors: z
46
48
  .union([
47
49
  z.boolean(),
@@ -101,6 +103,13 @@ const DevConfigSchema = z
101
103
  hmr: z.boolean().default(true),
102
104
  watchDirs: z.array(z.string()).default([]),
103
105
  observability: z.boolean().default(true),
106
+ /**
107
+ * Issue #191 — `_devtools.js` (~1.15 MB React dev runtime + Kitchen
108
+ * panel) injection override. `undefined` (omitted) = default
109
+ * auto-detect based on `manifest.hasIslands`. Explicit `true` / `false`
110
+ * force on / off. Only applies in dev mode.
111
+ */
112
+ devtools: z.boolean().optional(),
104
113
  })
105
114
  .strict();
106
115
 
@@ -127,6 +136,59 @@ const SeoConfigSchema = z
127
136
  })
128
137
  .strict();
129
138
 
139
+ /**
140
+ * Test 설정 스키마 (Phase 12.1 — strict)
141
+ *
142
+ * `.strict()` is applied at every nested object so stale / misspelt keys
143
+ * are caught at config-load time, not when the CLI trips over them. Each
144
+ * default mirrors the TypeScript documentation in `./mandu.ts`.
145
+ */
146
+ const TestUnitConfigSchema = z
147
+ .object({
148
+ include: z.array(z.string().min(1)).default(["**/*.test.ts", "**/*.test.tsx"]),
149
+ exclude: z
150
+ .array(z.string().min(1))
151
+ .default(["node_modules/**", ".mandu/**", "dist/**"]),
152
+ timeout: z.number().int().positive().default(30_000),
153
+ })
154
+ .strict();
155
+
156
+ const TestIntegrationConfigSchema = z
157
+ .object({
158
+ include: z
159
+ .array(z.string().min(1))
160
+ .default(["tests/integration/**/*.test.ts", "tests/integration/**/*.test.tsx"]),
161
+ exclude: z
162
+ .array(z.string().min(1))
163
+ .default(["node_modules/**", ".mandu/**", "dist/**"]),
164
+ dbUrl: z.string().min(1).default("sqlite::memory:"),
165
+ sessionStore: z.enum(["memory", "sqlite"]).default("memory"),
166
+ timeout: z.number().int().positive().default(60_000),
167
+ })
168
+ .strict();
169
+
170
+ const TestE2EConfigSchema = z
171
+ .object({
172
+ reserved: z.literal(true).optional(),
173
+ })
174
+ .strict();
175
+
176
+ const TestCoverageConfigSchema = z
177
+ .object({
178
+ lines: z.number().min(0).max(100).optional(),
179
+ branches: z.number().min(0).max(100).optional(),
180
+ })
181
+ .strict();
182
+
183
+ const TestConfigSchema = z
184
+ .object({
185
+ unit: TestUnitConfigSchema.default({}),
186
+ integration: TestIntegrationConfigSchema.default({}),
187
+ e2e: TestE2EConfigSchema.default({}),
188
+ coverage: TestCoverageConfigSchema.default({}),
189
+ })
190
+ .strict();
191
+
130
192
  const AdapterConfigSchema = z.custom<ManduAdapter | undefined>(
131
193
  (value) =>
132
194
  value === undefined ||
@@ -165,12 +227,23 @@ const ManduHooksSchema = z.custom<Partial<ManduHooks>>(
165
227
  export const ManduConfigSchema = z
166
228
  .object({
167
229
  adapter: AdapterConfigSchema.optional(),
230
+ /**
231
+ * Issue #192 — CSS View Transitions auto-inject. Default `true`.
232
+ * Set `false` to suppress the `@view-transition` `<style>` block.
233
+ */
234
+ transitions: z.boolean().default(true),
235
+ /**
236
+ * Issue #192 — Hover prefetch helper. Default `true`.
237
+ * Set `false` to suppress the ~500-byte prefetch IIFE.
238
+ */
239
+ prefetch: z.boolean().default(true),
168
240
  server: ServerConfigSchema.default({}),
169
241
  guard: GuardConfigSchema.default({}),
170
242
  build: BuildConfigSchema.default({}),
171
243
  dev: DevConfigSchema.default({}),
172
244
  fsRoutes: FsRoutesConfigSchema.default({}),
173
245
  seo: SeoConfigSchema.default({}),
246
+ test: TestConfigSchema.default({}),
174
247
  plugins: z.array(ManduPluginSchema).optional(),
175
248
  hooks: ManduHooksSchema.optional(),
176
249
  })
@@ -178,6 +251,19 @@ export const ManduConfigSchema = z
178
251
 
179
252
  export type ValidatedManduConfig = z.infer<typeof ManduConfigSchema>;
180
253
 
254
+ /** Validated `test` block (convenience re-export for fixtures/CLI). */
255
+ export type ValidatedTestConfig = z.infer<typeof TestConfigSchema>;
256
+
257
+ /**
258
+ * Resolve the `test` block with defaults filled in.
259
+ *
260
+ * Use this from fixtures / CLI test runners that need a guaranteed-shaped
261
+ * object without having to validate the whole config.
262
+ */
263
+ export function resolveTestConfig(raw?: unknown): ValidatedTestConfig {
264
+ return TestConfigSchema.parse(raw ?? {});
265
+ }
266
+
181
267
  /**
182
268
  * 검증 결과
183
269
  */
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Phase 11 C / M-02 — FFI fallback unit tests.
3
+ *
4
+ * Scope:
5
+ * 1. The module imports cleanly even when the webview-bun peer and the
6
+ * libwebview shared library are BOTH absent. Phase 9 released the
7
+ * barrel as importable under these conditions; Phase 11 C preserves
8
+ * that invariant for the fallback.
9
+ * 2. `_ffiSymbols` shape is frozen — matches the upstream C ABI we
10
+ * pinned in `webview-fallback.ts` header.
11
+ * 3. `_mapHintToInt` mirrors the upstream `WEBVIEW_HINT_*` enum.
12
+ * 4. `_getLibraryCandidates()` probes the three expected slots
13
+ * (env var → package-relative mirror → system default).
14
+ * 5. `createFallbackWebview` rejects bad options BEFORE any `dlopen`
15
+ * attempt, so a CI without libwebview still passes.
16
+ * 6. `loadFFILibwebview` failure yields a user-actionable error
17
+ * message enumerating every probed path.
18
+ * 7. (opt-in E2E) On a machine with libwebview installed, a real
19
+ * window can be created via the fallback. Gated by
20
+ * `MANDU_DESKTOP_FALLBACK_E2E=1`.
21
+ *
22
+ * These tests deliberately exercise the FFI contract WITHOUT actually
23
+ * dlopening the library — CI cannot assume libwebview is installed. The
24
+ * opt-in block (test 7) is only active under an explicit env flag.
25
+ *
26
+ * References:
27
+ * docs/bun/phase-9-diagnostics/webview-bun-ffi.md §8 (fallback design)
28
+ * packages/core/src/desktop/webview-fallback.ts
29
+ */
30
+
31
+ import {
32
+ describe,
33
+ it,
34
+ expect,
35
+ beforeEach,
36
+ afterEach,
37
+ } from "bun:test";
38
+
39
+ describe("@mandujs/core/desktop/webview-fallback — module import", () => {
40
+ it("imports without loading libwebview", async () => {
41
+ // The import itself must succeed even when `bun:ffi.dlopen` would
42
+ // fail (no libwebview). Module-level code must not `dlopen` — that
43
+ // happens only on `createFallbackWebview()`.
44
+ const mod = await import("../webview-fallback");
45
+ expect(typeof mod.createFallbackWebview).toBe("function");
46
+ expect(typeof mod.loadFFILibwebview).toBe("function");
47
+ expect(typeof mod._mapHintToInt).toBe("function");
48
+ expect(typeof mod._getLibraryCandidates).toBe("function");
49
+ expect(typeof mod._defaultLibName).toBe("function");
50
+ expect(typeof mod._resetFFICache).toBe("function");
51
+ });
52
+
53
+ it("module import is idempotent — cache loader does not fire on import", async () => {
54
+ // Import twice; neither call should throw. If `import.meta.url`-based
55
+ // candidate probing has a side effect, that would manifest on the
56
+ // second call.
57
+ const a = await import("../webview-fallback");
58
+ const b = await import("../webview-fallback");
59
+ expect(a).toBe(b); // same module instance from Bun's ESM cache
60
+ });
61
+ });
62
+
63
+ describe("@mandujs/core/desktop/webview-fallback — FFI symbol contract", () => {
64
+ it("_ffiSymbols declares the minimal webview C ABI we depend on", async () => {
65
+ const { _ffiSymbols } = await import("../webview-fallback");
66
+ // The set of symbols MUST match the upstream webview.h surface we
67
+ // pinned in Phase 11 C. Test the shape, not the exact order.
68
+ const expected = [
69
+ "webview_create",
70
+ "webview_navigate",
71
+ "webview_set_title",
72
+ "webview_set_size",
73
+ "webview_set_html",
74
+ "webview_run",
75
+ "webview_terminate",
76
+ "webview_destroy",
77
+ ];
78
+ for (const sym of expected) {
79
+ expect(_ffiSymbols).toHaveProperty(sym);
80
+ const entry = (_ffiSymbols as Record<string, { args: unknown; returns: unknown }>)[sym];
81
+ expect(Array.isArray(entry.args)).toBe(true);
82
+ expect(typeof entry.returns).toBe("string");
83
+ }
84
+ });
85
+
86
+ it("_ffiSymbols is frozen — no mutation allowed at runtime", async () => {
87
+ const { _ffiSymbols } = await import("../webview-fallback");
88
+ expect(Object.isFrozen(_ffiSymbols)).toBe(true);
89
+ });
90
+
91
+ it("webview_create returns a pointer and takes (i32, ptr)", async () => {
92
+ const { _ffiSymbols } = await import("../webview-fallback");
93
+ expect(_ffiSymbols.webview_create.args).toEqual(["i32", "ptr"]);
94
+ expect(_ffiSymbols.webview_create.returns).toBe("ptr");
95
+ });
96
+
97
+ it("webview_set_size has 4 args matching (ptr,i32,i32,i32)", async () => {
98
+ const { _ffiSymbols } = await import("../webview-fallback");
99
+ expect(_ffiSymbols.webview_set_size.args).toEqual([
100
+ "ptr",
101
+ "i32",
102
+ "i32",
103
+ "i32",
104
+ ]);
105
+ });
106
+ });
107
+
108
+ describe("@mandujs/core/desktop/webview-fallback — _mapHintToInt", () => {
109
+ it("matches WEBVIEW_HINT_* enum values from upstream webview.h", async () => {
110
+ const { _mapHintToInt } = await import("../webview-fallback");
111
+ expect(_mapHintToInt("none")).toBe(0);
112
+ expect(_mapHintToInt("min")).toBe(1);
113
+ expect(_mapHintToInt("max")).toBe(2);
114
+ expect(_mapHintToInt("fixed")).toBe(3);
115
+ expect(_mapHintToInt(undefined)).toBe(0);
116
+ });
117
+ });
118
+
119
+ describe("@mandujs/core/desktop/webview-fallback — library candidate probe", () => {
120
+ const ORIGINAL_ENV = process.env.MANDU_LIBWEBVIEW_PATH;
121
+
122
+ beforeEach(() => {
123
+ delete process.env.MANDU_LIBWEBVIEW_PATH;
124
+ });
125
+
126
+ afterEach(() => {
127
+ if (ORIGINAL_ENV !== undefined) {
128
+ process.env.MANDU_LIBWEBVIEW_PATH = ORIGINAL_ENV;
129
+ } else {
130
+ delete process.env.MANDU_LIBWEBVIEW_PATH;
131
+ }
132
+ });
133
+
134
+ it("_getLibraryCandidates includes the system default when no env is set", async () => {
135
+ const { _getLibraryCandidates, _defaultLibName } = await import(
136
+ "../webview-fallback"
137
+ );
138
+ const candidates = _getLibraryCandidates();
139
+ expect(candidates.length).toBeGreaterThanOrEqual(1);
140
+ // Last candidate is always the bare lib name.
141
+ expect(candidates[candidates.length - 1]).toBe(_defaultLibName());
142
+ });
143
+
144
+ it("_getLibraryCandidates prepends MANDU_LIBWEBVIEW_PATH when set", async () => {
145
+ process.env.MANDU_LIBWEBVIEW_PATH = "/opt/custom/libwebview.so";
146
+ const { _getLibraryCandidates } = await import("../webview-fallback");
147
+ const candidates = _getLibraryCandidates();
148
+ expect(candidates[0]).toBe("/opt/custom/libwebview.so");
149
+ });
150
+
151
+ it("_defaultLibName maps to platform extension", async () => {
152
+ const { _defaultLibName } = await import("../webview-fallback");
153
+ const name = _defaultLibName();
154
+ if (process.platform === "win32") expect(name).toBe("libwebview.dll");
155
+ else if (process.platform === "darwin")
156
+ expect(name).toBe("libwebview.dylib");
157
+ else expect(name).toBe("libwebview.so");
158
+ });
159
+ });
160
+
161
+ describe("@mandujs/core/desktop/webview-fallback — createFallbackWebview", () => {
162
+ it("rejects missing options before any dlopen attempt", async () => {
163
+ const { createFallbackWebview } = await import("../webview-fallback");
164
+ // An empty-object options bag must be caught by defensive guards
165
+ // BEFORE we touch the FFI peer — so this test passes on CI without
166
+ // libwebview.
167
+ await expect(
168
+ createFallbackWebview({} as never),
169
+ ).rejects.toThrow(TypeError);
170
+ });
171
+
172
+ it("rejects non-string url before any dlopen attempt", async () => {
173
+ const { createFallbackWebview } = await import("../webview-fallback");
174
+ await expect(
175
+ createFallbackWebview({ url: 42 as unknown as string }),
176
+ ).rejects.toThrow(TypeError);
177
+ });
178
+
179
+ it("rejects empty url before any dlopen attempt", async () => {
180
+ const { createFallbackWebview } = await import("../webview-fallback");
181
+ await expect(
182
+ createFallbackWebview({ url: "" }),
183
+ ).rejects.toThrow(TypeError);
184
+ });
185
+ });
186
+
187
+ describe("@mandujs/core/desktop/webview-fallback — loadFFILibwebview failure surface", () => {
188
+ const ORIGINAL_ENV = process.env.MANDU_LIBWEBVIEW_PATH;
189
+
190
+ beforeEach(async () => {
191
+ // Force an unreachable path so the loader's failure hint is
192
+ // exercised without depending on the actual libwebview install
193
+ // state of the CI runner.
194
+ process.env.MANDU_LIBWEBVIEW_PATH =
195
+ "/path/that/definitely/does/not/exist/libwebview.so";
196
+ const mod = await import("../webview-fallback");
197
+ mod._resetFFICache();
198
+ });
199
+
200
+ afterEach(async () => {
201
+ if (ORIGINAL_ENV !== undefined) {
202
+ process.env.MANDU_LIBWEBVIEW_PATH = ORIGINAL_ENV;
203
+ } else {
204
+ delete process.env.MANDU_LIBWEBVIEW_PATH;
205
+ }
206
+ const mod = await import("../webview-fallback");
207
+ mod._resetFFICache();
208
+ });
209
+
210
+ it("throws an actionable error enumerating every probed path", async () => {
211
+ const { loadFFILibwebview } = await import("../webview-fallback");
212
+ try {
213
+ await loadFFILibwebview();
214
+ throw new Error("unreachable — loadFFILibwebview should have thrown");
215
+ } catch (err) {
216
+ expect(err).toBeInstanceOf(Error);
217
+ const msg = (err as Error).message;
218
+ // Actionable hints — install options + env var hint.
219
+ expect(msg).toContain("libwebview");
220
+ expect(msg).toContain("MANDU_LIBWEBVIEW_PATH");
221
+ expect(msg).toContain("webview/webview");
222
+ // Should enumerate the failing candidate we injected.
223
+ expect(msg).toContain("libwebview.so");
224
+ }
225
+ });
226
+ });
227
+
228
+ // ─── Opt-in E2E (real window) ──────────────────────────────────────────────
229
+ //
230
+ // Runs ONLY when MANDU_DESKTOP_FALLBACK_E2E=1 AND platform supports it.
231
+ // CI skips this block unconditionally.
232
+
233
+ const canOpenFallbackWindow =
234
+ process.env.MANDU_DESKTOP_FALLBACK_E2E === "1" &&
235
+ (process.platform === "win32" ||
236
+ process.platform === "darwin" ||
237
+ process.platform === "linux");
238
+
239
+ describe.skipIf(!canOpenFallbackWindow)(
240
+ "@mandujs/core/desktop/webview-fallback — browser smoke (opt-in)",
241
+ () => {
242
+ it("opens a data: URL window via the FFI fallback", async () => {
243
+ const { createFallbackWebview } = await import("../webview-fallback");
244
+ const handle = await createFallbackWebview({
245
+ url: "data:text/html,<h1>Mandu FFI fallback smoke</h1>",
246
+ title: "Mandu Fallback E2E",
247
+ width: 400,
248
+ height: 300,
249
+ });
250
+ await handle.close();
251
+ await handle.closed;
252
+ });
253
+ },
254
+ );