@qontinui/ui-bridge-native 0.6.7 → 0.6.9

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.
package/dist/index.mjs CHANGED
@@ -26,7 +26,7 @@ function inferActions(type) {
26
26
  case "pressable":
27
27
  return [...baseActions, "click", "press", "longPress", "doubleTap"];
28
28
  case "input":
29
- return [...baseActions, "click", "press", "type", "clear"];
29
+ return [...baseActions, "click", "press", "type", "setValue", "clear"];
30
30
  case "text":
31
31
  return [...baseActions, "click", "press", "longPress"];
32
32
  case "view":
@@ -1040,6 +1040,8 @@ var DefaultNativeActionExecutor = class {
1040
1040
  return this.performDoubleTap(props);
1041
1041
  case "type":
1042
1042
  return this.performType(element, props, params);
1043
+ case "setValue":
1044
+ return this.performSetValue(element, props, params);
1043
1045
  case "clear":
1044
1046
  return this.performClear(element, props);
1045
1047
  case "focus":
@@ -1132,6 +1134,38 @@ var DefaultNativeActionExecutor = class {
1132
1134
  this.registry.updateElementState(element.id, { value: params.text });
1133
1135
  }
1134
1136
  }
1137
+ /**
1138
+ * Perform setValue action.
1139
+ *
1140
+ * Web/runner UI Bridge parity: `setValue` replaces the input's value
1141
+ * atomically in a single call (no per-character keystroke simulation),
1142
+ * mirroring the web bridge's `setValue` semantics. On native there is no
1143
+ * DOM value to assign, so we drive the controlled-input handler
1144
+ * (`onChangeText`, falling back to `onChange`) with the full string. The
1145
+ * registry's `state.value` is then synced so a subsequent GET on the
1146
+ * element reflects the new value.
1147
+ *
1148
+ * Unlike `type`, this does not append to existing content and ignores
1149
+ * `delay` — it is the deterministic "set the field to exactly this" action.
1150
+ */
1151
+ async performSetValue(element, props, params) {
1152
+ const next = typeof params?.text === "string" ? params.text : typeof params?.value === "string" ? params.value : void 0;
1153
+ if (typeof next !== "string") {
1154
+ throw new Error('setValue action requires a string "text" (or "value") parameter');
1155
+ }
1156
+ if (typeof props.onChangeText === "function") {
1157
+ props.onChangeText(next);
1158
+ } else if (typeof props.onChange === "function") {
1159
+ props.onChange({
1160
+ nativeEvent: { text: next }
1161
+ });
1162
+ } else {
1163
+ throw new Error("No text change handler found on element");
1164
+ }
1165
+ if (element) {
1166
+ this.registry.updateElementState(element.id, { value: next });
1167
+ }
1168
+ }
1135
1169
  /**
1136
1170
  * Perform clear action
1137
1171
  */
@@ -2095,6 +2129,35 @@ function diagnosePageHealth(elements, viewport) {
2095
2129
  }
2096
2130
 
2097
2131
  // src/server/handlers.ts
2132
+ function matchElementsByText(registry, text, opts) {
2133
+ const needle = text.toLowerCase();
2134
+ const contains = (v) => typeof v === "string" && v.toLowerCase().includes(needle);
2135
+ let all = registry.getAllElements();
2136
+ if (opts?.visibleOnly) {
2137
+ all = all.filter((e) => e.getState().visible);
2138
+ }
2139
+ const scored = [];
2140
+ for (const e of all) {
2141
+ let rank = Number.POSITIVE_INFINITY;
2142
+ if (contains(e.label)) rank = Math.min(rank, 0);
2143
+ const ident = e.getIdentifier();
2144
+ if (ident) {
2145
+ if (contains(ident.testId)) rank = Math.min(rank, 1);
2146
+ if (contains(ident.accessibilityLabel)) rank = Math.min(rank, 1);
2147
+ if (contains(ident.treePath)) rank = Math.min(rank, 1);
2148
+ if (contains(ident.uiId)) rank = Math.min(rank, 1);
2149
+ }
2150
+ if (rank === Number.POSITIVE_INFINITY) {
2151
+ const handlers = extractHandlerNames(e.props);
2152
+ if (handlers.some((h) => h.toLowerCase().includes(needle))) rank = 2;
2153
+ }
2154
+ if (rank !== Number.POSITIVE_INFINITY) {
2155
+ scored.push({ rank, element: { id: e.id, type: e.type, label: e.label } });
2156
+ }
2157
+ }
2158
+ scored.sort((a, b) => a.rank - b.rank);
2159
+ return scored.map((s) => s.element);
2160
+ }
2098
2161
  async function executeWorkflowStep(step, registry, executor) {
2099
2162
  const startTime = Date.now();
2100
2163
  try {
@@ -2691,6 +2754,89 @@ function createServerHandlers(registry, executor, config) {
2691
2754
  const failedCount = results.length - succeededCount;
2692
2755
  return success2({ results, succeededCount, failedCount });
2693
2756
  },
2757
+ // ========================================================================
2758
+ // App-agnostic interaction parity (cross-platform `/control/page/*`)
2759
+ //
2760
+ // Mirrors the web/runner convenience endpoints so the SAME contract drives
2761
+ // mobile. Text/label interactions compose `executor.find` (registry-based
2762
+ // discovery) + `executor.executeAction` (press/type). Selector-only
2763
+ // variants have no React-Native analog and return NOT_SUPPORTED — the
2764
+ // mobile bridge's snapshot + `/control/find` (testID / accessibilityLabel)
2765
+ // cover what a selector would on web.
2766
+ // ========================================================================
2767
+ clickByText: async (ctx) => {
2768
+ const body = ctx.body;
2769
+ const text = typeof body?.text === "string" ? body.text.trim() : "";
2770
+ if (!text) {
2771
+ return error2("text is required and must not be empty", "INVALID_REQUEST");
2772
+ }
2773
+ const match = matchElementsByText(registry, text, { visibleOnly: body?.visibleOnly })[0];
2774
+ if (!match) {
2775
+ return error2(`No element found with text "${text}"`, "ELEMENT_NOT_FOUND");
2776
+ }
2777
+ const response = await executor.executeAction(match.id, { action: "press" });
2778
+ if (!response.success) {
2779
+ return error2(response.error || "Press failed", "ACTION_FAILED");
2780
+ }
2781
+ return success2({
2782
+ clicked: true,
2783
+ element: { id: match.id, type: match.type, label: match.label }
2784
+ });
2785
+ },
2786
+ clickBySelector: async () => {
2787
+ return error2(
2788
+ "clickBySelector is not supported on native platform \u2014 React Native has no CSS-selector DOM. Use clickByText, or /control/find with testIdPattern/accessibilityLabelPattern then /control/element/:id/action.",
2789
+ "NOT_SUPPORTED"
2790
+ );
2791
+ },
2792
+ typeInto: async (ctx) => {
2793
+ const body = ctx.body;
2794
+ const label = typeof body?.label === "string" ? body.label.trim() : "";
2795
+ if (!label) {
2796
+ return error2(
2797
+ "label is required on native (selector-based typeInto is web-only)",
2798
+ "INVALID_REQUEST"
2799
+ );
2800
+ }
2801
+ const text = typeof body?.text === "string" ? body.text : "";
2802
+ const match = matchElementsByText(registry, label, { visibleOnly: body?.visibleOnly })[0];
2803
+ if (!match) {
2804
+ return error2(`No input found for label "${label}"`, "ELEMENT_NOT_FOUND");
2805
+ }
2806
+ const response = await executor.executeAction(match.id, {
2807
+ action: "type",
2808
+ params: { text, clear: body?.clear === true }
2809
+ });
2810
+ if (!response.success) {
2811
+ return error2(response.error || "Type failed", "ACTION_FAILED");
2812
+ }
2813
+ return success2({
2814
+ typed: true,
2815
+ element: { id: match.id, type: match.type, label: match.label }
2816
+ });
2817
+ },
2818
+ readValue: async () => {
2819
+ return error2(
2820
+ "readValue is not supported on native platform \u2014 React Native has no CSS-selector DOM. Use /control/element/:id/state to read an element value.",
2821
+ "NOT_SUPPORTED"
2822
+ );
2823
+ },
2824
+ findByText: async (ctx) => {
2825
+ const body = ctx.body;
2826
+ const text = typeof body?.text === "string" ? body.text.trim() : "";
2827
+ if (!text) {
2828
+ return error2("text is required and must not be empty", "INVALID_REQUEST");
2829
+ }
2830
+ const matches = matchElementsByText(registry, text, { visibleOnly: body?.visibleOnly });
2831
+ return success2(
2832
+ matches.map((el, index) => ({
2833
+ index,
2834
+ id: el.id,
2835
+ type: el.type,
2836
+ label: el.label
2837
+ }))
2838
+ );
2839
+ },
2694
2840
  // Coord-based tap
2695
2841
  //
2696
2842
  // Synthesizes a press at the given screen coordinates by scanning the
@@ -2785,7 +2931,8 @@ function createServerHandlers(registry, executor, config) {
2785
2931
  return success2({
2786
2932
  entries: [],
2787
2933
  count: 0,
2788
- bufferSize: 0
2934
+ bufferSize: 0,
2935
+ installed: false
2789
2936
  });
2790
2937
  }
2791
2938
  const entries = buf.entries({
@@ -2795,7 +2942,8 @@ function createServerHandlers(registry, executor, config) {
2795
2942
  return success2({
2796
2943
  entries,
2797
2944
  count: entries.length,
2798
- bufferSize: buf.size()
2945
+ bufferSize: buf.size(),
2946
+ installed: buf.isInstalled()
2799
2947
  });
2800
2948
  },
2801
2949
  getNetworkRequests: async (ctx) => {
@@ -2806,7 +2954,8 @@ function createServerHandlers(registry, executor, config) {
2806
2954
  return success2({
2807
2955
  entries: [],
2808
2956
  count: 0,
2809
- bufferSize: 0
2957
+ bufferSize: 0,
2958
+ installed: false
2810
2959
  });
2811
2960
  }
2812
2961
  const entries = buf.entries({
@@ -2816,7 +2965,8 @@ function createServerHandlers(registry, executor, config) {
2816
2965
  return success2({
2817
2966
  entries,
2818
2967
  count: entries.length,
2819
- bufferSize: buf.size()
2968
+ bufferSize: buf.size(),
2969
+ installed: buf.isInstalled()
2820
2970
  });
2821
2971
  },
2822
2972
  // Test hooks — modal stack manipulation
@@ -3235,7 +3385,10 @@ var WS_ROUTES = [
3235
3385
  // same handler at both paths to keep agents portable across platforms.
3236
3386
  { pattern: "ai/find", handler: "find", httpMethods: ["POST"] },
3237
3387
  { pattern: "control/snapshot", handler: "getSnapshot", httpMethods: ["GET"] },
3238
- { pattern: "control/discover", handler: "getSnapshot" },
3388
+ // Discovery alias — web/runner parity with `POST /control/discover` (a
3389
+ // legacy alias for snapshot/find). Mounted on HTTP (GET+POST) so mobile
3390
+ // doesn't 404 where web returns 200; delegates to the snapshot handler.
3391
+ { pattern: "control/discover", handler: "getSnapshot", httpMethods: ["GET", "POST"] },
3239
3392
  // Page health — accepts both GET and POST so the canonical skill invocation
3240
3393
  // (`POST .../control/page-health` with an empty body) and ad-hoc curls both
3241
3394
  // work without a body-shape gotcha.
@@ -3297,6 +3450,17 @@ var WS_ROUTES = [
3297
3450
  // Coord-based tap — synthesizes a press at the given screen coords by
3298
3451
  // matching against registered elements' layout rects.
3299
3452
  { pattern: "control/tap", handler: "tapAt", httpMethods: ["POST"] },
3453
+ // App-agnostic interaction parity — mirrors the web/runner convenience
3454
+ // endpoints (`/control/page/click-by-text` etc.) so the SAME cross-platform
3455
+ // contract drives mobile. Text/label-based interactions compose
3456
+ // `executor.find` + `executor.executeAction` over the registry; the
3457
+ // DOM-selector-only variants (`click-by-selector`, `read-value`) have no RN
3458
+ // analog and return a structured NOT_SUPPORTED envelope.
3459
+ { pattern: "control/page/click-by-text", handler: "clickByText", httpMethods: ["POST"] },
3460
+ { pattern: "control/page/click-by-selector", handler: "clickBySelector", httpMethods: ["POST"] },
3461
+ { pattern: "control/page/type-into", handler: "typeInto", httpMethods: ["POST"] },
3462
+ { pattern: "control/page/read-value", handler: "readValue", httpMethods: ["POST"] },
3463
+ { pattern: "control/page/find-by-text", handler: "findByText", httpMethods: ["POST"] },
3300
3464
  // Test hooks — gated behind `features.testHooks`. Let external runners
3301
3465
  // drive the ModalDetector over HTTP so tests can flip `snapshot.modalStack`
3302
3466
  // without mounting React components.
@@ -3318,19 +3482,26 @@ var WS_ROUTES = [
3318
3482
  httpMethods: ["POST"],
3319
3483
  requiresTestHooks: true
3320
3484
  },
3321
- // Observability — last-N console errors + network requests. testHooks-gated
3322
- // because patching `console.error` / `fetch` is invasive in production.
3485
+ // Observability — last-N console errors + network requests.
3486
+ //
3487
+ // Always mounted (not testHooks-gated) so they return a schema-valid 200
3488
+ // on every build instead of a 404 that agents can't distinguish from a
3489
+ // missing route. The INVASIVE part — monkey-patching `console.error` /
3490
+ // `fetch` / `XMLHttpRequest` — stays gated behind `testHooks` (see
3491
+ // NativeUIBridgeServer.start → buffer.install()). When the patches aren't
3492
+ // installed the handlers report `installed: false` + empty entries, so
3493
+ // production builds expose the contract without observing real users'
3494
+ // console/network traffic. Set `features.testHooks` (typically on
3495
+ // `__DEV__`) to populate the buffers with live data.
3323
3496
  {
3324
3497
  pattern: "control/console-errors",
3325
3498
  handler: "getConsoleErrors",
3326
- httpMethods: ["GET"],
3327
- requiresTestHooks: true
3499
+ httpMethods: ["GET"]
3328
3500
  },
3329
3501
  {
3330
3502
  pattern: "sdk/network-requests",
3331
3503
  handler: "getNetworkRequests",
3332
- httpMethods: ["GET"],
3333
- requiresTestHooks: true
3504
+ httpMethods: ["GET"]
3334
3505
  }
3335
3506
  ];
3336
3507
  var HANDLER_DESCRIPTIONS = {
@@ -3372,8 +3543,13 @@ var HANDLER_DESCRIPTIONS = {
3372
3543
  aiChangeBufferEnable: "STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Use WS event subscriptions for change notifications.",
3373
3544
  aiWaitForElement: "STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Use the WS waitForElement / waitForCondition JSON-RPC methods.",
3374
3545
  tapAt: "Synthesize a press at given screen coords by matching registered element layout rects: body { x, y, action? }",
3375
- getConsoleErrors: "TEST HOOK \u2014 last-N console.error/console.warn entries; ?since=<ms>&limit=<n>",
3376
- getNetworkRequests: "TEST HOOK \u2014 last-N fetch/XHR entries with status + duration; ?since=<ms>&limit=<n>",
3546
+ clickByText: "App-agnostic: press the first element matching free-text (label/accessibilityLabel/testID): body { text, visibleOnly? }",
3547
+ clickBySelector: "STUB \u2014 selector-based; NOT_SUPPORTED on native (no DOM). Use clickByText or /control/find + /control/element/:id/action.",
3548
+ typeInto: "App-agnostic: type into the input matching a label: body { label, text, clear?, visibleOnly? }. (selector form is web-only)",
3549
+ readValue: "STUB \u2014 selector-based; NOT_SUPPORTED on native (no DOM). Use /control/element/:id/state to read a value.",
3550
+ findByText: "App-agnostic: list elements matching free-text (no press): body { text, visibleOnly? }",
3551
+ getConsoleErrors: "Last-N console.error/console.warn entries; ?since=<ms>&limit=<n>. `installed:false` when capture is off (no testHooks) \u2014 entries empty but schema-valid.",
3552
+ getNetworkRequests: "Last-N fetch/XHR entries with status + duration; ?since=<ms>&limit=<n>. `installed:false` when capture is off (no testHooks) \u2014 entries empty but schema-valid.",
3377
3553
  pushModal: "TEST HOOK \u2014 push a modal onto the snapshot.modalStack via the registry ModalDetector",
3378
3554
  dismissModal: "TEST HOOK \u2014 dismiss a modal by id from the snapshot.modalStack via the registry ModalDetector",
3379
3555
  keepAwake: "TEST HOOK \u2014 keep the device screen awake via the injected keepAwakeProvider: body { enabled: boolean, durationMs?: number }"
@@ -5544,7 +5720,8 @@ function useUIElement(options) {
5544
5720
  parentPath,
5545
5721
  style,
5546
5722
  stateStyles: stateStylesProp,
5547
- handlers: handlersProp
5723
+ handlers: handlersProp,
5724
+ value: valueProp
5548
5725
  } = options;
5549
5726
  const treePath = parentPath ? `${parentPath}/${id}` : id;
5550
5727
  const bridgeProps = useMemo(
@@ -5570,9 +5747,12 @@ function useUIElement(options) {
5570
5747
  });
5571
5748
  registeredRef.current = true;
5572
5749
  registeredIdRef.current = id;
5750
+ if (typeof valueProp === "string") {
5751
+ bridge.registry.updateElementState(id, { value: valueProp });
5752
+ }
5573
5753
  setRegistered(true);
5574
5754
  scheduleDeadButtonWarning(bridge, id, type);
5575
- }, [bridge, id, type, label, actions, customActions, treePath, style, stateStylesProp]);
5755
+ }, [bridge, id, type, label, actions, customActions, treePath, style, stateStylesProp, valueProp]);
5576
5756
  const unregister = useCallback(() => {
5577
5757
  if (!bridge || !registeredRef.current) return;
5578
5758
  bridge.registry.unregisterElement(registeredIdRef.current ?? id);
@@ -5702,6 +5882,13 @@ function useUIElement(options) {
5702
5882
  }
5703
5883
  };
5704
5884
  }, [bridge, registered, id, handlersProp]);
5885
+ useEffect(() => {
5886
+ if (!bridge || !registered) return;
5887
+ if (typeof valueProp !== "string") return;
5888
+ const current = bridge.registry.getElement(id)?.getState().value;
5889
+ if (current === valueProp) return;
5890
+ bridge.registry.updateElementState(id, { value: valueProp });
5891
+ }, [bridge, registered, id, valueProp]);
5705
5892
  useCallback(
5706
5893
  (props) => {
5707
5894
  propsRef.current = { ...propsRef.current, ...props };
@@ -6093,6 +6280,11 @@ var UI_BRIDGE_NATIVE_ROUTES = {
6093
6280
  path: "/ui-bridge/control/snapshot",
6094
6281
  description: "Get full bridge snapshot"
6095
6282
  },
6283
+ DISCOVER: {
6284
+ method: "GET",
6285
+ path: "/ui-bridge/control/discover",
6286
+ description: "Discover elements (alias for snapshot; web/runner parity)"
6287
+ },
6096
6288
  PAGE_HEALTH: {
6097
6289
  method: "POST",
6098
6290
  path: "/ui-bridge/control/page-health",
@@ -6220,6 +6412,18 @@ var UI_BRIDGE_NATIVE_ROUTES = {
6220
6412
  path: "/ui-bridge/control/modal/dismiss/:id",
6221
6413
  description: "Dismiss a modal by id (testHooks)"
6222
6414
  },
6415
+ // Observability — last-N ring buffers (always mounted; `installed:false`
6416
+ // when the console/network patches are off, i.e. no testHooks).
6417
+ CONSOLE_ERRORS: {
6418
+ method: "GET",
6419
+ path: "/ui-bridge/control/console-errors",
6420
+ description: "Last-N captured console.error/console.warn entries"
6421
+ },
6422
+ NETWORK_REQUESTS: {
6423
+ method: "GET",
6424
+ path: "/ui-bridge/sdk/network-requests",
6425
+ description: "Last-N captured fetch/XHR requests with status + duration"
6426
+ },
6223
6427
  // Health
6224
6428
  HEALTH: {
6225
6429
  method: "GET",