@qontinui/ui-bridge-native 0.2.0 → 0.2.1

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.
Files changed (43) hide show
  1. package/dist/control/index.d.mts +4 -4
  2. package/dist/control/index.d.ts +4 -4
  3. package/dist/core/index.d.mts +3 -3
  4. package/dist/core/index.d.ts +3 -3
  5. package/dist/core/index.js +39 -1
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +39 -1
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/debug/index.js +2 -2
  10. package/dist/debug/index.js.map +1 -1
  11. package/dist/debug/index.mjs +2 -2
  12. package/dist/debug/index.mjs.map +1 -1
  13. package/dist/design/index.d.mts +1 -1
  14. package/dist/design/index.d.ts +1 -1
  15. package/dist/{http-server-DCdR8WlL.d.mts → http-server-D2U1k48d.d.mts} +89 -8
  16. package/dist/{http-server-Dj3grQW4.d.ts → http-server-ck8clUsZ.d.ts} +89 -8
  17. package/dist/index.d.mts +5 -5
  18. package/dist/index.d.ts +5 -5
  19. package/dist/index.js +364 -39
  20. package/dist/index.js.map +1 -1
  21. package/dist/index.mjs +364 -39
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/react/index.d.mts +5 -5
  24. package/dist/react/index.d.ts +5 -5
  25. package/dist/react/index.js +346 -39
  26. package/dist/react/index.js.map +1 -1
  27. package/dist/react/index.mjs +346 -39
  28. package/dist/react/index.mjs.map +1 -1
  29. package/dist/{registry-Cahd8Gn2.d.mts → registry-BxXaxmsT.d.ts} +24 -1
  30. package/dist/{registry-z-3WJsxp.d.ts → registry-KdUW_koV.d.mts} +24 -1
  31. package/dist/server/index.d.mts +5 -5
  32. package/dist/server/index.d.ts +5 -5
  33. package/dist/server/index.js +272 -16
  34. package/dist/server/index.js.map +1 -1
  35. package/dist/server/index.mjs +272 -16
  36. package/dist/server/index.mjs.map +1 -1
  37. package/dist/{types-Oz13YLZq.d.mts → types-BwdPNpII.d.mts} +52 -4
  38. package/dist/{types-Oz13YLZq.d.ts → types-BwdPNpII.d.ts} +52 -4
  39. package/dist/{types-ajakyp-l.d.ts → types-DCEv5qb6.d.ts} +1 -1
  40. package/dist/{types-DAfCeWXc.d.mts → types-DjB0zJJH.d.mts} +1 -1
  41. package/dist/{useUIBridgeToast-GmT1TzUe.d.mts → useUIBridgeToast-BPIEbsEc.d.mts} +11 -5
  42. package/dist/{useUIBridgeToast-D32KXyL1.d.ts → useUIBridgeToast-DD13oF3k.d.ts} +11 -5
  43. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -57,6 +57,13 @@ var NativeUIBridgeRegistry = class {
57
57
  this.eventListeners = /* @__PURE__ */ new Map();
58
58
  this.enrichers = {};
59
59
  this.snapshotExtras = /* @__PURE__ */ new Map();
60
+ /**
61
+ * Sticky flag: flips `true` the first time any element is registered and
62
+ * stays `true` even after elements are unregistered. Lets agents distinguish
63
+ * "this route never wired any elements" from "this route registered then
64
+ * unmounted".
65
+ */
66
+ this.everHadRegistrations = false;
60
67
  this.config = config;
61
68
  }
62
69
  // ============================================================================
