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