@qontinui/ui-bridge-native 0.3.0 → 0.3.2

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
@@ -55,6 +55,29 @@ var NativeUIBridgeRegistry = class {
55
55
  this.eventListeners = /* @__PURE__ */ new Map();
56
56
  this.enrichers = {};
57
57
  this.snapshotExtras = /* @__PURE__ */ new Map();
58
+ /**
59
+ * App identification metadata (appId/appName/appType/framework). Set via
60
+ * `setAppInfo` — typically wired in the server constructor from the
61
+ * `NativeUIBridgeConfig` the host passes to `UIBridgeNativeProvider`.
62
+ *
63
+ * When set, `createSnapshot` includes it on the resulting snapshot so
64
+ * agents can identify the app from snapshot bytes alone (parity with the
65
+ * `uiBridge` block on the `health` response). Snapshots created before
66
+ * this is wired simply omit the field.
67
+ */
68
+ this.appInfo = null;
69
+ /**
70
+ * Optional route provider. When set, `createSnapshot` reads
71
+ * `currentRoute`/`segments` from it whenever the caller does not pass an
72
+ * explicit `routeInfo` argument. This lets the default `getSnapshot` HTTP
73
+ * handler — which has no direct reference to the `NativeUIBridgeServer`
74
+ * instance — still emit a populated `currentRoute` field.
75
+ *
76
+ * Wired by `NativeUIBridgeServer.setRouteProvider`, which forwards the
77
+ * provider into the registry alongside the per-handler overrides it
78
+ * already installs.
79
+ */
80
+ this.routeProvider = null;
58
81
  /**
59
82
  * Sticky flag: flips `true` the first time any element is registered and
60
83
  * stays `true` even after elements are unregistered. Lets agents distinguish
@@ -65,6 +88,34 @@ var NativeUIBridgeRegistry = class {
65
88
  this.config = config;
66
89
  }
67
90
  // ============================================================================
91
+ // App Info & Route Provider
92
+ // ============================================================================
93
+ /**
94
+ * Set the app identification metadata returned alongside snapshots.
95
+ * Idempotent — re-calling with the same shape is a no-op.
96
+ * Pass `null` (or omit a previous call) to clear.
97
+ */
98
+ setAppInfo(info) {
99
+ this.appInfo = info ?? null;
100
+ }
101
+ /** Read-only accessor for the registered app info, if any. */
102
+ getAppInfo() {
103
+ return this.appInfo;
104
+ }
105
+ /**
106
+ * Set a route provider whose `getCurrentRoute`/`getSegments` are read by
107
+ * `createSnapshot` when no explicit `routeInfo` argument is supplied.
108
+ *
109
+ * Call with `null` to detach (e.g. when the host swaps providers on HMR).
110
+ */
111
+ setRouteProvider(provider) {
112
+ this.routeProvider = provider ?? null;
113
+ }
114
+ /** Read-only accessor for the registered route provider, if any. */
115
+ getRouteProvider() {
116
+ return this.routeProvider;
117
+ }
118
+ // ============================================================================
68
119
  // Snapshot Enricher Slots
69
120
  // ============================================================================
70
121
  /**
@@ -453,12 +504,41 @@ var NativeUIBridgeRegistry = class {
453
504
  // Snapshots
454
505
  // ============================================================================
455
506
  /**
456
- * Create a snapshot of the current state
507
+ * Create a snapshot of the current state.
508
+ *
509
+ * Route resolution: `routeInfo` wins when provided. Otherwise — and this
510
+ * is the path the default `getSnapshot` HTTP handler takes — we fall back
511
+ * to the registered `routeProvider`. This means callers that never wire a
512
+ * server-level override (the bare-server / cloud-relay path) still get a
513
+ * populated `currentRoute` field as long as `setRouteProvider` was called
514
+ * on the registry.
457
515
  */
458
516
  createSnapshot(routeInfo, options) {
517
+ const resolvedRoute = (() => {
518
+ if (routeInfo !== void 0) {
519
+ return {
520
+ currentRoute: routeInfo.currentRoute ?? null,
521
+ segments: routeInfo.segments
522
+ };
523
+ }
524
+ if (this.routeProvider) {
525
+ try {
526
+ return {
527
+ currentRoute: this.routeProvider.getCurrentRoute() ?? null,
528
+ segments: this.routeProvider.getSegments?.()
529
+ };
530
+ } catch (error3) {
531
+ if (this.config.verbose) {
532
+ console.warn(`[ui-bridge-native] routeProvider getCurrentRoute threw:`, error3);
533
+ }
534
+ return { currentRoute: null, segments: void 0 };
535
+ }
536
+ }
537
+ return { currentRoute: null, segments: void 0 };
538
+ })();
459
539
  let elements = options?.visibleOnly ? this.getVisibleElements() : this.getAllElements();
460
- if (options?.currentRouteOnly && routeInfo?.currentRoute) {
461
- const currentRoute = routeInfo.currentRoute;
540
+ if (options?.currentRouteOnly && resolvedRoute.currentRoute) {
541
+ const currentRoute = resolvedRoute.currentRoute;
462
542
  elements = elements.filter((e) => e.registrationRoute === currentRoute);
463
543
  }
464
544
  const snapshot = {
@@ -490,10 +570,13 @@ var NativeUIBridgeRegistry = class {
490
570
  description: w.description,
491
571
  stepCount: w.steps.length
492
572
  })),
493
- currentRoute: routeInfo?.currentRoute ?? null,
494
- segments: routeInfo?.segments,
573
+ currentRoute: resolvedRoute.currentRoute,
574
+ segments: resolvedRoute.segments,
495
575
  registration: this.getRegistrationCoverage()
496
576
  };
577
+ if (this.appInfo) {
578
+ snapshot.appInfo = this.appInfo;
579
+ }
497
580
  if (this.enrichers.modalDetector) {
498
581
  try {
499
582
  snapshot.modalStack = this.enrichers.modalDetector.getSnapshotModalContext();
@@ -522,7 +605,7 @@ var NativeUIBridgeRegistry = class {
522
605
  }
523
606
  }
524
607
  if (this.snapshotExtras.size > 0) {
525
- const ctx = { elements, currentRoute: routeInfo?.currentRoute ?? null };
608
+ const ctx = { elements, currentRoute: resolvedRoute.currentRoute };
526
609
  for (const [name, fn] of this.snapshotExtras) {
527
610
  try {
528
611
  const extra = fn(ctx);
@@ -1989,7 +2072,8 @@ function createServerHandlers(registry, executor, config) {
1989
2072
  },
1990
2073
  getSnapshot: async (ctx) => {
1991
2074
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
1992
- const snapshot = registry.createSnapshot(void 0, { visibleOnly });
2075
+ const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
2076
+ const snapshot = registry.createSnapshot(void 0, { visibleOnly, currentRouteOnly });
1993
2077
  return success2(snapshot);
1994
2078
  },
1995
2079
  // Workflows
@@ -2156,6 +2240,134 @@ function createServerHandlers(registry, executor, config) {
2156
2240
  const failedCount = results.length - succeededCount;
2157
2241
  return success2({ results, succeededCount, failedCount });
2158
2242
  },
2243
+ // Coord-based tap
2244
+ //
2245
+ // Synthesizes a press at the given screen coordinates by scanning the
2246
+ // registered elements' layout rects for a containing match. We prefer the
2247
+ // *topmost* match (smallest area = innermost child) since native UIs
2248
+ // generally render parents before children, and the user's tap should
2249
+ // logically hit the leaf-most interactive element.
2250
+ //
2251
+ // Coords are interpreted in the absolute (screen) space published by
2252
+ // `measureInWindow`; we compare against `pageX/pageY` when available and
2253
+ // fall back to the layout's relative `x/y` so this works in test fixtures
2254
+ // that don't measure (the fallback only matches single-rooted screens but
2255
+ // is good enough for the unit suite).
2256
+ tapAt: async (ctx) => {
2257
+ const body = ctx.body;
2258
+ const x = body?.x;
2259
+ const y = body?.y;
2260
+ if (typeof x !== "number" || !Number.isFinite(x)) {
2261
+ return error2('"x" must be a finite number', "INVALID_REQUEST");
2262
+ }
2263
+ if (typeof y !== "number" || !Number.isFinite(y)) {
2264
+ return error2('"y" must be a finite number', "INVALID_REQUEST");
2265
+ }
2266
+ const action = body?.action ?? "press";
2267
+ const allowedActions = [
2268
+ "press",
2269
+ "longPress",
2270
+ "doubleTap"
2271
+ ];
2272
+ if (!allowedActions.includes(action)) {
2273
+ return error2(
2274
+ `"action" must be one of: ${allowedActions.join(", ")}`,
2275
+ "INVALID_REQUEST"
2276
+ );
2277
+ }
2278
+ const candidates = [];
2279
+ for (const el of registry.getAllElements()) {
2280
+ const state = el.getState();
2281
+ const layout = state?.layout;
2282
+ if (!layout) continue;
2283
+ if (typeof layout.width !== "number" || typeof layout.height !== "number" || layout.width <= 0 || layout.height <= 0) {
2284
+ continue;
2285
+ }
2286
+ const hasPage = typeof layout.pageX === "number" && typeof layout.pageY === "number" && Number.isFinite(layout.pageX) && Number.isFinite(layout.pageY);
2287
+ const left = hasPage ? layout.pageX : layout.x;
2288
+ const top = hasPage ? layout.pageY : layout.y;
2289
+ if (x >= left && x <= left + layout.width && y >= top && y <= top + layout.height) {
2290
+ candidates.push({
2291
+ elementId: el.id,
2292
+ layout,
2293
+ area: layout.width * layout.height
2294
+ });
2295
+ }
2296
+ }
2297
+ if (candidates.length === 0) {
2298
+ return error2(
2299
+ `No element registered at (${x}, ${y})`,
2300
+ "no_element_at_coords"
2301
+ );
2302
+ }
2303
+ candidates.sort((a, b) => a.area - b.area);
2304
+ const target = candidates[0];
2305
+ const response = await executor.executeAction(target.elementId, { action });
2306
+ if (!response.success) {
2307
+ return error2(response.error || "Action failed", "ACTION_FAILED");
2308
+ }
2309
+ return success2({
2310
+ elementId: target.elementId,
2311
+ action,
2312
+ layout: {
2313
+ x: target.layout.x,
2314
+ y: target.layout.y,
2315
+ width: target.layout.width,
2316
+ height: target.layout.height,
2317
+ pageX: target.layout.pageX,
2318
+ pageY: target.layout.pageY
2319
+ }
2320
+ });
2321
+ },
2322
+ // Observability — console error / network request ring buffers.
2323
+ //
2324
+ // The buffers are owned by NativeUIBridgeServer and patched onto the
2325
+ // globals on `start()` (gated behind `features.testHooks`). When no
2326
+ // buffer is provided (no testHooks, or a stripped-down server fixture),
2327
+ // these handlers gracefully return empty arrays so callers can probe
2328
+ // without 500ing.
2329
+ getConsoleErrors: async (ctx) => {
2330
+ const buf = config?.consoleErrorBuffer;
2331
+ const since = ctx.query?.since !== void 0 ? Number(ctx.query.since) : void 0;
2332
+ const limit = ctx.query?.limit !== void 0 ? Number(ctx.query.limit) : void 0;
2333
+ if (!buf) {
2334
+ return success2({
2335
+ entries: [],
2336
+ count: 0,
2337
+ bufferSize: 0
2338
+ });
2339
+ }
2340
+ const entries = buf.entries({
2341
+ since: typeof since === "number" && Number.isFinite(since) ? since : void 0,
2342
+ limit: typeof limit === "number" && Number.isFinite(limit) ? limit : void 0
2343
+ });
2344
+ return success2({
2345
+ entries,
2346
+ count: entries.length,
2347
+ bufferSize: buf.size()
2348
+ });
2349
+ },
2350
+ getNetworkRequests: async (ctx) => {
2351
+ const buf = config?.networkRequestBuffer;
2352
+ const since = ctx.query?.since !== void 0 ? Number(ctx.query.since) : void 0;
2353
+ const limit = ctx.query?.limit !== void 0 ? Number(ctx.query.limit) : void 0;
2354
+ if (!buf) {
2355
+ return success2({
2356
+ entries: [],
2357
+ count: 0,
2358
+ bufferSize: 0
2359
+ });
2360
+ }
2361
+ const entries = buf.entries({
2362
+ since: typeof since === "number" && Number.isFinite(since) ? since : void 0,
2363
+ limit: typeof limit === "number" && Number.isFinite(limit) ? limit : void 0
2364
+ });
2365
+ return success2({
2366
+ entries,
2367
+ count: entries.length,
2368
+ bufferSize: buf.size()
2369
+ });
2370
+ },
2159
2371
  // Test hooks — modal stack manipulation
2160
2372
  //
2161
2373
  // These two endpoints exist so external automation runners can drive
@@ -2257,6 +2469,266 @@ function createServerHandlers(registry, executor, config) {
2257
2469
  };
2258
2470
  }
2259
2471
 
2472
+ // src/server/observability.ts
2473
+ var RingBuffer = class {
2474
+ constructor(capacity) {
2475
+ this.capacity = capacity;
2476
+ this.items = [];
2477
+ }
2478
+ push(entry) {
2479
+ this.items.push(entry);
2480
+ if (this.items.length > this.capacity) {
2481
+ this.items.splice(0, this.items.length - this.capacity);
2482
+ }
2483
+ }
2484
+ /**
2485
+ * Return entries optionally filtered by `since` (timestamp ms) and capped
2486
+ * at `limit`. Newest-end is preserved when `limit` would otherwise truncate.
2487
+ */
2488
+ entries(opts = {}) {
2489
+ const { since, limit } = opts;
2490
+ let filtered = this.items;
2491
+ if (typeof since === "number") {
2492
+ filtered = filtered.filter((e) => e.timestamp >= since);
2493
+ }
2494
+ if (typeof limit === "number" && limit >= 0 && filtered.length > limit) {
2495
+ filtered = filtered.slice(filtered.length - limit);
2496
+ }
2497
+ return filtered.slice();
2498
+ }
2499
+ size() {
2500
+ return this.items.length;
2501
+ }
2502
+ clear() {
2503
+ this.items.length = 0;
2504
+ }
2505
+ };
2506
+ function formatConsoleArgs(args) {
2507
+ return args.map((a) => {
2508
+ if (a instanceof Error) return a.message;
2509
+ if (typeof a === "string") return a;
2510
+ try {
2511
+ return JSON.stringify(a);
2512
+ } catch {
2513
+ return String(a);
2514
+ }
2515
+ }).join(" ");
2516
+ }
2517
+ function extractStack(args) {
2518
+ for (const a of args) {
2519
+ if (a instanceof Error && a.stack) return a.stack;
2520
+ }
2521
+ return void 0;
2522
+ }
2523
+ var ConsoleErrorBuffer = class {
2524
+ constructor(capacity = 100) {
2525
+ this.installed = false;
2526
+ this.buffer = new RingBuffer(capacity);
2527
+ }
2528
+ install() {
2529
+ if (this.installed) return;
2530
+ if (typeof console === "undefined") return;
2531
+ this.originalError = console.error;
2532
+ this.originalWarn = console.warn;
2533
+ const buffer = this.buffer;
2534
+ const origError = this.originalError;
2535
+ const origWarn = this.originalWarn;
2536
+ console.error = (...args) => {
2537
+ try {
2538
+ buffer.push({
2539
+ timestamp: Date.now(),
2540
+ level: "error",
2541
+ message: formatConsoleArgs(args),
2542
+ stack: extractStack(args)
2543
+ });
2544
+ } catch {
2545
+ }
2546
+ origError.apply(console, args);
2547
+ };
2548
+ console.warn = (...args) => {
2549
+ try {
2550
+ buffer.push({
2551
+ timestamp: Date.now(),
2552
+ level: "warn",
2553
+ message: formatConsoleArgs(args),
2554
+ stack: extractStack(args)
2555
+ });
2556
+ } catch {
2557
+ }
2558
+ origWarn.apply(console, args);
2559
+ };
2560
+ this.installed = true;
2561
+ }
2562
+ uninstall() {
2563
+ if (!this.installed) return;
2564
+ if (typeof console === "undefined") return;
2565
+ if (this.originalError) console.error = this.originalError;
2566
+ if (this.originalWarn) console.warn = this.originalWarn;
2567
+ this.originalError = void 0;
2568
+ this.originalWarn = void 0;
2569
+ this.installed = false;
2570
+ }
2571
+ entries(opts = {}) {
2572
+ return this.buffer.entries(opts);
2573
+ }
2574
+ size() {
2575
+ return this.buffer.size();
2576
+ }
2577
+ clear() {
2578
+ this.buffer.clear();
2579
+ }
2580
+ isInstalled() {
2581
+ return this.installed;
2582
+ }
2583
+ };
2584
+ function extractUrl(input) {
2585
+ if (typeof input === "string") return input;
2586
+ if (input && typeof input === "object") {
2587
+ const obj = input;
2588
+ if (typeof obj.url === "string") return obj.url;
2589
+ try {
2590
+ return String(input);
2591
+ } catch {
2592
+ return "";
2593
+ }
2594
+ }
2595
+ return "";
2596
+ }
2597
+ function extractMethod(input, init) {
2598
+ if (init && typeof init.method === "string") return init.method.toUpperCase();
2599
+ if (input && typeof input === "object") {
2600
+ const obj = input;
2601
+ if (typeof obj.method === "string") return obj.method.toUpperCase();
2602
+ }
2603
+ return "GET";
2604
+ }
2605
+ var NetworkRequestBuffer = class {
2606
+ constructor(capacity = 100) {
2607
+ this.installed = false;
2608
+ this.buffer = new RingBuffer(capacity);
2609
+ }
2610
+ install() {
2611
+ if (this.installed) return;
2612
+ const g = globalThis;
2613
+ if (typeof g.fetch === "function") {
2614
+ this.originalFetch = g.fetch;
2615
+ const buffer = this.buffer;
2616
+ const original = this.originalFetch;
2617
+ const patched = async (input, init) => {
2618
+ const start = Date.now();
2619
+ const url = extractUrl(input);
2620
+ const method = extractMethod(input, init);
2621
+ try {
2622
+ const res = await original.call(globalThis, input, init);
2623
+ buffer.push({
2624
+ timestamp: start,
2625
+ method,
2626
+ url,
2627
+ status: res.status,
2628
+ durationMs: Date.now() - start,
2629
+ ok: res.ok
2630
+ });
2631
+ return res;
2632
+ } catch (err) {
2633
+ buffer.push({
2634
+ timestamp: start,
2635
+ method,
2636
+ url,
2637
+ status: 0,
2638
+ durationMs: Date.now() - start,
2639
+ ok: false,
2640
+ error: err instanceof Error ? err.message : String(err)
2641
+ });
2642
+ throw err;
2643
+ }
2644
+ };
2645
+ g.fetch = patched;
2646
+ }
2647
+ if (typeof g.XMLHttpRequest === "function") {
2648
+ const Xhr = g.XMLHttpRequest;
2649
+ this.originalXhrOpen = Xhr.prototype.open;
2650
+ this.originalXhrSend = Xhr.prototype.send;
2651
+ const buffer = this.buffer;
2652
+ const origOpen = this.originalXhrOpen;
2653
+ Xhr.prototype.open = function(method, url, ...rest) {
2654
+ this.__uiBridgeMethod = method.toUpperCase();
2655
+ this.__uiBridgeUrl = typeof url === "string" ? url : String(url);
2656
+ return origOpen.call(
2657
+ this,
2658
+ method,
2659
+ url,
2660
+ ...rest
2661
+ );
2662
+ };
2663
+ const origSend = this.originalXhrSend;
2664
+ Xhr.prototype.send = function(body) {
2665
+ const start = Date.now();
2666
+ const method = this.__uiBridgeMethod ?? "GET";
2667
+ const url = this.__uiBridgeUrl ?? "";
2668
+ const finalize = (status, ok, error3) => {
2669
+ buffer.push({
2670
+ timestamp: start,
2671
+ method,
2672
+ url,
2673
+ status,
2674
+ durationMs: Date.now() - start,
2675
+ ok,
2676
+ error: error3
2677
+ });
2678
+ };
2679
+ const onLoadEnd = () => {
2680
+ this.removeEventListener("loadend", onLoadEnd);
2681
+ this.removeEventListener("error", onError);
2682
+ finalize(this.status, this.status >= 200 && this.status < 300);
2683
+ };
2684
+ const onError = () => {
2685
+ this.removeEventListener("loadend", onLoadEnd);
2686
+ this.removeEventListener("error", onError);
2687
+ finalize(0, false, "XMLHttpRequest error");
2688
+ };
2689
+ this.addEventListener("loadend", onLoadEnd);
2690
+ this.addEventListener("error", onError);
2691
+ return origSend.call(
2692
+ this,
2693
+ body
2694
+ );
2695
+ };
2696
+ }
2697
+ this.installed = true;
2698
+ }
2699
+ uninstall() {
2700
+ if (!this.installed) return;
2701
+ const g = globalThis;
2702
+ if (this.originalFetch) {
2703
+ g.fetch = this.originalFetch;
2704
+ this.originalFetch = void 0;
2705
+ }
2706
+ if (typeof g.XMLHttpRequest === "function") {
2707
+ if (this.originalXhrOpen) {
2708
+ g.XMLHttpRequest.prototype.open = this.originalXhrOpen;
2709
+ }
2710
+ if (this.originalXhrSend) {
2711
+ g.XMLHttpRequest.prototype.send = this.originalXhrSend;
2712
+ }
2713
+ }
2714
+ this.originalXhrOpen = void 0;
2715
+ this.originalXhrSend = void 0;
2716
+ this.installed = false;
2717
+ }
2718
+ entries(opts = {}) {
2719
+ return this.buffer.entries(opts);
2720
+ }
2721
+ size() {
2722
+ return this.buffer.size();
2723
+ }
2724
+ clear() {
2725
+ this.buffer.clear();
2726
+ }
2727
+ isInstalled() {
2728
+ return this.installed;
2729
+ }
2730
+ };
2731
+
2260
2732
  // src/server/ws-types.ts
2261
2733
  function isBatchRequest(msg) {
2262
2734
  return typeof msg === "object" && msg !== null && "batch" in msg && Array.isArray(msg.batch);
@@ -2307,6 +2779,10 @@ var WS_ROUTES = [
2307
2779
  },
2308
2780
  // Discovery
2309
2781
  { pattern: "control/find", handler: "find", httpMethods: ["POST"] },
2782
+ // Runner-parity alias for `control/find`; the runner exposes its
2783
+ // AI-powered element search at `/ui-bridge/ai/find`, so mobile mounts the
2784
+ // same handler at both paths to keep agents portable across platforms.
2785
+ { pattern: "ai/find", handler: "find", httpMethods: ["POST"] },
2310
2786
  { pattern: "control/snapshot", handler: "getSnapshot", httpMethods: ["GET"] },
2311
2787
  { pattern: "control/discover", handler: "getSnapshot" },
2312
2788
  // Workflows
@@ -2345,6 +2821,9 @@ var WS_ROUTES = [
2345
2821
  { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] },
2346
2822
  // AI helpers — mobile parity with the runner's `/ui-bridge/ai/*` family.
2347
2823
  { pattern: "ai/fill-form", handler: "fillForm", httpMethods: ["POST"] },
2824
+ // Coord-based tap — synthesizes a press at the given screen coords by
2825
+ // matching against registered elements' layout rects.
2826
+ { pattern: "control/tap", handler: "tapAt", httpMethods: ["POST"] },
2348
2827
  // Test hooks — gated behind `features.testHooks`. Let external runners
2349
2828
  // drive the ModalDetector over HTTP so tests can flip `snapshot.modalStack`
2350
2829
  // without mounting React components.
@@ -2359,6 +2838,20 @@ var WS_ROUTES = [
2359
2838
  handler: "dismissModal",
2360
2839
  httpMethods: ["POST"],
2361
2840
  requiresTestHooks: true
2841
+ },
2842
+ // Observability — last-N console errors + network requests. testHooks-gated
2843
+ // because patching `console.error` / `fetch` is invasive in production.
2844
+ {
2845
+ pattern: "control/console-errors",
2846
+ handler: "getConsoleErrors",
2847
+ httpMethods: ["GET"],
2848
+ requiresTestHooks: true
2849
+ },
2850
+ {
2851
+ pattern: "sdk/network-requests",
2852
+ handler: "getNetworkRequests",
2853
+ httpMethods: ["GET"],
2854
+ requiresTestHooks: true
2362
2855
  }
2363
2856
  ];
2364
2857
  var HANDLER_DESCRIPTIONS = {
@@ -2394,6 +2887,9 @@ var HANDLER_DESCRIPTIONS = {
2394
2887
  diffBaseline: "Diff current UI against a saved baseline",
2395
2888
  getMethods: "List every handler name (legacy introspection)",
2396
2889
  fillForm: "Fill multiple inputs in one call: body { fields: [{elementId, value}] } \u2014 each field dispatches `type` + `clear:true`",
2890
+ tapAt: "Synthesize a press at given screen coords by matching registered element layout rects: body { x, y, action? }",
2891
+ getConsoleErrors: "TEST HOOK \u2014 last-N console.error/console.warn entries; ?since=<ms>&limit=<n>",
2892
+ getNetworkRequests: "TEST HOOK \u2014 last-N fetch/XHR entries with status + duration; ?since=<ms>&limit=<n>",
2397
2893
  pushModal: "TEST HOOK \u2014 push a modal onto the snapshot.modalStack via the registry ModalDetector",
2398
2894
  dismissModal: "TEST HOOK \u2014 dismiss a modal by id from the snapshot.modalStack via the registry ModalDetector"
2399
2895
  };
@@ -2462,8 +2958,15 @@ var NativeUIBridgeServer = class {
2462
2958
  cors: true,
2463
2959
  ...config
2464
2960
  };
2961
+ this.consoleErrorBuffer = new ConsoleErrorBuffer();
2962
+ this.networkRequestBuffer = new NetworkRequestBuffer();
2963
+ if (this.config.appInfo) {
2964
+ this.registry.setAppInfo(this.config.appInfo);
2965
+ }
2465
2966
  this.handlers = createServerHandlers(registry, executor, {
2466
- appInfo: this.config.appInfo
2967
+ appInfo: this.config.appInfo,
2968
+ consoleErrorBuffer: this.consoleErrorBuffer,
2969
+ networkRequestBuffer: this.networkRequestBuffer
2467
2970
  });
2468
2971
  this.handlers.getMethods = async () => {
2469
2972
  const testHooks = this.config.testHooks === true;
@@ -2591,8 +3094,16 @@ var NativeUIBridgeServer = class {
2591
3094
  /**
2592
3095
  * Set a route provider for reporting the current navigation route.
2593
3096
  * When set, `control/snapshot` responses include `currentRoute` and `segments`.
3097
+ *
3098
+ * Also forwards the provider into the registry so the *default*
3099
+ * `getSnapshot` handler (and any other call site of
3100
+ * `registry.createSnapshot`) reads the route too — without this the
3101
+ * cloud-relay/bare-server path returned `currentRoute: null` even when
3102
+ * a `RouteTracker` was wired, because that path never installed the
3103
+ * server-level handler override below.
2594
3104
  */
2595
3105
  setRouteProvider(provider) {
3106
+ this.registry.setRouteProvider(provider);
2596
3107
  const baseGetElements = this.handlers.getElements;
2597
3108
  this.handlers.getElements = async (ctx) => {
2598
3109
  const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
@@ -2661,6 +3172,10 @@ var NativeUIBridgeServer = class {
2661
3172
  console.warn("[ui-bridge-native] See documentation for supported adapters.");
2662
3173
  return;
2663
3174
  }
3175
+ if (this.config.testHooks === true) {
3176
+ this.consoleErrorBuffer.install();
3177
+ this.networkRequestBuffer.install();
3178
+ }
2664
3179
  await this.adapter.start(this.config.serverPort, this.handleRequest.bind(this));
2665
3180
  this.running = true;
2666
3181
  console.log(`[ui-bridge-native] HTTP server started on port ${this.config.serverPort}`);
@@ -2674,6 +3189,8 @@ var NativeUIBridgeServer = class {
2674
3189
  }
2675
3190
  await this.adapter.stop();
2676
3191
  this.running = false;
3192
+ this.consoleErrorBuffer.uninstall();
3193
+ this.networkRequestBuffer.uninstall();
2677
3194
  console.log("[ui-bridge-native] HTTP server stopped");
2678
3195
  }
2679
3196
  /**
@@ -4502,56 +5019,35 @@ function useUIElement(options) {
4502
5019
  registeredIdRef.current = null;
4503
5020
  setRegistered(false);
4504
5021
  }, [bridge, id]);
5022
+ const onStateChangeRef = useRef(onStateChange);
5023
+ useEffect(() => {
5024
+ onStateChangeRef.current = onStateChange;
5025
+ }, [onStateChange]);
4505
5026
  const onLayout = useCallback(
4506
5027
  (event) => {
4507
5028
  const { x, y, width, height } = event.nativeEvent.layout;
5029
+ const writeLayout = (newLayout) => {
5030
+ setLayout(newLayout);
5031
+ if (!bridge) return;
5032
+ const newState = {
5033
+ mounted: true,
5034
+ visible: width > 0 && height > 0,
5035
+ enabled: true,
5036
+ focused: false,
5037
+ layout: newLayout
5038
+ };
5039
+ bridge.registry.updateElementState(id, newState);
5040
+ onStateChangeRef.current?.(newState);
5041
+ };
4508
5042
  if (ref.current && "measureInWindow" in ref.current) {
4509
5043
  ref.current.measureInWindow((pageX, pageY) => {
4510
- const newLayout = {
4511
- x,
4512
- y,
4513
- width,
4514
- height,
4515
- pageX,
4516
- pageY
4517
- };
4518
- setLayout(newLayout);
4519
- if (bridge && registered) {
4520
- const newState = {
4521
- mounted: true,
4522
- visible: width > 0 && height > 0,
4523
- enabled: true,
4524
- focused: false,
4525
- layout: newLayout
4526
- };
4527
- bridge.registry.updateElementState(id, newState);
4528
- onStateChange?.(newState);
4529
- }
5044
+ writeLayout({ x, y, width, height, pageX, pageY });
4530
5045
  });
4531
5046
  } else {
4532
- const newLayout = {
4533
- x,
4534
- y,
4535
- width,
4536
- height,
4537
- pageX: x,
4538
- pageY: y
4539
- };
4540
- setLayout(newLayout);
4541
- if (bridge && registered) {
4542
- const newState = {
4543
- mounted: true,
4544
- visible: width > 0 && height > 0,
4545
- enabled: true,
4546
- focused: false,
4547
- layout: newLayout
4548
- };
4549
- bridge.registry.updateElementState(id, newState);
4550
- onStateChange?.(newState);
4551
- }
5047
+ writeLayout({ x, y, width, height, pageX: x, pageY: y });
4552
5048
  }
4553
5049
  },
4554
- [bridge, registered, id, onStateChange]
5050
+ [bridge, id]
4555
5051
  );
4556
5052
  const registerRef = useRef(register);
4557
5053
  const unregisterRef = useRef(unregister);
@@ -4569,6 +5065,37 @@ function useUIElement(options) {
4569
5065
  }
4570
5066
  };
4571
5067
  }, [autoRegister, bridge]);
5068
+ useEffect(() => {
5069
+ if (!bridge || !registered) return;
5070
+ const node = ref.current;
5071
+ if (!node || typeof node.measureInWindow !== "function") return;
5072
+ let cancelled = false;
5073
+ node.measureInWindow((pageX, pageY, w, h) => {
5074
+ if (cancelled) return;
5075
+ if (w <= 0 || h <= 0) return;
5076
+ const newLayout = {
5077
+ x: pageX,
5078
+ y: pageY,
5079
+ width: w,
5080
+ height: h,
5081
+ pageX,
5082
+ pageY
5083
+ };
5084
+ setLayout(newLayout);
5085
+ const newState = {
5086
+ mounted: true,
5087
+ visible: true,
5088
+ enabled: true,
5089
+ focused: false,
5090
+ layout: newLayout
5091
+ };
5092
+ bridge.registry.updateElementState(id, newState);
5093
+ onStateChangeRef.current?.(newState);
5094
+ });
5095
+ return () => {
5096
+ cancelled = true;
5097
+ };
5098
+ }, [bridge, registered, id]);
4572
5099
  const registeredHandlerKeysRef = useRef([]);
4573
5100
  useEffect(() => {
4574
5101
  if (!bridge || !registered || !handlersProp) return;