@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/react/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
|
*/
|
|
@@ -1884,6 +1922,20 @@ function createServerHandlers(registry, executor, config) {
|
|
|
1884
1922
|
return success2(response);
|
|
1885
1923
|
},
|
|
1886
1924
|
// Discovery
|
|
1925
|
+
//
|
|
1926
|
+
// Native parity with the runner's `POST /ui-bridge/ai/find`. The runner
|
|
1927
|
+
// matches a free-text `query` against label > text > value > aria-label >
|
|
1928
|
+
// placeholder > name. We replicate that here against the native registry's
|
|
1929
|
+
// analogues:
|
|
1930
|
+
// 1. `label` (the registered display label)
|
|
1931
|
+
// 2. identifier fields: `testId`, `accessibilityLabel`, `treePath`, `uiId`
|
|
1932
|
+
// 3. registered handler names (e.g. `onPress`, `onChangeText`)
|
|
1933
|
+
//
|
|
1934
|
+
// Match is case-insensitive substring. Empty/missing query falls back to
|
|
1935
|
+
// the structured filters (types/visibleOnly/etc.) so callers that don't
|
|
1936
|
+
// pass `query` still get the legacy behaviour. Once we've narrowed by
|
|
1937
|
+
// query the response is shaped through the executor's NativeFindResponse
|
|
1938
|
+
// envelope so the wire format stays stable.
|
|
1887
1939
|
find: async (ctx) => {
|
|
1888
1940
|
const body = ctx.body;
|
|
1889
1941
|
const response = await executor.find({
|
|
@@ -1891,9 +1943,49 @@ function createServerHandlers(registry, executor, config) {
|
|
|
1891
1943
|
testIdPattern: body?.testIdPattern,
|
|
1892
1944
|
accessibilityLabelPattern: body?.accessibilityLabelPattern,
|
|
1893
1945
|
visibleOnly: body?.visibleOnly,
|
|
1894
|
-
limit
|
|
1946
|
+
// When `query` is supplied we apply our own limit *after* ranking so
|
|
1947
|
+
// the highest-priority matches survive the cap; defer slicing to us
|
|
1948
|
+
// by widening here.
|
|
1949
|
+
limit: body?.query && body.query.length > 0 ? void 0 : body?.limit
|
|
1950
|
+
});
|
|
1951
|
+
const rawQuery = typeof body?.query === "string" ? body.query.trim() : "";
|
|
1952
|
+
if (rawQuery.length === 0) {
|
|
1953
|
+
return success2(response);
|
|
1954
|
+
}
|
|
1955
|
+
const needle = rawQuery.toLowerCase();
|
|
1956
|
+
const containsNeedle = (value) => typeof value === "string" && value.toLowerCase().includes(needle);
|
|
1957
|
+
const scored = [];
|
|
1958
|
+
for (const el of response.elements) {
|
|
1959
|
+
let priorityRank = Number.POSITIVE_INFINITY;
|
|
1960
|
+
if (containsNeedle(el.label)) {
|
|
1961
|
+
priorityRank = Math.min(priorityRank, 0);
|
|
1962
|
+
}
|
|
1963
|
+
const ident = el.identifier;
|
|
1964
|
+
if (ident) {
|
|
1965
|
+
if (containsNeedle(ident.testId)) priorityRank = Math.min(priorityRank, 1);
|
|
1966
|
+
if (containsNeedle(ident.accessibilityLabel)) priorityRank = Math.min(priorityRank, 1);
|
|
1967
|
+
if (containsNeedle(ident.treePath)) priorityRank = Math.min(priorityRank, 1);
|
|
1968
|
+
if (containsNeedle(ident.uiId)) priorityRank = Math.min(priorityRank, 1);
|
|
1969
|
+
}
|
|
1970
|
+
if (priorityRank === Number.POSITIVE_INFINITY) {
|
|
1971
|
+
const registered = registry.getElement(el.id);
|
|
1972
|
+
const handlers = extractHandlerNames(registered?.props);
|
|
1973
|
+
if (handlers.some((h) => h.toLowerCase().includes(needle))) {
|
|
1974
|
+
priorityRank = 2;
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
if (priorityRank !== Number.POSITIVE_INFINITY) {
|
|
1978
|
+
scored.push({ priorityRank, element: el });
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
scored.sort((a, b) => a.priorityRank - b.priorityRank);
|
|
1982
|
+
const limit = body?.limit && body.limit > 0 ? body.limit : void 0;
|
|
1983
|
+
const filtered = limit ? scored.slice(0, limit) : scored;
|
|
1984
|
+
return success2({
|
|
1985
|
+
...response,
|
|
1986
|
+
elements: filtered.map((s) => s.element),
|
|
1987
|
+
total: filtered.length
|
|
1895
1988
|
});
|
|
1896
|
-
return success2(response);
|
|
1897
1989
|
},
|
|
1898
1990
|
getSnapshot: async (ctx) => {
|
|
1899
1991
|
const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
|
|
@@ -1994,15 +2086,162 @@ function createServerHandlers(registry, executor, config) {
|
|
|
1994
2086
|
getMethods: async () => {
|
|
1995
2087
|
return error2("Methods introspection not configured", "NOT_SUPPORTED");
|
|
1996
2088
|
},
|
|
2089
|
+
// AI helpers
|
|
2090
|
+
//
|
|
2091
|
+
// Mobile-side parity with the runner's `POST /ui-bridge/ai/fill-form`.
|
|
2092
|
+
// Each entry in `fields` is dispatched through the existing executor as a
|
|
2093
|
+
// `type` action with `clear: true` so the input value is replaced
|
|
2094
|
+
// atomically (the closest native equivalent to the web bridge's
|
|
2095
|
+
// "setValue" semantics — there is no `setValue` action on native).
|
|
2096
|
+
//
|
|
2097
|
+
// Failures do not short-circuit: every field is attempted, and the
|
|
2098
|
+
// response carries per-field success/error plus aggregate counts.
|
|
2099
|
+
fillForm: async (ctx) => {
|
|
2100
|
+
const body = ctx.body;
|
|
2101
|
+
const rawFields = body?.fields;
|
|
2102
|
+
if (!Array.isArray(rawFields)) {
|
|
2103
|
+
return error2('Request body must include "fields" array', "INVALID_REQUEST");
|
|
2104
|
+
}
|
|
2105
|
+
const results = [];
|
|
2106
|
+
for (let i = 0; i < rawFields.length; i++) {
|
|
2107
|
+
const field = rawFields[i];
|
|
2108
|
+
if (!field || typeof field !== "object") {
|
|
2109
|
+
results.push({
|
|
2110
|
+
elementId: "",
|
|
2111
|
+
success: false,
|
|
2112
|
+
error: `fields[${i}] must be an object with "elementId" and "value"`
|
|
2113
|
+
});
|
|
2114
|
+
continue;
|
|
2115
|
+
}
|
|
2116
|
+
const { elementId, value } = field;
|
|
2117
|
+
if (typeof elementId !== "string" || elementId.length === 0) {
|
|
2118
|
+
results.push({
|
|
2119
|
+
elementId: typeof elementId === "string" ? elementId : "",
|
|
2120
|
+
success: false,
|
|
2121
|
+
error: `fields[${i}] is missing a non-empty "elementId"`
|
|
2122
|
+
});
|
|
2123
|
+
continue;
|
|
2124
|
+
}
|
|
2125
|
+
if (typeof value !== "string") {
|
|
2126
|
+
results.push({
|
|
2127
|
+
elementId,
|
|
2128
|
+
success: false,
|
|
2129
|
+
error: `fields[${i}] is missing a string "value"`
|
|
2130
|
+
});
|
|
2131
|
+
continue;
|
|
2132
|
+
}
|
|
2133
|
+
try {
|
|
2134
|
+
const response = await executor.executeAction(elementId, {
|
|
2135
|
+
action: "type",
|
|
2136
|
+
params: { text: value, clear: true }
|
|
2137
|
+
});
|
|
2138
|
+
if (response.success) {
|
|
2139
|
+
results.push({ elementId, success: true });
|
|
2140
|
+
} else {
|
|
2141
|
+
results.push({
|
|
2142
|
+
elementId,
|
|
2143
|
+
success: false,
|
|
2144
|
+
error: response.error ?? "Action failed"
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
} catch (err) {
|
|
2148
|
+
results.push({
|
|
2149
|
+
elementId,
|
|
2150
|
+
success: false,
|
|
2151
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
const succeededCount = results.filter((r) => r.success).length;
|
|
2156
|
+
const failedCount = results.length - succeededCount;
|
|
2157
|
+
return success2({ results, succeededCount, failedCount });
|
|
2158
|
+
},
|
|
2159
|
+
// Test hooks — modal stack manipulation
|
|
2160
|
+
//
|
|
2161
|
+
// These two endpoints exist so external automation runners can drive
|
|
2162
|
+
// ModalDetector state via HTTP, mirroring the in-tree `pushModal` /
|
|
2163
|
+
// `dismissModal` calls that components normally make. They're gated by
|
|
2164
|
+
// `features.testHooks` at the route layer (see http-server.ts); the
|
|
2165
|
+
// handlers themselves still defend against a missing modalDetector
|
|
2166
|
+
// because the bare server path used by CloudRelayClient may run before
|
|
2167
|
+
// enrichers are wired.
|
|
2168
|
+
pushModal: async (ctx) => {
|
|
2169
|
+
const detector = registry.getEnrichers().modalDetector;
|
|
2170
|
+
if (!detector) {
|
|
2171
|
+
return error2(
|
|
2172
|
+
"modal detector not registered",
|
|
2173
|
+
"ENRICHER_UNAVAILABLE"
|
|
2174
|
+
);
|
|
2175
|
+
}
|
|
2176
|
+
const body = ctx.body;
|
|
2177
|
+
const id = body?.id;
|
|
2178
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
2179
|
+
return error2(
|
|
2180
|
+
'pushModal requires a non-empty "id"',
|
|
2181
|
+
"INVALID_REQUEST"
|
|
2182
|
+
);
|
|
2183
|
+
}
|
|
2184
|
+
const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
|
|
2185
|
+
const requestedType = body?.type;
|
|
2186
|
+
if (requestedType !== void 0 && (typeof requestedType !== "string" || !allowedTypes.includes(requestedType))) {
|
|
2187
|
+
return error2(
|
|
2188
|
+
`pushModal "type" must be one of: ${allowedTypes.join(", ")}`,
|
|
2189
|
+
"INVALID_REQUEST"
|
|
2190
|
+
);
|
|
2191
|
+
}
|
|
2192
|
+
detector.pushModal({
|
|
2193
|
+
id,
|
|
2194
|
+
title: body?.title,
|
|
2195
|
+
// requestedType is narrowed by the allowedTypes guard above; the cast
|
|
2196
|
+
// converts the validated string to the NativeModalInfo['type'] union.
|
|
2197
|
+
type: requestedType,
|
|
2198
|
+
blocking: body?.blocking,
|
|
2199
|
+
dismissible: body?.dismissible
|
|
2200
|
+
});
|
|
2201
|
+
const stored = detector.getActive().find((m) => m.id === id);
|
|
2202
|
+
return success2({
|
|
2203
|
+
pushed: true,
|
|
2204
|
+
id,
|
|
2205
|
+
title: stored?.title,
|
|
2206
|
+
type: stored?.type,
|
|
2207
|
+
blocking: stored?.blocking,
|
|
2208
|
+
dismissible: stored?.dismissible,
|
|
2209
|
+
pushedAt: stored?.detectedAt ?? Date.now()
|
|
2210
|
+
});
|
|
2211
|
+
},
|
|
2212
|
+
dismissModal: async (ctx) => {
|
|
2213
|
+
const detector = registry.getEnrichers().modalDetector;
|
|
2214
|
+
if (!detector) {
|
|
2215
|
+
return error2(
|
|
2216
|
+
"modal detector not registered",
|
|
2217
|
+
"ENRICHER_UNAVAILABLE"
|
|
2218
|
+
);
|
|
2219
|
+
}
|
|
2220
|
+
const { id } = ctx.params;
|
|
2221
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
2222
|
+
return error2(
|
|
2223
|
+
"dismissModal requires a modal id in the path",
|
|
2224
|
+
"INVALID_REQUEST"
|
|
2225
|
+
);
|
|
2226
|
+
}
|
|
2227
|
+
const wasPresent = detector.getActive().some((m) => m.id === id);
|
|
2228
|
+
if (!wasPresent) {
|
|
2229
|
+
return error2(`modal not found: ${id}`, "MODAL_NOT_FOUND");
|
|
2230
|
+
}
|
|
2231
|
+
detector.dismissModal(id);
|
|
2232
|
+
return success2({ dismissed: id });
|
|
2233
|
+
},
|
|
1997
2234
|
// Design Review
|
|
1998
2235
|
...designHandlers,
|
|
1999
2236
|
// Health
|
|
2000
2237
|
health: async () => {
|
|
2001
2238
|
const stats = registry.getStats();
|
|
2239
|
+
const registration = registry.getRegistrationCoverage();
|
|
2002
2240
|
const response = {
|
|
2003
2241
|
status: "healthy",
|
|
2004
2242
|
timestamp: Date.now(),
|
|
2005
|
-
...stats
|
|
2243
|
+
...stats,
|
|
2244
|
+
registration
|
|
2006
2245
|
};
|
|
2007
2246
|
if (config?.appInfo) {
|
|
2008
2247
|
response.uiBridge = {
|
|
@@ -2103,7 +2342,24 @@ var WS_ROUTES = [
|
|
|
2103
2342
|
{ pattern: "design/evaluate/baseline", handler: "saveBaseline", httpMethods: ["POST"] },
|
|
2104
2343
|
{ pattern: "design/evaluate/diff", handler: "diffBaseline", httpMethods: ["POST"] },
|
|
2105
2344
|
// Meta / Introspection
|
|
2106
|
-
{ pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] }
|
|
2345
|
+
{ pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] },
|
|
2346
|
+
// AI helpers — mobile parity with the runner's `/ui-bridge/ai/*` family.
|
|
2347
|
+
{ pattern: "ai/fill-form", handler: "fillForm", httpMethods: ["POST"] },
|
|
2348
|
+
// Test hooks — gated behind `features.testHooks`. Let external runners
|
|
2349
|
+
// drive the ModalDetector over HTTP so tests can flip `snapshot.modalStack`
|
|
2350
|
+
// without mounting React components.
|
|
2351
|
+
{
|
|
2352
|
+
pattern: "control/modal/push",
|
|
2353
|
+
handler: "pushModal",
|
|
2354
|
+
httpMethods: ["POST"],
|
|
2355
|
+
requiresTestHooks: true
|
|
2356
|
+
},
|
|
2357
|
+
{
|
|
2358
|
+
pattern: "control/modal/dismiss/:id",
|
|
2359
|
+
handler: "dismissModal",
|
|
2360
|
+
httpMethods: ["POST"],
|
|
2361
|
+
requiresTestHooks: true
|
|
2362
|
+
}
|
|
2107
2363
|
];
|
|
2108
2364
|
var HANDLER_DESCRIPTIONS = {
|
|
2109
2365
|
health: "Liveness probe; returns app metadata + element count",
|
|
@@ -2136,7 +2392,10 @@ var HANDLER_DESCRIPTIONS = {
|
|
|
2136
2392
|
getQualityContexts: "List available quality evaluation contexts",
|
|
2137
2393
|
saveBaseline: "Save a quality baseline snapshot",
|
|
2138
2394
|
diffBaseline: "Diff current UI against a saved baseline",
|
|
2139
|
-
getMethods: "List every handler name (legacy introspection)"
|
|
2395
|
+
getMethods: "List every handler name (legacy introspection)",
|
|
2396
|
+
fillForm: "Fill multiple inputs in one call: body { fields: [{elementId, value}] } \u2014 each field dispatches `type` + `clear:true`",
|
|
2397
|
+
pushModal: "TEST HOOK \u2014 push a modal onto the snapshot.modalStack via the registry ModalDetector",
|
|
2398
|
+
dismissModal: "TEST HOOK \u2014 dismiss a modal by id from the snapshot.modalStack via the registry ModalDetector"
|
|
2140
2399
|
};
|
|
2141
2400
|
var ACTION_REFERENCE = [
|
|
2142
2401
|
{ name: "press", aliases: ["click"], params: "PressActionParams (optional position)" },
|
|
@@ -2153,10 +2412,11 @@ var ACTION_REFERENCE = [
|
|
|
2153
2412
|
{ name: "swipe", params: "SwipeActionParams { direction, distance }" },
|
|
2154
2413
|
{ name: "toggle", params: "none \u2014 flips boolean controls" }
|
|
2155
2414
|
];
|
|
2156
|
-
function buildRoutesPayload() {
|
|
2415
|
+
function buildRoutesPayload(testHooks) {
|
|
2157
2416
|
const entries = [];
|
|
2158
2417
|
for (const r of WS_ROUTES) {
|
|
2159
2418
|
if (!r.httpMethods) continue;
|
|
2419
|
+
if (r.requiresTestHooks && !testHooks) continue;
|
|
2160
2420
|
for (const m of r.httpMethods) {
|
|
2161
2421
|
entries.push({ method: m, path: `/ui-bridge/${r.pattern}` });
|
|
2162
2422
|
}
|
|
@@ -2166,11 +2426,11 @@ function buildRoutesPayload() {
|
|
|
2166
2426
|
entries.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
|
|
2167
2427
|
return { routes: entries, count: entries.length };
|
|
2168
2428
|
}
|
|
2169
|
-
function buildHelpPayload() {
|
|
2429
|
+
function buildHelpPayload(testHooks) {
|
|
2170
2430
|
return {
|
|
2171
2431
|
version: 1,
|
|
2172
2432
|
description: "UI Bridge Native HTTP surface. Prefix every path with /ui-bridge/.",
|
|
2173
|
-
routes: WS_ROUTES.map((r) => ({
|
|
2433
|
+
routes: WS_ROUTES.filter((r) => !r.requiresTestHooks || testHooks).map((r) => ({
|
|
2174
2434
|
pattern: r.pattern,
|
|
2175
2435
|
httpMethods: r.httpMethods ?? [],
|
|
2176
2436
|
wsOnly: !r.httpMethods,
|
|
@@ -2206,7 +2466,8 @@ var NativeUIBridgeServer = class {
|
|
|
2206
2466
|
appInfo: this.config.appInfo
|
|
2207
2467
|
});
|
|
2208
2468
|
this.handlers.getMethods = async () => {
|
|
2209
|
-
const
|
|
2469
|
+
const testHooks = this.config.testHooks === true;
|
|
2470
|
+
const methods = WS_ROUTES.filter((r) => !r.requiresTestHooks || testHooks).map((r) => ({
|
|
2210
2471
|
method: r.pattern,
|
|
2211
2472
|
httpMethods: r.httpMethods ? [...r.httpMethods] : void 0
|
|
2212
2473
|
}));
|
|
@@ -2249,7 +2510,11 @@ var NativeUIBridgeServer = class {
|
|
|
2249
2510
|
timestamp: Date.now()
|
|
2250
2511
|
};
|
|
2251
2512
|
} catch (e) {
|
|
2252
|
-
return {
|
|
2513
|
+
return {
|
|
2514
|
+
success: false,
|
|
2515
|
+
error: `Navigation failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2516
|
+
timestamp: Date.now()
|
|
2517
|
+
};
|
|
2253
2518
|
}
|
|
2254
2519
|
};
|
|
2255
2520
|
this.handlers.pageReplace = async (ctx) => {
|
|
@@ -2275,7 +2540,7 @@ var NativeUIBridgeServer = class {
|
|
|
2275
2540
|
} catch (e) {
|
|
2276
2541
|
return {
|
|
2277
2542
|
success: false,
|
|
2278
|
-
error: `Replace navigation failed: ${e.message}`,
|
|
2543
|
+
error: `Replace navigation failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2279
2544
|
timestamp: Date.now()
|
|
2280
2545
|
};
|
|
2281
2546
|
}
|
|
@@ -2294,7 +2559,7 @@ var NativeUIBridgeServer = class {
|
|
|
2294
2559
|
} catch (e) {
|
|
2295
2560
|
return {
|
|
2296
2561
|
success: false,
|
|
2297
|
-
error: `Back navigation failed: ${e.message}`,
|
|
2562
|
+
error: `Back navigation failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2298
2563
|
timestamp: Date.now()
|
|
2299
2564
|
};
|
|
2300
2565
|
}
|
|
@@ -2315,7 +2580,11 @@ var NativeUIBridgeServer = class {
|
|
|
2315
2580
|
timestamp: Date.now()
|
|
2316
2581
|
};
|
|
2317
2582
|
} catch (e) {
|
|
2318
|
-
return {
|
|
2583
|
+
return {
|
|
2584
|
+
success: false,
|
|
2585
|
+
error: `Refresh failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2586
|
+
timestamp: Date.now()
|
|
2587
|
+
};
|
|
2319
2588
|
}
|
|
2320
2589
|
};
|
|
2321
2590
|
}
|
|
@@ -2373,7 +2642,7 @@ var NativeUIBridgeServer = class {
|
|
|
2373
2642
|
} catch (e) {
|
|
2374
2643
|
return {
|
|
2375
2644
|
success: false,
|
|
2376
|
-
error: `Screenshot capture failed: ${e.message}`,
|
|
2645
|
+
error: `Screenshot capture failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2377
2646
|
timestamp: Date.now()
|
|
2378
2647
|
};
|
|
2379
2648
|
}
|
|
@@ -2928,9 +3197,11 @@ var NativeUIBridgeServer = class {
|
|
|
2928
3197
|
* "control/component/my-comp/action/submit" → handlers.executeComponentAction
|
|
2929
3198
|
*/
|
|
2930
3199
|
async routeMethodToHandler(method, params) {
|
|
3200
|
+
const testHooks = this.config.testHooks === true;
|
|
2931
3201
|
for (const route of WS_ROUTES) {
|
|
2932
3202
|
const pathParams = parsePath(route.pattern, method);
|
|
2933
3203
|
if (!pathParams) continue;
|
|
3204
|
+
if (route.requiresTestHooks && !testHooks) continue;
|
|
2934
3205
|
const ctx = {
|
|
2935
3206
|
params: pathParams,
|
|
2936
3207
|
query: params.query && typeof params.query === "object" ? params.query : {},
|
|
@@ -3008,17 +3279,18 @@ var NativeUIBridgeServer = class {
|
|
|
3008
3279
|
};
|
|
3009
3280
|
}
|
|
3010
3281
|
const stripped = path.slice(PREFIX.length);
|
|
3282
|
+
const testHooks = this.config.testHooks === true;
|
|
3011
3283
|
if (method === "GET" && stripped === "_help") {
|
|
3012
3284
|
return {
|
|
3013
3285
|
success: true,
|
|
3014
|
-
data: buildHelpPayload(),
|
|
3286
|
+
data: buildHelpPayload(testHooks),
|
|
3015
3287
|
timestamp: Date.now()
|
|
3016
3288
|
};
|
|
3017
3289
|
}
|
|
3018
3290
|
if (method === "GET" && stripped === "_routes") {
|
|
3019
3291
|
return {
|
|
3020
3292
|
success: true,
|
|
3021
|
-
data: buildRoutesPayload(),
|
|
3293
|
+
data: buildRoutesPayload(testHooks),
|
|
3022
3294
|
timestamp: Date.now()
|
|
3023
3295
|
};
|
|
3024
3296
|
}
|
|
@@ -3029,6 +3301,9 @@ var NativeUIBridgeServer = class {
|
|
|
3029
3301
|
if (!route.httpMethods) {
|
|
3030
3302
|
continue;
|
|
3031
3303
|
}
|
|
3304
|
+
if (route.requiresTestHooks && !testHooks) {
|
|
3305
|
+
continue;
|
|
3306
|
+
}
|
|
3032
3307
|
if (!route.httpMethods.includes(method)) {
|
|
3033
3308
|
patternMatchedButWrongVerb = true;
|
|
3034
3309
|
continue;
|
|
@@ -3322,6 +3597,27 @@ var WebSocketEventBridge = class {
|
|
|
3322
3597
|
}, this.heartbeatIntervalMs);
|
|
3323
3598
|
}
|
|
3324
3599
|
};
|
|
3600
|
+
|
|
3601
|
+
// src/transport/logger.ts
|
|
3602
|
+
function createTransportLogger() {
|
|
3603
|
+
return {
|
|
3604
|
+
silent: false,
|
|
3605
|
+
log(message, ...args) {
|
|
3606
|
+
if (!this.silent) {
|
|
3607
|
+
console.log(message, ...args);
|
|
3608
|
+
}
|
|
3609
|
+
},
|
|
3610
|
+
warn(message, ...args) {
|
|
3611
|
+
console.warn(message, ...args);
|
|
3612
|
+
},
|
|
3613
|
+
error(message, ...args) {
|
|
3614
|
+
console.error(message, ...args);
|
|
3615
|
+
}
|
|
3616
|
+
};
|
|
3617
|
+
}
|
|
3618
|
+
var transportLogger = createTransportLogger();
|
|
3619
|
+
|
|
3620
|
+
// src/transport/DeviceAnnouncer.ts
|
|
3325
3621
|
var DEFAULT_PORT = 8087;
|
|
3326
3622
|
var RECONNECT_INITIAL_DELAY_MS = 2e3;
|
|
3327
3623
|
var RECONNECT_MAX_DELAY_MS = 6e4;
|
|
@@ -3356,7 +3652,7 @@ var DeviceAnnouncer = class {
|
|
|
3356
3652
|
*/
|
|
3357
3653
|
async startMdnsAdvertise(ZeroconfCtor) {
|
|
3358
3654
|
if (!ZeroconfCtor) {
|
|
3359
|
-
|
|
3655
|
+
transportLogger.log(
|
|
3360
3656
|
"[DeviceAnnouncer] mDNS advertisement skipped (no Zeroconf constructor provided). Pass one to enable mDNS."
|
|
3361
3657
|
);
|
|
3362
3658
|
return;
|
|
@@ -3372,9 +3668,9 @@ var DeviceAnnouncer = class {
|
|
|
3372
3668
|
pairing_token: this.pairingToken
|
|
3373
3669
|
});
|
|
3374
3670
|
this.state = { ...this.state, mdnsActive: true };
|
|
3375
|
-
|
|
3671
|
+
transportLogger.log(`[DeviceAnnouncer] mDNS: advertising "${serviceName}" on port ${port}`);
|
|
3376
3672
|
} catch (err) {
|
|
3377
|
-
|
|
3673
|
+
transportLogger.warn("[DeviceAnnouncer] mDNS start failed:", err);
|
|
3378
3674
|
}
|
|
3379
3675
|
}
|
|
3380
3676
|
// ── Cloud relay ───────────────────────────────────────────────────────────
|
|
@@ -3390,7 +3686,7 @@ var DeviceAnnouncer = class {
|
|
|
3390
3686
|
}
|
|
3391
3687
|
if (this.stopped) return;
|
|
3392
3688
|
const url = `${this.config.cloudRelayUrl}?token=${encodeURIComponent(this.config.cloudToken)}`;
|
|
3393
|
-
|
|
3689
|
+
transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
|
|
3394
3690
|
const ws = new WebSocket(url);
|
|
3395
3691
|
this.cloudWs = ws;
|
|
3396
3692
|
ws.onopen = () => {
|
|
@@ -3400,7 +3696,7 @@ var DeviceAnnouncer = class {
|
|
|
3400
3696
|
}
|
|
3401
3697
|
this.reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
|
|
3402
3698
|
this.state = { ...this.state, cloudConnected: true };
|
|
3403
|
-
|
|
3699
|
+
transportLogger.log("[DeviceAnnouncer] Cloud relay: connected");
|
|
3404
3700
|
ws.send(
|
|
3405
3701
|
JSON.stringify({
|
|
3406
3702
|
type: "device_register",
|
|
@@ -3415,7 +3711,7 @@ var DeviceAnnouncer = class {
|
|
|
3415
3711
|
ws.onmessage = (event) => {
|
|
3416
3712
|
try {
|
|
3417
3713
|
const msg = JSON.parse(event.data);
|
|
3418
|
-
|
|
3714
|
+
transportLogger.log("[DeviceAnnouncer] Cloud relay message:", msg.type);
|
|
3419
3715
|
} catch {
|
|
3420
3716
|
}
|
|
3421
3717
|
};
|
|
@@ -3426,7 +3722,7 @@ var DeviceAnnouncer = class {
|
|
|
3426
3722
|
}
|
|
3427
3723
|
};
|
|
3428
3724
|
ws.onerror = (err) => {
|
|
3429
|
-
|
|
3725
|
+
transportLogger.warn("[DeviceAnnouncer] Cloud relay error:", err);
|
|
3430
3726
|
};
|
|
3431
3727
|
}
|
|
3432
3728
|
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
@@ -3461,7 +3757,7 @@ var DeviceAnnouncer = class {
|
|
|
3461
3757
|
scheduleReconnect() {
|
|
3462
3758
|
if (this.stopped) return;
|
|
3463
3759
|
const delay = this.reconnectDelay;
|
|
3464
|
-
|
|
3760
|
+
transportLogger.log(`[DeviceAnnouncer] Cloud relay: reconnecting in ${delay}ms`);
|
|
3465
3761
|
this.reconnectTimer = setTimeout(() => {
|
|
3466
3762
|
this.reconnectTimer = null;
|
|
3467
3763
|
if (!this.stopped) {
|
|
@@ -3523,7 +3819,7 @@ var CloudRelayClient = class {
|
|
|
3523
3819
|
connect() {
|
|
3524
3820
|
if (this.stopped) return;
|
|
3525
3821
|
const url = `${this.config.relayUrl}?token=${encodeURIComponent(this.config.authToken)}`;
|
|
3526
|
-
|
|
3822
|
+
transportLogger.log("[CloudRelayClient] Connecting to", url);
|
|
3527
3823
|
const ws = new WebSocket(url);
|
|
3528
3824
|
this.ws = ws;
|
|
3529
3825
|
ws.onopen = () => {
|
|
@@ -3533,7 +3829,7 @@ var CloudRelayClient = class {
|
|
|
3533
3829
|
}
|
|
3534
3830
|
this._isConnected = true;
|
|
3535
3831
|
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
|
3536
|
-
|
|
3832
|
+
transportLogger.log("[CloudRelayClient] Connected to relay");
|
|
3537
3833
|
ws.send(
|
|
3538
3834
|
JSON.stringify({
|
|
3539
3835
|
type: "device_register",
|
|
@@ -3551,7 +3847,7 @@ var CloudRelayClient = class {
|
|
|
3551
3847
|
}
|
|
3552
3848
|
};
|
|
3553
3849
|
ws.onerror = (err) => {
|
|
3554
|
-
|
|
3850
|
+
transportLogger.warn("[CloudRelayClient] WebSocket error:", err);
|
|
3555
3851
|
};
|
|
3556
3852
|
}
|
|
3557
3853
|
async onMessage(raw) {
|
|
@@ -3559,12 +3855,12 @@ var CloudRelayClient = class {
|
|
|
3559
3855
|
try {
|
|
3560
3856
|
msg = JSON.parse(raw);
|
|
3561
3857
|
} catch {
|
|
3562
|
-
|
|
3858
|
+
transportLogger.warn("[CloudRelayClient] Received non-JSON message");
|
|
3563
3859
|
return;
|
|
3564
3860
|
}
|
|
3565
3861
|
if (msg["type"] !== "tunnel_request") return;
|
|
3566
3862
|
if (typeof msg["id"] !== "string" || typeof msg["method"] !== "string" || typeof msg["path"] !== "string") {
|
|
3567
|
-
|
|
3863
|
+
transportLogger.warn("[CloudRelayClient] Malformed tunnel_request \u2014 missing required fields");
|
|
3568
3864
|
return;
|
|
3569
3865
|
}
|
|
3570
3866
|
const req = {
|
|
@@ -3605,7 +3901,7 @@ var CloudRelayClient = class {
|
|
|
3605
3901
|
body: bodyValue
|
|
3606
3902
|
};
|
|
3607
3903
|
} catch (err) {
|
|
3608
|
-
|
|
3904
|
+
transportLogger.error("[CloudRelayClient] Error handling tunneled request:", err);
|
|
3609
3905
|
return {
|
|
3610
3906
|
id: req.id,
|
|
3611
3907
|
status: 500,
|
|
@@ -3620,7 +3916,7 @@ var CloudRelayClient = class {
|
|
|
3620
3916
|
scheduleReconnect() {
|
|
3621
3917
|
if (this.stopped) return;
|
|
3622
3918
|
const delay = this.reconnectDelay;
|
|
3623
|
-
|
|
3919
|
+
transportLogger.log(`[CloudRelayClient] Reconnecting in ${delay}ms`);
|
|
3624
3920
|
this.reconnectTimer = setTimeout(() => {
|
|
3625
3921
|
this.reconnectTimer = null;
|
|
3626
3922
|
if (!this.stopped) {
|
|
@@ -3663,8 +3959,14 @@ var ModalDetector = class {
|
|
|
3663
3959
|
};
|
|
3664
3960
|
this.modals.set(modal.id, next);
|
|
3665
3961
|
}
|
|
3962
|
+
/**
|
|
3963
|
+
* Remove a modal from the stack. Returns `true` if a modal with that id
|
|
3964
|
+
* was actually deleted, `false` if nothing matched (idempotent so repeated
|
|
3965
|
+
* calls don't throw, but callers — e.g. the HTTP test-hook endpoint — can
|
|
3966
|
+
* surface the miss to clients).
|
|
3967
|
+
*/
|
|
3666
3968
|
dismissModal(id) {
|
|
3667
|
-
this.modals.delete(id);
|
|
3969
|
+
return this.modals.delete(id);
|
|
3668
3970
|
}
|
|
3669
3971
|
getSnapshotModalContext() {
|
|
3670
3972
|
const active = this.getActive();
|
|
@@ -3876,7 +4178,8 @@ function UIBridgeNativeProvider({
|
|
|
3876
4178
|
const server = createNativeServer(registry, executor, {
|
|
3877
4179
|
serverPort: config.serverPort || 8087,
|
|
3878
4180
|
cors: true,
|
|
3879
|
-
appInfo: config.appInfo
|
|
4181
|
+
appInfo: config.appInfo,
|
|
4182
|
+
testHooks: features.testHooks === true
|
|
3880
4183
|
});
|
|
3881
4184
|
server.setAdapter(serverAdapter);
|
|
3882
4185
|
if (navigationProvider) {
|
|
@@ -3930,7 +4233,9 @@ function UIBridgeNativeProvider({
|
|
|
3930
4233
|
}
|
|
3931
4234
|
}, [
|
|
3932
4235
|
features.server,
|
|
4236
|
+
features.testHooks,
|
|
3933
4237
|
config.serverPort,
|
|
4238
|
+
config.appInfo,
|
|
3934
4239
|
registry,
|
|
3935
4240
|
executor,
|
|
3936
4241
|
serverAdapter,
|
|
@@ -3964,7 +4269,8 @@ function UIBridgeNativeProvider({
|
|
|
3964
4269
|
const bareServer = createNativeServer(registry, executor, {
|
|
3965
4270
|
serverPort: config.serverPort || 8087,
|
|
3966
4271
|
cors: true,
|
|
3967
|
-
appInfo: config.appInfo
|
|
4272
|
+
appInfo: config.appInfo,
|
|
4273
|
+
testHooks: features.testHooks === true
|
|
3968
4274
|
});
|
|
3969
4275
|
if (navigationProvider) bareServer.setNavigationProvider(navigationProvider);
|
|
3970
4276
|
if (screenshotProvider) bareServer.setScreenshotProvider(screenshotProvider);
|
|
@@ -4195,7 +4501,7 @@ function useUIElement(options) {
|
|
|
4195
4501
|
registeredRef.current = false;
|
|
4196
4502
|
registeredIdRef.current = null;
|
|
4197
4503
|
setRegistered(false);
|
|
4198
|
-
}, [bridge,
|
|
4504
|
+
}, [bridge, id]);
|
|
4199
4505
|
const onLayout = useCallback(
|
|
4200
4506
|
(event) => {
|
|
4201
4507
|
const { x, y, width, height } = event.nativeEvent.layout;
|
|
@@ -4461,7 +4767,7 @@ function useUIComponent(options) {
|
|
|
4461
4767
|
const registeredComponent = useMemo(() => {
|
|
4462
4768
|
if (!bridge) return null;
|
|
4463
4769
|
return bridge.registry.getComponent(id) || null;
|
|
4464
|
-
}, [bridge, id
|
|
4770
|
+
}, [bridge, id]);
|
|
4465
4771
|
return {
|
|
4466
4772
|
registered: registeredRef.current,
|
|
4467
4773
|
executeAction,
|
|
@@ -4605,11 +4911,12 @@ function useAutoRegister(options = {}) {
|
|
|
4605
4911
|
[bridge, onUnregister]
|
|
4606
4912
|
);
|
|
4607
4913
|
useEffect(() => {
|
|
4914
|
+
const registeredIds = registeredIdsRef.current;
|
|
4608
4915
|
return () => {
|
|
4609
|
-
|
|
4916
|
+
registeredIds.forEach((id) => {
|
|
4610
4917
|
bridge?.registry.unregisterElement(id);
|
|
4611
4918
|
});
|
|
4612
|
-
|
|
4919
|
+
registeredIds.clear();
|
|
4613
4920
|
};
|
|
4614
4921
|
}, [bridge]);
|
|
4615
4922
|
return {
|