@qontinui/ui-bridge-native 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/{useUIBridgeToast-GmT1TzUe.d.mts → StateAnnotation-D8reaRcS.d.mts} +42 -7
  2. package/dist/{useUIBridgeToast-D32KXyL1.d.ts → StateAnnotation-WmK1YjwP.d.ts} +42 -7
  3. package/dist/control/index.d.mts +4 -4
  4. package/dist/control/index.d.ts +4 -4
  5. package/dist/core/index.d.mts +3 -3
  6. package/dist/core/index.d.ts +3 -3
  7. package/dist/core/index.js +39 -1
  8. package/dist/core/index.js.map +1 -1
  9. package/dist/core/index.mjs +39 -1
  10. package/dist/core/index.mjs.map +1 -1
  11. package/dist/debug/index.js +2 -2
  12. package/dist/debug/index.js.map +1 -1
  13. package/dist/debug/index.mjs +2 -2
  14. package/dist/debug/index.mjs.map +1 -1
  15. package/dist/design/index.d.mts +1 -1
  16. package/dist/design/index.d.ts +1 -1
  17. package/dist/{http-server-DCdR8WlL.d.mts → http-server-D2U1k48d.d.mts} +89 -8
  18. package/dist/{http-server-Dj3grQW4.d.ts → http-server-ck8clUsZ.d.ts} +89 -8
  19. package/dist/index.d.mts +5 -5
  20. package/dist/index.d.ts +5 -5
  21. package/dist/index.js +372 -39
  22. package/dist/index.js.map +1 -1
  23. package/dist/index.mjs +372 -41
  24. package/dist/index.mjs.map +1 -1
  25. package/dist/react/index.d.mts +5 -5
  26. package/dist/react/index.d.ts +5 -5
  27. package/dist/react/index.js +354 -39
  28. package/dist/react/index.js.map +1 -1
  29. package/dist/react/index.mjs +354 -41
  30. package/dist/react/index.mjs.map +1 -1
  31. package/dist/{registry-Cahd8Gn2.d.mts → registry-BxXaxmsT.d.ts} +24 -1
  32. package/dist/{registry-z-3WJsxp.d.ts → registry-KdUW_koV.d.mts} +24 -1
  33. package/dist/server/index.d.mts +5 -5
  34. package/dist/server/index.d.ts +5 -5
  35. package/dist/server/index.js +272 -16
  36. package/dist/server/index.js.map +1 -1
  37. package/dist/server/index.mjs +272 -16
  38. package/dist/server/index.mjs.map +1 -1
  39. package/dist/{types-Oz13YLZq.d.mts → types-BwdPNpII.d.mts} +52 -4
  40. package/dist/{types-Oz13YLZq.d.ts → types-BwdPNpII.d.ts} +52 -4
  41. package/dist/{types-ajakyp-l.d.ts → types-DCEv5qb6.d.ts} +1 -1
  42. package/dist/{types-DAfCeWXc.d.mts → types-DjB0zJJH.d.mts} +1 -1
  43. package/package.json +2 -2
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createContext, useRef, useState, useCallback, useEffect, useMemo, useContext } from 'react';
2
2
  import { Dimensions, StyleSheet, Platform, TouchableOpacity, Text, Modal, View, ScrollView } from 'react-native';
3
- import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
3
+ import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
4
4
 
5
5
  var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
6
6
  get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
@@ -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
  */
@@ -1936,6 +1974,20 @@ function createServerHandlers(registry, executor, config) {
1936
1974
  return success2(response);
1937
1975
  },
1938
1976
  // Discovery
