@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
@@ -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);
@@ -1991,7 +2074,8 @@ function createServerHandlers(registry, executor, config) {
1991
2074
  },
1992
2075
  getSnapshot: async (ctx) => {
1993
2076
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
1994
- const snapshot = registry.createSnapshot(void 0, { visibleOnly });
2077
+ const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
2078
+ const snapshot = registry.createSnapshot(void 0, { visibleOnly, currentRouteOnly });
1995
2079
  return success2(snapshot);
1996
2080
  },
1997
2081
  // Workflows
@@ -2158,6 +2242,134 @@ function createServerHandlers(registry, executor, config) {
2158
2242
  const failedCount = results.length - succeededCount;
2159
2243
  return success2({ results, succeededCount, failedCount });
2160
2244
  },
2245
+ // Coord-based tap
2246
+ //
2247
+ // Synthesizes a press at the given screen coordinates by scanning the
2248
+ // registered elements' layout rects for a containing match. We prefer the
2249
+ // *topmost* match (smallest area = innermost child) since native UIs
2250
+ // generally render parents before children, and the user's tap should
2251
+ // logically hit the leaf-most interactive element.
2252
+ //
2253
+ // Coords are interpreted in the absolute (screen) space published by
2254
+ // `measureInWindow`; we compare against `pageX/pageY` when available and
2255
+ // fall back to the layout's relative `x/y` so this works in test fixtures
2256
+ // that don't measure (the fallback only matches single-rooted screens but
2257
+ // is good enough for the unit suite).
2258
+ tapAt: async (ctx) => {
2259
+ const body = ctx.body;
2260
+ const x = body?.x;
2261
+ const y = body?.y;
2262
+ if (typeof x !== "number" || !Number.isFinite(x)) {
2263
+ return error2('"x" must be a finite number', "INVALID_REQUEST");
2264
+ }
2265
+ if (typeof y !== "number" || !Number.isFinite(y)) {
2266
+ return error2('"y" must be a finite number', "INVALID_REQUEST");
2267
+ }
2268
+ const action = body?.action ?? "press";
2269
+ const allowedActions = [
2270
+ "press",
2271
+ "longPress",
2272
+ "doubleTap"
2273
+ ];
2274
+ if (!allowedActions.includes(action)) {
2275
+ return error2(
2276
+ `"action" must be one of: ${allowedActions.join(", ")}`,
2277
+ "INVALID_REQUEST"
2278
+ );
2279
+ }
2280
+ const candidates = [];
2281
+ for (const el of registry.getAllElements()) {
2282
+ const state = el.getState();
2283
+ const layout = state?.layout;
2284
+ if (!layout) continue;
2285
+ if (typeof layout.width !== "number" || typeof layout.height !== "number" || layout.width <= 0 || layout.height <= 0) {
2286
+ continue;
2287
+ }
2288
+ const hasPage = typeof layout.pageX === "number" && typeof layout.pageY === "number" && Number.isFinite(layout.pageX) && Number.isFinite(layout.pageY);
2289
+ const left = hasPage ? layout.pageX : layout.x;
2290
+ const top = hasPage ? layout.pageY : layout.y;
2291
+ if (x >= left && x <= left + layout.width && y >= top && y <= top + layout.height) {
2292
+ candidates.push({
2293
+ elementId: el.id,
2294
+ layout,
2295
+ area: layout.width * layout.height
2296
+ });
2297
+ }
2298
+ }
2299
+ if (candidates.length === 0) {
2300
+ return error2(
2301
+ `No element registered at (${x}, ${y})`,
2302
+ "no_element_at_coords"
2303
+ );
2304
+ }
2305
+ candidates.sort((a, b) => a.area - b.area);
2306
+ const target = candidates[0];
2307
+ const response = await executor.executeAction(target.elementId, { action });
2308
+ if (!response.success) {
2309
+ return error2(response.error || "Action failed", "ACTION_FAILED");
2310
+ }
2311
+ return success2({
2312
+ elementId: target.elementId,
2313
+ action,
2314
+ layout: {
2315
+ x: target.layout.x,
2316
+ y: target.layout.y,
2317
+ width: target.layout.width,
2318
+ height: target.layout.height,
2319
+ pageX: target.layout.pageX,
2320
+ pageY: target.layout.pageY
2321
+ }
2322
+ });
2323
+ },
2324
+ // Observability — console error / network request ring buffers.
2325
+ //
2326
+ // The buffers are owned by NativeUIBridgeServer and patched onto the
2327
+ // globals on `start()` (gated behind `features.testHooks`). When no
2328
+ // buffer is provided (no testHooks, or a stripped-down server fixture),
2329
+ // these handlers gracefully return empty arrays so callers can probe
2330
+ // without 500ing.
2331
+ getConsoleErrors: async (ctx) => {
2332
+ const buf = config?.consoleErrorBuffer;
2333
+ const since = ctx.query?.since !== void 0 ? Number(ctx.query.since) : void 0;
2334
+ const limit = ctx.query?.limit !== void 0 ? Number(ctx.query.limit) : void 0;
2335
+ if (!buf) {
2336
+ return success2({
2337
+ entries: [],
2338
+ count: 0,
2339
+ bufferSize: 0
2340
+ });
2341
+ }
2342
+ const entries = buf.entries({
2343
+ since: typeof since === "number" && Number.isFinite(since) ? since : void 0,
2344
+ limit: typeof limit === "number" && Number.isFinite(limit) ? limit : void 0
2345
+ });
2346
+ return success2({
2347
+ entries,
2348
+ count: entries.length,
2349
+ bufferSize: buf.size()
2350
+ });
2351
+ },
2352
+ getNetworkRequests: async (ctx) => {
2353
+ const buf = config?.networkRequestBuffer;
2354
+ const since = ctx.query?.since !== void 0 ? Number(ctx.query.since) : void 0;
2355
+ const limit = ctx.query?.limit !== void 0 ? Number(ctx.query.limit) : void 0;
2356
+ if (!buf) {
2357
+ return success2({
2358
+ entries: [],
2359
+ count: 0,
2360
+ bufferSize: 0
2361
+ });
2362
+ }
2363
+ const entries = buf.entries({
2364
+ since: typeof since === "number" && Number.isFinite(since) ? since : void 0,
2365
+ limit: typeof limit === "number" && Number.isFinite(limit) ? limit : void 0
2366
+ });
2367
+ return success2({
2368
+ entries,
2369
+ count: entries.length,
2370
+ bufferSize: buf.size()
2371
+ });
2372
+ },
2161
2373
  // Test hooks — modal stack manipulation
