@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
@@ -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
  */
@@ -1886,6 +1924,20 @@ function createServerHandlers(registry, executor, config) {
1886
1924
  return success2(response);
1887
1925
  },
1888
1926
  // Discovery
1927
+ //
1928
+ // Native parity with the runner's `POST /ui-bridge/ai/find`. The runner
1929
+ // matches a free-text `query` against label > text > value > aria-label >
1930
+ // placeholder > name. We replicate that here against the native registry's
1931
+ // analogues:
1932
+ // 1. `label` (the registered display label)
1933
+ // 2. identifier fields: `testId`, `accessibilityLabel`, `treePath`, `uiId`
1934
+ // 3. registered handler names (e.g. `onPress`, `onChangeText`)
1935
+ //
1936
+ // Match is case-insensitive substring. Empty/missing query falls back to
1937
+ // the structured filters (types/visibleOnly/etc.) so callers that don't
1938
+ // pass `query` still get the legacy behaviour. Once we've narrowed by
1939
+ // query the response is shaped through the executor's NativeFindResponse
1940
+ // envelope so the wire format stays stable.
1889
1941
  find: async (ctx) => {
1890
1942
  const body = ctx.body;
1891
1943
  const response = await executor.find({
@@ -1893,9 +1945,49 @@ function createServerHandlers(registry, executor, config) {
1893
1945
  testIdPattern: body?.testIdPattern,
1894
1946
  accessibilityLabelPattern: body?.accessibilityLabelPattern,
1895
1947
  visibleOnly: body?.visibleOnly,
1896
- limit: body?.limit
1948
+ // When `query` is supplied we apply our own limit *after* ranking so
1949
+ // the highest-priority matches survive the cap; defer slicing to us
1950
+ // by widening here.
1951
+ limit: body?.query && body.query.length > 0 ? void 0 : body?.limit
1952
+ });
1953
+ const rawQuery = typeof body?.query === "string" ? body.query.trim() : "";
1954
+ if (rawQuery.length === 0) {
1955
+ return success2(response);
1956
+ }
1957
+ const needle = rawQuery.toLowerCase();
1958
+ const containsNeedle = (value) => typeof value === "string" && value.toLowerCase().includes(needle);
1959
+ const scored = [];
1960
+ for (const el of response.elements) {
1961
+ let priorityRank = Number.POSITIVE_INFINITY;
1962
+ if (containsNeedle(el.label)) {
1963
+ priorityRank = Math.min(priorityRank, 0);
1964
+ }
1965
+ const ident = el.identifier;
1966
+ if (ident) {
1967
+ if (containsNeedle(ident.testId)) priorityRank = Math.min(priorityRank, 1);
1968
+ if (containsNeedle(ident.accessibilityLabel)) priorityRank = Math.min(priorityRank, 1);
1969
+ if (containsNeedle(ident.treePath)) priorityRank = Math.min(priorityRank, 1);
1970
+ if (containsNeedle(ident.uiId)) priorityRank = Math.min(priorityRank, 1);
1971
+ }
1972
+ if (priorityRank === Number.POSITIVE_INFINITY) {
1973
+ const registered = registry.getElement(el.id);
1974
+ const handlers = extractHandlerNames(registered?.props);
1975
+ if (handlers.some((h) => h.toLowerCase().includes(needle))) {
1976
+ priorityRank = 2;
1977
+ }
1978
+ }
1979
+ if (priorityRank !== Number.POSITIVE_INFINITY) {
1980
+ scored.push({ priorityRank, element: el });
1981
+ }
1982
+ }
1983
+ scored.sort((a, b) => a.priorityRank - b.priorityRank);
1984
+ const limit = body?.limit && body.limit > 0 ? body.limit : void 0;
1985
+ const filtered = limit ? scored.slice(0, limit) : scored;
1986
+ return success2({
1987
+ ...response,
1988
+ elements: filtered.map((s) => s.element),
1989
+ total: filtered.length
1897
1990
  });
1898
- return success2(response);
1899
1991
  },
1900
1992
  getSnapshot: async (ctx) => {
1901
1993
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
@@ -1996,15 +2088,162 @@ function createServerHandlers(registry, executor, config) {
1996
2088
  getMethods: async () => {
1997
2089
  return error2("Methods introspection not configured", "NOT_SUPPORTED");
1998
2090
  },
2091
+ // AI helpers
2092
+ //
2093
+ // Mobile-side parity with the runner's `POST /ui-bridge/ai/fill-form`.
2094
+ // Each entry in `fields` is dispatched through the existing executor as a
2095
+ // `type` action with `clear: true` so the input value is replaced
2096
+ // atomically (the closest native equivalent to the web bridge's
2097
+ // "setValue" semantics — there is no `setValue` action on native).
2098
+ //
2099
+ // Failures do not short-circuit: every field is attempted, and the
2100
+ // response carries per-field success/error plus aggregate counts.
2101
+ fillForm: async (ctx) => {
2102
+ const body = ctx.body;
2103
+ const rawFields = body?.fields;
2104
+ if (!Array.isArray(rawFields)) {
2105
+ return error2('Request body must include "fields" array', "INVALID_REQUEST");
2106
+ }
2107
+ const results = [];
2108
+ for (let i = 0; i < rawFields.length; i++) {
2109
+ const field = rawFields[i];
2110
+ if (!field || typeof field !== "object") {
2111
+ results.push({
2112
+ elementId: "",
2113
+ success: false,
2114
+ error: `fields[${i}] must be an object with "elementId" and "value"`
2115
+ });
2116
+ continue;
2117
+ }
2118
+ const { elementId, value } = field;
2119
+ if (typeof elementId !== "string" || elementId.length === 0) {
2120
+ results.push({
2121
+ elementId: typeof elementId === "string" ? elementId : "",
2122
+ success: false,
2123
+ error: `fields[${i}] is missing a non-empty "elementId"`
2124
+ });
2125
+ continue;
2126
+ }
2127
+ if (typeof value !== "string") {
2128
+ results.push({
2129
+ elementId,
2130
+ success: false,
2131
+ error: `fields[${i}] is missing a string "value"`
2132
+ });
2133
+ continue;
2134
+ }
2135
+ try {
2136
+ const response = await executor.executeAction(elementId, {
2137
+ action: "type",
2138
+ params: { text: value, clear: true }
2139
+ });
2140
+ if (response.success) {
2141
+ results.push({ elementId, success: true });
2142
+ } else {
2143
+ results.push({
2144
+ elementId,
2145
+ success: false,
2146
+ error: response.error ?? "Action failed"
2147
+ });
2148
+ }
2149
+ } catch (err) {
2150
+ results.push({
2151
+ elementId,
2152
+ success: false,
2153
+ error: err instanceof Error ? err.message : String(err)
2154
+ });
2155
+ }
2156
+ }
2157
+ const succeededCount = results.filter((r) => r.success).length;
2158
+ const failedCount = results.length - succeededCount;
2159
+ return success2({ results, succeededCount, failedCount });
2160
+ },
2161
+ // Test hooks — modal stack manipulation
2162
+ //
2163
+ // These two endpoints exist so external automation runners can drive
2164
+ // ModalDetector state via HTTP, mirroring the in-tree `pushModal` /
2165
+ // `dismissModal` calls that components normally make. They're gated by
2166
+ // `features.testHooks` at the route layer (see http-server.ts); the
2167
+ // handlers themselves still defend against a missing modalDetector
2168
+ // because the bare server path used by CloudRelayClient may run before
2169
+ // enrichers are wired.
2170
+ pushModal: async (ctx) => {
2171
+ const detector = registry.getEnrichers().modalDetector;
2172
+ if (!detector) {
2173
+ return error2(
2174
+ "modal detector not registered",
2175
+ "ENRICHER_UNAVAILABLE"
2176
+ );
2177
+ }
2178
+ const body = ctx.body;
2179
+ const id = body?.id;
2180
+ if (typeof id !== "string" || id.length === 0) {
2181
+ return error2(
2182
+ 'pushModal requires a non-empty "id"',
2183
+ "INVALID_REQUEST"
2184
+ );
2185
+ }
2186
+ const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
2187
+ const requestedType = body?.type;
2188
+ if (requestedType !== void 0 && (typeof requestedType !== "string" || !allowedTypes.includes(requestedType))) {
2189
+ return error2(
2190
+ `pushModal "type" must be one of: ${allowedTypes.join(", ")}`,
2191
+ "INVALID_REQUEST"
2192
+ );
2193
+ }
2194
+ detector.pushModal({
2195
+ id,
2196
+ title: body?.title,
2197
+ // requestedType is narrowed by the allowedTypes guard above; the cast
2198
+ // converts the validated string to the NativeModalInfo['type'] union.
2199
+ type: requestedType,
2200
+ blocking: body?.blocking,
2201
+ dismissible: body?.dismissible
2202
+ });
2203
+ const stored = detector.getActive().find((m) => m.id === id);
2204
+ return success2({
2205
+ pushed: true,
2206
+ id,
2207
+ title: stored?.title,
2208
+ type: stored?.type,
2209
+ blocking: stored?.blocking,
2210
+ dismissible: stored?.dismissible,
2211
+ pushedAt: stored?.detectedAt ?? Date.now()
2212
+ });
2213
+ },
2214
+ dismissModal: async (ctx) => {
2215
+ const detector = registry.getEnrichers().modalDetector;
2216
+ if (!detector) {
2217
+ return error2(
2218
+ "modal detector not registered",
2219
+ "ENRICHER_UNAVAILABLE"
2220
+ );
2221
+ }
2222
+ const { id } = ctx.params;
2223
+ if (typeof id !== "string" || id.length === 0) {
2224
+ return error2(
2225
+ "dismissModal requires a modal id in the path",
2226
+ "INVALID_REQUEST"
2227
+ );
2228
+ }
2229
+ const wasPresent = detector.getActive().some((m) => m.id === id);
2230
+ if (!wasPresent) {
2231
+ return error2(`modal not found: ${id}`, "MODAL_NOT_FOUND");
2232
+ }
2233
+ detector.dismissModal(id);
2234
+ return success2({ dismissed: id });
2235
+ },
1999
2236
  // Design Review
2000
2237
  ...designHandlers,
2001
2238
  // Health
2002
2239
  health: async () => {
2003
2240
  const stats = registry.getStats();
2241
+ const registration = registry.getRegistrationCoverage();
2004
2242
  const response = {
2005
2243
  status: "healthy",
2006
2244
  timestamp: Date.now(),
2007
- ...stats
2245
+ ...stats,
2246
+ registration
2008
2247
  };
2009
2248
  if (config?.appInfo) {
2010
2249
  response.uiBridge = {
@@ -2105,7 +2344,24 @@ var WS_ROUTES = [
2105
2344
  { pattern: "design/evaluate/baseline", handler: "saveBaseline", httpMethods: ["POST"] },
2106
2345
  { pattern: "design/evaluate/diff", handler: "diffBaseline", httpMethods: ["POST"] },
2107
2346
  // Meta / Introspection
2108
- { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] }
2347
+ { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] },
2348
+ // AI helpers — mobile parity with the runner's `/ui-bridge/ai/*` family.
2349
+ { pattern: "ai/fill-form", handler: "fillForm", httpMethods: ["POST"] },
2350
+ // Test hooks — gated behind `features.testHooks`. Let external runners
2351
+ // drive the ModalDetector over HTTP so tests can flip `snapshot.modalStack`
2352
+ // without mounting React components.
2353
+ {
2354
+ pattern: "control/modal/push",
2355
+ handler: "pushModal",
2356
+ httpMethods: ["POST"],
2357
+ requiresTestHooks: true
2358
+ },
2359
+ {
2360
+ pattern: "control/modal/dismiss/:id",
2361
+ handler: "dismissModal",
2362
+ httpMethods: ["POST"],
2363
+ requiresTestHooks: true
2364
+ }
2109
2365
  ];
2110
2366
  var HANDLER_DESCRIPTIONS = {
2111
2367
  health: "Liveness probe; returns app metadata + element count",
@@ -2138,7 +2394,10 @@ var HANDLER_DESCRIPTIONS = {
2138
2394
  getQualityContexts: "List available quality evaluation contexts",
2139
2395
  saveBaseline: "Save a quality baseline snapshot",
2140
2396
  diffBaseline: "Diff current UI against a saved baseline",
2141
- getMethods: "List every handler name (legacy introspection)"
2397
+ getMethods: "List every handler name (legacy introspection)",
2398
+ fillForm: "Fill multiple inputs in one call: body { fields: [{elementId, value}] } \u2014 each field dispatches `type` + `clear:true`",
2399
+ pushModal: "TEST HOOK \u2014 push a modal onto the snapshot.modalStack via the registry ModalDetector",
2400
+ dismissModal: "TEST HOOK \u2014 dismiss a modal by id from the snapshot.modalStack via the registry ModalDetector"
2142
2401
  };
2143
2402
  var ACTION_REFERENCE = [
2144
2403
  { name: "press", aliases: ["click"], params: "PressActionParams (optional position)" },
@@ -2155,10 +2414,11 @@ var ACTION_REFERENCE = [
2155
2414
  { name: "swipe", params: "SwipeActionParams { direction, distance }" },
2156
2415
  { name: "toggle", params: "none \u2014 flips boolean controls" }
2157
2416
  ];
2158
- function buildRoutesPayload() {
2417
+ function buildRoutesPayload(testHooks) {
2159
2418
  const entries = [];
2160
2419
  for (const r of WS_ROUTES) {
2161
2420
  if (!r.httpMethods) continue;
2421
+ if (r.requiresTestHooks && !testHooks) continue;
2162
2422
  for (const m of r.httpMethods) {
2163
2423
  entries.push({ method: m, path: `/ui-bridge/${r.pattern}` });
2164
2424
  }
@@ -2168,11 +2428,11 @@ function buildRoutesPayload() {
2168
2428
  entries.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
2169
2429
  return { routes: entries, count: entries.length };
2170
2430
  }
2171
- function buildHelpPayload() {
2431
+ function buildHelpPayload(testHooks) {
2172
2432
  return {
2173
2433
  version: 1,
2174
2434
  description: "UI Bridge Native HTTP surface. Prefix every path with /ui-bridge/.",
2175
- routes: WS_ROUTES.map((r) => ({
2435
+ routes: WS_ROUTES.filter((r) => !r.requiresTestHooks || testHooks).map((r) => ({
2176
2436
  pattern: r.pattern,
2177
2437
  httpMethods: r.httpMethods ?? [],
2178
2438
  wsOnly: !r.httpMethods,
@@ -2208,7 +2468,8 @@ var NativeUIBridgeServer = class {
2208
2468
  appInfo: this.config.appInfo
2209
2469
  });
2210
2470
  this.handlers.getMethods = async () => {
2211
- const methods = WS_ROUTES.map((r) => ({
2471
+ const testHooks = this.config.testHooks === true;
2472
+ const methods = WS_ROUTES.filter((r) => !r.requiresTestHooks || testHooks).map((r) => ({
2212
2473
  method: r.pattern,
2213
2474
  httpMethods: r.httpMethods ? [...r.httpMethods] : void 0
2214
2475
  }));
@@ -2251,7 +2512,11 @@ var NativeUIBridgeServer = class {
2251
2512
  timestamp: Date.now()
2252
2513
  };
2253
2514
  } catch (e) {
2254
- return { success: false, error: `Navigation failed: ${e.message}`, timestamp: Date.now() };
2515
+ return {
2516
+ success: false,
2517
+ error: `Navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2518
+ timestamp: Date.now()
2519
+ };
2255
2520
  }
2256
2521
  };
2257
2522
  this.handlers.pageReplace = async (ctx) => {
@@ -2277,7 +2542,7 @@ var NativeUIBridgeServer = class {
2277
2542
  } catch (e) {
2278
2543
  return {
2279
2544
  success: false,
2280
- error: `Replace navigation failed: ${e.message}`,
2545
+ error: `Replace navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2281
2546
  timestamp: Date.now()
2282
2547
  };
2283
2548
  }
@@ -2296,7 +2561,7 @@ var NativeUIBridgeServer = class {
2296
2561
  } catch (e) {
2297
2562
  return {
2298
2563
  success: false,
2299
- error: `Back navigation failed: ${e.message}`,
2564
+ error: `Back navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2300
2565
  timestamp: Date.now()
2301
2566
  };
2302
2567
  }
@@ -2317,7 +2582,11 @@ var NativeUIBridgeServer = class {
2317
2582
  timestamp: Date.now()
2318
2583
  };
2319
2584
  } catch (e) {
2320
- return { success: false, error: `Refresh failed: ${e.message}`, timestamp: Date.now() };
2585
+ return {
2586
+ success: false,
2587
+ error: `Refresh failed: ${e instanceof Error ? e.message : String(e)}`,
2588
+ timestamp: Date.now()
2589
+ };
2321
2590
  }
2322
2591
  };
2323
2592
  }
@@ -2375,7 +2644,7 @@ var NativeUIBridgeServer = class {
2375
2644
  } catch (e) {
2376
2645
  return {
2377
2646
  success: false,
2378
- error: `Screenshot capture failed: ${e.message}`,
2647
+ error: `Screenshot capture failed: ${e instanceof Error ? e.message : String(e)}`,
2379
2648
  timestamp: Date.now()
2380
2649
  };
2381
2650
  }
@@ -2930,9 +3199,11 @@ var NativeUIBridgeServer = class {
2930
3199
  * "control/component/my-comp/action/submit" → handlers.executeComponentAction
2931
3200
  */
2932
3201
  async routeMethodToHandler(method, params) {
3202
+ const testHooks = this.config.testHooks === true;
2933
3203
  for (const route of WS_ROUTES) {
2934
3204
  const pathParams = parsePath(route.pattern, method);
2935
3205
  if (!pathParams) continue;
3206
+ if (route.requiresTestHooks && !testHooks) continue;
2936
3207
  const ctx = {
2937
3208
  params: pathParams,
2938
3209
  query: params.query && typeof params.query === "object" ? params.query : {},
@@ -3010,17 +3281,18 @@ var NativeUIBridgeServer = class {
3010
3281
  };
3011
3282
  }
3012
3283
  const stripped = path.slice(PREFIX.length);
3284
+ const testHooks = this.config.testHooks === true;
3013
3285
  if (method === "GET" && stripped === "_help") {
3014
3286
  return {
3015
3287
  success: true,
3016
- data: buildHelpPayload(),
3288
+ data: buildHelpPayload(testHooks),
3017
3289
  timestamp: Date.now()
3018
3290
  };
3019
3291
  }
3020
3292
  if (method === "GET" && stripped === "_routes") {
3021
3293
  return {
3022
3294
  success: true,
3023
- data: buildRoutesPayload(),
3295
+ data: buildRoutesPayload(testHooks),
3024
3296
  timestamp: Date.now()
3025
3297
  };
3026
3298
  }
@@ -3031,6 +3303,9 @@ var NativeUIBridgeServer = class {
3031
3303
  if (!route.httpMethods) {
3032
3304
  continue;
3033
3305
  }
3306
+ if (route.requiresTestHooks && !testHooks) {
3307
+ continue;
3308
+ }
3034
3309
  if (!route.httpMethods.includes(method)) {
3035
3310
  patternMatchedButWrongVerb = true;
3036
3311
  continue;
@@ -3324,6 +3599,27 @@ var WebSocketEventBridge = class {
3324
3599
  }, this.heartbeatIntervalMs);
3325
3600
  }
3326
3601
  };
3602
+
3603
+ // src/transport/logger.ts
3604
+ function createTransportLogger() {
3605
+ return {
3606
+ silent: false,
3607
+ log(message, ...args) {
3608
+ if (!this.silent) {
3609
+ console.log(message, ...args);
3610
+ }
3611
+ },
3612
+ warn(message, ...args) {
3613
+ console.warn(message, ...args);
3614
+ },
3615
+ error(message, ...args) {
3616
+ console.error(message, ...args);
3617
+ }
3618
+ };
3619
+ }
3620
+ var transportLogger = createTransportLogger();
3621
+
3622
+ // src/transport/DeviceAnnouncer.ts
3327
3623
  var DEFAULT_PORT = 8087;
3328
3624
  var RECONNECT_INITIAL_DELAY_MS = 2e3;
3329
3625
  var RECONNECT_MAX_DELAY_MS = 6e4;
@@ -3358,7 +3654,7 @@ var DeviceAnnouncer = class {
3358
3654
  */
3359
3655
  async startMdnsAdvertise(ZeroconfCtor) {
3360
3656
  if (!ZeroconfCtor) {
3361
- console.log(
3657
+ transportLogger.log(
3362
3658
  "[DeviceAnnouncer] mDNS advertisement skipped (no Zeroconf constructor provided). Pass one to enable mDNS."
3363
3659
  );
3364
3660
  return;
@@ -3374,9 +3670,9 @@ var DeviceAnnouncer = class {
3374
3670
  pairing_token: this.pairingToken
3375
3671
  });
3376
3672
  this.state = { ...this.state, mdnsActive: true };
3377
- console.log(`[DeviceAnnouncer] mDNS: advertising "${serviceName}" on port ${port}`);
3673
+ transportLogger.log(`[DeviceAnnouncer] mDNS: advertising "${serviceName}" on port ${port}`);
3378
3674
  } catch (err) {
3379
- console.warn("[DeviceAnnouncer] mDNS start failed:", err);
3675
+ transportLogger.warn("[DeviceAnnouncer] mDNS start failed:", err);
3380
3676
  }
3381
3677
  }
3382
3678
  // ── Cloud relay ───────────────────────────────────────────────────────────
@@ -3392,7 +3688,7 @@ var DeviceAnnouncer = class {
3392
3688
  }
3393
3689
  if (this.stopped) return;
3394
3690
  const url = `${this.config.cloudRelayUrl}?token=${encodeURIComponent(this.config.cloudToken)}`;
3395
- console.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
3691
+ transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
3396
3692
  const ws = new WebSocket(url);
3397
3693
  this.cloudWs = ws;
3398
3694
  ws.onopen = () => {
@@ -3402,7 +3698,7 @@ var DeviceAnnouncer = class {
3402
3698
  }
3403
3699
  this.reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
3404
3700
  this.state = { ...this.state, cloudConnected: true };
3405
- console.log("[DeviceAnnouncer] Cloud relay: connected");
3701
+ transportLogger.log("[DeviceAnnouncer] Cloud relay: connected");
3406
3702
  ws.send(
3407
3703
  JSON.stringify({
3408
3704
  type: "device_register",
@@ -3417,7 +3713,7 @@ var DeviceAnnouncer = class {
3417
3713
  ws.onmessage = (event) => {
3418
3714
  try {
3419
3715
  const msg = JSON.parse(event.data);
3420
- console.log("[DeviceAnnouncer] Cloud relay message:", msg.type);
3716
+ transportLogger.log("[DeviceAnnouncer] Cloud relay message:", msg.type);
3421
3717
  } catch {
3422
3718
  }
3423
3719
  };
@@ -3428,7 +3724,7 @@ var DeviceAnnouncer = class {
3428
3724
  }
3429
3725
  };
3430
3726
  ws.onerror = (err) => {
3431
- console.warn("[DeviceAnnouncer] Cloud relay error:", err);
3727
+ transportLogger.warn("[DeviceAnnouncer] Cloud relay error:", err);
3432
3728
  };
3433
3729
  }
3434
3730
  // ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -3463,7 +3759,7 @@ var DeviceAnnouncer = class {
3463
3759
  scheduleReconnect() {
3464
3760
  if (this.stopped) return;
3465
3761
  const delay = this.reconnectDelay;
3466
- console.log(`[DeviceAnnouncer] Cloud relay: reconnecting in ${delay}ms`);
3762
+ transportLogger.log(`[DeviceAnnouncer] Cloud relay: reconnecting in ${delay}ms`);
3467
3763
  this.reconnectTimer = setTimeout(() => {
3468
3764
  this.reconnectTimer = null;
3469
3765
  if (!this.stopped) {
@@ -3525,7 +3821,7 @@ var CloudRelayClient = class {
3525
3821
  connect() {
3526
3822
  if (this.stopped) return;
3527
3823
  const url = `${this.config.relayUrl}?token=${encodeURIComponent(this.config.authToken)}`;
3528
- console.log("[CloudRelayClient] Connecting to", url);
3824
+ transportLogger.log("[CloudRelayClient] Connecting to", url);
3529
3825
  const ws = new WebSocket(url);
3530
3826
  this.ws = ws;
3531
3827
  ws.onopen = () => {
@@ -3535,7 +3831,7 @@ var CloudRelayClient = class {
3535
3831
  }
3536
3832
  this._isConnected = true;
3537
3833
  this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
3538
- console.log("[CloudRelayClient] Connected to relay");
3834
+ transportLogger.log("[CloudRelayClient] Connected to relay");
3539
3835
  ws.send(
3540
3836
  JSON.stringify({
3541
3837
  type: "device_register",
@@ -3553,7 +3849,7 @@ var CloudRelayClient = class {
3553
3849
  }
3554
3850
  };
3555
3851
  ws.onerror = (err) => {
3556
- console.warn("[CloudRelayClient] WebSocket error:", err);
3852
+ transportLogger.warn("[CloudRelayClient] WebSocket error:", err);
3557
3853
  };
3558
3854
  }
3559
3855
  async onMessage(raw) {
@@ -3561,12 +3857,12 @@ var CloudRelayClient = class {
3561
3857
  try {
3562
3858
  msg = JSON.parse(raw);
3563
3859
  } catch {
3564
- console.warn("[CloudRelayClient] Received non-JSON message");
3860
+ transportLogger.warn("[CloudRelayClient] Received non-JSON message");
3565
3861
  return;
3566
3862
  }
3567
3863
  if (msg["type"] !== "tunnel_request") return;
3568
3864
  if (typeof msg["id"] !== "string" || typeof msg["method"] !== "string" || typeof msg["path"] !== "string") {
3569
- console.warn("[CloudRelayClient] Malformed tunnel_request \u2014 missing required fields");
3865
+ transportLogger.warn("[CloudRelayClient] Malformed tunnel_request \u2014 missing required fields");
3570
3866
  return;
3571
3867
  }
3572
3868
  const req = {
@@ -3607,7 +3903,7 @@ var CloudRelayClient = class {
3607
3903
  body: bodyValue
3608
3904
  };
3609
3905
  } catch (err) {
3610
- console.error("[CloudRelayClient] Error handling tunneled request:", err);
3906
+ transportLogger.error("[CloudRelayClient] Error handling tunneled request:", err);
3611
3907
  return {
3612
3908
  id: req.id,
3613
3909
  status: 500,
@@ -3622,7 +3918,7 @@ var CloudRelayClient = class {
3622
3918
  scheduleReconnect() {
3623
3919
  if (this.stopped) return;
3624
3920
  const delay = this.reconnectDelay;
3625
- console.log(`[CloudRelayClient] Reconnecting in ${delay}ms`);
3921
+ transportLogger.log(`[CloudRelayClient] Reconnecting in ${delay}ms`);
3626
3922
  this.reconnectTimer = setTimeout(() => {
3627
3923
  this.reconnectTimer = null;
3628
3924
  if (!this.stopped) {
@@ -3665,8 +3961,14 @@ var ModalDetector = class {
3665
3961
  };
3666
3962
  this.modals.set(modal.id, next);
3667
3963
  }
3964
+ /**
3965
+ * Remove a modal from the stack. Returns `true` if a modal with that id
3966
+ * was actually deleted, `false` if nothing matched (idempotent so repeated
3967
+ * calls don't throw, but callers — e.g. the HTTP test-hook endpoint — can
3968
+ * surface the miss to clients).
3969
+ */
3668
3970
  dismissModal(id) {
3669
- this.modals.delete(id);
3971
+ return this.modals.delete(id);
3670
3972
  }
3671
3973
  getSnapshotModalContext() {
3672
3974
  const active = this.getActive();
@@ -3878,7 +4180,8 @@ function UIBridgeNativeProvider({
3878
4180
  const server = createNativeServer(registry, executor, {
3879
4181
  serverPort: config.serverPort || 8087,
3880
4182
  cors: true,
3881
- appInfo: config.appInfo
4183
+ appInfo: config.appInfo,
4184
+ testHooks: features.testHooks === true
3882
4185
  });
3883
4186
  server.setAdapter(serverAdapter);
3884
4187
  if (navigationProvider) {
@@ -3932,7 +4235,9 @@ function UIBridgeNativeProvider({
3932
4235
  }
3933
4236
  }, [
3934
4237
  features.server,
4238
+ features.testHooks,
3935
4239
  config.serverPort,
4240
+ config.appInfo,
3936
4241
  registry,
3937
4242
  executor,
3938
4243
  serverAdapter,
@@ -3966,7 +4271,8 @@ function UIBridgeNativeProvider({
3966
4271
  const bareServer = createNativeServer(registry, executor, {
3967
4272
  serverPort: config.serverPort || 8087,
3968
4273
  cors: true,
3969
- appInfo: config.appInfo
4274
+ appInfo: config.appInfo,
4275
+ testHooks: features.testHooks === true
3970
4276
  });
3971
4277
  if (navigationProvider) bareServer.setNavigationProvider(navigationProvider);
3972
4278
  if (screenshotProvider) bareServer.setScreenshotProvider(screenshotProvider);
@@ -4197,7 +4503,7 @@ function useUIElement(options) {
4197
4503
  registeredRef.current = false;
4198
4504
  registeredIdRef.current = null;
4199
4505
  setRegistered(false);
4200
- }, [bridge, registered, id]);
4506
+ }, [bridge, id]);
4201
4507
  const onLayout = react.useCallback(
4202
4508
  (event) => {
4203
4509
  const { x, y, width, height } = event.nativeEvent.layout;
@@ -4463,7 +4769,7 @@ function useUIComponent(options) {
4463
4769
  const registeredComponent = react.useMemo(() => {
4464
4770
  if (!bridge) return null;
4465
4771
  return bridge.registry.getComponent(id) || null;
4466
- }, [bridge, id, registeredRef.current]);
4772
+ }, [bridge, id]);
4467
4773
  return {
4468
4774
  registered: registeredRef.current,
4469
4775
  executeAction,
@@ -4607,11 +4913,12 @@ function useAutoRegister(options = {}) {
4607
4913
  [bridge, onUnregister]
4608
4914
  );
4609
4915
  react.useEffect(() => {
4916
+ const registeredIds = registeredIdsRef.current;
4610
4917
  return () => {
4611
- registeredIdsRef.current.forEach((id) => {
4918
+ registeredIds.forEach((id) => {
4612
4919
  bridge?.registry.unregisterElement(id);
4613
4920
  });
4614
- registeredIdsRef.current.clear();
4921
+ registeredIds.clear();
4615
4922
  };
4616
4923
  }, [bridge]);
4617
4924
  return {