@qontinui/ui-bridge-native 0.2.0 → 0.3.0
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/{useUIBridgeToast-GmT1TzUe.d.mts → StateAnnotation-D8reaRcS.d.mts} +42 -7
- package/dist/{useUIBridgeToast-D32KXyL1.d.ts → StateAnnotation-WmK1YjwP.d.ts} +42 -7
- 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 +372 -39
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +372 -41
- 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 +354 -39
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +354 -41
- 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/package.json +2 -2
package/dist/react/index.js
CHANGED
|
@@ -57,6 +57,13 @@ var NativeUIBridgeRegistry = class {
|
|
|
57
57
|
this.eventListeners = /* @__PURE__ */ new Map();
|
|
58
58
|
this.enrichers = {};
|
|
59
59
|
this.snapshotExtras = /* @__PURE__ */ new Map();
|
|
60
|
+
/**
|
|
61
|
+
* Sticky flag: flips `true` the first time any element is registered and
|
|
62
|
+
* stays `true` even after elements are unregistered. Lets agents distinguish
|
|
63
|
+
* "this route never wired any elements" from "this route registered then
|
|
64
|
+
* unmounted".
|
|
65
|
+
*/
|
|
66
|
+
this.everHadRegistrations = false;
|
|
60
67
|
this.config = config;
|
|
61
68
|
}
|
|
62
69
|
// ============================================================================
|
|
@@ -69,6 +76,16 @@ var NativeUIBridgeRegistry = class {
|
|
|
69
76
|
setEnrichers(e) {
|
|
70
77
|
this.enrichers = { ...this.enrichers, ...e };
|
|
71
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Read-only accessor for the canonical enrichers slot. Returned object is
|
|
81
|
+
* the live reference, so callers can detect "no detector wired" via
|
|
82
|
+
* `getEnrichers().modalDetector === undefined`. Used by the test-hook HTTP
|
|
83
|
+
* handlers (`control/modal/push`, `control/modal/dismiss/:id`) which need
|
|
84
|
+
* to drive the ModalDetector directly without re-instantiating it.
|
|
85
|
+
*/
|
|
86
|
+
getEnrichers() {
|
|
87
|
+
return this.enrichers;
|
|
88
|
+
}
|
|
72
89
|
/**
|
|
73
90
|
* Register a custom snapshot enricher. The returned object will be
|
|
74
91
|
* `Object.assign`ed onto the snapshot, so use unique top-level keys to avoid
|
|
@@ -148,6 +165,7 @@ var NativeUIBridgeRegistry = class {
|
|
|
148
165
|
stateStyles
|
|
149
166
|
};
|
|
150
167
|
this.elements.set(id, registered);
|
|
168
|
+
this.everHadRegistrations = true;
|
|
151
169
|
this.emit("element:registered", { id, type, label });
|
|
152
170
|
if (this.config.verbose) {
|
|
153
171
|
console.log(`[ui-bridge-native] Registered element: ${id} (${type})`);
|
|
@@ -475,7 +493,8 @@ var NativeUIBridgeRegistry = class {
|
|
|
475
493
|
stepCount: w.steps.length
|
|
476
494
|
})),
|
|
477
495
|
currentRoute: routeInfo?.currentRoute ?? null,
|
|
478
|
-
segments: routeInfo?.segments
|
|
496
|
+
segments: routeInfo?.segments,
|
|
497
|
+
registration: this.getRegistrationCoverage()
|
|
479
498
|
};
|
|
480
499
|
if (this.enrichers.modalDetector) {
|
|
481
500
|
try {
|
|
@@ -531,6 +550,25 @@ var NativeUIBridgeRegistry = class {
|
|
|
531
550
|
workflows: this.workflows.size
|
|
532
551
|
};
|
|
533
552
|
}
|
|
553
|
+
/**
|
|
554
|
+
* Compute registration coverage metadata for the current registry state.
|
|
555
|
+
*
|
|
556
|
+
* Groups currently-registered elements by `registrationRoute`, bucketing
|
|
557
|
+
* elements without a route under `'?'`. `everHadRegistrations` is sticky —
|
|
558
|
+
* once true, it stays true for the lifetime of the registry instance.
|
|
559
|
+
*/
|
|
560
|
+
getRegistrationCoverage() {
|
|
561
|
+
const byRoute = {};
|
|
562
|
+
for (const element of this.elements.values()) {
|
|
563
|
+
const key = element.registrationRoute == null || element.registrationRoute === "" ? "?" : element.registrationRoute;
|
|
564
|
+
byRoute[key] = (byRoute[key] ?? 0) + 1;
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
totalRegistered: this.elements.size,
|
|
568
|
+
everHadRegistrations: this.everHadRegistrations,
|
|
569
|
+
byRoute
|
|
570
|
+
};
|
|
571
|
+
}
|
|
534
572
|
/**
|
|
535
573
|
* Clear all registrations
|
|
536
574
|
*/
|
|
@@ -1886,6 +1924,20 @@ function createServerHandlers(registry, executor, config) {
|
|
|
1886
1924
|
return success2(response);
|
|
1887
1925
|
},
|
|
1888
1926
|
// Discovery
|
|
1927
|
+
//
|
|
1928
|
+
// Native parity with the runner's `POST /ui-bridge/ai/find`. The runner
|
|
1929
|
+
// matches a free-text `query` against label > text > value > aria-label >
|
|
1930
|
+
// placeholder > name. We replicate that here against the native registry's
|
|
1931
|
+
// analogues:
|
|
1932
|
+
// 1. `label` (the registered display label)
|
|
1933
|
+
// 2. identifier fields: `testId`, `accessibilityLabel`, `treePath`, `uiId`
|
|
1934
|
+
// 3. registered handler names (e.g. `onPress`, `onChangeText`)
|
|
1935
|
+
//
|
|
1936
|
+
// Match is case-insensitive substring. Empty/missing query falls back to
|
|
1937
|
+
// the structured filters (types/visibleOnly/etc.) so callers that don't
|
|
1938
|
+
// pass `query` still get the legacy behaviour. Once we've narrowed by
|
|
1939
|
+
// query the response is shaped through the executor's NativeFindResponse
|
|
1940
|
+
// envelope so the wire format stays stable.
|
|
1889
1941
|
find: async (ctx) => {
|
|
1890
1942
|
const body = ctx.body;
|
|
1891
1943
|
const response = await executor.find({
|
|
@@ -1893,9 +1945,49 @@ function createServerHandlers(registry, executor, config) {
|
|
|
1893
1945
|
testIdPattern: body?.testIdPattern,
|
|
1894
1946
|
accessibilityLabelPattern: body?.accessibilityLabelPattern,
|
|
1895
1947
|
visibleOnly: body?.visibleOnly,
|
|
1896
|
-
limit
|
|
1948
|
+
// When `query` is supplied we apply our own limit *after* ranking so
|
|
1949
|
+
// the highest-priority matches survive the cap; defer slicing to us
|
|
1950
|
+
// by widening here.
|
|
1951
|
+
limit: body?.query && body.query.length > 0 ? void 0 : body?.limit
|
|
1952
|
+
});
|
|
1953
|
+
const rawQuery = typeof body?.query === "string" ? body.query.trim() : "";
|
|
1954
|
+
if (rawQuery.length === 0) {
|
|
1955
|
+
return success2(response);
|
|
1956
|
+
}
|
|
1957
|
+
const needle = rawQuery.toLowerCase();
|
|
1958
|
+
const containsNeedle = (value) => typeof value === "string" && value.toLowerCase().includes(needle);
|
|
1959
|
+
const scored = [];
|
|
1960
|
+
for (const el of response.elements) {
|
|
1961
|
+
let priorityRank = Number.POSITIVE_INFINITY;
|
|
1962
|
+
if (containsNeedle(el.label)) {
|
|
1963
|
+
priorityRank = Math.min(priorityRank, 0);
|
|
1964
|
+
}
|
|
1965
|
+
const ident = el.identifier;
|
|
1966
|
+
if (ident) {
|
|
1967
|
+
if (containsNeedle(ident.testId)) priorityRank = Math.min(priorityRank, 1);
|
|
1968
|
+
if (containsNeedle(ident.accessibilityLabel)) priorityRank = Math.min(priorityRank, 1);
|
|
1969
|
+
if (containsNeedle(ident.treePath)) priorityRank = Math.min(priorityRank, 1);
|
|
1970
|
+
if (containsNeedle(ident.uiId)) priorityRank = Math.min(priorityRank, 1);
|
|
1971
|
+
}
|
|
1972
|
+
if (priorityRank === Number.POSITIVE_INFINITY) {
|
|
1973
|
+
const registered = registry.getElement(el.id);
|
|
1974
|
+
const handlers = extractHandlerNames(registered?.props);
|
|
1975
|
+
if (handlers.some((h) => h.toLowerCase().includes(needle))) {
|
|
1976
|
+
priorityRank = 2;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
if (priorityRank !== Number.POSITIVE_INFINITY) {
|
|
1980
|
+
scored.push({ priorityRank, element: el });
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
scored.sort((a, b) => a.priorityRank - b.priorityRank);
|
|
1984
|
+
const limit = body?.limit && body.limit > 0 ? body.limit : void 0;
|
|
1985
|
+
const filtered = limit ? scored.slice(0, limit) : scored;
|
|
1986
|
+
return success2({
|
|
1987
|
+
...response,
|
|
1988
|
+
elements: filtered.map((s) => s.element),
|
|
1989
|
+
total: filtered.length
|
|
1897
1990
|
});
|
|
1898
|
-
return success2(response);
|
|
1899
1991
|
},
|
|
1900
1992
|
getSnapshot: async (ctx) => {
|
|
1901
1993
|
const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
|
|
@@ -1996,15 +2088,162 @@ function createServerHandlers(registry, executor, config) {
|
|
|
1996
2088
|
getMethods: async () => {
|
|
1997
2089
|
return error2("Methods introspection not configured", "NOT_SUPPORTED");
|
|
1998
2090
|
},
|
|
2091
|
+
// AI helpers
|
|
2092
|
+
//
|
|
2093
|
+
// Mobile-side parity with the runner's `POST /ui-bridge/ai/fill-form`.
|
|
2094
|
+
// Each entry in `fields` is dispatched through the existing executor as a
|
|
2095
|
+
// `type` action with `clear: true` so the input value is replaced
|
|
2096
|
+
// atomically (the closest native equivalent to the web bridge's
|
|
2097
|
+
// "setValue" semantics — there is no `setValue` action on native).
|
|
2098
|
+
//
|
|
2099
|
+
// Failures do not short-circuit: every field is attempted, and the
|
|
2100
|
+
// response carries per-field success/error plus aggregate counts.
|
|
2101
|
+
fillForm: async (ctx) => {
|
|
2102
|
+
const body = ctx.body;
|
|
2103
|
+
const rawFields = body?.fields;
|
|
2104
|
+
if (!Array.isArray(rawFields)) {
|
|
2105
|
+
return error2('Request body must include "fields" array', "INVALID_REQUEST");
|
|
2106
|
+
}
|
|
2107
|
+
const results = [];
|
|
2108
|
+
for (let i = 0; i < rawFields.length; i++) {
|
|
2109
|
+
const field = rawFields[i];
|
|
2110
|
+
if (!field || typeof field !== "object") {
|
|
2111
|
+
results.push({
|
|
2112
|
+
elementId: "",
|
|
2113
|
+
success: false,
|
|
2114
|
+
error: `fields[${i}] must be an object with "elementId" and "value"`
|
|
2115
|
+
});
|
|
2116
|
+
continue;
|
|
2117
|
+
}
|
|
2118
|
+
const { elementId, value } = field;
|
|
2119
|
+
if (typeof elementId !== "string" || elementId.length === 0) {
|
|
2120
|
+
results.push({
|
|
2121
|
+
elementId: typeof elementId === "string" ? elementId : "",
|
|
2122
|
+
success: false,
|
|
2123
|
+
error: `fields[${i}] is missing a non-empty "elementId"`
|
|
2124
|
+
});
|
|
2125
|
+
continue;
|
|
2126
|
+
}
|
|
2127
|
+
if (typeof value !== "string") {
|
|
2128
|
+
results.push({
|
|
2129
|
+
elementId,
|
|
2130
|
+
success: false,
|
|
2131
|
+
error: `fields[${i}] is missing a string "value"`
|
|
2132
|
+
});
|
|
2133
|
+
continue;
|
|
2134
|
+
}
|
|
2135
|
+
try {
|
|
2136
|
+
const response = await executor.executeAction(elementId, {
|
|
2137
|
+
action: "type",
|
|
2138
|
+
params: { text: value, clear: true }
|
|
2139
|
+
});
|
|
2140
|
+
if (response.success) {
|
|
2141
|
+
results.push({ elementId, success: true });
|
|
2142
|
+
} else {
|
|
2143
|
+
results.push({
|
|
2144
|
+
elementId,
|
|
2145
|
+
success: false,
|
|
2146
|
+
error: response.error ?? "Action failed"
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
} catch (err) {
|
|
2150
|
+
results.push({
|
|
2151
|
+
elementId,
|
|
2152
|
+
success: false,
|
|
2153
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
const succeededCount = results.filter((r) => r.success).length;
|
|
2158
|
+
const failedCount = results.length - succeededCount;
|
|
2159
|
+
return success2({ results, succeededCount, failedCount });
|
|
2160
|
+
},
|
|
2161
|
+
// Test hooks — modal stack manipulation
|
|
2162
|
+
//
|
|
2163
|
+
// These two endpoints exist so external automation runners can drive
|
|
2164
|
+
// ModalDetector state via HTTP, mirroring the in-tree `pushModal` /
|
|
2165
|
+
// `dismissModal` calls that components normally make. They're gated by
|
|
2166
|
+
// `features.testHooks` at the route layer (see http-server.ts); the
|
|
2167
|
+
// handlers themselves still defend against a missing modalDetector
|
|
2168
|
+
// because the bare server path used by CloudRelayClient may run before
|
|
2169
|
+
// enrichers are wired.
|
|
2170
|
+
pushModal: async (ctx) => {
|
|
2171
|
+
const detector = registry.getEnrichers().modalDetector;
|
|
2172
|
+
if (!detector) {
|
|
2173
|
+
return error2(
|
|
2174
|
+
"modal detector not registered",
|
|
2175
|
+
"ENRICHER_UNAVAILABLE"
|
|
2176
|
+
);
|
|
2177
|
+
}
|
|
2178
|
+
const body = ctx.body;
|
|
2179
|
+
const id = body?.id;
|
|
2180
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
2181
|
+
return error2(
|
|
2182
|
+
'pushModal requires a non-empty "id"',
|
|
2183
|
+
"INVALID_REQUEST"
|
|
2184
|
+
);
|
|
2185
|
+
}
|
|
2186
|
+
const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
|
|
2187
|
+
const requestedType = body?.type;
|
|
2188
|
+
if (requestedType !== void 0 && (typeof requestedType !== "string" || !allowedTypes.includes(requestedType))) {
|
|
2189
|
+
return error2(
|
|
2190
|
+
`pushModal "type" must be one of: ${allowedTypes.join(", ")}`,
|
|
2191
|
+
"INVALID_REQUEST"
|
|
2192
|
+
);
|
|
2193
|
+
}
|
|
2194
|
+
detector.pushModal({
|
|
2195
|
+
id,
|
|
2196
|
+
title: body?.title,
|
|
2197
|
+
// requestedType is narrowed by the allowedTypes guard above; the cast
|
|
2198
|
+
// converts the validated string to the NativeModalInfo['type'] union.
|
|
2199
|
+
type: requestedType,
|
|
2200
|
+
blocking: body?.blocking,
|
|
2201
|
+
dismissible: body?.dismissible
|
|
2202
|
+
});
|
|
2203
|
+
const stored = detector.getActive().find((m) => m.id === id);
|
|
2204
|
+
return success2({
|
|
2205
|
+
pushed: true,
|
|
2206
|
+
id,
|
|
2207
|
+
title: stored?.title,
|
|
2208
|
+
type: stored?.type,
|
|
2209
|
+
blocking: stored?.blocking,
|
|
2210
|
+
dismissible: stored?.dismissible,
|
|
2211
|
+
pushedAt: stored?.detectedAt ?? Date.now()
|
|
2212
|
+
});
|
|
2213
|
+
},
|
|
2214
|
+
dismissModal: async (ctx) => {
|
|
2215
|
+
const detector = registry.getEnrichers().modalDetector;
|
|
2216
|
+
if (!detector) {
|
|
2217
|
+
return error2(
|
|
2218
|
+
"modal detector not registered",
|
|
2219
|
+
"ENRICHER_UNAVAILABLE"
|
|
2220
|
+
);
|
|
2221
|
+
}
|
|
2222
|
+
const { id } = ctx.params;
|
|
2223
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
2224
|
+
return error2(
|
|
2225
|
+
"dismissModal requires a modal id in the path",
|
|
2226
|
+
"INVALID_REQUEST"
|
|
2227
|
+
);
|
|
2228
|
+
}
|
|
2229
|
+
const wasPresent = detector.getActive().some((m) => m.id === id);
|
|
2230
|
+
if (!wasPresent) {
|
|
2231
|
+
return error2(`modal not found: ${id}`, "MODAL_NOT_FOUND");
|
|
2232
|
+
}
|
|
2233
|
+
detector.dismissModal(id);
|
|
2234
|
+
return success2({ dismissed: id });
|
|
2235
|
+
},
|
|
1999
2236
|
// Design Review
|
|
2000
2237
|
...designHandlers,
|
|
2001
2238
|
// Health
|
|
2002
2239
|
health: async () => {
|
|
2003
2240
|
const stats = registry.getStats();
|
|
2241
|
+
const registration = registry.getRegistrationCoverage();
|
|
2004
2242
|
const response = {
|
|
2005
2243
|
status: "healthy",
|
|
2006
2244
|
timestamp: Date.now(),
|
|
2007
|
-
...stats
|
|
2245
|
+
...stats,
|
|
2246
|
+
registration
|
|
2008
2247
|
};
|
|
2009
2248
|
if (config?.appInfo) {
|
|
2010
2249
|
response.uiBridge = {
|
|
@@ -2105,7 +2344,24 @@ var WS_ROUTES = [
|
|
|
2105
2344
|
{ pattern: "design/evaluate/baseline", handler: "saveBaseline", httpMethods: ["POST"] },
|
|
2106
2345
|
{ pattern: "design/evaluate/diff", handler: "diffBaseline", httpMethods: ["POST"] },
|
|
2107
2346
|
// Meta / Introspection
|
|
2108
|
-
{ pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] }
|
|
2347
|
+
{ pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] },
|
|
2348
|
+
// AI helpers — mobile parity with the runner's `/ui-bridge/ai/*` family.
|
|
2349
|
+
{ pattern: "ai/fill-form", handler: "fillForm", httpMethods: ["POST"] },
|
|
2350
|
+
// Test hooks — gated behind `features.testHooks`. Let external runners
|
|
2351
|
+
// drive the ModalDetector over HTTP so tests can flip `snapshot.modalStack`
|
|
2352
|
+
// without mounting React components.
|
|
2353
|
+
{
|
|
2354
|
+
pattern: "control/modal/push",
|
|
2355
|
+
handler: "pushModal",
|
|
2356
|
+
httpMethods: ["POST"],
|
|
2357
|
+
requiresTestHooks: true
|
|
2358
|
+
},
|
|
2359
|
+
{
|
|
2360
|
+
pattern: "control/modal/dismiss/:id",
|
|
2361
|
+
handler: "dismissModal",
|
|
2362
|
+
httpMethods: ["POST"],
|
|
2363
|
+
requiresTestHooks: true
|
|
2364
|
+
}
|
|
2109
2365
|
];
|
|
2110
2366
|
var HANDLER_DESCRIPTIONS = {
|
|
2111
2367
|
health: "Liveness probe; returns app metadata + element count",
|
|
@@ -2138,7 +2394,10 @@ var HANDLER_DESCRIPTIONS = {
|
|
|
2138
2394
|
getQualityContexts: "List available quality evaluation contexts",
|
|
2139
2395
|
saveBaseline: "Save a quality baseline snapshot",
|
|
2140
2396
|
diffBaseline: "Diff current UI against a saved baseline",
|
|
2141
|
-
getMethods: "List every handler name (legacy introspection)"
|
|
2397
|
+
getMethods: "List every handler name (legacy introspection)",
|
|
2398
|
+
fillForm: "Fill multiple inputs in one call: body { fields: [{elementId, value}] } \u2014 each field dispatches `type` + `clear:true`",
|
|
2399
|
+
pushModal: "TEST HOOK \u2014 push a modal onto the snapshot.modalStack via the registry ModalDetector",
|
|
2400
|
+
dismissModal: "TEST HOOK \u2014 dismiss a modal by id from the snapshot.modalStack via the registry ModalDetector"
|
|
2142
2401
|
};
|
|
2143
2402
|
var ACTION_REFERENCE = [
|
|
2144
2403
|
{ name: "press", aliases: ["click"], params: "PressActionParams (optional position)" },
|
|
@@ -2155,10 +2414,11 @@ var ACTION_REFERENCE = [
|
|
|
2155
2414
|
{ name: "swipe", params: "SwipeActionParams { direction, distance }" },
|
|
2156
2415
|
{ name: "toggle", params: "none \u2014 flips boolean controls" }
|
|
2157
2416
|
];
|
|
2158
|
-
function buildRoutesPayload() {
|
|
2417
|
+
function buildRoutesPayload(testHooks) {
|
|
2159
2418
|
const entries = [];
|
|
2160
2419
|
for (const r of WS_ROUTES) {
|
|
2161
2420
|
if (!r.httpMethods) continue;
|
|
2421
|
+
if (r.requiresTestHooks && !testHooks) continue;
|
|
2162
2422
|
for (const m of r.httpMethods) {
|
|
2163
2423
|
entries.push({ method: m, path: `/ui-bridge/${r.pattern}` });
|
|
2164
2424
|
}
|
|
@@ -2168,11 +2428,11 @@ function buildRoutesPayload() {
|
|
|
2168
2428
|
entries.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
|
|
2169
2429
|
return { routes: entries, count: entries.length };
|
|
2170
2430
|
}
|
|
2171
|
-
function buildHelpPayload() {
|
|
2431
|
+
function buildHelpPayload(testHooks) {
|
|
2172
2432
|
return {
|
|
2173
2433
|
version: 1,
|
|
2174
2434
|
description: "UI Bridge Native HTTP surface. Prefix every path with /ui-bridge/.",
|
|
2175
|
-
routes: WS_ROUTES.map((r) => ({
|
|
2435
|
+
routes: WS_ROUTES.filter((r) => !r.requiresTestHooks || testHooks).map((r) => ({
|
|
2176
2436
|
pattern: r.pattern,
|
|
2177
2437
|
httpMethods: r.httpMethods ?? [],
|
|
2178
2438
|
wsOnly: !r.httpMethods,
|
|
@@ -2208,7 +2468,8 @@ var NativeUIBridgeServer = class {
|
|
|
2208
2468
|
appInfo: this.config.appInfo
|
|
2209
2469
|
});
|
|
2210
2470
|
this.handlers.getMethods = async () => {
|
|
2211
|
-
const
|
|
2471
|
+
const testHooks = this.config.testHooks === true;
|
|
2472
|
+
const methods = WS_ROUTES.filter((r) => !r.requiresTestHooks || testHooks).map((r) => ({
|
|
2212
2473
|
method: r.pattern,
|
|
2213
2474
|
httpMethods: r.httpMethods ? [...r.httpMethods] : void 0
|
|
2214
2475
|
}));
|
|
@@ -2251,7 +2512,11 @@ var NativeUIBridgeServer = class {
|
|
|
2251
2512
|
timestamp: Date.now()
|
|
2252
2513
|
};
|
|
2253
2514
|
} catch (e) {
|
|
2254
|
-
return {
|
|
2515
|
+
return {
|
|
2516
|
+
success: false,
|
|
2517
|
+
error: `Navigation failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2518
|
+
timestamp: Date.now()
|
|
2519
|
+
};
|
|
2255
2520
|
}
|
|
2256
2521
|
};
|
|
2257
2522
|
this.handlers.pageReplace = async (ctx) => {
|
|
@@ -2277,7 +2542,7 @@ var NativeUIBridgeServer = class {
|
|
|
2277
2542
|
} catch (e) {
|
|
2278
2543
|
return {
|
|
2279
2544
|
success: false,
|
|
2280
|
-
error: `Replace navigation failed: ${e.message}`,
|
|
2545
|
+
error: `Replace navigation failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2281
2546
|
timestamp: Date.now()
|
|
2282
2547
|
};
|
|
2283
2548
|
}
|
|
@@ -2296,7 +2561,7 @@ var NativeUIBridgeServer = class {
|
|
|
2296
2561
|
} catch (e) {
|
|
2297
2562
|
return {
|
|
2298
2563
|
success: false,
|
|
2299
|
-
error: `Back navigation failed: ${e.message}`,
|
|
2564
|
+
error: `Back navigation failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2300
2565
|
timestamp: Date.now()
|
|
2301
2566
|
};
|
|
2302
2567
|
}
|
|
@@ -2317,7 +2582,11 @@ var NativeUIBridgeServer = class {
|
|
|
2317
2582
|
timestamp: Date.now()
|
|
2318
2583
|
};
|
|
2319
2584
|
} catch (e) {
|
|
2320
|
-
return {
|
|
2585
|
+
return {
|
|
2586
|
+
success: false,
|
|
2587
|
+
error: `Refresh failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2588
|
+
timestamp: Date.now()
|
|
2589
|
+
};
|
|
2321
2590
|
}
|
|
2322
2591
|
};
|
|
2323
2592
|
}
|
|
@@ -2375,7 +2644,7 @@ var NativeUIBridgeServer = class {
|
|
|
2375
2644
|
} catch (e) {
|
|
2376
2645
|
return {
|
|
2377
2646
|
success: false,
|
|
2378
|
-
error: `Screenshot capture failed: ${e.message}`,
|
|
2647
|
+
error: `Screenshot capture failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2379
2648
|
timestamp: Date.now()
|
|
2380
2649
|
};
|
|
2381
2650
|
}
|
|
@@ -2930,9 +3199,11 @@ var NativeUIBridgeServer = class {
|
|
|
2930
3199
|
* "control/component/my-comp/action/submit" → handlers.executeComponentAction
|
|
2931
3200
|
*/
|
|
2932
3201
|
async routeMethodToHandler(method, params) {
|
|
3202
|
+
const testHooks = this.config.testHooks === true;
|
|
2933
3203
|
for (const route of WS_ROUTES) {
|
|
2934
3204
|
const pathParams = parsePath(route.pattern, method);
|
|
2935
3205
|
if (!pathParams) continue;
|
|
3206
|
+
if (route.requiresTestHooks && !testHooks) continue;
|
|
2936
3207
|
const ctx = {
|
|
2937
3208
|
params: pathParams,
|
|
2938
3209
|
query: params.query && typeof params.query === "object" ? params.query : {},
|
|
@@ -3010,17 +3281,18 @@ var NativeUIBridgeServer = class {
|
|
|
3010
3281
|
};
|
|
3011
3282
|
}
|
|
3012
3283
|
const stripped = path.slice(PREFIX.length);
|
|
3284
|
+
const testHooks = this.config.testHooks === true;
|
|
3013
3285
|
if (method === "GET" && stripped === "_help") {
|
|
3014
3286
|
return {
|
|
3015
3287
|
success: true,
|
|
3016
|
-
data: buildHelpPayload(),
|
|
3288
|
+
data: buildHelpPayload(testHooks),
|
|
3017
3289
|
timestamp: Date.now()
|
|
3018
3290
|
};
|
|
3019
3291
|
}
|
|
3020
3292
|
if (method === "GET" && stripped === "_routes") {
|
|
3021
3293
|
return {
|
|
3022
3294
|
success: true,
|
|
3023
|
-
data: buildRoutesPayload(),
|
|
3295
|
+
data: buildRoutesPayload(testHooks),
|
|
3024
3296
|
timestamp: Date.now()
|
|
3025
3297
|
};
|
|
3026
3298
|
}
|
|
@@ -3031,6 +3303,9 @@ var NativeUIBridgeServer = class {
|
|
|
3031
3303
|
if (!route.httpMethods) {
|
|
3032
3304
|
continue;
|
|
3033
3305
|
}
|
|
3306
|
+
if (route.requiresTestHooks && !testHooks) {
|
|
3307
|
+
continue;
|
|
3308
|
+
}
|
|
3034
3309
|
if (!route.httpMethods.includes(method)) {
|
|
3035
3310
|
patternMatchedButWrongVerb = true;
|
|
3036
3311
|
continue;
|
|
@@ -3324,6 +3599,27 @@ var WebSocketEventBridge = class {
|
|
|
3324
3599
|
}, this.heartbeatIntervalMs);
|
|
3325
3600
|
}
|
|
3326
3601
|
};
|
|
3602
|
+
|
|
3603
|
+
// src/transport/logger.ts
|
|
3604
|
+
function createTransportLogger() {
|
|
3605
|
+
return {
|
|
3606
|
+
silent: false,
|
|
3607
|
+
log(message, ...args) {
|
|
3608
|
+
if (!this.silent) {
|
|
3609
|
+
console.log(message, ...args);
|
|
3610
|
+
}
|
|
3611
|
+
},
|
|
3612
|
+
warn(message, ...args) {
|
|
3613
|
+
console.warn(message, ...args);
|
|
3614
|
+
},
|
|
3615
|
+
error(message, ...args) {
|
|
3616
|
+
console.error(message, ...args);
|
|
3617
|
+
}
|
|
3618
|
+
};
|
|
3619
|
+
}
|
|
3620
|
+
var transportLogger = createTransportLogger();
|
|
3621
|
+
|
|
3622
|
+
// src/transport/DeviceAnnouncer.ts
|
|
3327
3623
|
var DEFAULT_PORT = 8087;
|
|
3328
3624
|
var RECONNECT_INITIAL_DELAY_MS = 2e3;
|
|
3329
3625
|
var RECONNECT_MAX_DELAY_MS = 6e4;
|
|
@@ -3358,7 +3654,7 @@ var DeviceAnnouncer = class {
|
|
|
3358
3654
|
*/
|
|
3359
3655
|
async startMdnsAdvertise(ZeroconfCtor) {
|
|
3360
3656
|
if (!ZeroconfCtor) {
|
|
3361
|
-
|
|
3657
|
+
transportLogger.log(
|
|
3362
3658
|
"[DeviceAnnouncer] mDNS advertisement skipped (no Zeroconf constructor provided). Pass one to enable mDNS."
|
|
3363
3659
|
);
|
|
3364
3660
|
return;
|
|
@@ -3374,9 +3670,9 @@ var DeviceAnnouncer = class {
|
|
|
3374
3670
|
pairing_token: this.pairingToken
|
|
3375
3671
|
});
|
|
3376
3672
|
this.state = { ...this.state, mdnsActive: true };
|
|
3377
|
-
|
|
3673
|
+
transportLogger.log(`[DeviceAnnouncer] mDNS: advertising "${serviceName}" on port ${port}`);
|
|
3378
3674
|
} catch (err) {
|
|
3379
|
-
|
|
3675
|
+
transportLogger.warn("[DeviceAnnouncer] mDNS start failed:", err);
|
|
3380
3676
|
}
|
|
3381
3677
|
}
|
|
3382
3678
|
// ── Cloud relay ───────────────────────────────────────────────────────────
|
|
@@ -3392,7 +3688,7 @@ var DeviceAnnouncer = class {
|
|
|
3392
3688
|
}
|
|
3393
3689
|
if (this.stopped) return;
|
|
3394
3690
|
const url = `${this.config.cloudRelayUrl}?token=${encodeURIComponent(this.config.cloudToken)}`;
|
|
3395
|
-
|
|
3691
|
+
transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
|
|
3396
3692
|
const ws = new WebSocket(url);
|
|
3397
3693
|
this.cloudWs = ws;
|
|
3398
3694
|
ws.onopen = () => {
|
|
@@ -3402,7 +3698,7 @@ var DeviceAnnouncer = class {
|
|
|
3402
3698
|
}
|
|
3403
3699
|
this.reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
|
|
3404
3700
|
this.state = { ...this.state, cloudConnected: true };
|
|
3405
|
-
|
|
3701
|
+
transportLogger.log("[DeviceAnnouncer] Cloud relay: connected");
|
|
3406
3702
|
ws.send(
|
|
3407
3703
|
JSON.stringify({
|
|
3408
3704
|
type: "device_register",
|
|
@@ -3417,7 +3713,7 @@ var DeviceAnnouncer = class {
|
|
|
3417
3713
|
ws.onmessage = (event) => {
|
|
3418
3714
|
try {
|
|
3419
3715
|
const msg = JSON.parse(event.data);
|
|
3420
|
-
|
|
3716
|
+
transportLogger.log("[DeviceAnnouncer] Cloud relay message:", msg.type);
|
|
3421
3717
|
} catch {
|
|
3422
3718
|
}
|
|
3423
3719
|
};
|
|
@@ -3428,7 +3724,7 @@ var DeviceAnnouncer = class {
|
|
|
3428
3724
|
}
|
|
3429
3725
|
};
|
|
3430
3726
|
ws.onerror = (err) => {
|
|
3431
|
-
|
|
3727
|
+
transportLogger.warn("[DeviceAnnouncer] Cloud relay error:", err);
|
|
3432
3728
|
};
|
|
3433
3729
|
}
|
|
3434
3730
|
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
@@ -3463,7 +3759,7 @@ var DeviceAnnouncer = class {
|
|
|
3463
3759
|
scheduleReconnect() {
|
|
3464
3760
|
if (this.stopped) return;
|
|
3465
3761
|
const delay = this.reconnectDelay;
|
|
3466
|
-
|
|
3762
|
+
transportLogger.log(`[DeviceAnnouncer] Cloud relay: reconnecting in ${delay}ms`);
|
|
3467
3763
|
this.reconnectTimer = setTimeout(() => {
|
|
3468
3764
|
this.reconnectTimer = null;
|
|
3469
3765
|
if (!this.stopped) {
|
|
@@ -3525,7 +3821,7 @@ var CloudRelayClient = class {
|
|
|
3525
3821
|
connect() {
|
|
3526
3822
|
if (this.stopped) return;
|
|
3527
3823
|
const url = `${this.config.relayUrl}?token=${encodeURIComponent(this.config.authToken)}`;
|
|
3528
|
-
|
|
3824
|
+
transportLogger.log("[CloudRelayClient] Connecting to", url);
|
|
3529
3825
|
const ws = new WebSocket(url);
|
|
3530
3826
|
this.ws = ws;
|
|
3531
3827
|
ws.onopen = () => {
|
|
@@ -3535,7 +3831,7 @@ var CloudRelayClient = class {
|
|
|
3535
3831
|
}
|
|
3536
3832
|
this._isConnected = true;
|
|
3537
3833
|
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
|
3538
|
-
|
|
3834
|
+
transportLogger.log("[CloudRelayClient] Connected to relay");
|
|
3539
3835
|
ws.send(
|
|
3540
3836
|
JSON.stringify({
|
|
3541
3837
|
type: "device_register",
|
|
@@ -3553,7 +3849,7 @@ var CloudRelayClient = class {
|
|
|
3553
3849
|
}
|
|
3554
3850
|
};
|
|
3555
3851
|
ws.onerror = (err) => {
|
|
3556
|
-
|
|
3852
|
+
transportLogger.warn("[CloudRelayClient] WebSocket error:", err);
|
|
3557
3853
|
};
|
|
3558
3854
|
}
|
|
3559
3855
|
async onMessage(raw) {
|
|
@@ -3561,12 +3857,12 @@ var CloudRelayClient = class {
|
|
|
3561
3857
|
try {
|
|
3562
3858
|
msg = JSON.parse(raw);
|
|
3563
3859
|
} catch {
|
|
3564
|
-
|
|
3860
|
+
transportLogger.warn("[CloudRelayClient] Received non-JSON message");
|
|
3565
3861
|
return;
|
|
3566
3862
|
}
|
|
3567
3863
|
if (msg["type"] !== "tunnel_request") return;
|
|
3568
3864
|
if (typeof msg["id"] !== "string" || typeof msg["method"] !== "string" || typeof msg["path"] !== "string") {
|
|
3569
|
-
|
|
3865
|
+
transportLogger.warn("[CloudRelayClient] Malformed tunnel_request \u2014 missing required fields");
|
|
3570
3866
|
return;
|
|
3571
3867
|
}
|
|
3572
3868
|
const req = {
|
|
@@ -3607,7 +3903,7 @@ var CloudRelayClient = class {
|
|
|
3607
3903
|
body: bodyValue
|
|
3608
3904
|
};
|
|
3609
3905
|
} catch (err) {
|
|
3610
|
-
|
|
3906
|
+
transportLogger.error("[CloudRelayClient] Error handling tunneled request:", err);
|
|
3611
3907
|
return {
|
|
3612
3908
|
id: req.id,
|
|
3613
3909
|
status: 500,
|
|
@@ -3622,7 +3918,7 @@ var CloudRelayClient = class {
|
|
|
3622
3918
|
scheduleReconnect() {
|
|
3623
3919
|
if (this.stopped) return;
|
|
3624
3920
|
const delay = this.reconnectDelay;
|
|
3625
|
-
|
|
3921
|
+
transportLogger.log(`[CloudRelayClient] Reconnecting in ${delay}ms`);
|
|
3626
3922
|
this.reconnectTimer = setTimeout(() => {
|
|
3627
3923
|
this.reconnectTimer = null;
|
|
3628
3924
|
if (!this.stopped) {
|
|
@@ -3665,8 +3961,14 @@ var ModalDetector = class {
|
|
|
3665
3961
|
};
|
|
3666
3962
|
this.modals.set(modal.id, next);
|
|
3667
3963
|
}
|
|
3964
|
+
/**
|
|
3965
|
+
* Remove a modal from the stack. Returns `true` if a modal with that id
|
|
3966
|
+
* was actually deleted, `false` if nothing matched (idempotent so repeated
|
|
3967
|
+
* calls don't throw, but callers — e.g. the HTTP test-hook endpoint — can
|
|
3968
|
+
* surface the miss to clients).
|
|
3969
|
+
*/
|
|
3668
3970
|
dismissModal(id) {
|
|
3669
|
-
this.modals.delete(id);
|
|
3971
|
+
return this.modals.delete(id);
|
|
3670
3972
|
}
|
|
3671
3973
|
getSnapshotModalContext() {
|
|
3672
3974
|
const active = this.getActive();
|
|
@@ -3878,7 +4180,8 @@ function UIBridgeNativeProvider({
|
|
|
3878
4180
|
const server = createNativeServer(registry, executor, {
|
|
3879
4181
|
serverPort: config.serverPort || 8087,
|
|
3880
4182
|
cors: true,
|
|
3881
|
-
appInfo: config.appInfo
|
|
4183
|
+
appInfo: config.appInfo,
|
|
4184
|
+
testHooks: features.testHooks === true
|
|
3882
4185
|
});
|
|
3883
4186
|
server.setAdapter(serverAdapter);
|
|
3884
4187
|
if (navigationProvider) {
|
|
@@ -3932,7 +4235,9 @@ function UIBridgeNativeProvider({
|
|
|
3932
4235
|
}
|
|
3933
4236
|
}, [
|
|
3934
4237
|
features.server,
|
|
4238
|
+
features.testHooks,
|
|
3935
4239
|
config.serverPort,
|
|
4240
|
+
config.appInfo,
|
|
3936
4241
|
registry,
|
|
3937
4242
|
executor,
|
|
3938
4243
|
serverAdapter,
|
|
@@ -3966,7 +4271,8 @@ function UIBridgeNativeProvider({
|
|
|
3966
4271
|
const bareServer = createNativeServer(registry, executor, {
|
|
3967
4272
|
serverPort: config.serverPort || 8087,
|
|
3968
4273
|
cors: true,
|
|
3969
|
-
appInfo: config.appInfo
|
|
4274
|
+
appInfo: config.appInfo,
|
|
4275
|
+
testHooks: features.testHooks === true
|
|
3970
4276
|
});
|
|
3971
4277
|
if (navigationProvider) bareServer.setNavigationProvider(navigationProvider);
|
|
3972
4278
|
if (screenshotProvider) bareServer.setScreenshotProvider(screenshotProvider);
|
|
@@ -4197,7 +4503,7 @@ function useUIElement(options) {
|
|
|
4197
4503
|
registeredRef.current = false;
|
|
4198
4504
|
registeredIdRef.current = null;
|
|
4199
4505
|
setRegistered(false);
|
|
4200
|
-
}, [bridge,
|
|
4506
|
+
}, [bridge, id]);
|
|
4201
4507
|
const onLayout = react.useCallback(
|
|
4202
4508
|
(event) => {
|
|
4203
4509
|
const { x, y, width, height } = event.nativeEvent.layout;
|
|
@@ -4463,7 +4769,7 @@ function useUIComponent(options) {
|
|
|
4463
4769
|
const registeredComponent = react.useMemo(() => {
|
|
4464
4770
|
if (!bridge) return null;
|
|
4465
4771
|
return bridge.registry.getComponent(id) || null;
|
|
4466
|
-
}, [bridge, id
|
|
4772
|
+
}, [bridge, id]);
|
|
4467
4773
|
return {
|
|
4468
4774
|
registered: registeredRef.current,
|
|
4469
4775
|
executeAction,
|
|
@@ -4607,11 +4913,12 @@ function useAutoRegister(options = {}) {
|
|
|
4607
4913
|
[bridge, onUnregister]
|
|
4608
4914
|
);
|
|
4609
4915
|
react.useEffect(() => {
|
|
4916
|
+
const registeredIds = registeredIdsRef.current;
|
|
4610
4917
|
return () => {
|
|
4611
|
-
|
|
4918
|
+
registeredIds.forEach((id) => {
|
|
4612
4919
|
bridge?.registry.unregisterElement(id);
|
|
4613
4920
|
});
|
|
4614
|
-
|
|
4921
|
+
registeredIds.clear();
|
|
4615
4922
|
};
|
|
4616
4923
|
}, [bridge]);
|
|
4617
4924
|
return {
|
|
@@ -4651,7 +4958,15 @@ function useToastRecorder() {
|
|
|
4651
4958
|
const dismissToast = react.useCallback((id) => toastCapture.dismissToast(id), [toastCapture]);
|
|
4652
4959
|
return { recordToast, dismissToast };
|
|
4653
4960
|
}
|
|
4961
|
+
function State(props) {
|
|
4962
|
+
return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: props.children });
|
|
4963
|
+
}
|
|
4964
|
+
function TransitionTo(props) {
|
|
4965
|
+
return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: props.children });
|
|
4966
|
+
}
|
|
4654
4967
|
|
|
4968
|
+
exports.State = State;
|
|
4969
|
+
exports.TransitionTo = TransitionTo;
|
|
4655
4970
|
exports.UIBridgeNativeProvider = UIBridgeNativeProvider;
|
|
4656
4971
|
exports.useAutoRegister = useAutoRegister;
|
|
4657
4972
|
exports.useToastRecorder = useToastRecorder;
|