2162
2374
  //
2163
2375
  // These two endpoints exist so external automation runners can drive
@@ -2259,6 +2471,266 @@ function createServerHandlers(registry, executor, config) {
2259
2471
  };
2260
2472
  }
2261
2473
 
2474
+ // src/server/observability.ts
2475
+ var RingBuffer = class {
2476
+ constructor(capacity) {
2477
+ this.capacity = capacity;
2478
+ this.items = [];
2479
+ }
2480
+ push(entry) {
2481
+ this.items.push(entry);
2482
+ if (this.items.length > this.capacity) {
2483
+ this.items.splice(0, this.items.length - this.capacity);
2484
+ }
2485
+ }
2486
+ /**
2487
+ * Return entries optionally filtered by `since` (timestamp ms) and capped
2488
+ * at `limit`. Newest-end is preserved when `limit` would otherwise truncate.
2489
+ */
2490
+ entries(opts = {}) {
2491
+ const { since, limit } = opts;
2492
+ let filtered = this.items;
2493
+ if (typeof since === "number") {
2494
+ filtered = filtered.filter((e) => e.timestamp >= since);
2495
+ }
2496
+ if (typeof limit === "number" && limit >= 0 && filtered.length > limit) {
2497
+ filtered = filtered.slice(filtered.length - limit);
2498
+ }
2499
+ return filtered.slice();
2500
+ }
2501
+ size() {
2502
+ return this.items.length;
2503
+ }
2504
+ clear() {
2505
+ this.items.length = 0;
2506
+ }
2507
+ };
2508
+ function formatConsoleArgs(args) {
2509
+ return args.map((a) => {
2510
+ if (a instanceof Error) return a.message;
2511
+ if (typeof a === "string") return a;
2512
+ try {
2513
+ return JSON.stringify(a);
2514
+ } catch {
2515
+ return String(a);
2516
+ }
2517
+ }).join(" ");
2518
+ }
2519
+ function extractStack(args) {
2520
+ for (const a of args) {
2521
+ if (a instanceof Error && a.stack) return a.stack;
2522
+ }
2523
+ return void 0;
2524
+ }
2525
+ var ConsoleErrorBuffer = class {
2526
+ constructor(capacity = 100) {
2527
+ this.installed = false;
2528
+ this.buffer = new RingBuffer(capacity);
2529
+ }
2530
+ install() {
2531
+ if (this.installed) return;
2532
+ if (typeof console === "undefined") return;
2533
+ this.originalError = console.error;
2534
+ this.originalWarn = console.warn;
2535
+ const buffer = this.buffer;
2536
+ const origError = this.originalError;
2537
+ const origWarn = this.originalWarn;
2538
+ console.error = (...args) => {
2539
+ try {
2540
+ buffer.push({
2541
+ timestamp: Date.now(),
2542
+ level: "error",
2543
+ message: formatConsoleArgs(args),
2544
+ stack: extractStack(args)
2545
+ });
2546
+ } catch {
2547
+ }
2548
+ origError.apply(console, args);
2549
+ };
2550
+ console.warn = (...args) => {
2551
+ try {
2552
+ buffer.push({
2553
+ timestamp: Date.now(),
2554
+ level: "warn",
2555
+ message: formatConsoleArgs(args),
2556
+ stack: extractStack(args)
2557
+ });
2558
+ } catch {
2559
+ }
2560
+ origWarn.apply(console, args);
2561
+ };
2562
+ this.installed = true;
2563
+ }
2564
+ uninstall() {
2565
+ if (!this.installed) return;
2566
+ if (typeof console === "undefined") return;
2567
+ if (this.originalError) console.error = this.originalError;
2568
+ if (this.originalWarn) console.warn = this.originalWarn;
2569
+ this.originalError = void 0;
2570
+ this.originalWarn = void 0;
2571
+ this.installed = false;
2572
+ }
2573
+ entries(opts = {}) {
2574
+ return this.buffer.entries(opts);
2575
+ }
2576
+ size() {
2577
+ return this.buffer.size();
2578
+ }
2579
+ clear() {
2580
+ this.buffer.clear();
2581
+ }
2582
+ isInstalled() {
2583
+ return this.installed;
2584
+ }
2585
+ };
2586
+ function extractUrl(input) {
2587
+ if (typeof input === "string") return input;
2588
+ if (input && typeof input === "object") {
2589
+ const obj = input;
2590
+ if (typeof obj.url === "string") return obj.url;
2591
+ try {
2592
+ return String(input);
2593
+ } catch {
2594
+ return "";
2595
+ }
2596
+ }
2597
+ return "";
2598
+ }
2599
+ function extractMethod(input, init) {
2600
+ if (init && typeof init.method === "string") return init.method.toUpperCase();
2601
+ if (input && typeof input === "object") {
2602
+ const obj = input;
2603
+ if (typeof obj.method === "string") return obj.method.toUpperCase();
2604
+ }
2605
+ return "GET";
2606
+ }
2607
+ var NetworkRequestBuffer = class {
2608
+ constructor(capacity = 100) {
2609
+ this.installed = false;
2610
+ this.buffer = new RingBuffer(capacity);
2611
+ }
2612
+ install() {
2613
+ if (this.installed) return;
2614
+ const g = globalThis;
2615
+ if (typeof g.fetch === "function") {
2616
+ this.originalFetch = g.fetch;
2617
+ const buffer = this.buffer;
2618
+ const original = this.originalFetch;
2619
+ const patched = async (input, init) => {
2620
+ const start = Date.now();
2621
+ const url = extractUrl(input);
2622
+ const method = extractMethod(input, init);
2623
+ try {
2624
+ const res = await original.call(globalThis, input, init);
2625
+ buffer.push({
2626
+ timestamp: start,
2627
+ method,
2628
+ url,
2629
+ status: res.status,
2630
+ durationMs: Date.now() - start,
2631
+ ok: res.ok
2632
+ });
2633
+ return res;
2634
+ } catch (err) {
2635
+ buffer.push({
2636
+ timestamp: start,
2637
+ method,
2638
+ url,
2639
+ status: 0,
2640
+ durationMs: Date.now() - start,
2641
+ ok: false,
2642
+ error: err instanceof Error ? err.message : String(err)
2643
+ });
2644
+ throw err;
2645
+ }
2646
+ };
2647
+ g.fetch = patched;
2648
+ }
2649
+ if (typeof g.XMLHttpRequest === "function") {
2650
+ const Xhr = g.XMLHttpRequest;
2651
+ this.originalXhrOpen = Xhr.prototype.open;
2652
+ this.originalXhrSend = Xhr.prototype.send;
2653
+ const buffer = this.buffer;
2654
+ const origOpen = this.originalXhrOpen;
2655
+ Xhr.prototype.open = function(method, url, ...rest) {
2656
+ this.__uiBridgeMethod = method.toUpperCase();
2657
+ this.__uiBridgeUrl = typeof url === "string" ? url : String(url);
2658
+ return origOpen.call(
2659
+ this,
2660
+ method,
2661
+ url,
2662
+ ...rest
2663
+ );
2664
+ };
2665
+ const origSend = this.originalXhrSend;
2666
+ Xhr.prototype.send = function(body) {
2667
+ const start = Date.now();
2668
+ const method = this.__uiBridgeMethod ?? "GET";
2669
+ const url = this.__uiBridgeUrl ?? "";
2670
+ const finalize = (status, ok, error3) => {
2671
+ buffer.push({
2672
+ timestamp: start,
2673
+ method,
2674
+ url,
2675
+ status,
2676
+ durationMs: Date.now() - start,
2677
+ ok,
2678
+ error: error3
2679
+ });
2680
+ };
2681
+ const onLoadEnd = () => {
2682
+ this.removeEventListener("loadend", onLoadEnd);
2683
+ this.removeEventListener("error", onError);
2684
+ finalize(this.status, this.status >= 200 && this.status < 300);
2685
+ };
2686
+ const onError = () => {
2687
+ this.removeEventListener("loadend", onLoadEnd);
2688
+ this.removeEventListener("error", onError);
2689
+ finalize(0, false, "XMLHttpRequest error");
2690
+ };
2691
+ this.addEventListener("loadend", onLoadEnd);
2692
+ this.addEventListener("error", onError);
2693
+ return origSend.call(
2694
+ this,
2695
+ body
2696
+ );
2697
+ };
2698
+ }
2699
+ this.installed = true;
2700
+ }
2701
+ uninstall() {
2702
+ if (!this.installed) return;
2703
+ const g = globalThis;
2704
+ if (this.originalFetch) {
2705
+ g.fetch = this.originalFetch;
2706
+ this.originalFetch = void 0;
2707
+ }
2708
+ if (typeof g.XMLHttpRequest === "function") {
2709
+ if (this.originalXhrOpen) {
2710
+ g.XMLHttpRequest.prototype.open = this.originalXhrOpen;
2711
+ }
2712
+ if (this.originalXhrSend) {
2713
+ g.XMLHttpRequest.prototype.send = this.originalXhrSend;
2714
+ }
2715
+ }
2716
+ this.originalXhrOpen = void 0;
2717
+ this.originalXhrSend = void 0;
2718
+ this.installed = false;
2719
+ }
2720
+ entries(opts = {}) {
2721
+ return this.buffer.entries(opts);
2722
+ }
2723
+ size() {
2724
+ return this.buffer.size();
2725
+ }
2726
+ clear() {
2727
+ this.buffer.clear();
2728
+ }
2729
+ isInstalled() {
2730
+ return this.installed;
2731
+ }
2732
+ };
2733
+
2262
2734
  // src/server/ws-types.ts