1977
+ //
1978
+ // Native parity with the runner's `POST /ui-bridge/ai/find`. The runner
1979
+ // matches a free-text `query` against label > text > value > aria-label >
1980
+ // placeholder > name. We replicate that here against the native registry's
1981
+ // analogues:
1982
+ // 1. `label` (the registered display label)
1983
+ // 2. identifier fields: `testId`, `accessibilityLabel`, `treePath`, `uiId`
1984
+ // 3. registered handler names (e.g. `onPress`, `onChangeText`)
1985
+ //
1986
+ // Match is case-insensitive substring. Empty/missing query falls back to
1987
+ // the structured filters (types/visibleOnly/etc.) so callers that don't
1988
+ // pass `query` still get the legacy behaviour. Once we've narrowed by
1989
+ // query the response is shaped through the executor's NativeFindResponse
1990
+ // envelope so the wire format stays stable.
1939
1991
  find: async (ctx) => {
1940
1992
  const body = ctx.body;
1941
1993
  const response = await executor.find({
@@ -1943,9 +1995,49 @@ function createServerHandlers(registry, executor, config) {
1943
1995
  testIdPattern: body?.testIdPattern,
1944
1996
  accessibilityLabelPattern: body?.accessibilityLabelPattern,
1945
1997
  visibleOnly: body?.visibleOnly,
1946
- limit: body?.limit
1998
+ // When `query` is supplied we apply our own limit *after* ranking so
1999
+ // the highest-priority matches survive the cap; defer slicing to us
2000
+ // by widening here.
2001
+ limit: body?.query && body.query.length > 0 ? void 0 : body?.limit
2002
+ });
2003
+ const rawQuery = typeof body?.query === "string" ? body.query.trim() : "";
2004
+ if (rawQuery.length === 0) {
2005
+ return success2(response);
2006
+ }
2007
+ const needle = rawQuery.toLowerCase();
2008
+ const containsNeedle = (value) => typeof value === "string" && value.toLowerCase().includes(needle);
2009
+ const scored = [];
2010
+ for (const el of response.elements) {
2011
+ let priorityRank = Number.POSITIVE_INFINITY;
2012
+ if (containsNeedle(el.label)) {
2013
+ priorityRank = Math.min(priorityRank, 0);
2014
+ }
2015
+ const ident = el.identifier;
2016
+ if (ident) {
2017
+ if (containsNeedle(ident.testId)) priorityRank = Math.min(priorityRank, 1);
2018
+ if (containsNeedle(ident.accessibilityLabel)) priorityRank = Math.min(priorityRank, 1);
2019
+ if (containsNeedle(ident.treePath)) priorityRank = Math.min(priorityRank, 1);
2020
+ if (containsNeedle(ident.uiId)) priorityRank = Math.min(priorityRank, 1);
2021
+ }
2022
+ if (priorityRank === Number.POSITIVE_INFINITY) {
2023
+ const registered = registry.getElement(el.id);
2024
+ const handlers = extractHandlerNames(registered?.props);
2025
+ if (handlers.some((h) => h.toLowerCase().includes(needle))) {
2026
+ priorityRank = 2;
2027
+ }
2028
+ }
2029
+ if (priorityRank !== Number.POSITIVE_INFINITY) {
2030
+ scored.push({ priorityRank, element: el });
2031
+ }
2032
+ }
2033
+ scored.sort((a, b) => a.priorityRank - b.priorityRank);
2034
+ const limit = body?.limit && body.limit > 0 ? body.limit : void 0;
2035
+ const filtered = limit ? scored.slice(0, limit) : scored;
2036
+ return success2({
2037
+ ...response,
2038
+ elements: filtered.map((s) => s.element),
2039
+ total: filtered.length
1947
2040
  });
1948
- return success2(response);
1949
2041
  },
1950
2042
  getSnapshot: async (ctx) => {
1951
2043
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
@@ -2046,15 +2138,162 @@ function createServerHandlers(registry, executor, config) {
2046
2138
  getMethods: async () => {
2047
2139
  return error2("Methods introspection not configured", "NOT_SUPPORTED");
2048
2140
  },
2141
+ // AI helpers
2142
+ //
2143
+ // Mobile-side parity with the runner's `POST /ui-bridge/ai/fill-form`.
2144
+ // Each entry in `fields` is dispatched through the existing executor as a
2145
+ // `type` action with `clear: true` so the input value is replaced
2146
+ // atomically (the closest native equivalent to the web bridge's
2147
+ // "setValue" semantics — there is no `setValue` action on native).
2148
+ //
2149
+ // Failures do not short-circuit: every field is attempted, and the
2150
+ // response carries per-field success/error plus aggregate counts.
2151
+ fillForm: async (ctx) => {
2152
+ const body = ctx.body;
2153
+ const rawFields = body?.fields;
2154
+ if (!Array.isArray(rawFields)) {
2155
+ return error2('Request body must include "fields" array', "INVALID_REQUEST");
2156
+ }
2157
+ const results = [];
2158
+ for (let i = 0; i < rawFields.length; i++) {
2159
+ const field = rawFields[i];
2160
+ if (!field || typeof field !== "object") {
2161
+ results.push({
2162
+ elementId: "",
2163
+ success: false,
2164
+ error: `fields[${i}] must be an object with "elementId" and "value"`
2165
+ });
2166
+ continue;
2167
+ }
2168
+ const { elementId, value } = field;
2169
+ if (typeof elementId !== "string" || elementId.length === 0) {
2170
+ results.push({
2171
+ elementId: typeof elementId === "string" ? elementId : "",
2172
+ success: false,
2173
+ error: `fields[${i}] is missing a non-empty "elementId"`
2174
+ });
2175
+ continue;
2176
+ }
2177
+ if (typeof value !== "string") {
2178
+ results.push({
2179
+ elementId,
2180
+ success: false,
2181
+ error: `fields[${i}] is missing a string "value"`
2182
+ });
2183
+ continue;
2184
+ }
2185
+ try {
2186
+ const response = await executor.executeAction(elementId, {
2187
+ action: "type",
2188
+ params: { text: value, clear: true }
2189
+ });
2190
+ if (response.success) {
2191
+ results.push({ elementId, success: true });
2192
+ } else {
2193
+ results.push({
2194
+ elementId,
2195
+ success: false,
2196
+ error: response.error ?? "Action failed"
2197
+ });
2198
+ }
2199
+ } catch (err) {
2200
+ results.push({
2201
+ elementId,
2202
+ success: false,
2203
+ error: err instanceof Error ? err.message : String(err)
2204
+ });
2205
+ }
2206
+ }
2207
+ const succeededCount = results.filter((r) => r.success).length;
2208
+ const failedCount = results.length - succeededCount;
2209
+ return success2({ results, succeededCount, failedCount });
2210
+ },
2211
+ // Test hooks — modal stack manipulation
2212
+ //
2213
+ // These two endpoints exist so external automation runners can drive
2214
+ // ModalDetector state via HTTP, mirroring the in-tree `pushModal` /
2215
+ // `dismissModal` calls that components normally make. They're gated by
2216
+ // `features.testHooks` at the route layer (see http-server.ts); the
2217
+ // handlers themselves still defend against a missing modalDetector
2218
+ // because the bare server path used by CloudRelayClient may run before
2219
+ // enrichers are wired.
2220
+ pushModal: async (ctx) => {
2221
+ const detector = registry.getEnrichers().modalDetector;
2222
+ if (!detector) {
2223
+ return error2(
2224
+ "modal detector not registered",
2225
+ "ENRICHER_UNAVAILABLE"
2226
+ );
2227
+ }
2228
+ const body = ctx.body;
2229
+ const id = body?.id;
2230
+ if (typeof id !== "string" || id.length === 0) {
2231
+ return error2(
2232
+ 'pushModal requires a non-empty "id"',
2233
+ "INVALID_REQUEST"
2234
+ );
2235
+ }
2236
+ const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
2237
+ const requestedType = body?.type;
2238
+ if (requestedType !== void 0 && (typeof requestedType !== "string" || !allowedTypes.includes(requestedType))) {
2239
+ return error2(
2240
+ `pushModal "type" must be one of: ${allowedTypes.join(", ")}`,
2241
+ "INVALID_REQUEST"
2242
+ );
2243
+ }
2244
+ detector.pushModal({
2245
+ id,
2246
+ title: body?.title,
2247
+ // requestedType is narrowed by the allowedTypes guard above; the cast
2248
+ // converts the validated string to the NativeModalInfo['type'] union.
2249
+ type: requestedType,
2250
+ blocking: body?.blocking,
2251
+ dismissible: body?.dismissible
2252
+ });
2253
+ const stored = detector.getActive().find((m) => m.id === id);
2254
+ return success2({
2255
+ pushed: true,
2256
+ id,
2257
+ title: stored?.title,
2258
+ type: stored?.type,
2259
+ blocking: stored?.blocking,
2260
+ dismissible: stored?.dismissible,
2261
+ pushedAt: stored?.detectedAt ?? Date.now()
2262
+ });
2263
+ },
2264
+ dismissModal: async (ctx) => {
2265
+ const detector = registry.getEnrichers().modalDetector;
2266
+ if (!detector) {
2267
+ return error2(
2268
+ "modal detector not registered",
2269
+ "ENRICHER_UNAVAILABLE"
2270
+ );
2271
+ }
2272
+ const { id } = ctx.params;
2273
+ if (typeof id !== "string" || id.length === 0) {
2274
+ return error2(
2275
+ "dismissModal requires a modal id in the path",
2276
+ "INVALID_REQUEST"
2277
+ );
2278
+ }
2279
+ const wasPresent = detector.getActive().some((m) => m.id === id);
2280
+ if (!wasPresent) {
2281
+ return error2(`modal not found: ${id}`, "MODAL_NOT_FOUND");
2282
+ }
2283
+ detector.dismissModal(id);
2284
+ return success2({ dismissed: id });
2285
+ },
2049
2286
  // Design Review
2050
2287
  ...designHandlers,
2051
2288
  // Health
2052
2289
  health: async () => {
2053
2290
  const stats = registry.getStats();
2291
+ const registration = registry.getRegistrationCoverage();
2054
2292
  const response = {
2055
2293
  status: "healthy",
2056
2294
  timestamp: Date.now(),
2057
- ...stats
2295
+ ...stats,
2296
+ registration
2058
2297
  };
2059
2298
  if (config?.appInfo) {
2060
2299
  response.uiBridge = {
@@ -2155,7 +2394,24 @@ var WS_ROUTES = [
2155
2394
  { pattern: "design/evaluate/baseline", handler: "saveBaseline", httpMethods: ["POST"] },
2156
2395
  { pattern: "design/evaluate/diff", handler: "diffBaseline", httpMethods: ["POST"] },
2157
2396
  // Meta / Introspection
2158
- { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] }
2397
+ { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] },
2398
+ // AI helpers — mobile parity with the runner's `/ui-bridge/ai/*` family.
2399
+ { pattern: "ai/fill-form", handler: "fillForm", httpMethods: ["POST"] },
2400
+ // Test hooks — gated behind `features.testHooks`. Let external runners
2401
+ // drive the ModalDetector over HTTP so tests can flip `snapshot.modalStack`
2402
+ // without mounting React components.
2403
+ {
2404
+ pattern: "control/modal/push",
2405
+ handler: "pushModal",
2406
+ httpMethods: ["POST"],
2407
+ requiresTestHooks: true
2408
+ },
2409
+ {
2410
+ pattern: "control/modal/dismiss/:id",
2411
+ handler: "dismissModal",
2412
+ httpMethods: ["POST"],
2413
+ requiresTestHooks: true
2414
+ }
2159
2415
  ];
2160
2416
  var HANDLER_DESCRIPTIONS = {
2161
2417
  health: "Liveness probe; returns app metadata + element count",
@@ -2188,7 +2444,10 @@ var HANDLER_DESCRIPTIONS = {
2188
2444
  getQualityContexts: "List available quality evaluation contexts",
2189
2445
  saveBaseline: "Save a quality baseline snapshot",
2190
2446
  diffBaseline: "Diff current UI against a saved baseline",
2191
- getMethods: "List every handler name (legacy introspection)"
2447
+ getMethods: "List every handler name (legacy introspection)",
2448
+ fillForm: "Fill multiple inputs in one call: body { fields: [{elementId, value}] } \u2014 each field dispatches `type` + `clear:true`",
2449
+ pushModal: "TEST HOOK \u2014 push a modal onto the snapshot.modalStack via the registry ModalDetector",
2450
+ dismissModal: "TEST HOOK \u2014 dismiss a modal by id from the snapshot.modalStack via the registry ModalDetector"
2192
2451
  };
2193
2452
  var ACTION_REFERENCE = [
2194
2453
  { name: "press", aliases: ["click"], params: "PressActionParams (optional position)" },
@@ -2205,10 +2464,11 @@ var ACTION_REFERENCE = [
2205
2464
  { name: "swipe", params: "SwipeActionParams { direction, distance }" },
2206
2465
  { name: "toggle", params: "none \u2014 flips boolean controls" }
2207
2466
  ];
2208
- function buildRoutesPayload() {
2467
+ function buildRoutesPayload(testHooks) {
2209
2468
  const entries = [];
2210
2469
  for (const r of WS_ROUTES) {
2211
2470
  if (!r.httpMethods) continue;
2471
+ if (r.requiresTestHooks && !testHooks) continue;
2212
2472
  for (const m of r.httpMethods) {
2213
2473
  entries.push({ method: m, path: `/ui-bridge/${r.pattern}` });
2214
2474
  }
@@ -2218,11 +2478,11 @@ function buildRoutesPayload() {
2218
2478
  entries.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
2219
2479
  return { routes: entries, count: entries.length };
2220
2480
  }
2221
- function buildHelpPayload() {
2481
+ function buildHelpPayload(testHooks) {
2222
2482
  return {
2223
2483
  version: 1,
2224
2484
  description: "UI Bridge Native HTTP surface. Prefix every path with /ui-bridge/.",
2225
- routes: WS_ROUTES.map((r) => ({
2485
+ routes: WS_ROUTES.filter((r) => !r.requiresTestHooks || testHooks).map((r) => ({
2226
2486
  pattern: r.pattern,
2227
2487
  httpMethods: r.httpMethods ?? [],
2228
2488
  wsOnly: !r.httpMethods,
@@ -2258,7 +2518,8 @@ var NativeUIBridgeServer = class {
2258
2518
  appInfo: this.config.appInfo
2259
2519
  });
2260
2520
  this.handlers.getMethods = async () => {
2261
- const methods = WS_ROUTES.map((r) => ({
2521
+ const testHooks = this.config.testHooks === true;
2522
+ const methods = WS_ROUTES.filter((r) => !r.requiresTestHooks || testHooks).map((r) => ({
2262
2523
  method: r.pattern,
2263
2524
  httpMethods: r.httpMethods ? [...r.httpMethods] : void 0
2264
2525
  }));
@@ -2301,7 +2562,11 @@ var NativeUIBridgeServer = class {
2301
2562
  timestamp: Date.now()
2302
2563
  };
2303
2564
  } catch (e) {
2304
- return { success: false, error: `Navigation failed: ${e.message}`, timestamp: Date.now() };
2565
+ return {
2566
+ success: false,
2567
+ error: `Navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2568
+ timestamp: Date.now()
2569
+ };
2305
2570
  }
2306
2571
  };
2307
2572
  this.handlers.pageReplace = async (ctx) => {
@@ -2327,7 +2592,7 @@ var NativeUIBridgeServer = class {
2327
2592
  } catch (e) {
2328
2593
  return {
2329
2594
  success: false,
2330
- error: `Replace navigation failed: ${e.message}`,
2595
+ error: `Replace navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2331
2596
  timestamp: Date.now()
2332
2597
  };
2333
2598
  }
@@ -2346,7 +2611,7 @@ var NativeUIBridgeServer = class {
2346
2611
  } catch (e) {
2347
2612
  return {
2348
2613
  success: false,
2349
- error: `Back navigation failed: ${e.message}`,
2614
+ error: `Back navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2350
2615
  timestamp: Date.now()
2351
2616
  };
2352
2617
  }
@@ -2367,7 +2632,11 @@ var NativeUIBridgeServer = class {
2367
2632
  timestamp: Date.now()
2368
2633
  };
2369
2634
  } catch (e) {
2370
- return { success: false, error: `Refresh failed: ${e.message}`, timestamp: Date.now() };
2635
+ return {
2636
+ success: false,
2637
+ error: `Refresh failed: ${e instanceof Error ? e.message : String(e)}`,
2638
+ timestamp: Date.now()
2639
+ };
2371
2640
  }
2372
2641
  };
2373
2642
  }
@@ -2425,7 +2694,7 @@ var NativeUIBridgeServer = class {
2425
2694
  } catch (e) {
2426
2695
  return {
2427
2696
  success: false,
2428
- error: `Screenshot capture failed: ${e.message}`,
2697
+ error: `Screenshot capture failed: ${e instanceof Error ? e.message : String(e)}`,
2429
2698
  timestamp: Date.now()
2430
2699
  };
2431
2700
  }
@@ -2980,9 +3249,11 @@ var NativeUIBridgeServer = class {
2980
3249
  * "control/component/my-comp/action/submit" → handlers.executeComponentAction
2981
3250
  */
2982
3251
  async routeMethodToHandler(method, params) {
3252
+ const testHooks = this.config.testHooks === true;
2983
3253
  for (const route of WS_ROUTES) {
2984
3254
  const pathParams = parsePath(route.pattern, method);
2985
3255
  if (!pathParams) continue;
3256
+ if (route.requiresTestHooks && !testHooks) continue;
2986
3257
  const ctx = {
2987
3258
  params: pathParams,
2988
3259
  query: params.query && typeof params.query === "object" ? params.query : {},
@@ -3060,17 +3331,18 @@ var NativeUIBridgeServer = class {
3060
3331
  };
3061
3332
  }
3062
3333
  const stripped = path.slice(PREFIX.length);
3334
+ const testHooks = this.config.testHooks === true;
3063
3335
  if (method === "GET" && stripped === "_help") {
3064
3336
  return {
3065
3337
  success: true,
3066
- data: buildHelpPayload(),
3338
+ data: buildHelpPayload(testHooks),
3067
3339
  timestamp: Date.now()
3068
3340
  };
3069
3341
  }
3070
3342
  if (method === "GET" && stripped === "_routes") {
3071
3343
  return {
3072
3344
  success: true,
3073
- data: buildRoutesPayload(),
3345
+ data: buildRoutesPayload(testHooks),
3074
3346
  timestamp: Date.now()
3075
3347
  };
3076
3348
  }
@@ -3081,6 +3353,9 @@ var NativeUIBridgeServer = class {
3081
3353
  if (!route.httpMethods) {
3082
3354
  continue;
3083
3355
  }
3356
+ if (route.requiresTestHooks && !testHooks) {
3357
+ continue;
3358
+ }
3084
3359
  if (!route.httpMethods.includes(method)) {
3085
3360
  patternMatchedButWrongVerb = true;
3086
3361
  continue;
@@ -3374,6 +3649,27 @@ var WebSocketEventBridge = class {
3374
3649
  }, this.heartbeatIntervalMs);
3375
3650
  }
3376
3651
  };
3652
+
3653
+ // src/transport/logger.ts
3654
+ function createTransportLogger() {
3655
+ return {
3656
+ silent: false,
3657
+ log(message, ...args) {
3658
+ if (!this.silent) {
3659
+ console.log(message, ...args);
3660
+ }
3661
+ },
3662
+ warn(message, ...args) {
3663
+ console.warn(message, ...args);
3664
+ },
3665
+ error(message, ...args) {
3666
+ console.error(message, ...args);
3667
+ }
3668
+ };
3669
+ }
3670
+ var transportLogger = createTransportLogger();
3671
+
3672
+ // src/transport/DeviceAnnouncer.ts
3377
3673
  var DEFAULT_PORT = 8087;
3378
3674
  var RECONNECT_INITIAL_DELAY_MS = 2e3;
3379
3675
  var RECONNECT_MAX_DELAY_MS = 6e4;
@@ -3408,7 +3704,7 @@ var DeviceAnnouncer = class {
3408
3704
  */
3409
3705
  async startMdnsAdvertise(ZeroconfCtor) {
3410
3706
  if (!ZeroconfCtor) {
3411
- console.log(
3707
+ transportLogger.log(
3412
3708
  "[DeviceAnnouncer] mDNS advertisement skipped (no Zeroconf constructor provided). Pass one to enable mDNS."
3413
3709
  );
3414
3710
  return;
@@ -3424,9 +3720,9 @@ var DeviceAnnouncer = class {
3424
3720
  pairing_token: this.pairingToken
3425
3721
  });
3426
3722
  this.state = { ...this.state, mdnsActive: true };
3427
- console.log(`[DeviceAnnouncer] mDNS: advertising "${serviceName}" on port ${port}`);
3723
+ transportLogger.log(`[DeviceAnnouncer] mDNS: advertising "${serviceName}" on port ${port}`);
3428
3724
  } catch (err) {
3429
- console.warn("[DeviceAnnouncer] mDNS start failed:", err);
3725
+ transportLogger.warn("[DeviceAnnouncer] mDNS start failed:", err);
3430
3726
  }
3431
3727
  }
3432
3728
  // ── Cloud relay ───────────────────────────────────────────────────────────
@@ -3442,7 +3738,7 @@ var DeviceAnnouncer = class {
3442
3738
  }
3443
3739
  if (this.stopped) return;
3444
3740
  const url = `${this.config.cloudRelayUrl}?token=${encodeURIComponent(this.config.cloudToken)}`;
3445
- console.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
3741
+ transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
3446
3742
  const ws = new WebSocket(url);
3447
3743
  this.cloudWs = ws;
3448
3744
  ws.onopen = () => {
@@ -3452,7 +3748,7 @@ var DeviceAnnouncer = class {
3452
3748
  }
3453
3749
  this.reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
3454
3750
  this.state = { ...this.state, cloudConnected: true };
3455
- console.log("[DeviceAnnouncer] Cloud relay: connected");
3751
+ transportLogger.log("[DeviceAnnouncer] Cloud relay: connected");
3456
3752
  ws.send(
3457
3753
  JSON.stringify({
3458
3754
  type: "device_register",
@@ -3467,7 +3763,7 @@ var DeviceAnnouncer = class {
3467
3763
  ws.onmessage = (event) => {
3468
3764
  try {
3469
3765
  const msg = JSON.parse(event.data);
3470
- console.log("[DeviceAnnouncer] Cloud relay message:", msg.type);
3766
+ transportLogger.log("[DeviceAnnouncer] Cloud relay message:", msg.type);
3471
3767
  } catch {
3472
3768
  }
3473
3769
  };
@@ -3478,7 +3774,7 @@ var DeviceAnnouncer = class {
3478
3774
  }
3479
3775
  };
3480
3776
  ws.onerror = (err) => {
3481
- console.warn("[DeviceAnnouncer] Cloud relay error:", err);
3777
+ transportLogger.warn("[DeviceAnnouncer] Cloud relay error:", err);
3482
3778
  };
3483
3779
  }
3484
3780
  // ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -3513,7 +3809,7 @@ var DeviceAnnouncer = class {
3513
3809
  scheduleReconnect() {
3514
3810
  if (this.stopped) return;
3515
3811
  const delay = this.reconnectDelay;
3516
- console.log(`[DeviceAnnouncer] Cloud relay: reconnecting in ${delay}ms`);
3812
+ transportLogger.log(`[DeviceAnnouncer] Cloud relay: reconnecting in ${delay}ms`);
3517
3813
  this.reconnectTimer = setTimeout(() => {
3518
3814
  this.reconnectTimer = null;
3519
3815
  if (!this.stopped) {
@@ -3575,7 +3871,7 @@ var CloudRelayClient = class {
3575
3871
  connect() {
3576
3872
  if (this.stopped) return;
3577
3873
  const url = `${this.config.relayUrl}?token=${encodeURIComponent(this.config.authToken)}`;
3578
- console.log("[CloudRelayClient] Connecting to", url);
3874
+ transportLogger.log("[CloudRelayClient] Connecting to", url);
3579
3875
  const ws = new WebSocket(url);
3580
3876
  this.ws = ws;
3581
3877
  ws.onopen = () => {
@@ -3585,7 +3881,7 @@ var CloudRelayClient = class {
3585
3881
  }
3586
3882
  this._isConnected = true;
3587
3883
  this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
3588
- console.log("[CloudRelayClient] Connected to relay");
3884
+ transportLogger.log("[CloudRelayClient] Connected to relay");
3589
3885
  ws.send(
3590
3886
  JSON.stringify({
3591
3887
  type: "device_register",
@@ -3603,7 +3899,7 @@ var CloudRelayClient = class {
3603
3899
  }
3604
3900
  };
3605
3901
  ws.onerror = (err) => {
3606
- console.warn("[CloudRelayClient] WebSocket error:", err);
3902
+ transportLogger.warn("[CloudRelayClient] WebSocket error:", err);
3607
3903
  };
3608
3904
  }
3609
3905
  async onMessage(raw) {
@@ -3611,12 +3907,12 @@ var CloudRelayClient = class {
3611
3907
  try {
3612
3908
  msg = JSON.parse(raw);
3613
3909
  } catch {
3614
- console.warn("[CloudRelayClient] Received non-JSON message");
3910
+ transportLogger.warn("[CloudRelayClient] Received non-JSON message");
3615
3911
  return;
3616
3912
  }
3617
3913
  if (msg["type"] !== "tunnel_request") return;
3618
3914
  if (typeof msg["id"] !== "string" || typeof msg["method"] !== "string" || typeof msg["path"] !== "string") {
3619
- console.warn("[CloudRelayClient] Malformed tunnel_request \u2014 missing required fields");
3915
+ transportLogger.warn("[CloudRelayClient] Malformed tunnel_request \u2014 missing required fields");
3620
3916
  return;
3621
3917
  }
3622
3918
  const req = {
@@ -3657,7 +3953,7 @@ var CloudRelayClient = class {
3657
3953
  body: bodyValue
3658
3954
  };
3659
3955
  } catch (err) {
3660
- console.error("[CloudRelayClient] Error handling tunneled request:", err);
3956
+ transportLogger.error("[CloudRelayClient] Error handling tunneled request:", err);
3661
3957
  return {
3662
3958
  id: req.id,
3663
3959
  status: 500,
@@ -3672,7 +3968,7 @@ var CloudRelayClient = class {
3672
3968
  scheduleReconnect() {
3673
3969
  if (this.stopped) return;
3674
3970
  const delay = this.reconnectDelay;
3675
- console.log(`[CloudRelayClient] Reconnecting in ${delay}ms`);
3971
+ transportLogger.log(`[CloudRelayClient] Reconnecting in ${delay}ms`);
3676
3972
  this.reconnectTimer = setTimeout(() => {
3677
3973
  this.reconnectTimer = null;
3678
3974
  if (!this.stopped) {
@@ -3715,8 +4011,14 @@ var ModalDetector = class {
3715
4011
  };
3716
4012
  this.modals.set(modal.id, next);
3717
4013
  }
4014
+ /**
4015
+ * Remove a modal from the stack. Returns `true` if a modal with that id
4016
+ * was actually deleted, `false` if nothing matched (idempotent so repeated
4017
+ * calls don't throw, but callers — e.g. the HTTP test-hook endpoint — can
4018
+ * surface the miss to clients).
4019
+ */
3718
4020
  dismissModal(id) {
3719
- this.modals.delete(id);
4021
+ return this.modals.delete(id);
3720
4022
  }
3721
4023
  getSnapshotModalContext() {
3722
4024
  const active = this.getActive();
@@ -3928,7 +4230,8 @@ function UIBridgeNativeProvider({
3928
4230
  const server = createNativeServer(registry, executor, {
3929
4231
  serverPort: config.serverPort || 8087,
3930
4232
  cors: true,
3931
- appInfo: config.appInfo
4233
+ appInfo: config.appInfo,
4234
+ testHooks: features.testHooks === true
3932
4235
  });
3933
4236
  server.setAdapter(serverAdapter);
3934
4237
  if (navigationProvider) {
@@ -3982,7 +4285,9 @@ function UIBridgeNativeProvider({
3982
4285
  }
3983
4286
  }, [
3984
4287
  features.server,
4288
+ features.testHooks,
3985
4289
  config.serverPort,
4290
+ config.appInfo,
3986
4291
  registry,
3987
4292
  executor,
3988
4293
  serverAdapter,
@@ -4016,7 +4321,8 @@ function UIBridgeNativeProvider({
4016
4321
  const bareServer = createNativeServer(registry, executor, {
4017
4322
  serverPort: config.serverPort || 8087,
4018
4323
  cors: true,
4019
- appInfo: config.appInfo
4324
+ appInfo: config.appInfo,
4325
+ testHooks: features.testHooks === true
4020
4326
  });
4021
4327
  if (navigationProvider) bareServer.setNavigationProvider(navigationProvider);
4022
4328
  if (screenshotProvider) bareServer.setScreenshotProvider(screenshotProvider);
@@ -4247,7 +4553,7 @@ function useUIElement(options) {
4247
4553
  registeredRef.current = false;
4248
4554
  registeredIdRef.current = null;
4249
4555
  setRegistered(false);
4250
- }, [bridge, registered, id]);
4556
+ }, [bridge, id]);
4251
4557
  const onLayout = useCallback(
4252
4558
  (event) => {
4253
4559
  const { x, y, width, height } = event.nativeEvent.layout;
@@ -4513,7 +4819,7 @@ function useUIComponent(options) {
4513
4819
  const registeredComponent = useMemo(() => {
4514
4820
  if (!bridge) return null;
4515
4821
  return bridge.registry.getComponent(id) || null;
4516
- }, [bridge, id, registeredRef.current]);
4822
+ }, [bridge, id]);
4517
4823
  return {
4518
4824
  registered: registeredRef.current,
4519
4825
  executeAction,
@@ -4664,6 +4970,12 @@ function useToastRecorder() {
4664
4970
  const dismissToast = useCallback((id) => toastCapture.dismissToast(id), [toastCapture]);
4665
4971
  return { recordToast, dismissToast };
4666
4972
  }
4973
+ function State(props) {
4974
+ return /* @__PURE__ */ jsx(Fragment, { children: props.children });
4975
+ }
4976
+ function TransitionTo(props) {
4977
+ return /* @__PURE__ */ jsx(Fragment, { children: props.children });
4978
+ }
4667
4979
 
4668
4980
  // src/server/types.ts
4669
4981
  var UI_BRIDGE_NATIVE_ROUTES = {
@@ -4793,6 +5105,25 @@ var UI_BRIDGE_NATIVE_ROUTES = {
4793
5105
  path: "/ui-bridge/design/style-guide",
4794
5106
  description: "Clear loaded style guide"
4795
5107
  },
5108
+ // AI helpers
5109
+ AI_FILL_FORM: {
5110
+ method: "POST",
5111
+ path: "/ui-bridge/ai/fill-form",
5112
+ description: "Fill multiple input elements in a single call"
5113
+ },
5114
+ // Test hooks — drive ModalDetector state from outside the React tree
5115
+ // (gated by `features.testHooks`). Mirrors `pushModal` / `dismissModal`
5116
+ // calls that components normally make in-process.
5117
+ PUSH_MODAL: {
5118
+ method: "POST",
5119
+ path: "/ui-bridge/control/modal/push",
5120
+ description: "Push a modal onto the modal stack (testHooks)"
5121
+ },
5122
+ DISMISS_MODAL: {
5123
+ method: "POST",
5124
+ path: "/ui-bridge/control/modal/dismiss/:id",
5125
+ description: "Dismiss a modal by id (testHooks)"
5126
+ },
4796
5127
  // Health
4797
5128
  HEALTH: {
4798
5129
  method: "GET",
@@ -5302,8 +5633,8 @@ function UIBridgeInspector({
5302
5633
  const bridge = useUIBridgeNativeOptional();
5303
5634
  const [showInspector, setShowInspector] = useState(visible);
5304
5635
  const [selectedElement, setSelectedElement] = useState(null);
5305
- const elements = useMemo(() => bridge ? bridge.getElements() : [], [bridge, showInspector]);
5306
- const components = useMemo(() => bridge ? bridge.getComponents() : [], [bridge, showInspector]);
5636
+ const elements = useMemo(() => bridge ? bridge.getElements() : [], [bridge]);
5637
+ const components = useMemo(() => bridge ? bridge.getComponents() : [], [bridge]);
5307
5638
  const handleToggle = useCallback(() => {
5308
5639
  setShowInspector((prev) => !prev);
5309
5640
  }, []);
@@ -5565,6 +5896,6 @@ var styles = StyleSheet.create({
5565
5896
  }
5566
5897
  });
5567
5898
 
5568
- export { CloudRelayClient, DefaultNativeActionExecutor, DeviceAnnouncer, ModalDetector, NativeUIBridgeRegistry, NativeUIBridgeServer, ToastCapture, UIBridgeInspector, UIBridgeNativeProvider, UI_BRIDGE_NATIVE_ROUTES, UndoTracker, WebSocketConnection, WebSocketEventBridge, buildTreePath, buildUpgradeResponse, captureNativeResponsiveSnapshot, captureNativeStateVariations, computeAcceptKey, createDesignHandlers, createNativeActionExecutor, createNativeElementIdentifier, createNativeServer, createServerHandlers, extractHandlerNames, findAllByPattern, findElementByIdentifier, getGlobalRegistry, getNativeElementDesignData, mapNativeStyleToExtended, matchesIdentifier, parseTreePath, resetGlobalRegistry, setGlobalRegistry, useToastRecorder, useUIBridge, useUIBridgeModal, useUIBridgeNative, useUIBridgeNativeOptional, useUIBridgeNativeRequired, useUIBridgeRequired, useUIBridgeToast, useUIComponent, useUIComponentAction, useUIElement, useUIElementWithProps };
5899
+ export { CloudRelayClient, DefaultNativeActionExecutor, DeviceAnnouncer, ModalDetector, NativeUIBridgeRegistry, NativeUIBridgeServer, State, ToastCapture, TransitionTo, UIBridgeInspector, UIBridgeNativeProvider, UI_BRIDGE_NATIVE_ROUTES, UndoTracker, WebSocketConnection, WebSocketEventBridge, buildTreePath, buildUpgradeResponse, captureNativeResponsiveSnapshot, captureNativeStateVariations, computeAcceptKey, createDesignHandlers, createNativeActionExecutor, createNativeElementIdentifier, createNativeServer, createServerHandlers, extractHandlerNames, findAllByPattern, findElementByIdentifier, getGlobalRegistry, getNativeElementDesignData, mapNativeStyleToExtended, matchesIdentifier, parseTreePath, resetGlobalRegistry, setGlobalRegistry, useToastRecorder, useUIBridge, useUIBridgeModal, useUIBridgeNative, useUIBridgeNativeOptional, useUIBridgeNativeRequired, useUIBridgeRequired, useUIBridgeToast, useUIComponent, useUIComponentAction, useUIElement, useUIElementWithProps };
5569
5900
  //# sourceMappingURL=index.mjs.map
5570
5901
  //# sourceMappingURL=index.mjs.map