@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.
@@ -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
+ );
@@ -165,8 +165,84 @@ describe("@mandujs/core/desktop — createWindow peer loading", () => {
165
165
  return;
166
166
  }
167
167
 
168
- await expect(
169
- createWindow({ url: "http://127.0.0.1:1" }),
170
- ).rejects.toThrow(/webview-bun/);
168
+ // Ensure the FFI fallback also can't resolve — otherwise `createWindow`
169
+ // silently flips to the FFI path. We force an unreachable library path
170
+ // so both paths deterministically fail, which is the state the
171
+ // actionable error contract guards.
172
+ const originalFFIPath = process.env.MANDU_LIBWEBVIEW_PATH;
173
+ process.env.MANDU_LIBWEBVIEW_PATH =
174
+ "/path/that/does/not/exist/libwebview.so";
175
+ try {
176
+ await expect(
177
+ createWindow({ url: "http://127.0.0.1:1" }),
178
+ ).rejects.toThrow(/webview-bun/);
179
+ } finally {
180
+ if (originalFFIPath !== undefined) {
181
+ process.env.MANDU_LIBWEBVIEW_PATH = originalFFIPath;
182
+ } else {
183
+ delete process.env.MANDU_LIBWEBVIEW_PATH;
184
+ }
185
+ }
186
+ });
187
+
188
+ it("MANDU_DESKTOP_INLINE_FFI=1 routes to the FFI fallback path", async () => {
189
+ // With the env flag set, `createWindow` must import
190
+ // `./webview-fallback` instead of `webview-bun`. We don't assert on
191
+ // the exact success/failure of the FFI call (that depends on the
192
+ // machine's libwebview install state) — we only assert that the
193
+ // branch is taken. The simplest observable is: when the library
194
+ // actually loads AND the test machine has a webview backend
195
+ // (WebView2 on Windows etc.), a real window would open. To stay
196
+ // CI-safe we monkey-patch dynamic import of the fallback module to
197
+ // detect invocation.
198
+ //
199
+ // Bun's ESM cache makes this tricky — instead we verify the call
200
+ // selector by checking that `createWindow` does NOT throw the
201
+ // webview-bun "install me" error when the flag is set. That error
202
+ // is only reachable via the primary `_loadWebviewBun` path; if the
203
+ // FFI branch is skipped or the webview-bun import is tried in its
204
+ // place, the test would observe the install-me error.
205
+ const originalFlag = process.env.MANDU_DESKTOP_INLINE_FFI;
206
+ const originalFFIPath = process.env.MANDU_LIBWEBVIEW_PATH;
207
+ process.env.MANDU_DESKTOP_INLINE_FFI = "1";
208
+ // Force an unreachable libwebview so the fallback fails deterministically
209
+ // and the error surfaces the fallback's distinctive language (which
210
+ // is what we assert on).
211
+ process.env.MANDU_LIBWEBVIEW_PATH = "/nonexistent/libwebview-test-stub.so";
212
+
213
+ try {
214
+ // Resolve whatever error comes back and assert it's NOT the
215
+ // webview-bun peer-missing error. The exact failure mode of the
216
+ // FFI path varies by CI machine (libwebview missing, dlopen
217
+ // succeeds on a stub, or the FFI cstring call errors), but none
218
+ // of those produce the distinctive "bun add webview-bun" string
219
+ // from the primary path.
220
+ let caught: unknown;
221
+ try {
222
+ const handle = await createWindow({ url: "http://127.0.0.1:1" });
223
+ // Unexpected success — close to clean up, then fail.
224
+ await handle.close();
225
+ throw new Error(
226
+ "unreachable — createWindow should fail with MANDU_DESKTOP_INLINE_FFI=1 + bogus MANDU_LIBWEBVIEW_PATH",
227
+ );
228
+ } catch (err) {
229
+ caught = err;
230
+ }
231
+ const msg = caught instanceof Error ? caught.message : String(caught);
232
+ // The webview-bun install-me message always mentions "bun add webview-bun".
233
+ // The fallback path's failure never does.
234
+ expect(msg).not.toContain("bun add webview-bun");
235
+ } finally {
236
+ if (originalFlag !== undefined) {
237
+ process.env.MANDU_DESKTOP_INLINE_FFI = originalFlag;
238
+ } else {
239
+ delete process.env.MANDU_DESKTOP_INLINE_FFI;
240
+ }
241
+ if (originalFFIPath !== undefined) {
242
+ process.env.MANDU_LIBWEBVIEW_PATH = originalFFIPath;
243
+ } else {
244
+ delete process.env.MANDU_LIBWEBVIEW_PATH;
245
+ }
246
+ }
171
247
  });
172
248
  });