2263
2735
  function isBatchRequest(msg) {
2264
2736
  return typeof msg === "object" && msg !== null && "batch" in msg && Array.isArray(msg.batch);
@@ -2309,6 +2781,10 @@ var WS_ROUTES = [
2309
2781
  },
2310
2782
  // Discovery
2311
2783
  { pattern: "control/find", handler: "find", httpMethods: ["POST"] },
2784
+ // Runner-parity alias for `control/find`; the runner exposes its
2785
+ // AI-powered element search at `/ui-bridge/ai/find`, so mobile mounts the
2786
+ // same handler at both paths to keep agents portable across platforms.
2787
+ { pattern: "ai/find", handler: "find", httpMethods: ["POST"] },
2312
2788
  { pattern: "control/snapshot", handler: "getSnapshot", httpMethods: ["GET"] },
2313
2789
  { pattern: "control/discover", handler: "getSnapshot" },
2314
2790
  // Workflows
@@ -2347,6 +2823,9 @@ var WS_ROUTES = [
2347
2823
  { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] },
2348
2824
  // AI helpers — mobile parity with the runner's `/ui-bridge/ai/*` family.
2349
2825
  { pattern: "ai/fill-form", handler: "fillForm", httpMethods: ["POST"] },
2826
+ // Coord-based tap — synthesizes a press at the given screen coords by
2827
+ // matching against registered elements' layout rects.
2828
+ { pattern: "control/tap", handler: "tapAt", httpMethods: ["POST"] },
2350
2829
  // Test hooks — gated behind `features.testHooks`. Let external runners
2351
2830
  // drive the ModalDetector over HTTP so tests can flip `snapshot.modalStack`
2352
2831
  // without mounting React components.
@@ -2361,6 +2840,20 @@ var WS_ROUTES = [
2361
2840
  handler: "dismissModal",
2362
2841
  httpMethods: ["POST"],
2363
2842
  requiresTestHooks: true
2843
+ },
2844
+ // Observability — last-N console errors + network requests. testHooks-gated
2845
+ // because patching `console.error` / `fetch` is invasive in production.
2846
+ {
2847
+ pattern: "control/console-errors",
2848
+ handler: "getConsoleErrors",
2849
+ httpMethods: ["GET"],
2850
+ requiresTestHooks: true
2851
+ },
2852
+ {
2853
+ pattern: "sdk/network-requests",
2854
+ handler: "getNetworkRequests",
2855
+ httpMethods: ["GET"],
2856
+ requiresTestHooks: true
2364
2857
  }
2365
2858
  ];
