@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
@@ -55,6 +55,13 @@ var NativeUIBridgeRegistry = class {
55
55
  this.eventListeners = /* @__PURE__ */ new Map();
56
56
  this.enrichers = {};
57
57
  this.snapshotExtras = /* @__PURE__ */ new Map();
58
+ /**
59
+ * Sticky flag: flips `true` the first time any element is registered and
60
+ * stays `true` even after elements are unregistered. Lets agents distinguish
61
+ * "this route never wired any elements" from "this route registered then
62
+ * unmounted".
63
+ */
64
+ this.everHadRegistrations = false;
58
65
  this.config = config;
59
66
  }
60
67
  // ============================================================================
@@ -67,6 +74,16 @@ var NativeUIBridgeRegistry = class {
67
74
  setEnrichers(e) {
68
75
  this.enrichers = { ...this.enrichers, ...e };
69
76
  }
77
+ /**
78
+ * Read-only accessor for the canonical enrichers slot. Returned object is
79
+ * the live reference, so callers can detect "no detector wired" via
80
+ * `getEnrichers().modalDetector === undefined`. Used by the test-hook HTTP
81
+ * handlers (`control/modal/push`, `control/modal/dismiss/:id`) which need
82
+ * to drive the ModalDetector directly without re-instantiating it.
83
+ */
84
+ getEnrichers() {
85
+ return this.enrichers;
86
+ }
70
87
  /**
71
88
  * Register a custom snapshot enricher. The returned object will be
72
89
  * `Object.assign`ed onto the snapshot, so use unique top-level keys to avoid
@@ -146,6 +163,7 @@ var NativeUIBridgeRegistry = class {
146
163
  stateStyles
147
164
  };
148
165
  this.elements.set(id, registered);
166
+ this.everHadRegistrations = true;
149
167
  this.emit("element:registered", { id, type, label });
150
168
  if (this.config.verbose) {
151
169
  console.log(`[ui-bridge-native] Registered element: ${id} (${type})`);
@@ -473,7 +491,8 @@ var NativeUIBridgeRegistry = class {
473
491
  stepCount: w.steps.length
474
492
  })),
475
493
  currentRoute: routeInfo?.currentRoute ?? null,
476
- segments: routeInfo?.segments
494
+ segments: routeInfo?.segments,
495
+ registration: this.getRegistrationCoverage()
477
496
  };
478
497
  if (this.enrichers.modalDetector) {
479
498
  try {
@@ -529,6 +548,25 @@ var NativeUIBridgeRegistry = class {
529
548
  workflows: this.workflows.size
530
549
  };
531
550
  }
551
+ /**
552
+ * Compute registration coverage metadata for the current registry state.
553
+ *
554
+ * Groups currently-registered elements by `registrationRoute`, bucketing
555
+ * elements without a route under `'?'`. `everHadRegistrations` is sticky —
556
+ * once true, it stays true for the lifetime of the registry instance.
557
+ */
558
+ getRegistrationCoverage() {
559
+ const byRoute = {};
560
+ for (const element of this.elements.values()) {
561
+ const key = element.registrationRoute == null || element.registrationRoute === "" ? "?" : element.registrationRoute;
562
+ byRoute[key] = (byRoute[key] ?? 0) + 1;
563
+ }
564
+ return {
565
+ totalRegistered: this.elements.size,
566
+ everHadRegistrations: this.everHadRegistrations,
567
+ byRoute
568
+ };
569
+ }
532
570
  /**
533
571
  * Clear all registrations
534
572
  */
@@ -1884,6 +1922,20 @@ function createServerHandlers(registry, executor, config) {
1884
1922
  return success2(response);
1885
1923
  },
1886
1924
  // Discovery
1925
+ //
1926
+ // Native parity with the runner's `POST /ui-bridge/ai/find`. The runner
1927
+ // matches a free-text `query` against label > text > value > aria-label >
1928
+ // placeholder > name. We replicate that here against the native registry's
1929
+ // analogues:
1930
+ // 1. `label` (the registered display label)
1931
+ // 2. identifier fields: `testId`, `accessibilityLabel`, `treePath`, `uiId`
1932
+ // 3. registered handler names (e.g. `onPress`, `onChangeText`)
1933
+ //
1934
+ // Match is case-insensitive substring. Empty/missing query falls back to
1935
+ // the structured filters (types/visibleOnly/etc.) so callers that don't
1936
+ // pass `query` still get the legacy behaviour. Once we've narrowed by
1937
+ // query the response is shaped through the executor's NativeFindResponse
1938
+ // envelope so the wire format stays stable.
1887
1939
  find: async (ctx) => {
1888
1940
  const body = ctx.body;
1889
1941
  const response = await executor.find({
@@ -1891,9 +1943,49 @@ function createServerHandlers(registry, executor, config) {
1891
1943
  testIdPattern: body?.testIdPattern,
1892
1944
  accessibilityLabelPattern: body?.accessibilityLabelPattern,
1893
1945
  visibleOnly: body?.visibleOnly,
1894
- limit: body?.limit
1946
+ // When `query` is supplied we apply our own limit *after* ranking so
1947
+ // the highest-priority matches survive the cap; defer slicing to us
1948
+ // by widening here.
1949
+ limit: body?.query && body.query.length > 0 ? void 0 : body?.limit
1950
+ });
1951
+ const rawQuery = typeof body?.query === "string" ? body.query.trim() : "";
1952
+ if (rawQuery.length === 0) {
1953
+ return success2(response);
1954
+ }
1955
+ const needle = rawQuery.toLowerCase();
1956
+ const containsNeedle = (value) => typeof value === "string" && value.toLowerCase().includes(needle);
1957
+ const scored = [];
1958
+ for (const el of response.elements) {
1959
+ let priorityRank = Number.POSITIVE_INFINITY;
1960
+ if (containsNeedle(el.label)) {
1961
+ priorityRank = Math.min(priorityRank, 0);
1962
+ }
1963
+ const ident = el.identifier;
1964
+ if (ident) {
1965
+ if (containsNeedle(ident.testId)) priorityRank = Math.min(priorityRank, 1);
1966
+ if (containsNeedle(ident.accessibilityLabel)) priorityRank = Math.min(priorityRank, 1);
1967
+ if (containsNeedle(ident.treePath)) priorityRank = Math.min(priorityRank, 1);
1968
+ if (containsNeedle(ident.uiId)) priorityRank = Math.min(priorityRank, 1);
1969
+ }
1970
+ if (priorityRank === Number.POSITIVE_INFINITY) {
1971
+ const registered = registry.getElement(el.id);
1972
+ const handlers = extractHandlerNames(registered?.props);
1973
+ if (handlers.some((h) => h.toLowerCase().includes(needle))) {
1974
+ priorityRank = 2;
1975
+ }
1976
+ }
1977
+ if (priorityRank !== Number.POSITIVE_INFINITY) {
1978
+ scored.push({ priorityRank, element: el });
1979
+ }
1980
+ }
1981
+ scored.sort((a, b) => a.priorityRank - b.priorityRank);
1982
+ const limit = body?.limit && body.limit > 0 ? body.limit : void 0;
1983
+ const filtered = limit ? scored.slice(0, limit) : scored;
1984
+ return success2({
1985
+ ...response,
1986
+ elements: filtered.map((s) => s.element),
1987
+ total: filtered.length
1895
1988
  });
1896
- return success2(response);
1897
1989
  },
1898
1990
  getSnapshot: async (ctx) => {
1899
1991
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
@@ -1994,15 +2086,162 @@ function createServerHandlers(registry, executor, config) {
1994
2086
  getMethods: async () => {
1995
2087
  return error2("Methods introspection not configured", "NOT_SUPPORTED");
1996
2088
  },
2089
+ // AI helpers
2090
+ //
2091
+ // Mobile-side parity with the runner's `POST /ui-bridge/ai/fill-form`.
2092
+ // Each entry in `fields` is dispatched through the existing executor as a
2093
+ // `type` action with `clear: true` so the input value is replaced
2094
+ // atomically (the closest native equivalent to the web bridge's
2095
+ // "setValue" semantics — there is no `setValue` action on native).
2096
+ //
2097
+ // Failures do not short-circuit: every field is attempted, and the
2098
+ // response carries per-field success/error plus aggregate counts.
2099
+ fillForm: async (ctx) => {
2100
+ const body = ctx.body;
2101
+ const rawFields = body?.fields;
2102
+ if (!Array.isArray(rawFields)) {
2103
+ return error2('Request body must include "fields" array', "INVALID_REQUEST");
2104
+ }
2105
+ const results = [];
2106
+ for (let i = 0; i < rawFields.length; i++) {
2107
+ const field = rawFields[i];
2108
+ if (!field || typeof field !== "object") {
2109
+ results.push({
2110
+ elementId: "",
2111
+ success: false,
2112
+ error: `fields[${i}] must be an object with "elementId" and "value"`
2113
+ });
2114
+ continue;
2115
+ }
2116
+ const { elementId, value } = field;
2117
+ if (typeof elementId !== "string" || elementId.length === 0) {
2118
+ results.push({
2119
+ elementId: typeof elementId === "string" ? elementId : "",
2120
+ success: false,
2121
+ error: `fields[${i}] is missing a non-empty "elementId"`
2122
+ });
2123
+ continue;
2124
+ }
2125
+ if (typeof value !== "string") {
2126
+ results.push({
2127
+ elementId,
2128
+ success: false,
2129
+ error: `fields[${i}] is missing a string "value"`
2130
+ });
2131
+ continue;
2132
+ }
2133
+ try {
2134
+ const response = await executor.executeAction(elementId, {
2135
+ action: "type",
2136
+ params: { text: value, clear: true }
2137
+ });
2138
+ if (response.success) {
2139
+ results.push({ elementId, success: true });
2140
+ } else {
2141
+ results.push({
2142
+ elementId,
2143
+ success: false,
2144
+ error: response.error ?? "Action failed"
2145
+ });
2146
+ }
2147
+ } catch (err) {
2148
+ results.push({
2149
+ elementId,
2150
+ success: false,
2151
+ error: err instanceof Error ? err.message : String(err)
2152
+ });
2153
+ }
2154
+ }
2155
+ const succeededCount = results.filter((r) => r.success).length;
2156
+ const failedCount = results.length - succeededCount;
2157
+ return success2({ results, succeededCount, failedCount });
2158
+ },
2159
+ // Test hooks — modal stack manipulation
2160
+ //
2161
+ // These two endpoints exist so external automation runners can drive
2162
+ // ModalDetector state via HTTP, mirroring the in-tree `pushModal` /
2163
+ // `dismissModal` calls that components normally make. They're gated by
2164
+ // `features.testHooks` at the route layer (see http-server.ts); the
2165
+ // handlers themselves still defend against a missing modalDetector
2166
+ // because the bare server path used by CloudRelayClient may run before
2167
+ // enrichers are wired.
2168
+ pushModal: async (ctx) => {
2169
+ const detector = registry.getEnrichers().modalDetector;
2170
+ if (!detector) {
2171
+ return error2(
2172
+ "modal detector not registered",
2173
+ "ENRICHER_UNAVAILABLE"
2174
+ );
2175
+ }
2176
+ const body = ctx.body;
2177
+ const id = body?.id;
2178
+ if (typeof id !== "string" || id.length === 0) {
2179
+ return error2(
2180
+ 'pushModal requires a non-empty "id"',
2181
+ "INVALID_REQUEST"
2182
+ );
2183
+ }
2184
+ const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
2185
+ const requestedType = body?.type;
2186
+ if (requestedType !== void 0 && (typeof requestedType !== "string" || !allowedTypes.includes(requestedType))) {
2187
+ return error2(
2188
+ `pushModal "type" must be one of: ${allowedTypes.join(", ")}`,
2189
+ "INVALID_REQUEST"
2190
+ );
2191
+ }
2192
+ detector.pushModal({
2193
+ id,
2194
+ title: body?.title,
2195
+ // requestedType is narrowed by the allowedTypes guard above; the cast
2196
+ // converts the validated string to the NativeModalInfo['type'] union.
2197
+ type: requestedType,
2198
+ blocking: body?.blocking,
2199
+ dismissible: body?.dismissible
2200
+ });
2201
+ const stored = detector.getActive().find((m) => m.id === id);
2202
+ return success2({
2203
+ pushed: true,
2204
+ id,
2205
+ title: stored?.title,
2206
+ type: stored?.type,
2207
+ blocking: stored?.blocking,
2208
+ dismissible: stored?.dismissible,
2209
+ pushedAt: stored?.detectedAt ?? Date.now()
2210
+ });
2211
+ },
2212
+ dismissModal: async (ctx) => {
2213
+ const detector = registry.getEnrichers().modalDetector;
2214
+ if (!detector) {
2215
+ return error2(
2216
+ "modal detector not registered",
2217
+ "ENRICHER_UNAVAILABLE"
2218
+ );
2219
+ }
2220
+ const { id } = ctx.params;
2221
+ if (typeof id !== "string" || id.length === 0) {
2222
+ return error2(
2223
+ "dismissModal requires a modal id in the path",
2224
+ "INVALID_REQUEST"
2225
+ );
2226
+ }
2227
+ const wasPresent = detector.getActive().some((m) => m.id === id);
2228
+ if (!wasPresent) {
2229
+ return error2(`modal not found: ${id}`, "MODAL_NOT_FOUND");
2230
+ }
2231
+ detector.dismissModal(id);
2232
+ return success2({ dismissed: id });
2233
+ },
1997
2234
  // Design Review
1998
2235
  ...designHandlers,
1999
2236
  // Health
2000
2237
  health: async () => {
2001
2238
  const stats = registry.getStats();
2239
+ const registration = registry.getRegistrationCoverage();
2002
2240
  const response = {
2003
2241
  status: "healthy",
2004
2242
  timestamp: Date.now(),
2005
- ...stats
2243
+ ...stats,
2244
+ registration
2006
2245
  };
2007
2246
  if (config?.appInfo) {
2008
2247
  response.uiBridge = {
@@ -2103,7 +2342,24 @@ var WS_ROUTES = [
2103
2342
  { pattern: "design/evaluate/baseline", handler: "saveBaseline", httpMethods: ["POST"] },
2104
2343
  { pattern: "design/evaluate/diff", handler: "diffBaseline", httpMethods: ["POST"] },
2105
2344
  // Meta / Introspection
2106
- { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] }
2345
+ { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] },
2346
+ // AI helpers — mobile parity with the runner's `/ui-bridge/ai/*` family.
2347
+ { pattern: "ai/fill-form", handler: "fillForm", httpMethods: ["POST"] },
2348
+ // Test hooks — gated behind `features.testHooks`. Let external runners
2349
+ // drive the ModalDetector over HTTP so tests can flip `snapshot.modalStack`
2350
+ // without mounting React components.
2351
+ {
2352
+ pattern: "control/modal/push",
2353
+ handler: "pushModal",
2354
+ httpMethods: ["POST"],
2355
+ requiresTestHooks: true
2356
+ },
2357
+ {
2358
+ pattern: "control/modal/dismiss/:id",
2359
+ handler: "dismissModal",
2360
+ httpMethods: ["POST"],
2361
+ requiresTestHooks: true
2362
+ }
2107
2363
  ];
2108
2364
  var HANDLER_DESCRIPTIONS = {
2109
2365
  health: "Liveness probe; returns app metadata + element count",
@@ -2136,7 +2392,10 @@ var HANDLER_DESCRIPTIONS = {
2136
2392
  getQualityContexts: "List available quality evaluation contexts",
2137
2393
  saveBaseline: "Save a quality baseline snapshot",
2138
2394
  diffBaseline: "Diff current UI against a saved baseline",
2139
- getMethods: "List every handler name (legacy introspection)"
2395
+ getMethods: "List every handler name (legacy introspection)",
2396
+ fillForm: "Fill multiple inputs in one call: body { fields: [{elementId, value}] } \u2014 each field dispatches `type` + `clear:true`",
2397
+ pushModal: "TEST HOOK \u2014 push a modal onto the snapshot.modalStack via the registry ModalDetector",
2398
+ dismissModal: "TEST HOOK \u2014 dismiss a modal by id from the snapshot.modalStack via the registry ModalDetector"
2140
2399
  };
2141
2400
  var ACTION_REFERENCE = [
2142
2401
  { name: "press", aliases: ["click"], params: "PressActionParams (optional position)" },
@@ -2153,10 +2412,11 @@ var ACTION_REFERENCE = [
2153
2412
  { name: "swipe", params: "SwipeActionParams { direction, distance }" },
2154
2413
  { name: "toggle", params: "none \u2014 flips boolean controls" }
2155
2414
  ];
2156
- function buildRoutesPayload() {
2415
+ function buildRoutesPayload(testHooks) {
2157
2416
  const entries = [];
2158
2417
  for (const r of WS_ROUTES) {
2159
2418
  if (!r.httpMethods) continue;
2419
+ if (r.requiresTestHooks && !testHooks) continue;
2160
2420
  for (const m of r.httpMethods) {
2161
2421
  entries.push({ method: m, path: `/ui-bridge/${r.pattern}` });
2162
2422
  }
@@ -2166,11 +2426,11 @@ function buildRoutesPayload() {
2166
2426
  entries.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
2167
2427
  return { routes: entries, count: entries.length };
2168
2428
  }
2169
- function buildHelpPayload() {
2429
+ function buildHelpPayload(testHooks) {
2170
2430
  return {
2171
2431
  version: 1,
2172
2432
  description: "UI Bridge Native HTTP surface. Prefix every path with /ui-bridge/.",
2173
- routes: WS_ROUTES.map((r) => ({
2433
+ routes: WS_ROUTES.filter((r) => !r.requiresTestHooks || testHooks).map((r) => ({
2174
2434
  pattern: r.pattern,
2175
2435
  httpMethods: r.httpMethods ?? [],
2176
2436
  wsOnly: !r.httpMethods,
@@ -2206,7 +2466,8 @@ var NativeUIBridgeServer = class {
2206
2466
  appInfo: this.config.appInfo
2207
2467
  });
2208
2468
  this.handlers.getMethods = async () => {
2209
- const methods = WS_ROUTES.map((r) => ({
2469
+ const testHooks = this.config.testHooks === true;
2470
+ const methods = WS_ROUTES.filter((r) => !r.requiresTestHooks || testHooks).map((r) => ({
2210
2471
  method: r.pattern,
2211
2472
  httpMethods: r.httpMethods ? [...r.httpMethods] : void 0
2212
2473
  }));
@@ -2249,7 +2510,11 @@ var NativeUIBridgeServer = class {
2249
2510
  timestamp: Date.now()
2250
2511
  };
2251
2512
  } catch (e) {
2252
- return { success: false, error: `Navigation failed: ${e.message}`, timestamp: Date.now() };
2513
+ return {
2514
+ success: false,
2515
+ error: `Navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2516
+ timestamp: Date.now()
2517
+ };
2253
2518
  }
2254
2519
  };
2255
2520
  this.handlers.pageReplace = async (ctx) => {
@@ -2275,7 +2540,7 @@ var NativeUIBridgeServer = class {
2275
2540
  } catch (e) {
2276
2541
  return {
2277
2542
  success: false,
2278
- error: `Replace navigation failed: ${e.message}`,
2543
+ error: `Replace navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2279
2544
  timestamp: Date.now()
2280
2545
  };
2281
2546
  }
@@ -2294,7 +2559,7 @@ var NativeUIBridgeServer = class {
2294
2559
  } catch (e) {
2295
2560
  return {
2296
2561
  success: false,
2297
- error: `Back navigation failed: ${e.message}`,
2562
+ error: `Back navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2298
2563
  timestamp: Date.now()
2299
2564
  };
2300
2565
  }
@@ -2315,7 +2580,11 @@ var NativeUIBridgeServer = class {
2315
2580
  timestamp: Date.now()
2316
2581
  };
2317
2582
  } catch (e) {
2318
- return { success: false, error: `Refresh failed: ${e.message}`, timestamp: Date.now() };
2583
+ return {
2584
+ success: false,
2585
+ error: `Refresh failed: ${e instanceof Error ? e.message : String(e)}`,
2586
+ timestamp: Date.now()
2587
+ };
2319
2588
  }
2320
2589
  };
2321
2590
  }
@@ -2373,7 +2642,7 @@ var NativeUIBridgeServer = class {
2373
2642
  } catch (e) {
2374
2643
  return {
2375
2644
  success: false,
2376
- error: `Screenshot capture failed: ${e.message}`,
2645
+ error: `Screenshot capture failed: ${e instanceof Error ? e.message : String(e)}`,
2377
2646
  timestamp: Date.now()
2378
2647
  };
2379
2648
  }
@@ -2928,9 +3197,11 @@ var NativeUIBridgeServer = class {
2928
3197
  * "control/component/my-comp/action/submit" → handlers.executeComponentAction
2929
3198
  */
2930
3199
  async routeMethodToHandler(method, params) {
3200
+ const testHooks = this.config.testHooks === true;
2931
3201
  for (const route of WS_ROUTES) {
2932
3202
  const pathParams = parsePath(route.pattern, method);
2933
3203
  if (!pathParams) continue;
3204
+ if (route.requiresTestHooks && !testHooks) continue;
2934
3205
  const ctx = {
2935
3206
  params: pathParams,
2936
3207
  query: params.query && typeof params.query === "object" ? params.query : {},
@@ -3008,17 +3279,18 @@ var NativeUIBridgeServer = class {
3008
3279
  };
3009
3280
  }
3010
3281
  const stripped = path.slice(PREFIX.length);
3282
+ const testHooks = this.config.testHooks === true;
3011
3283
  if (method === "GET" && stripped === "_help") {
3012
3284
  return {
3013
3285
  success: true,
3014
- data: buildHelpPayload(),
3286
+ data: buildHelpPayload(testHooks),
3015
3287
  timestamp: Date.now()
3016
3288
  };
3017
3289
  }
3018
3290
  if (method === "GET" && stripped === "_routes") {
3019
3291
  return {
3020
3292
  success: true,
3021
- data: buildRoutesPayload(),
3293
+ data: buildRoutesPayload(testHooks),
3022
3294
  timestamp: Date.now()
3023
3295
  };
3024
3296
  }
@@ -3029,6 +3301,9 @@ var NativeUIBridgeServer = class {
3029
3301
  if (!route.httpMethods) {
3030
3302
  continue;
3031
3303
  }
3304
+ if (route.requiresTestHooks && !testHooks) {
3305
+ continue;
3306
+ }
3032
3307
  if (!route.httpMethods.includes(method)) {
3033
3308
  patternMatchedButWrongVerb = true;
3034
3309
  continue;
@@ -3322,6 +3597,27 @@ var WebSocketEventBridge = class {
3322
3597
  }, this.heartbeatIntervalMs);
3323
3598
  }
3324
3599
  };
3600
+
3601
+ // src/transport/logger.ts
3602
+ function createTransportLogger() {
3603
+ return {
3604
+ silent: false,
3605
+ log(message, ...args) {
3606
+ if (!this.silent) {
3607
+ console.log(message, ...args);
3608
+ }
3609
+ },
3610
+ warn(message, ...args) {
3611
+ console.warn(message, ...args);
3612
+ },
3613
+ error(message, ...args) {
3614
+ console.error(message, ...args);
3615
+ }
3616
+ };
3617
+ }
3618
+ var transportLogger = createTransportLogger();
3619
+
3620
+ // src/transport/DeviceAnnouncer.ts
3325
3621
  var DEFAULT_PORT = 8087;
3326
3622
  var RECONNECT_INITIAL_DELAY_MS = 2e3;
3327
3623
  var RECONNECT_MAX_DELAY_MS = 6e4;
@@ -3356,7 +3652,7 @@ var DeviceAnnouncer = class {
3356
3652
  */
3357
3653
  async startMdnsAdvertise(ZeroconfCtor) {
3358
3654
  if (!ZeroconfCtor) {
3359
- console.log(
3655
+ transportLogger.log(
3360
3656
  "[DeviceAnnouncer] mDNS advertisement skipped (no Zeroconf constructor provided). Pass one to enable mDNS."
3361
3657
  );
3362
3658
  return;
@@ -3372,9 +3668,9 @@ var DeviceAnnouncer = class {
3372
3668
  pairing_token: this.pairingToken
3373
3669
  });
3374
3670
  this.state = { ...this.state, mdnsActive: true };
3375
- console.log(`[DeviceAnnouncer] mDNS: advertising "${serviceName}" on port ${port}`);
3671
+ transportLogger.log(`[DeviceAnnouncer] mDNS: advertising "${serviceName}" on port ${port}`);
3376
3672
  } catch (err) {
3377
- console.warn("[DeviceAnnouncer] mDNS start failed:", err);
3673
+ transportLogger.warn("[DeviceAnnouncer] mDNS start failed:", err);
3378
3674
  }
3379
3675
  }
3380
3676
  // ── Cloud relay ───────────────────────────────────────────────────────────
@@ -3390,7 +3686,7 @@ var DeviceAnnouncer = class {
3390
3686
  }
3391
3687
  if (this.stopped) return;
3392
3688
  const url = `${this.config.cloudRelayUrl}?token=${encodeURIComponent(this.config.cloudToken)}`;
3393
- console.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
3689
+ transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
3394
3690
  const ws = new WebSocket(url);
3395
3691
  this.cloudWs = ws;
3396
3692
  ws.onopen = () => {
@@ -3400,7 +3696,7 @@ var DeviceAnnouncer = class {
3400
3696
  }
3401
3697
  this.reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
3402
3698
  this.state = { ...this.state, cloudConnected: true };
3403
- console.log("[DeviceAnnouncer] Cloud relay: connected");
3699
+ transportLogger.log("[DeviceAnnouncer] Cloud relay: connected");
3404
3700
  ws.send(
3405
3701
  JSON.stringify({
3406
3702
  type: "device_register",
@@ -3415,7 +3711,7 @@ var DeviceAnnouncer = class {
3415
3711
  ws.onmessage = (event) => {
3416
3712
  try {
3417
3713
  const msg = JSON.parse(event.data);
3418
- console.log("[DeviceAnnouncer] Cloud relay message:", msg.type);
3714
+ transportLogger.log("[DeviceAnnouncer] Cloud relay message:", msg.type);
3419
3715
  } catch {
3420
3716
  }
3421
3717
  };
@@ -3426,7 +3722,7 @@ var DeviceAnnouncer = class {
3426
3722
  }
3427
3723
  };
3428
3724
  ws.onerror = (err) => {
3429
- console.warn("[DeviceAnnouncer] Cloud relay error:", err);
3725
+ transportLogger.warn("[DeviceAnnouncer] Cloud relay error:", err);
3430
3726
  };
3431
3727
  }
3432
3728
  // ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -3461,7 +3757,7 @@ var DeviceAnnouncer = class {
3461
3757
  scheduleReconnect() {
3462
3758
  if (this.stopped) return;
3463
3759
  const delay = this.reconnectDelay;
3464
- console.log(`[DeviceAnnouncer] Cloud relay: reconnecting in ${delay}ms`);
3760
+ transportLogger.log(`[DeviceAnnouncer] Cloud relay: reconnecting in ${delay}ms`);
3465
3761
  this.reconnectTimer = setTimeout(() => {
3466
3762
  this.reconnectTimer = null;
3467
3763
  if (!this.stopped) {
@@ -3523,7 +3819,7 @@ var CloudRelayClient = class {
3523
3819
  connect() {
3524
3820
  if (this.stopped) return;
3525
3821
  const url = `${this.config.relayUrl}?token=${encodeURIComponent(this.config.authToken)}`;
3526
- console.log("[CloudRelayClient] Connecting to", url);
3822
+ transportLogger.log("[CloudRelayClient] Connecting to", url);
3527
3823
  const ws = new WebSocket(url);
3528
3824
  this.ws = ws;
3529
3825
  ws.onopen = () => {
@@ -3533,7 +3829,7 @@ var CloudRelayClient = class {
3533
3829
  }
3534
3830
  this._isConnected = true;
3535
3831
  this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
3536
- console.log("[CloudRelayClient] Connected to relay");
3832
+ transportLogger.log("[CloudRelayClient] Connected to relay");
3537
3833
  ws.send(
3538
3834
  JSON.stringify({
3539
3835
  type: "device_register",
@@ -3551,7 +3847,7 @@ var CloudRelayClient = class {
3551
3847
  }
3552
3848
  };
3553
3849
  ws.onerror = (err) => {
3554
- console.warn("[CloudRelayClient] WebSocket error:", err);
3850
+ transportLogger.warn("[CloudRelayClient] WebSocket error:", err);
3555
3851
  };
3556
3852
  }
3557
3853
  async onMessage(raw) {
@@ -3559,12 +3855,12 @@ var CloudRelayClient = class {
3559
3855
  try {
3560
3856
  msg = JSON.parse(raw);
3561
3857
  } catch {
3562
- console.warn("[CloudRelayClient] Received non-JSON message");
3858
+ transportLogger.warn("[CloudRelayClient] Received non-JSON message");
3563
3859
  return;
3564
3860
  }
3565
3861
  if (msg["type"] !== "tunnel_request") return;
3566
3862
  if (typeof msg["id"] !== "string" || typeof msg["method"] !== "string" || typeof msg["path"] !== "string") {
3567
- console.warn("[CloudRelayClient] Malformed tunnel_request \u2014 missing required fields");
3863
+ transportLogger.warn("[CloudRelayClient] Malformed tunnel_request \u2014 missing required fields");
3568
3864
  return;
3569
3865
  }
3570
3866
  const req = {
@@ -3605,7 +3901,7 @@ var CloudRelayClient = class {
3605
3901
  body: bodyValue
3606
3902
  };
3607
3903
  } catch (err) {
3608
- console.error("[CloudRelayClient] Error handling tunneled request:", err);
3904
+ transportLogger.error("[CloudRelayClient] Error handling tunneled request:", err);
3609
3905
  return {
3610
3906
  id: req.id,
3611
3907
  status: 500,
@@ -3620,7 +3916,7 @@ var CloudRelayClient = class {
3620
3916
  scheduleReconnect() {
3621
3917
  if (this.stopped) return;
3622
3918
  const delay = this.reconnectDelay;
3623
- console.log(`[CloudRelayClient] Reconnecting in ${delay}ms`);
3919
+ transportLogger.log(`[CloudRelayClient] Reconnecting in ${delay}ms`);
3624
3920
  this.reconnectTimer = setTimeout(() => {
3625
3921
  this.reconnectTimer = null;
3626
3922
  if (!this.stopped) {
@@ -3663,8 +3959,14 @@ var ModalDetector = class {
3663
3959
  };
3664
3960
  this.modals.set(modal.id, next);
3665
3961
  }
3962
+ /**
3963
+ * Remove a modal from the stack. Returns `true` if a modal with that id
3964
+ * was actually deleted, `false` if nothing matched (idempotent so repeated
3965
+ * calls don't throw, but callers — e.g. the HTTP test-hook endpoint — can
3966
+ * surface the miss to clients).
3967
+ */
3666
3968
  dismissModal(id) {
3667
- this.modals.delete(id);
3969
+ return this.modals.delete(id);
3668
3970
  }
3669
3971
  getSnapshotModalContext() {
3670
3972
  const active = this.getActive();
@@ -3876,7 +4178,8 @@ function UIBridgeNativeProvider({
3876
4178
  const server = createNativeServer(registry, executor, {
3877
4179
  serverPort: config.serverPort || 8087,
3878
4180
  cors: true,
3879
- appInfo: config.appInfo
4181
+ appInfo: config.appInfo,
4182
+ testHooks: features.testHooks === true
3880
4183
  });
3881
4184
  server.setAdapter(serverAdapter);
3882
4185
  if (navigationProvider) {
@@ -3930,7 +4233,9 @@ function UIBridgeNativeProvider({
3930
4233
  }
3931
4234
  }, [
3932
4235
  features.server,
4236
+ features.testHooks,
3933
4237
  config.serverPort,
4238
+ config.appInfo,
3934
4239
  registry,
3935
4240
  executor,
3936
4241
  serverAdapter,
@@ -3964,7 +4269,8 @@ function UIBridgeNativeProvider({
3964
4269
  const bareServer = createNativeServer(registry, executor, {
3965
4270
  serverPort: config.serverPort || 8087,
3966
4271
  cors: true,
3967
- appInfo: config.appInfo
4272
+ appInfo: config.appInfo,
4273
+ testHooks: features.testHooks === true
3968
4274
  });
3969
4275
  if (navigationProvider) bareServer.setNavigationProvider(navigationProvider);
3970
4276
  if (screenshotProvider) bareServer.setScreenshotProvider(screenshotProvider);
@@ -4195,7 +4501,7 @@ function useUIElement(options) {
4195
4501
  registeredRef.current = false;
4196
4502
  registeredIdRef.current = null;
4197
4503
  setRegistered(false);
4198
- }, [bridge, registered, id]);
4504
+ }, [bridge, id]);
4199
4505
  const onLayout = useCallback(
4200
4506
  (event) => {
4201
4507
  const { x, y, width, height } = event.nativeEvent.layout;
@@ -4461,7 +4767,7 @@ function useUIComponent(options) {
4461
4767
  const registeredComponent = useMemo(() => {
4462
4768
  if (!bridge) return null;
4463
4769
  return bridge.registry.getComponent(id) || null;
4464
- }, [bridge, id, registeredRef.current]);
4770
+ }, [bridge, id]);
4465
4771
  return {
4466
4772
  registered: registeredRef.current,
4467
4773
  executeAction,
@@ -4605,11 +4911,12 @@ function useAutoRegister(options = {}) {
4605
4911
  [bridge, onUnregister]
4606
4912
  );
4607
4913
  useEffect(() => {
4914
+ const registeredIds = registeredIdsRef.current;
4608
4915
  return () => {
4609
- registeredIdsRef.current.forEach((id) => {
4916
+ registeredIds.forEach((id) => {
4610
4917
  bridge?.registry.unregisterElement(id);
4611
4918
  });
4612
- registeredIdsRef.current.clear();
4919
+ registeredIds.clear();
4613
4920
  };
4614
4921
  }, [bridge]);
4615
4922
  return {