@@ -69,6 +76,16 @@ var NativeUIBridgeRegistry = class {
69
76
  setEnrichers(e) {
70
77
  this.enrichers = { ...this.enrichers, ...e };
71
78
  }
79
+ /**
80
+ * Read-only accessor for the canonical enrichers slot. Returned object is
81
+ * the live reference, so callers can detect "no detector wired" via
82
+ * `getEnrichers().modalDetector === undefined`. Used by the test-hook HTTP
83
+ * handlers (`control/modal/push`, `control/modal/dismiss/:id`) which need
84
+ * to drive the ModalDetector directly without re-instantiating it.
85
+ */
86
+ getEnrichers() {
87
+ return this.enrichers;
88
+ }
72
89
  /**
73
90
  * Register a custom snapshot enricher. The returned object will be
74
91
  * `Object.assign`ed onto the snapshot, so use unique top-level keys to avoid
@@ -148,6 +165,7 @@ var NativeUIBridgeRegistry = class {
148
165
  stateStyles
149
166
  };
150
167
  this.elements.set(id, registered);
168
+ this.everHadRegistrations = true;
151
169
  this.emit("element:registered", { id, type, label });
152
170
  if (this.config.verbose) {
153
171
  console.log(`[ui-bridge-native] Registered element: ${id} (${type})`);
@@ -475,7 +493,8 @@ var NativeUIBridgeRegistry = class {
475
493
  stepCount: w.steps.length
476
494
  })),
477
495
  currentRoute: routeInfo?.currentRoute ?? null,
478
- segments: routeInfo?.segments
496
+ segments: routeInfo?.segments,
497
+ registration: this.getRegistrationCoverage()
479
498
  };
480
499
  if (this.enrichers.modalDetector) {
481
500
  try {
@@ -531,6 +550,25 @@ var NativeUIBridgeRegistry = class {
531
550
  workflows: this.workflows.size
532
551
  };
533
552
  }
553
+ /**
554
+ * Compute registration coverage metadata for the current registry state.
555
+ *
556
+ * Groups currently-registered elements by `registrationRoute`, bucketing
557
+ * elements without a route under `'?'`. `everHadRegistrations` is sticky —
558
+ * once true, it stays true for the lifetime of the registry instance.
559
+ */
560
+ getRegistrationCoverage() {
561
+ const byRoute = {};
562
+ for (const element of this.elements.values()) {
563
+ const key = element.registrationRoute == null || element.registrationRoute === "" ? "?" : element.registrationRoute;
564
+ byRoute[key] = (byRoute[key] ?? 0) + 1;
565
+ }
566
+ return {
567
+ totalRegistered: this.elements.size,
568
+ everHadRegistrations: this.everHadRegistrations,
569
+ byRoute
570
+ };
571
+ }
534
572
  /**
535
573
  * Clear all registrations
536
574
  */
@@ -1938,6 +1976,20 @@ function createServerHandlers(registry, executor, config) {
1938
1976
  return success2(response);
1939
1977
  },
1940
1978
  // Discovery
1979
+ //
1980
+ // Native parity with the runner's `POST /ui-bridge/ai/find`. The runner
1981
+ // matches a free-text `query` against label > text > value > aria-label >
1982
+ // placeholder > name. We replicate that here against the native registry's
1983
+ // analogues:
1984
+ // 1. `label` (the registered display label)
1985
+ // 2. identifier fields: `testId`, `accessibilityLabel`, `treePath`, `uiId`
1986
+ // 3. registered handler names (e.g. `onPress`, `onChangeText`)
1987
+ //
1988
+ // Match is case-insensitive substring. Empty/missing query falls back to
1989
+ // the structured filters (types/visibleOnly/etc.) so callers that don't
1990
+ // pass `query` still get the legacy behaviour. Once we've narrowed by
1991
+ // query the response is shaped through the executor's NativeFindResponse
1992
+ // envelope so the wire format stays stable.
1941
1993
  find: async (ctx) => {
1942
1994
  const body = ctx.body;
1943
1995
  const response = await executor.find({
@@ -1945,9 +1997,49 @@ function createServerHandlers(registry, executor, config) {
1945
1997
  testIdPattern: body?.testIdPattern,
1946
1998
  accessibilityLabelPattern: body?.accessibilityLabelPattern,
1947
1999
  visibleOnly: body?.visibleOnly,
1948
- limit: body?.limit
2000
+ // When `query` is supplied we apply our own limit *after* ranking so
2001
+ // the highest-priority matches survive the cap; defer slicing to us
2002
+ // by widening here.
2003
+ limit: body?.query && body.query.length > 0 ? void 0 : body?.limit
2004
+ });
2005
+ const rawQuery = typeof body?.query === "string" ? body.query.trim() : "";
2006
+ if (rawQuery.length === 0) {
2007
+ return success2(response);
2008
+ }
2009
+ const needle = rawQuery.toLowerCase();
2010
+ const containsNeedle = (value) => typeof value === "string" && value.toLowerCase().includes(needle);
2011
+ const scored = [];
2012
+ for (const el of response.elements) {
2013
+ let priorityRank = Number.POSITIVE_INFINITY;
2014
+ if (containsNeedle(el.label)) {
2015
+ priorityRank = Math.min(priorityRank, 0);
2016
+ }
2017
+ const ident = el.identifier;
2018
+ if (ident) {
2019
+ if (containsNeedle(ident.testId)) priorityRank = Math.min(priorityRank, 1);
2020
+ if (containsNeedle(ident.accessibilityLabel)) priorityRank = Math.min(priorityRank, 1);
2021
+ if (containsNeedle(ident.treePath)) priorityRank = Math.min(priorityRank, 1);
2022
+ if (containsNeedle(ident.uiId)) priorityRank = Math.min(priorityRank, 1);
2023
+ }
2024
+ if (priorityRank === Number.POSITIVE_INFINITY) {
2025
+ const registered = registry.getElement(el.id);
2026
+ const handlers = extractHandlerNames(registered?.props);
2027
+ if (handlers.some((h) => h.toLowerCase().includes(needle))) {
2028
+ priorityRank = 2;
2029
+ }
2030
+ }
2031
+ if (priorityRank !== Number.POSITIVE_INFINITY) {
2032
+ scored.push({ priorityRank, element: el });
2033
+ }
2034
+ }
2035
+ scored.sort((a, b) => a.priorityRank - b.priorityRank);
2036
+ const limit = body?.limit && body.limit > 0 ? body.limit : void 0;
2037
+ const filtered = limit ? scored.slice(0, limit) : scored;
2038
+ return success2({
2039
+ ...response,
2040
+ elements: filtered.map((s) => s.element),
2041
+ total: filtered.length
1949
2042
  });
1950
- return success2(response);
1951
2043
  },
1952
2044
  getSnapshot: async (ctx) => {
1953
2045
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
@@ -2048,15 +2140,162 @@ function createServerHandlers(registry, executor, config) {
2048
2140
  getMethods: async () => {
2049
2141
  return error2("Methods introspection not configured", "NOT_SUPPORTED");
2050
2142
  },
2143
+ // AI helpers
2144
+ //
2145
+ // Mobile-side parity with the runner's `POST /ui-bridge/ai/fill-form`.
2146
+ // Each entry in `fields` is dispatched through the existing executor as a
2147
+ // `type` action with `clear: true` so the input value is replaced
2148
+ // atomically (the closest native equivalent to the web bridge's
2149
+ // "setValue" semantics — there is no `setValue` action on native).
2150
+ //
2151
+ // Failures do not short-circuit: every field is attempted, and the
2152
+ // response carries per-field success/error plus aggregate counts.
2153
+ fillForm: async (ctx) => {
2154
+ const body = ctx.body;
2155
+ const rawFields = body?.fields;
2156
+ if (!Array.isArray(rawFields)) {
2157
+ return error2('Request body must include "fields" array', "INVALID_REQUEST");
2158
+ }
2159
+ const results = [];
2160
+ for (let i = 0; i < rawFields.length; i++) {
2161
+ const field = rawFields[i];
2162
+ if (!field || typeof field !== "object") {
2163
+ results.push({
2164
+ elementId: "",
2165
+ success: false,
2166
+ error: `fields[${i}] must be an object with "elementId" and "value"`
2167
+ });
2168
+ continue;
2169
+ }
2170
+ const { elementId, value } = field;
2171
+ if (typeof elementId !== "string" || elementId.length === 0) {
2172
+ results.push({
2173
+ elementId: typeof elementId === "string" ? elementId : "",
2174
+ success: false,
2175
+ error: `fields[${i}] is missing a non-empty "elementId"`
2176
+ });
2177
+ continue;
2178
+ }
2179
+ if (typeof value !== "string") {
2180
+ results.push({
2181
+ elementId,
2182
+ success: false,
2183
+ error: `fields[${i}] is missing a string "value"`
2184
+ });
2185
+ continue;
2186
+ }
2187
+ try {
2188
+ const response = await executor.executeAction(elementId, {
2189
+ action: "type",
2190
+ params: { text: value, clear: true }
2191
+ });
2192
+ if (response.success) {
2193
+ results.push({ elementId, success: true });
2194
+ } else {
2195
+ results.push({
2196
+ elementId,
2197
+ success: false,
2198
+ error: response.error ?? "Action failed"
2199
+ });
2200
+ }
2201
+ } catch (err) {
2202
+ results.push({
2203
+ elementId,
2204
+ success: false,
2205
+ error: err instanceof Error ? err.message : String(err)
2206
+ });
2207
+ }
2208
+ }
2209
+ const succeededCount = results.filter((r) => r.success).length;
2210
+ const failedCount = results.length - succeededCount;
2211
+ return success2({ results, succeededCount, failedCount });
2212
+ },
2213
+ // Test hooks — modal stack manipulation
2214
+ //
2215
+ // These two endpoints exist so external automation runners can drive
2216
+ // ModalDetector state via HTTP, mirroring the in-tree `pushModal` /
2217
+ // `dismissModal` calls that components normally make. They're gated by
2218
+ // `features.testHooks` at the route layer (see http-server.ts); the
2219
+ // handlers themselves still defend against a missing modalDetector
2220
+ // because the bare server path used by CloudRelayClient may run before
2221
+ // enrichers are wired.
2222
+ pushModal: async (ctx) => {
2223
+ const detector = registry.getEnrichers().modalDetector;
2224
+ if (!detector) {
2225
+ return error2(
2226
+ "modal detector not registered",
2227
+ "ENRICHER_UNAVAILABLE"
2228
+ );
2229
+ }
2230
+ const body = ctx.body;
2231
+ const id = body?.id;
2232
+ if (typeof id !== "string" || id.length === 0) {
2233
+ return error2(
2234
+ 'pushModal requires a non-empty "id"',
2235
+ "INVALID_REQUEST"
2236
+ );
2237
+ }
2238
+ const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
2239
+ const requestedType = body?.type;
2240
+ if (requestedType !== void 0 && (typeof requestedType !== "string" || !allowedTypes.includes(requestedType))) {
2241
+ return error2(
2242
+ `pushModal "type" must be one of: ${allowedTypes.join(", ")}`,
2243
+ "INVALID_REQUEST"
2244
+ );
2245
+ }
2246
+ detector.pushModal({
2247
+ id,
2248
+ title: body?.title,
2249
+ // requestedType is narrowed by the allowedTypes guard above; the cast
2250
+ // converts the validated string to the NativeModalInfo['type'] union.
2251
+ type: requestedType,
2252
+ blocking: body?.blocking,
2253
+ dismissible: body?.dismissible
2254
+ });
2255
+ const stored = detector.getActive().find((m) => m.id === id);
2256
+ return success2({
2257
+ pushed: true,
2258
+ id,
2259
+ title: stored?.title,
2260
+ type: stored?.type,
2261
+ blocking: stored?.blocking,
2262
+ dismissible: stored?.dismissible,
2263
+ pushedAt: stored?.detectedAt ?? Date.now()
2264
+ });
2265
+ },
2266
+ dismissModal: async (ctx) => {
2267
+ const detector = registry.getEnrichers().modalDetector;
2268
+ if (!detector) {
2269
+ return error2(
2270
+ "modal detector not registered",
2271
+ "ENRICHER_UNAVAILABLE"
2272
+ );
2273
+ }
2274
+ const { id } = ctx.params;
2275
+ if (typeof id !== "string" || id.length === 0) {
2276
+ return error2(
2277
+ "dismissModal requires a modal id in the path",
2278
+ "INVALID_REQUEST"
2279
+ );
2280
+ }
2281
+ const wasPresent = detector.getActive().some((m) => m.id === id);
2282
+ if (!wasPresent) {
2283
+ return error2(`modal not found: ${id}`, "MODAL_NOT_FOUND");
2284
+ }
2285
+ detector.dismissModal(id);
2286
+ return success2({ dismissed: id });
2287
+ },
2051
2288
  // Design Review
2052
2289
  ...designHandlers,
2053
2290
  // Health
2054
2291
  health: async () => {
2055
2292
  const stats = registry.getStats();
2293
+ const registration = registry.getRegistrationCoverage();
2056
2294
  const response = {
2057
2295
  status: "healthy",
2058
2296
  timestamp: Date.now(),
2059
- ...stats
2297
+ ...stats,
2298
+ registration
2060
2299
  };
2061
2300
  if (config?.appInfo) {
2062
2301
  response.uiBridge = {
@@ -2157,7 +2396,24 @@ var WS_ROUTES = [
2157
2396
  { pattern: "design/evaluate/baseline", handler: "saveBaseline", httpMethods: ["POST"] },
2158
2397
  { pattern: "design/evaluate/diff", handler: "diffBaseline", httpMethods: ["POST"] },
2159
2398
  // Meta / Introspection
2160
- { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] }
2399
+ { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] },
2400
+ // AI helpers — mobile parity with the runner's `/ui-bridge/ai/*` family.
2401
+ { pattern: "ai/fill-form", handler: "fillForm", httpMethods: ["POST"] },
2402
+ // Test hooks — gated behind `features.testHooks`. Let external runners
2403
+ // drive the ModalDetector over HTTP so tests can flip `snapshot.modalStack`
2404
+ // without mounting React components.
2405
+ {
2406
+ pattern: "control/modal/push",
2407
+ handler: "pushModal",
2408
+ httpMethods: ["POST"],
2409
+ requiresTestHooks: true
2410
+ },
2411
+ {
2412
+ pattern: "control/modal/dismiss/:id",
2413
+ handler: "dismissModal",
2414
+ httpMethods: ["POST"],
2415
+ requiresTestHooks: true
2416
+ }
2161
2417
  ];
2162
2418
  var HANDLER_DESCRIPTIONS = {
2163
2419
  health: "Liveness probe; returns app metadata + element count",
@@ -2190,7 +2446,10 @@ var HANDLER_DESCRIPTIONS = {
2190
2446
  getQualityContexts: "List available quality evaluation contexts",
2191
2447
  saveBaseline: "Save a quality baseline snapshot",
2192
2448
  diffBaseline: "Diff current UI against a saved baseline",
2193
- getMethods: "List every handler name (legacy introspection)"
2449
+ getMethods: "List every handler name (legacy introspection)",
2450
+ fillForm: "Fill multiple inputs in one call: body { fields: [{elementId, value}] } \u2014 each field dispatches `type` + `clear:true`",
2451
+ pushModal: "TEST HOOK \u2014 push a modal onto the snapshot.modalStack via the registry ModalDetector",
2452
+ dismissModal: "TEST HOOK \u2014 dismiss a modal by id from the snapshot.modalStack via the registry ModalDetector"
2194
2453
  };
2195
2454
  var ACTION_REFERENCE = [
2196
2455
  { name: "press", aliases: ["click"], params: "PressActionParams (optional position)" },
@@ -2207,10 +2466,11 @@ var ACTION_REFERENCE = [
2207
2466
  { name: "swipe", params: "SwipeActionParams { direction, distance }" },
2208
2467
  { name: "toggle", params: "none \u2014 flips boolean controls" }
2209
2468
  ];
2210
- function buildRoutesPayload() {
2469
+ function buildRoutesPayload(testHooks) {
2211
2470
  const entries = [];
2212
2471
  for (const r of WS_ROUTES) {
2213
2472
  if (!r.httpMethods) continue;
2473
+ if (r.requiresTestHooks && !testHooks) continue;
2214
2474
  for (const m of r.httpMethods) {
2215
2475
  entries.push({ method: m, path: `/ui-bridge/${r.pattern}` });
2216
2476
  }
@@ -2220,11 +2480,11 @@ function buildRoutesPayload() {
2220
2480
  entries.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
2221
2481
  return { routes: entries, count: entries.length };
2222
2482
  }
2223
- function buildHelpPayload() {
2483
+ function buildHelpPayload(testHooks) {
2224
2484
  return {
2225
2485
  version: 1,
2226
2486
  description: "UI Bridge Native HTTP surface. Prefix every path with /ui-bridge/.",
2227
- routes: WS_ROUTES.map((r) => ({
2487
+ routes: WS_ROUTES.filter((r) => !r.requiresTestHooks || testHooks).map((r) => ({
2228
2488
  pattern: r.pattern,
2229
2489
  httpMethods: r.httpMethods ?? [],
2230
2490
  wsOnly: !r.httpMethods,
@@ -2260,7 +2520,8 @@ var NativeUIBridgeServer = class {
2260
2520
  appInfo: this.config.appInfo
2261
2521
  });
2262
2522
  this.handlers.getMethods = async () => {
2263
- const methods = WS_ROUTES.map((r) => ({
2523
+ const testHooks = this.config.testHooks === true;
2524
+ const methods = WS_ROUTES.filter((r) => !r.requiresTestHooks || testHooks).map((r) => ({
2264
2525
  method: r.pattern,
2265
2526
  httpMethods: r.httpMethods ? [...r.httpMethods] : void 0
2266
2527
  }));
@@ -2303,7 +2564,11 @@ var NativeUIBridgeServer = class {
2303
2564
  timestamp: Date.now()
2304
2565
  };
2305
2566
  } catch (e) {
2306
- return { success: false, error: `Navigation failed: ${e.message}`, timestamp: Date.now() };
2567
+ return {
2568
+ success: false,
2569
+ error: `Navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2570
+ timestamp: Date.now()
2571
+ };
2307
2572
  }
2308
2573
  };
2309
2574
  this.handlers.pageReplace = async (ctx) => {
@@ -2329,7 +2594,7 @@ var NativeUIBridgeServer = class {
2329
2594
  } catch (e) {
2330
2595
  return {
2331
2596
  success: false,
2332
- error: `Replace navigation failed: ${e.message}`,
2597
+ error: `Replace navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2333
2598
  timestamp: Date.now()
2334
2599
  };
2335
2600
  }
@@ -2348,7 +2613,7 @@ var NativeUIBridgeServer = class {
2348
2613
  } catch (e) {
2349
2614
  return {
2350
2615
  success: false,
2351
- error: `Back navigation failed: ${e.message}`,
2616
+ error: `Back navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2352
2617
  timestamp: Date.now()
2353
2618
  };
2354
2619
  }
@@ -2369,7 +2634,11 @@ var NativeUIBridgeServer = class {
2369
2634
  timestamp: Date.now()
2370
2635
  };
2371
2636
  } catch (e) {
2372
- return { success: false, error: `Refresh failed: ${e.message}`, timestamp: Date.now() };
2637
+ return {
2638
+ success: false,
2639
+ error: `Refresh failed: ${e instanceof Error ? e.message : String(e)}`,
2640
+ timestamp: Date.now()
2641
+ };
2373
2642
  }
2374
2643
  };
2375
2644
  }
@@ -2427,7 +2696,7 @@ var NativeUIBridgeServer = class {
2427
2696
  } catch (e) {
2428
2697
  return {
2429
2698
  success: false,
2430
- error: `Screenshot capture failed: ${e.message}`,
2699
+ error: `Screenshot capture failed: ${e instanceof Error ? e.message : String(e)}`,
2431
2700
  timestamp: Date.now()
2432
2701
  };
2433
2702
  }
@@ -2982,9 +3251,11 @@ var NativeUIBridgeServer = class {
2982
3251
  * "control/component/my-comp/action/submit" → handlers.executeComponentAction
2983
3252
  */
2984
3253
  async routeMethodToHandler(method, params) {
3254
+ const testHooks = this.config.testHooks === true;
2985
3255
  for (const route of WS_ROUTES) {
2986
3256
  const pathParams = parsePath(route.pattern, method);
2987
3257
  if (!pathParams) continue;
3258
+ if (route.requiresTestHooks && !testHooks) continue;
2988
3259
  const ctx = {
2989
3260
  params: pathParams,
2990
3261
  query: params.query && typeof params.query === "object" ? params.query : {},
@@ -3062,17 +3333,18 @@ var NativeUIBridgeServer = class {
3062
3333
  };
3063
3334
  }
3064
3335
  const stripped = path.slice(PREFIX.length);
3336
+ const testHooks = this.config.testHooks === true;
3065
3337
  if (method === "GET" && stripped === "_help") {
3066
3338
  return {
3067
3339
  success: true,
3068
- data: buildHelpPayload(),
3340
+ data: buildHelpPayload(testHooks),
3069
3341
  timestamp: Date.now()
3070
3342
  };
3071
3343
  }
3072
3344
  if (method === "GET" && stripped === "_routes") {
3073
3345
  return {
3074
3346
  success: true,
3075
- data: buildRoutesPayload(),
3347
+ data: buildRoutesPayload(testHooks),
3076
3348
  timestamp: Date.now()
3077
3349
  };
3078
3350
  }
@@ -3083,6 +3355,9 @@ var NativeUIBridgeServer = class {
3083
3355
  if (!route.httpMethods) {
3084
3356
  continue;
3085
3357
  }
3358
+ if (route.requiresTestHooks && !testHooks) {
3359
+ continue;
3360
+ }
3086
3361
  if (!route.httpMethods.includes(method)) {
3087
3362
  patternMatchedButWrongVerb = true;
3088
3363
  continue;
@@ -3376,6 +3651,27 @@ var WebSocketEventBridge = class {
3376
3651
  }, this.heartbeatIntervalMs);
3377
3652
  }
3378
3653
  };
3654
+
3655
+ // src/transport/logger.ts
3656
+ function createTransportLogger() {
3657
+ return {
3658
+ silent: false,
3659
+ log(message, ...args) {
3660
+ if (!this.silent) {
3661
+ console.log(message, ...args);
3662
+ }
3663
+ },
3664
+ warn(message, ...args) {
3665
+ console.warn(message, ...args);
3666
+ },
3667
+ error(message, ...args) {
3668
+ console.error(message, ...args);
3669
+ }
3670
+ };
3671
+ }
3672
+ var transportLogger = createTransportLogger();
3673
+
3674
+ // src/transport/DeviceAnnouncer.ts
3379
3675
  var DEFAULT_PORT = 8087;
3380
3676
  var RECONNECT_INITIAL_DELAY_MS = 2e3;
3381
3677
  var RECONNECT_MAX_DELAY_MS = 6e4;
@@ -3410,7 +3706,7 @@ var DeviceAnnouncer = class {
3410
3706
  */
3411
3707
  async startMdnsAdvertise(ZeroconfCtor) {
3412
3708
  if (!ZeroconfCtor) {
3413
- console.log(
3709
+ transportLogger.log(
3414
3710
  "[DeviceAnnouncer] mDNS advertisement skipped (no Zeroconf constructor provided). Pass one to enable mDNS."
3415
3711
  );
3416
3712
  return;
@@ -3426,9 +3722,9 @@ var DeviceAnnouncer = class {
3426
3722
  pairing_token: this.pairingToken
3427
3723
  });
3428
3724
  this.state = { ...this.state, mdnsActive: true };
3429
- console.log(`[DeviceAnnouncer] mDNS: advertising "${serviceName}" on port ${port}`);
3725
+ transportLogger.log(`[DeviceAnnouncer] mDNS: advertising "${serviceName}" on port ${port}`);
3430
3726
  } catch (err) {
3431
- console.warn("[DeviceAnnouncer] mDNS start failed:", err);
3727
+ transportLogger.warn("[DeviceAnnouncer] mDNS start failed:", err);
3432
3728
  }
3433
3729
  }
3434
3730
  // ── Cloud relay ───────────────────────────────────────────────────────────
@@ -3444,7 +3740,7 @@ var DeviceAnnouncer = class {
3444
3740
  }
3445
3741
  if (this.stopped) return;
3446
3742
  const url = `${this.config.cloudRelayUrl}?token=${encodeURIComponent(this.config.cloudToken)}`;
3447
- console.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
3743
+ transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
3448
3744
  const ws = new WebSocket(url);
3449
3745
  this.cloudWs = ws;
3450
3746
  ws.onopen = () => {
@@ -3454,7 +3750,7 @@ var DeviceAnnouncer = class {
3454
3750
  }
3455
3751
  this.reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
3456
3752
  this.state = { ...this.state, cloudConnected: true };
3457
- console.log("[DeviceAnnouncer] Cloud relay: connected");
3753
+ transportLogger.log("[DeviceAnnouncer] Cloud relay: connected");
3458
3754
  ws.send(
3459
3755
  JSON.stringify({
3460
3756
  type: "device_register",
@@ -3469,7 +3765,7 @@ var DeviceAnnouncer = class {
3469
3765
  ws.onmessage = (event) => {
3470
3766
  try {
3471
3767
  const msg = JSON.parse(event.data);
3472
- console.log("[DeviceAnnouncer] Cloud relay message:", msg.type);
3768
+ transportLogger.log("[DeviceAnnouncer] Cloud relay message:", msg.type);
3473
3769
  } catch {
3474
3770
  }
3475
3771
  };
@@ -3480,7 +3776,7 @@ var DeviceAnnouncer = class {
3480
3776
  }
3481
3777
  };
3482
3778
  ws.onerror = (err) => {
3483
- console.warn("[DeviceAnnouncer] Cloud relay error:", err);
3779
+ transportLogger.warn("[DeviceAnnouncer] Cloud relay error:", err);
3484
3780
  };
3485
3781
  }
3486
3782
  // ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -3515,7 +3811,7 @@ var DeviceAnnouncer = class {
3515
3811
  scheduleReconnect() {
3516
3812
  if (this.stopped) return;
3517
3813
  const delay = this.reconnectDelay;
3518
- console.log(`[DeviceAnnouncer] Cloud relay: reconnecting in ${delay}ms`);
3814
+ transportLogger.log(`[DeviceAnnouncer] Cloud relay: reconnecting in ${delay}ms`);
3519
3815
  this.reconnectTimer = setTimeout(() => {
3520
3816
  this.reconnectTimer = null;
3521
3817
  if (!this.stopped) {
@@ -3577,7 +3873,7 @@ var CloudRelayClient = class {
3577
3873
  connect() {
3578
3874
  if (this.stopped) return;
3579
3875
  const url = `${this.config.relayUrl}?token=${encodeURIComponent(this.config.authToken)}`;
3580
- console.log("[CloudRelayClient] Connecting to", url);
3876
+ transportLogger.log("[CloudRelayClient] Connecting to", url);
3581
3877
  const ws = new WebSocket(url);
3582
3878
  this.ws = ws;
3583
3879
  ws.onopen = () => {
@@ -3587,7 +3883,7 @@ var CloudRelayClient = class {
3587
3883
  }
3588
3884
  this._isConnected = true;
3589
3885
  this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
3590
- console.log("[CloudRelayClient] Connected to relay");
3886
+ transportLogger.log("[CloudRelayClient] Connected to relay");
3591
3887
  ws.send(
3592
3888
  JSON.stringify({
3593
3889
  type: "device_register",
@@ -3605,7 +3901,7 @@ var CloudRelayClient = class {
3605
3901
  }
3606
3902
  };
3607
3903
  ws.onerror = (err) => {
3608
- console.warn("[CloudRelayClient] WebSocket error:", err);
3904
+ transportLogger.warn("[CloudRelayClient] WebSocket error:", err);
3609
3905
  };
3610
3906
  }
3611
3907
  async onMessage(raw) {
@@ -3613,12 +3909,12 @@ var CloudRelayClient = class {
3613
3909
  try {
3614
3910
  msg = JSON.parse(raw);
3615
3911
  } catch {
3616
- console.warn("[CloudRelayClient] Received non-JSON message");
3912
+ transportLogger.warn("[CloudRelayClient] Received non-JSON message");
3617
3913
  return;
3618
3914
  }
3619
3915
  if (msg["type"] !== "tunnel_request") return;
3620
3916
  if (typeof msg["id"] !== "string" || typeof msg["method"] !== "string" || typeof msg["path"] !== "string") {
3621
- console.warn("[CloudRelayClient] Malformed tunnel_request \u2014 missing required fields");
3917
+ transportLogger.warn("[CloudRelayClient] Malformed tunnel_request \u2014 missing required fields");
3622
3918
  return;
3623
3919
  }
3624
3920
  const req = {
@@ -3659,7 +3955,7 @@ var CloudRelayClient = class {
3659
3955
  body: bodyValue
3660
3956
  };
3661
3957
  } catch (err) {
3662
- console.error("[CloudRelayClient] Error handling tunneled request:", err);
3958
+ transportLogger.error("[CloudRelayClient] Error handling tunneled request:", err);
3663
3959
  return {
3664
3960
  id: req.id,
3665
3961
  status: 500,
@@ -3674,7 +3970,7 @@ var CloudRelayClient = class {
3674
3970
  scheduleReconnect() {
3675
3971
  if (this.stopped) return;
3676
3972
  const delay = this.reconnectDelay;
3677
- console.log(`[CloudRelayClient] Reconnecting in ${delay}ms`);
3973
+ transportLogger.log(`[CloudRelayClient] Reconnecting in ${delay}ms`);
3678
3974
  this.reconnectTimer = setTimeout(() => {
3679
3975
  this.reconnectTimer = null;
3680
3976
  if (!this.stopped) {
@@ -3717,8 +4013,14 @@ var ModalDetector = class {
3717
4013
  };
3718
4014
  this.modals.set(modal.id, next);
3719
4015
  }
4016
+ /**
4017
+ * Remove a modal from the stack. Returns `true` if a modal with that id
4018
+ * was actually deleted, `false` if nothing matched (idempotent so repeated
4019
+ * calls don't throw, but callers — e.g. the HTTP test-hook endpoint — can
4020
+ * surface the miss to clients).
4021
+ */
3720
4022
  dismissModal(id) {
3721
- this.modals.delete(id);
4023
+ return this.modals.delete(id);
3722
4024
  }
3723
4025
  getSnapshotModalContext() {
3724
4026
  const active = this.getActive();
@@ -3930,7 +4232,8 @@ function UIBridgeNativeProvider({
3930
4232
  const server = createNativeServer(registry, executor, {
3931
4233
  serverPort: config.serverPort || 8087,
3932
4234
  cors: true,
3933
- appInfo: config.appInfo
4235
+ appInfo: config.appInfo,
4236
+ testHooks: features.testHooks === true
3934
4237
  });
3935
4238
  server.setAdapter(serverAdapter);
3936
4239
  if (navigationProvider) {
@@ -3984,7 +4287,9 @@ function UIBridgeNativeProvider({
3984
4287
  }
3985
4288
  }, [
3986
4289
  features.server,
4290
+ features.testHooks,
3987
4291
  config.serverPort,
4292
+ config.appInfo,
3988
4293
  registry,
3989
4294
  executor,
3990
4295
  serverAdapter,
@@ -4018,7 +4323,8 @@ function UIBridgeNativeProvider({
4018
4323
  const bareServer = createNativeServer(registry, executor, {
4019
4324
  serverPort: config.serverPort || 8087,
4020
4325
  cors: true,
4021
- appInfo: config.appInfo
4326
+ appInfo: config.appInfo,
4327
+ testHooks: features.testHooks === true
4022
4328
  });
4023
4329
  if (navigationProvider) bareServer.setNavigationProvider(navigationProvider);
4024
4330
  if (screenshotProvider) bareServer.setScreenshotProvider(screenshotProvider);
@@ -4249,7 +4555,7 @@ function useUIElement(options) {
4249
4555
  registeredRef.current = false;
4250
4556
  registeredIdRef.current = null;
4251
4557
  setRegistered(false);
4252
- }, [bridge, registered, id]);
4558
+ }, [bridge, id]);
4253
4559
  const onLayout = react.useCallback(
4254
4560
  (event) => {
4255
4561
  const { x, y, width, height } = event.nativeEvent.layout;
@@ -4515,7 +4821,7 @@ function useUIComponent(options) {
4515
4821
  const registeredComponent = react.useMemo(() => {
4516
4822
  if (!bridge) return null;
4517
4823
  return bridge.registry.getComponent(id) || null;
4518
- }, [bridge, id, registeredRef.current]);
4824
+ }, [bridge, id]);
4519
4825
  return {
4520
4826
  registered: registeredRef.current,
4521
4827
  executeAction,
@@ -4795,6 +5101,25 @@ var UI_BRIDGE_NATIVE_ROUTES = {
4795
5101
  path: "/ui-bridge/design/style-guide",
4796
5102
  description: "Clear loaded style guide"
4797
5103
  },
5104
+ // AI helpers
5105
+ AI_FILL_FORM: {
5106
+ method: "POST",
5107
+ path: "/ui-bridge/ai/fill-form",
5108
+ description: "Fill multiple input elements in a single call"
5109
+ },
5110
+ // Test hooks — drive ModalDetector state from outside the React tree
5111
+ // (gated by `features.testHooks`). Mirrors `pushModal` / `dismissModal`
5112
+ // calls that components normally make in-process.
5113
+ PUSH_MODAL: {
5114
+ method: "POST",
5115
+ path: "/ui-bridge/control/modal/push",
5116
+ description: "Push a modal onto the modal stack (testHooks)"
5117
+ },
5118
+ DISMISS_MODAL: {
5119
+ method: "POST",
5120
+ path: "/ui-bridge/control/modal/dismiss/:id",
5121
+ description: "Dismiss a modal by id (testHooks)"
5122
+ },
4798
5123
  // Health
4799
5124
  HEALTH: {
4800
5125
  method: "GET",
@@ -5304,8 +5629,8 @@ function UIBridgeInspector({
5304
5629
  const bridge = useUIBridgeNativeOptional();
5305
5630
  const [showInspector, setShowInspector] = react.useState(visible);
5306
5631
  const [selectedElement, setSelectedElement] = react.useState(null);
5307
- const elements = react.useMemo(() => bridge ? bridge.getElements() : [], [bridge, showInspector]);
5308
- const components = react.useMemo(() => bridge ? bridge.getComponents() : [], [bridge, showInspector]);
5632
+ const elements = react.useMemo(() => bridge ? bridge.getElements() : [], [bridge]);
5633
+ const components = react.useMemo(() => bridge ? bridge.getComponents() : [], [bridge]);
5309
5634
  const handleToggle = react.useCallback(() => {
5310
5635
  setShowInspector((prev) => !prev);
5311
5636
  }, []);