2366
2859
  var HANDLER_DESCRIPTIONS = {
@@ -2396,6 +2889,9 @@ var HANDLER_DESCRIPTIONS = {
2396
2889
  diffBaseline: "Diff current UI against a saved baseline",
2397
2890
  getMethods: "List every handler name (legacy introspection)",
2398
2891
  fillForm: "Fill multiple inputs in one call: body { fields: [{elementId, value}] } \u2014 each field dispatches `type` + `clear:true`",
2892
+ tapAt: "Synthesize a press at given screen coords by matching registered element layout rects: body { x, y, action? }",
2893
+ getConsoleErrors: "TEST HOOK \u2014 last-N console.error/console.warn entries; ?since=<ms>&limit=<n>",
2894
+ getNetworkRequests: "TEST HOOK \u2014 last-N fetch/XHR entries with status + duration; ?since=<ms>&limit=<n>",
2399
2895
  pushModal: "TEST HOOK \u2014 push a modal onto the snapshot.modalStack via the registry ModalDetector",
2400
2896
  dismissModal: "TEST HOOK \u2014 dismiss a modal by id from the snapshot.modalStack via the registry ModalDetector"
2401
2897
  };
@@ -2464,8 +2960,15 @@ var NativeUIBridgeServer = class {
2464
2960
  cors: true,
2465
2961
  ...config
2466
2962
  };
2963
+ this.consoleErrorBuffer = new ConsoleErrorBuffer();
2964
+ this.networkRequestBuffer = new NetworkRequestBuffer();
2965
+ if (this.config.appInfo) {
2966
+ this.registry.setAppInfo(this.config.appInfo);
2967
+ }
2467
2968
  this.handlers = createServerHandlers(registry, executor, {
2468
- appInfo: this.config.appInfo
2969
+ appInfo: this.config.appInfo,
2970
+ consoleErrorBuffer: this.consoleErrorBuffer,
2971
+ networkRequestBuffer: this.networkRequestBuffer
2469
2972
  });
2470
2973
  this.handlers.getMethods = async () => {
2471
2974
  const testHooks = this.config.testHooks === true;
@@ -2593,8 +3096,16 @@ var NativeUIBridgeServer = class {
2593
3096
  /**
2594
3097
  * Set a route provider for reporting the current navigation route.
2595
3098
  * When set, `control/snapshot` responses include `currentRoute` and `segments`.
3099
+ *
3100
+ * Also forwards the provider into the registry so the *default*
3101
+ * `getSnapshot` handler (and any other call site of
3102
+ * `registry.createSnapshot`) reads the route too — without this the
3103
+ * cloud-relay/bare-server path returned `currentRoute: null` even when
3104
+ * a `RouteTracker` was wired, because that path never installed the
3105
+ * server-level handler override below.
2596
3106
  */
2597
3107
  setRouteProvider(provider) {
3108
+ this.registry.setRouteProvider(provider);
2598
3109
  const baseGetElements = this.handlers.getElements;
2599
3110
  this.handlers.getElements = async (ctx) => {
2600
3111
  const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
@@ -2663,6 +3174,10 @@ var NativeUIBridgeServer = class {
2663
3174
  console.warn("[ui-bridge-native] See documentation for supported adapters.");
2664
3175
  return;
2665
3176
  }
3177
+ if (this.config.testHooks === true) {
3178
+ this.consoleErrorBuffer.install();
3179
+ this.networkRequestBuffer.install();
3180
+ }
2666
3181
  await this.adapter.start(this.config.serverPort, this.handleRequest.bind(this));
2667
3182
  this.running = true;
2668
3183
  console.log(`[ui-bridge-native] HTTP server started on port ${this.config.serverPort}`);
@@ -2676,6 +3191,8 @@ var NativeUIBridgeServer = class {
2676
3191
  }
2677
3192
  await this.adapter.stop();
2678
3193
  this.running = false;
3194
+ this.consoleErrorBuffer.uninstall();
3195
+ this.networkRequestBuffer.uninstall();
2679
3196
  console.log("[ui-bridge-native] HTTP server stopped");
2680
3197
  }
2681
3198
  /**
@@ -4504,56 +5021,35 @@ function useUIElement(options) {
4504
5021
  registeredIdRef.current = null;
4505
5022
  setRegistered(false);
4506
5023
  }, [bridge, id]);
5024
+ const onStateChangeRef = react.useRef(onStateChange);
5025
+ react.useEffect(() => {
5026
+ onStateChangeRef.current = onStateChange;
5027
+ }, [onStateChange]);
4507
5028
  const onLayout = react.useCallback(
4508
5029
  (event) => {
4509
5030
  const { x, y, width, height } = event.nativeEvent.layout;
5031
+ const writeLayout = (newLayout) => {
5032
+ setLayout(newLayout);
5033
+ if (!bridge) return;
5034
+ const newState = {
5035
+ mounted: true,
5036
+ visible: width > 0 && height > 0,
5037
+ enabled: true,
5038
+ focused: false,
5039
+ layout: newLayout
5040
+ };
5041
+ bridge.registry.updateElementState(id, newState);
5042
+ onStateChangeRef.current?.(newState);
5043
+ };
4510
5044
  if (ref.current && "measureInWindow" in ref.current) {
4511
5045
  ref.current.measureInWindow((pageX, pageY) => {
4512
- const newLayout = {
4513
- x,
4514
- y,
4515
- width,
4516
- height,
4517
- pageX,
4518
- pageY
4519
- };
4520
- setLayout(newLayout);
4521
- if (bridge && registered) {
4522
- const newState = {
4523
- mounted: true,
4524
- visible: width > 0 && height > 0,
4525
- enabled: true,
4526
- focused: false,
4527
- layout: newLayout
4528
- };
4529
- bridge.registry.updateElementState(id, newState);
4530
- onStateChange?.(newState);
4531
- }
5046
+ writeLayout({ x, y, width, height, pageX, pageY });
4532
5047
  });
4533
5048
  } else {
4534
- const newLayout = {
4535
- x,
4536
- y,
4537
- width,
4538
- height,
4539
- pageX: x,
4540
- pageY: y
4541
- };
4542
- setLayout(newLayout);
4543
- if (bridge && registered) {
4544
- const newState = {
4545
- mounted: true,
4546
- visible: width > 0 && height > 0,
4547
- enabled: true,
4548
- focused: false,
4549
- layout: newLayout
4550
- };
4551
- bridge.registry.updateElementState(id, newState);
4552
- onStateChange?.(newState);
4553
- }
5049
+ writeLayout({ x, y, width, height, pageX: x, pageY: y });
4554
5050
  }
4555
5051
  },
4556
- [bridge, registered, id, onStateChange]
5052
+ [bridge, id]
4557
5053
  );
4558
5054
  const registerRef = react.useRef(register);
4559
5055
  const unregisterRef = react.useRef(unregister);
@@ -4571,6 +5067,37 @@ function useUIElement(options) {
4571
5067
  }
4572
5068
  };
4573
5069
  }, [autoRegister, bridge]);
5070
+ react.useEffect(() => {
5071
+ if (!bridge || !registered) return;
5072
+ const node = ref.current;
5073
+ if (!node || typeof node.measureInWindow !== "function") return;
5074
+ let cancelled = false;
5075
+ node.measureInWindow((pageX, pageY, w, h) => {
5076
+ if (cancelled) return;
5077
+ if (w <= 0 || h <= 0) return;
5078
+ const newLayout = {
5079
+ x: pageX,
5080
+ y: pageY,
5081
+ width: w,
5082
+ height: h,
5083
+ pageX,
5084
+ pageY
5085
+ };
5086
+ setLayout(newLayout);
5087
+ const newState = {
5088
+ mounted: true,
5089
+ visible: true,
5090
+ enabled: true,
5091
+ focused: false,
5092
+ layout: newLayout
5093
+ };
5094
+ bridge.registry.updateElementState(id, newState);
5095
+ onStateChangeRef.current?.(newState);
5096
+ });
5097
+ return () => {
5098
+ cancelled = true;
5099
+ };
5100
+ }, [bridge, registered, id]);
4574
5101
  const registeredHandlerKeysRef = react.useRef([]);
4575
5102
  react.useEffect(() => {
4576
5103
  if (!bridge || !registered || !handlersProp) return;