@qontinui/ui-bridge-native 0.3.0 → 0.3.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 (39) hide show
  1. package/dist/{StateAnnotation-WmK1YjwP.d.ts → StateAnnotation--zm0Jfqj.d.ts} +4 -4
  2. package/dist/{StateAnnotation-D8reaRcS.d.mts → StateAnnotation-BgFoVWFy.d.mts} +4 -4
  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 +89 -6
  8. package/dist/core/index.js.map +1 -1
  9. package/dist/core/index.mjs +89 -6
  10. package/dist/core/index.mjs.map +1 -1
  11. package/dist/design/index.d.mts +1 -1
  12. package/dist/design/index.d.ts +1 -1
  13. package/dist/{http-server-D2U1k48d.d.mts → http-server--550eD1F.d.mts} +106 -4
  14. package/dist/{http-server-ck8clUsZ.d.ts → http-server-C9NaNBEP.d.ts} +106 -4
  15. package/dist/index.d.mts +5 -5
  16. package/dist/index.d.ts +5 -5
  17. package/dist/index.js +576 -49
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +576 -49
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/react/index.d.mts +5 -5
  22. package/dist/react/index.d.ts +5 -5
  23. package/dist/react/index.js +576 -49
  24. package/dist/react/index.js.map +1 -1
  25. package/dist/react/index.mjs +576 -49
  26. package/dist/react/index.mjs.map +1 -1
  27. package/dist/{registry-KdUW_koV.d.mts → registry-B5JadBN9.d.mts} +49 -2
  28. package/dist/{registry-BxXaxmsT.d.ts → registry-pAUpQq2O.d.ts} +49 -2
  29. package/dist/server/index.d.mts +65 -6
  30. package/dist/server/index.d.ts +65 -6
  31. package/dist/server/index.js +438 -2
  32. package/dist/server/index.js.map +1 -1
  33. package/dist/server/index.mjs +437 -3
  34. package/dist/server/index.mjs.map +1 -1
  35. package/dist/{types-DjB0zJJH.d.mts → types-B0XA4vOF.d.mts} +1 -1
  36. package/dist/{types-BwdPNpII.d.mts → types-CGVut7mc.d.mts} +43 -1
  37. package/dist/{types-BwdPNpII.d.ts → types-CGVut7mc.d.ts} +43 -1
  38. package/dist/{types-DCEv5qb6.d.ts → types-CjEKTDYT.d.ts} +1 -1
  39. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -57,6 +57,29 @@ var NativeUIBridgeRegistry = class {
57
57
  this.eventListeners = /* @__PURE__ */ new Map();
58
58
  this.enrichers = {};
59
59
  this.snapshotExtras = /* @__PURE__ */ new Map();
60
+ /**
61
+ * App identification metadata (appId/appName/appType/framework). Set via
62
+ * `setAppInfo` — typically wired in the server constructor from the
63
+ * `NativeUIBridgeConfig` the host passes to `UIBridgeNativeProvider`.
64
+ *
65
+ * When set, `createSnapshot` includes it on the resulting snapshot so
66
+ * agents can identify the app from snapshot bytes alone (parity with the
67
+ * `uiBridge` block on the `health` response). Snapshots created before
68
+ * this is wired simply omit the field.
69
+ */
70
+ this.appInfo = null;
71
+ /**
72
+ * Optional route provider. When set, `createSnapshot` reads
73
+ * `currentRoute`/`segments` from it whenever the caller does not pass an
74
+ * explicit `routeInfo` argument. This lets the default `getSnapshot` HTTP
75
+ * handler — which has no direct reference to the `NativeUIBridgeServer`
76
+ * instance — still emit a populated `currentRoute` field.
77
+ *
78
+ * Wired by `NativeUIBridgeServer.setRouteProvider`, which forwards the
79
+ * provider into the registry alongside the per-handler overrides it
80
+ * already installs.
81
+ */
82
+ this.routeProvider = null;
60
83
  /**
61
84
  * Sticky flag: flips `true` the first time any element is registered and
62
85
  * stays `true` even after elements are unregistered. Lets agents distinguish
@@ -67,6 +90,34 @@ var NativeUIBridgeRegistry = class {
67
90
  this.config = config;
68
91
  }
69
92
  // ============================================================================
93
+ // App Info & Route Provider
94
+ // ============================================================================
95
+ /**
96
+ * Set the app identification metadata returned alongside snapshots.
97
+ * Idempotent — re-calling with the same shape is a no-op.
98
+ * Pass `null` (or omit a previous call) to clear.
99
+ */
100
+ setAppInfo(info) {
101
+ this.appInfo = info ?? null;
102
+ }
103
+ /** Read-only accessor for the registered app info, if any. */
104
+ getAppInfo() {
105
+ return this.appInfo;
106
+ }
107
+ /**
108
+ * Set a route provider whose `getCurrentRoute`/`getSegments` are read by
109
+ * `createSnapshot` when no explicit `routeInfo` argument is supplied.
110
+ *
111
+ * Call with `null` to detach (e.g. when the host swaps providers on HMR).
112
+ */
113
+ setRouteProvider(provider) {
114
+ this.routeProvider = provider ?? null;
115
+ }
116
+ /** Read-only accessor for the registered route provider, if any. */
117
+ getRouteProvider() {
118
+ return this.routeProvider;
119
+ }
120
+ // ============================================================================
70
121
  // Snapshot Enricher Slots
71
122
  // ============================================================================
72
123
  /**
@@ -455,12 +506,41 @@ var NativeUIBridgeRegistry = class {
455
506
  // Snapshots
456
507
  // ============================================================================
457
508
  /**
458
- * Create a snapshot of the current state
509
+ * Create a snapshot of the current state.
510
+ *
511
+ * Route resolution: `routeInfo` wins when provided. Otherwise — and this
512
+ * is the path the default `getSnapshot` HTTP handler takes — we fall back
513
+ * to the registered `routeProvider`. This means callers that never wire a
514
+ * server-level override (the bare-server / cloud-relay path) still get a
515
+ * populated `currentRoute` field as long as `setRouteProvider` was called
516
+ * on the registry.
459
517
  */
460
518
  createSnapshot(routeInfo, options) {
519
+ const resolvedRoute = (() => {
520
+ if (routeInfo !== void 0) {
521
+ return {
522
+ currentRoute: routeInfo.currentRoute ?? null,
523
+ segments: routeInfo.segments
524
+ };
525
+ }
526
+ if (this.routeProvider) {
527
+ try {
528
+ return {
529
+ currentRoute: this.routeProvider.getCurrentRoute() ?? null,
530
+ segments: this.routeProvider.getSegments?.()
531
+ };
532
+ } catch (error3) {
533
+ if (this.config.verbose) {
534
+ console.warn(`[ui-bridge-native] routeProvider getCurrentRoute threw:`, error3);
535
+ }
536
+ return { currentRoute: null, segments: void 0 };
537
+ }
538
+ }
539
+ return { currentRoute: null, segments: void 0 };
540
+ })();
461
541
  let elements = options?.visibleOnly ? this.getVisibleElements() : this.getAllElements();
462
- if (options?.currentRouteOnly && routeInfo?.currentRoute) {
463
- const currentRoute = routeInfo.currentRoute;
542
+ if (options?.currentRouteOnly && resolvedRoute.currentRoute) {
543
+ const currentRoute = resolvedRoute.currentRoute;
464
544
  elements = elements.filter((e) => e.registrationRoute === currentRoute);
465
545
  }
466
546
  const snapshot = {
@@ -492,10 +572,13 @@ var NativeUIBridgeRegistry = class {
492
572
  description: w.description,
493
573
  stepCount: w.steps.length
494
574
  })),
495
- currentRoute: routeInfo?.currentRoute ?? null,
496
- segments: routeInfo?.segments,
575
+ currentRoute: resolvedRoute.currentRoute,
576
+ segments: resolvedRoute.segments,
497
577
  registration: this.getRegistrationCoverage()
498
578
  };
579
+ if (this.appInfo) {
580
+ snapshot.appInfo = this.appInfo;
581
+ }
499
582
  if (this.enrichers.modalDetector) {
500
583
  try {
501
584
  snapshot.modalStack = this.enrichers.modalDetector.getSnapshotModalContext();
@@ -524,7 +607,7 @@ var NativeUIBridgeRegistry = class {
524
607
  }
525
608
  }
526
609
  if (this.snapshotExtras.size > 0) {
527
- const ctx = { elements, currentRoute: routeInfo?.currentRoute ?? null };
610
+ const ctx = { elements, currentRoute: resolvedRoute.currentRoute };
528
611
  for (const [name, fn] of this.snapshotExtras) {
529
612
  try {
530
613
  const extra = fn(ctx);
@@ -2043,7 +2126,8 @@ function createServerHandlers(registry, executor, config) {
2043
2126
  },
2044
2127
  getSnapshot: async (ctx) => {
2045
2128
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
2046
- const snapshot = registry.createSnapshot(void 0, { visibleOnly });
2129
+ const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
2130
+ const snapshot = registry.createSnapshot(void 0, { visibleOnly, currentRouteOnly });
2047
2131
  return success2(snapshot);
2048
2132
  },
2049
2133
  // Workflows
@@ -2210,6 +2294,134 @@ function createServerHandlers(registry, executor, config) {
2210
2294
  const failedCount = results.length - succeededCount;
2211
2295
  return success2({ results, succeededCount, failedCount });
2212
2296
  },
2297
+ // Coord-based tap
2298
+ //
2299
+ // Synthesizes a press at the given screen coordinates by scanning the
2300
+ // registered elements' layout rects for a containing match. We prefer the
2301
+ // *topmost* match (smallest area = innermost child) since native UIs
2302
+ // generally render parents before children, and the user's tap should
2303
+ // logically hit the leaf-most interactive element.
2304
+ //
2305
+ // Coords are interpreted in the absolute (screen) space published by
2306
+ // `measureInWindow`; we compare against `pageX/pageY` when available and
2307
+ // fall back to the layout's relative `x/y` so this works in test fixtures
2308
+ // that don't measure (the fallback only matches single-rooted screens but
2309
+ // is good enough for the unit suite).
2310
+ tapAt: async (ctx) => {
2311
+ const body = ctx.body;
2312
+ const x = body?.x;
2313
+ const y = body?.y;
2314
+ if (typeof x !== "number" || !Number.isFinite(x)) {
2315
+ return error2('"x" must be a finite number', "INVALID_REQUEST");
2316
+ }
2317
+ if (typeof y !== "number" || !Number.isFinite(y)) {
2318
+ return error2('"y" must be a finite number', "INVALID_REQUEST");
2319
+ }
2320
+ const action = body?.action ?? "press";
2321
+ const allowedActions = [
2322
+ "press",
2323
+ "longPress",
2324
+ "doubleTap"
2325
+ ];
2326
+ if (!allowedActions.includes(action)) {
2327
+ return error2(
2328
+ `"action" must be one of: ${allowedActions.join(", ")}`,
2329
+ "INVALID_REQUEST"
2330
+ );
2331
+ }
2332
+ const candidates = [];
2333
+ for (const el of registry.getAllElements()) {
2334
+ const state = el.getState();
2335
+ const layout = state?.layout;
2336
+ if (!layout) continue;
2337
+ if (typeof layout.width !== "number" || typeof layout.height !== "number" || layout.width <= 0 || layout.height <= 0) {
2338
+ continue;
2339
+ }
2340
+ const hasPage = typeof layout.pageX === "number" && typeof layout.pageY === "number" && Number.isFinite(layout.pageX) && Number.isFinite(layout.pageY);
2341
+ const left = hasPage ? layout.pageX : layout.x;
2342
+ const top = hasPage ? layout.pageY : layout.y;
2343
+ if (x >= left && x <= left + layout.width && y >= top && y <= top + layout.height) {
2344
+ candidates.push({
2345
+ elementId: el.id,
2346
+ layout,
2347
+ area: layout.width * layout.height
2348
+ });
2349
+ }
2350
+ }
2351
+ if (candidates.length === 0) {
2352
+ return error2(
2353
+ `No element registered at (${x}, ${y})`,
2354
+ "no_element_at_coords"
2355
+ );
2356
+ }
2357
+ candidates.sort((a, b) => a.area - b.area);
2358
+ const target = candidates[0];
2359
+ const response = await executor.executeAction(target.elementId, { action });
2360
+ if (!response.success) {
2361
+ return error2(response.error || "Action failed", "ACTION_FAILED");
2362
+ }
2363
+ return success2({
2364
+ elementId: target.elementId,
2365
+ action,
2366
+ layout: {
2367
+ x: target.layout.x,
2368
+ y: target.layout.y,
2369
+ width: target.layout.width,
2370
+ height: target.layout.height,
2371
+ pageX: target.layout.pageX,
2372
+ pageY: target.layout.pageY
2373
+ }
2374
+ });
2375
+ },
2376
+ // Observability — console error / network request ring buffers.
2377
+ //
2378
+ // The buffers are owned by NativeUIBridgeServer and patched onto the
2379
+ // globals on `start()` (gated behind `features.testHooks`). When no
2380
+ // buffer is provided (no testHooks, or a stripped-down server fixture),
2381
+ // these handlers gracefully return empty arrays so callers can probe
2382
+ // without 500ing.
2383
+ getConsoleErrors: async (ctx) => {
2384
+ const buf = config?.consoleErrorBuffer;
2385
+ const since = ctx.query?.since !== void 0 ? Number(ctx.query.since) : void 0;
2386
+ const limit = ctx.query?.limit !== void 0 ? Number(ctx.query.limit) : void 0;
2387
+ if (!buf) {
2388
+ return success2({
2389
+ entries: [],
2390
+ count: 0,
2391
+ bufferSize: 0
2392
+ });
2393
+ }
2394
+ const entries = buf.entries({
2395
+ since: typeof since === "number" && Number.isFinite(since) ? since : void 0,
2396
+ limit: typeof limit === "number" && Number.isFinite(limit) ? limit : void 0
2397
+ });
2398
+ return success2({
2399
+ entries,
2400
+ count: entries.length,
2401
+ bufferSize: buf.size()
2402
+ });
2403
+ },
2404
+ getNetworkRequests: async (ctx) => {
2405
+ const buf = config?.networkRequestBuffer;
2406
+ const since = ctx.query?.since !== void 0 ? Number(ctx.query.since) : void 0;
2407
+ const limit = ctx.query?.limit !== void 0 ? Number(ctx.query.limit) : void 0;
2408
+ if (!buf) {
2409
+ return success2({
2410
+ entries: [],
2411
+ count: 0,
2412
+ bufferSize: 0
2413
+ });
2414
+ }
2415
+ const entries = buf.entries({
2416
+ since: typeof since === "number" && Number.isFinite(since) ? since : void 0,
2417
+ limit: typeof limit === "number" && Number.isFinite(limit) ? limit : void 0
2418
+ });
2419
+ return success2({
2420
+ entries,
2421
+ count: entries.length,
2422
+ bufferSize: buf.size()
2423
+ });
2424
+ },
2213
2425
  // Test hooks — modal stack manipulation
2214
2426
  //
2215
2427
  // These two endpoints exist so external automation runners can drive
@@ -2311,6 +2523,266 @@ function createServerHandlers(registry, executor, config) {
2311
2523
  };
2312
2524
  }
2313
2525
 
2526
+ // src/server/observability.ts
2527
+ var RingBuffer = class {
2528
+ constructor(capacity) {
2529
+ this.capacity = capacity;
2530
+ this.items = [];
2531
+ }
2532
+ push(entry) {
2533
+ this.items.push(entry);
2534
+ if (this.items.length > this.capacity) {
2535
+ this.items.splice(0, this.items.length - this.capacity);
2536
+ }
2537
+ }
2538
+ /**
2539
+ * Return entries optionally filtered by `since` (timestamp ms) and capped
2540
+ * at `limit`. Newest-end is preserved when `limit` would otherwise truncate.
2541
+ */
2542
+ entries(opts = {}) {
2543
+ const { since, limit } = opts;
2544
+ let filtered = this.items;
2545
+ if (typeof since === "number") {
2546
+ filtered = filtered.filter((e) => e.timestamp >= since);
2547
+ }
2548
+ if (typeof limit === "number" && limit >= 0 && filtered.length > limit) {
2549
+ filtered = filtered.slice(filtered.length - limit);
2550
+ }
2551
+ return filtered.slice();
2552
+ }
2553
+ size() {
2554
+ return this.items.length;
2555
+ }
2556
+ clear() {
2557
+ this.items.length = 0;
2558
+ }
2559
+ };
2560
+ function formatConsoleArgs(args) {
2561
+ return args.map((a) => {
2562
+ if (a instanceof Error) return a.message;
2563
+ if (typeof a === "string") return a;
2564
+ try {
2565
+ return JSON.stringify(a);
2566
+ } catch {
2567
+ return String(a);
2568
+ }
2569
+ }).join(" ");
2570
+ }
2571
+ function extractStack(args) {
2572
+ for (const a of args) {
2573
+ if (a instanceof Error && a.stack) return a.stack;
2574
+ }
2575
+ return void 0;
2576
+ }
2577
+ var ConsoleErrorBuffer = class {
2578
+ constructor(capacity = 100) {
2579
+ this.installed = false;
2580
+ this.buffer = new RingBuffer(capacity);
2581
+ }
2582
+ install() {
2583
+ if (this.installed) return;
2584
+ if (typeof console === "undefined") return;
2585
+ this.originalError = console.error;
2586
+ this.originalWarn = console.warn;
2587
+ const buffer = this.buffer;
2588
+ const origError = this.originalError;
2589
+ const origWarn = this.originalWarn;
2590
+ console.error = (...args) => {
2591
+ try {
2592
+ buffer.push({
2593
+ timestamp: Date.now(),
2594
+ level: "error",
2595
+ message: formatConsoleArgs(args),
2596
+ stack: extractStack(args)
2597
+ });
2598
+ } catch {
2599
+ }
2600
+ origError.apply(console, args);
2601
+ };
2602
+ console.warn = (...args) => {
2603
+ try {
2604
+ buffer.push({
2605
+ timestamp: Date.now(),
2606
+ level: "warn",
2607
+ message: formatConsoleArgs(args),
2608
+ stack: extractStack(args)
2609
+ });
2610
+ } catch {
2611
+ }
2612
+ origWarn.apply(console, args);
2613
+ };
2614
+ this.installed = true;
2615
+ }
2616
+ uninstall() {
2617
+ if (!this.installed) return;
2618
+ if (typeof console === "undefined") return;
2619
+ if (this.originalError) console.error = this.originalError;
2620
+ if (this.originalWarn) console.warn = this.originalWarn;
2621
+ this.originalError = void 0;
2622
+ this.originalWarn = void 0;
2623
+ this.installed = false;
2624
+ }
2625
+ entries(opts = {}) {
2626
+ return this.buffer.entries(opts);
2627
+ }
2628
+ size() {
2629
+ return this.buffer.size();
2630
+ }
2631
+ clear() {
2632
+ this.buffer.clear();
2633
+ }
2634
+ isInstalled() {
2635
+ return this.installed;
2636
+ }
2637
+ };
2638
+ function extractUrl(input) {
2639
+ if (typeof input === "string") return input;
2640
+ if (input && typeof input === "object") {
2641
+ const obj = input;
2642
+ if (typeof obj.url === "string") return obj.url;
2643
+ try {
2644
+ return String(input);
2645
+ } catch {
2646
+ return "";
2647
+ }
2648
+ }
2649
+ return "";
2650
+ }
2651
+ function extractMethod(input, init) {
2652
+ if (init && typeof init.method === "string") return init.method.toUpperCase();
2653
+ if (input && typeof input === "object") {
2654
+ const obj = input;
2655
+ if (typeof obj.method === "string") return obj.method.toUpperCase();
2656
+ }
2657
+ return "GET";
2658
+ }
2659
+ var NetworkRequestBuffer = class {
2660
+ constructor(capacity = 100) {
2661
+ this.installed = false;
2662
+ this.buffer = new RingBuffer(capacity);
2663
+ }
2664
+ install() {
2665
+ if (this.installed) return;
2666
+ const g = globalThis;
2667
+ if (typeof g.fetch === "function") {
2668
+ this.originalFetch = g.fetch;
2669
+ const buffer = this.buffer;
2670
+ const original = this.originalFetch;
2671
+ const patched = async (input, init) => {
2672
+ const start = Date.now();
2673
+ const url = extractUrl(input);
2674
+ const method = extractMethod(input, init);
2675
+ try {
2676
+ const res = await original.call(globalThis, input, init);
2677
+ buffer.push({
2678
+ timestamp: start,
2679
+ method,
2680
+ url,
2681
+ status: res.status,
2682
+ durationMs: Date.now() - start,
2683
+ ok: res.ok
2684
+ });
2685
+ return res;
2686
+ } catch (err) {
2687
+ buffer.push({
2688
+ timestamp: start,
2689
+ method,
2690
+ url,
2691
+ status: 0,
2692
+ durationMs: Date.now() - start,
2693
+ ok: false,
2694
+ error: err instanceof Error ? err.message : String(err)
2695
+ });
2696
+ throw err;
2697
+ }
2698
+ };
2699
+ g.fetch = patched;
2700
+ }
2701
+ if (typeof g.XMLHttpRequest === "function") {
2702
+ const Xhr = g.XMLHttpRequest;
2703
+ this.originalXhrOpen = Xhr.prototype.open;
2704
+ this.originalXhrSend = Xhr.prototype.send;
2705
+ const buffer = this.buffer;
2706
+ const origOpen = this.originalXhrOpen;
2707
+ Xhr.prototype.open = function(method, url, ...rest) {
2708
+ this.__uiBridgeMethod = method.toUpperCase();
2709
+ this.__uiBridgeUrl = typeof url === "string" ? url : String(url);
2710
+ return origOpen.call(
2711
+ this,
2712
+ method,
2713
+ url,
2714
+ ...rest
2715
+ );
2716
+ };
2717
+ const origSend = this.originalXhrSend;
2718
+ Xhr.prototype.send = function(body) {
2719
+ const start = Date.now();
2720
+ const method = this.__uiBridgeMethod ?? "GET";
2721
+ const url = this.__uiBridgeUrl ?? "";
2722
+ const finalize = (status, ok, error3) => {
2723
+ buffer.push({
2724
+ timestamp: start,
2725
+ method,
2726
+ url,
2727
+ status,
2728
+ durationMs: Date.now() - start,
2729
+ ok,
2730
+ error: error3
2731
+ });
2732
+ };
2733
+ const onLoadEnd = () => {
2734
+ this.removeEventListener("loadend", onLoadEnd);
2735
+ this.removeEventListener("error", onError);
2736
+ finalize(this.status, this.status >= 200 && this.status < 300);
2737
+ };
2738
+ const onError = () => {
2739
+ this.removeEventListener("loadend", onLoadEnd);
2740
+ this.removeEventListener("error", onError);
2741
+ finalize(0, false, "XMLHttpRequest error");
2742
+ };
2743
+ this.addEventListener("loadend", onLoadEnd);
2744
+ this.addEventListener("error", onError);
2745
+ return origSend.call(
2746
+ this,
2747
+ body
2748
+ );
2749
+ };
2750
+ }
2751
+ this.installed = true;
2752
+ }
2753
+ uninstall() {
2754
+ if (!this.installed) return;
2755
+ const g = globalThis;
2756
+ if (this.originalFetch) {
2757
+ g.fetch = this.originalFetch;
2758
+ this.originalFetch = void 0;
2759
+ }
2760
+ if (typeof g.XMLHttpRequest === "function") {
2761
+ if (this.originalXhrOpen) {
2762
+ g.XMLHttpRequest.prototype.open = this.originalXhrOpen;
2763
+ }
2764
+ if (this.originalXhrSend) {
2765
+ g.XMLHttpRequest.prototype.send = this.originalXhrSend;
2766
+ }
2767
+ }
2768
+ this.originalXhrOpen = void 0;
2769
+ this.originalXhrSend = void 0;
2770
+ this.installed = false;
2771
+ }
2772
+ entries(opts = {}) {
2773
+ return this.buffer.entries(opts);
2774
+ }
2775
+ size() {
2776
+ return this.buffer.size();
2777
+ }
2778
+ clear() {
2779
+ this.buffer.clear();
2780
+ }
2781
+ isInstalled() {
2782
+ return this.installed;
2783
+ }
2784
+ };
2785
+
2314
2786
  // src/server/ws-types.ts
2315
2787
  function isBatchRequest(msg) {
2316
2788
  return typeof msg === "object" && msg !== null && "batch" in msg && Array.isArray(msg.batch);
@@ -2361,6 +2833,10 @@ var WS_ROUTES = [
2361
2833
  },
2362
2834
  // Discovery
2363
2835
  { pattern: "control/find", handler: "find", httpMethods: ["POST"] },
2836
+ // Runner-parity alias for `control/find`; the runner exposes its
2837
+ // AI-powered element search at `/ui-bridge/ai/find`, so mobile mounts the
2838
+ // same handler at both paths to keep agents portable across platforms.
2839
+ { pattern: "ai/find", handler: "find", httpMethods: ["POST"] },
2364
2840
  { pattern: "control/snapshot", handler: "getSnapshot", httpMethods: ["GET"] },
2365
2841
  { pattern: "control/discover", handler: "getSnapshot" },
2366
2842
  // Workflows
@@ -2399,6 +2875,9 @@ var WS_ROUTES = [
2399
2875
  { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] },
2400
2876
  // AI helpers — mobile parity with the runner's `/ui-bridge/ai/*` family.
2401
2877
  { pattern: "ai/fill-form", handler: "fillForm", httpMethods: ["POST"] },
2878
+ // Coord-based tap — synthesizes a press at the given screen coords by
2879
+ // matching against registered elements' layout rects.
2880
+ { pattern: "control/tap", handler: "tapAt", httpMethods: ["POST"] },
2402
2881
  // Test hooks — gated behind `features.testHooks`. Let external runners
2403
2882
  // drive the ModalDetector over HTTP so tests can flip `snapshot.modalStack`
2404
2883
  // without mounting React components.
@@ -2413,6 +2892,20 @@ var WS_ROUTES = [
2413
2892
  handler: "dismissModal",
2414
2893
  httpMethods: ["POST"],
2415
2894
  requiresTestHooks: true
2895
+ },
2896
+ // Observability — last-N console errors + network requests. testHooks-gated
2897
+ // because patching `console.error` / `fetch` is invasive in production.
2898
+ {
2899
+ pattern: "control/console-errors",
2900
+ handler: "getConsoleErrors",
2901
+ httpMethods: ["GET"],
2902
+ requiresTestHooks: true
2903
+ },
2904
+ {
2905
+ pattern: "sdk/network-requests",
2906
+ handler: "getNetworkRequests",
2907
+ httpMethods: ["GET"],
2908
+ requiresTestHooks: true
2416
2909
  }
2417
2910
  ];
2418
2911
  var HANDLER_DESCRIPTIONS = {
@@ -2448,6 +2941,9 @@ var HANDLER_DESCRIPTIONS = {
2448
2941
  diffBaseline: "Diff current UI against a saved baseline",
2449
2942
  getMethods: "List every handler name (legacy introspection)",
2450
2943
  fillForm: "Fill multiple inputs in one call: body { fields: [{elementId, value}] } \u2014 each field dispatches `type` + `clear:true`",
2944
+ tapAt: "Synthesize a press at given screen coords by matching registered element layout rects: body { x, y, action? }",
2945
+ getConsoleErrors: "TEST HOOK \u2014 last-N console.error/console.warn entries; ?since=<ms>&limit=<n>",
2946
+ getNetworkRequests: "TEST HOOK \u2014 last-N fetch/XHR entries with status + duration; ?since=<ms>&limit=<n>",
2451
2947
  pushModal: "TEST HOOK \u2014 push a modal onto the snapshot.modalStack via the registry ModalDetector",
2452
2948
  dismissModal: "TEST HOOK \u2014 dismiss a modal by id from the snapshot.modalStack via the registry ModalDetector"
2453
2949
  };
@@ -2516,8 +3012,15 @@ var NativeUIBridgeServer = class {
2516
3012
  cors: true,
2517
3013
  ...config
2518
3014
  };
3015
+ this.consoleErrorBuffer = new ConsoleErrorBuffer();
3016
+ this.networkRequestBuffer = new NetworkRequestBuffer();
3017
+ if (this.config.appInfo) {
3018
+ this.registry.setAppInfo(this.config.appInfo);
3019
+ }
2519
3020
  this.handlers = createServerHandlers(registry, executor, {
2520
- appInfo: this.config.appInfo
3021
+ appInfo: this.config.appInfo,
3022
+ consoleErrorBuffer: this.consoleErrorBuffer,
3023
+ networkRequestBuffer: this.networkRequestBuffer
2521
3024
  });
2522
3025
  this.handlers.getMethods = async () => {
2523
3026
  const testHooks = this.config.testHooks === true;
@@ -2645,8 +3148,16 @@ var NativeUIBridgeServer = class {
2645
3148
  /**
2646
3149
  * Set a route provider for reporting the current navigation route.
2647
3150
  * When set, `control/snapshot` responses include `currentRoute` and `segments`.
3151
+ *
3152
+ * Also forwards the provider into the registry so the *default*
3153
+ * `getSnapshot` handler (and any other call site of
3154
+ * `registry.createSnapshot`) reads the route too — without this the
3155
+ * cloud-relay/bare-server path returned `currentRoute: null` even when
3156
+ * a `RouteTracker` was wired, because that path never installed the
3157
+ * server-level handler override below.
2648
3158
  */
2649
3159
  setRouteProvider(provider) {
3160
+ this.registry.setRouteProvider(provider);
2650
3161
  const baseGetElements = this.handlers.getElements;
2651
3162
  this.handlers.getElements = async (ctx) => {
2652
3163
  const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
@@ -2715,6 +3226,10 @@ var NativeUIBridgeServer = class {
2715
3226
  console.warn("[ui-bridge-native] See documentation for supported adapters.");
2716
3227
  return;
2717
3228
  }
3229
+ if (this.config.testHooks === true) {
3230
+ this.consoleErrorBuffer.install();
3231
+ this.networkRequestBuffer.install();
3232
+ }
2718
3233
  await this.adapter.start(this.config.serverPort, this.handleRequest.bind(this));
2719
3234
  this.running = true;
2720
3235
  console.log(`[ui-bridge-native] HTTP server started on port ${this.config.serverPort}`);
@@ -2728,6 +3243,8 @@ var NativeUIBridgeServer = class {
2728
3243
  }
2729
3244
  await this.adapter.stop();
2730
3245
  this.running = false;
3246
+ this.consoleErrorBuffer.uninstall();
3247
+ this.networkRequestBuffer.uninstall();
2731
3248
  console.log("[ui-bridge-native] HTTP server stopped");
2732
3249
  }
2733
3250
  /**
@@ -4556,56 +5073,35 @@ function useUIElement(options) {
4556
5073
  registeredIdRef.current = null;
4557
5074
  setRegistered(false);
4558
5075
  }, [bridge, id]);
5076
+ const onStateChangeRef = react.useRef(onStateChange);
5077
+ react.useEffect(() => {
5078
+ onStateChangeRef.current = onStateChange;
5079
+ }, [onStateChange]);
4559
5080
  const onLayout = react.useCallback(
4560
5081
  (event) => {
4561
5082
  const { x, y, width, height } = event.nativeEvent.layout;
5083
+ const writeLayout = (newLayout) => {
5084
+ setLayout(newLayout);
5085
+ if (!bridge) return;
5086
+ const newState = {
5087
+ mounted: true,
5088
+ visible: width > 0 && height > 0,
5089
+ enabled: true,
5090
+ focused: false,
5091
+ layout: newLayout
5092
+ };
5093
+ bridge.registry.updateElementState(id, newState);
5094
+ onStateChangeRef.current?.(newState);
5095
+ };
4562
5096
  if (ref.current && "measureInWindow" in ref.current) {
4563
5097
  ref.current.measureInWindow((pageX, pageY) => {
4564
- const newLayout = {
4565
- x,
4566
- y,
4567
- width,
4568
- height,
4569
- pageX,
4570
- pageY
4571
- };
4572
- setLayout(newLayout);
4573
- if (bridge && registered) {
4574
- const newState = {
4575
- mounted: true,
4576
- visible: width > 0 && height > 0,
4577
- enabled: true,
4578
- focused: false,
4579
- layout: newLayout
4580
- };
4581
- bridge.registry.updateElementState(id, newState);
4582
- onStateChange?.(newState);
4583
- }
5098
+ writeLayout({ x, y, width, height, pageX, pageY });
4584
5099
  });
4585
5100
  } else {
4586
- const newLayout = {
4587
- x,
4588
- y,
4589
- width,
4590
- height,
4591
- pageX: x,
4592
- pageY: y
4593
- };
4594
- setLayout(newLayout);
4595
- if (bridge && registered) {
4596
- const newState = {
4597
- mounted: true,
4598
- visible: width > 0 && height > 0,
4599
- enabled: true,
4600
- focused: false,
4601
- layout: newLayout
4602
- };
4603
- bridge.registry.updateElementState(id, newState);
4604
- onStateChange?.(newState);
4605
- }
5101
+ writeLayout({ x, y, width, height, pageX: x, pageY: y });
4606
5102
  }
4607
5103
  },
4608
- [bridge, registered, id, onStateChange]
5104
+ [bridge, id]
4609
5105
  );
4610
5106
  const registerRef = react.useRef(register);
4611
5107
  const unregisterRef = react.useRef(unregister);
@@ -4623,6 +5119,37 @@ function useUIElement(options) {
4623
5119
  }
4624
5120
  };
4625
5121
  }, [autoRegister, bridge]);
5122
+ react.useEffect(() => {
5123
+ if (!bridge || !registered) return;
5124
+ const node = ref.current;
5125
+ if (!node || typeof node.measureInWindow !== "function") return;
5126
+ let cancelled = false;
5127
+ node.measureInWindow((pageX, pageY, w, h) => {
5128
+ if (cancelled) return;
5129
+ if (w <= 0 || h <= 0) return;
5130
+ const newLayout = {
5131
+ x: pageX,
5132
+ y: pageY,
5133
+ width: w,
5134
+ height: h,
5135
+ pageX,
5136
+ pageY
5137
+ };
5138
+ setLayout(newLayout);
5139
+ const newState = {
5140
+ mounted: true,
5141
+ visible: true,
5142
+ enabled: true,
5143
+ focused: false,
5144
+ layout: newLayout
5145
+ };
5146
+ bridge.registry.updateElementState(id, newState);
5147
+ onStateChangeRef.current?.(newState);
5148
+ });
5149
+ return () => {
5150
+ cancelled = true;
5151
+ };
5152
+ }, [bridge, registered, id]);
4626
5153
  const registeredHandlerKeysRef = react.useRef([]);
4627
5154
  react.useEffect(() => {
4628
5155
  if (!bridge || !registered || !handlersProp) return;