@aarwitz/tapp 0.15.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/AGENTS.md +123 -0
- package/Harness/OCQAHarness/AppDelegate.swift +21 -0
- package/Harness/OCQAHarness/Info.plist +26 -0
- package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
- package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
- package/Harness/OCQAHarnessUITests/Info.plist +22 -0
- package/Harness/generate-harness-xcodeproj.rb +254 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/bin/tapp.js +1382 -0
- package/browser/app.css +227 -0
- package/browser/app.js +675 -0
- package/browser/index.html +195 -0
- package/browser/product-contract.js +25 -0
- package/browser/view-model.js +16 -0
- package/docs/BROWSER-PRODUCT.md +72 -0
- package/docs/PRODUCT-ENGINE.md +102 -0
- package/docs/application-model.md +276 -0
- package/docs/scenarios.md +95 -0
- package/mcp-server/src/android-driver.js +287 -0
- package/mcp-server/src/android-explorer.js +197 -0
- package/mcp-server/src/android-flow.js +89 -0
- package/mcp-server/src/application-model.js +1597 -0
- package/mcp-server/src/browser-product.js +659 -0
- package/mcp-server/src/browser-workspaces.js +234 -0
- package/mcp-server/src/ci-report.js +557 -0
- package/mcp-server/src/ci-setup.js +359 -0
- package/mcp-server/src/contract-authoring.js +10 -0
- package/mcp-server/src/enrich.js +57 -0
- package/mcp-server/src/flow-runtime.js +127 -0
- package/mcp-server/src/html-report.js +124 -0
- package/mcp-server/src/index.js +3775 -0
- package/mcp-server/src/maintenance-proposal.js +178 -0
- package/mcp-server/src/managed-operation.js +61 -0
- package/mcp-server/src/pr-selection.js +841 -0
- package/mcp-server/src/product-execution.js +155 -0
- package/mcp-server/src/product-operations.js +526 -0
- package/mcp-server/src/project-config.js +101 -0
- package/mcp-server/src/release-contract.d.ts +81 -0
- package/mcp-server/src/release-contract.js +226 -0
- package/mcp-server/src/report.js +363 -0
- package/mcp-server/src/scenario-runtime.js +139 -0
- package/mcp-server/src/static-server.js +44 -0
- package/mcp-server/src/task-runtime.js +266 -0
- package/mcp-server/src/ui-map.js +661 -0
- package/mcp-server/src/web-explorer.js +493 -0
- package/mcp-server/src/web-flow.js +238 -0
- package/package.json +82 -0
- package/scripts/android-corpus-e2e.sh +30 -0
- package/scripts/ci-gate.sh +323 -0
- package/scripts/cleanup-xcode.sh +157 -0
- package/scripts/compile-contract.js +27 -0
- package/scripts/compile-flow.js +18 -0
- package/scripts/corpus-apps.txt +9 -0
- package/scripts/corpus-sweep.sh +121 -0
- package/scripts/coverage-eval.sh +92 -0
- package/scripts/coverage_eval_parse.py +95 -0
- package/scripts/deploy-and-build.sh +99 -0
- package/scripts/flow-platform.js +18 -0
- package/scripts/flow_ai_judge.py +102 -0
- package/scripts/flow_lib.py +154 -0
- package/scripts/mutation-recall-desktop.sh +186 -0
- package/scripts/mutation-recall.sh +121 -0
- package/scripts/mutation_lib.py +128 -0
- package/scripts/mutation_operators.py +144 -0
- package/scripts/platform-gate.js +186 -0
- package/scripts/pr-plan.js +68 -0
- package/scripts/quick-capture.sh +419 -0
- package/scripts/run-android-flow.js +27 -0
- package/scripts/run-flow.sh +90 -0
- package/scripts/run-web-flow.js +28 -0
- package/scripts/run-web-scenario.js +23 -0
- package/scripts/validation-matrix.sh +146 -0
- package/scripts/vision-fp-eval.sh +206 -0
- package/scripts/vision_escalation_responder.py +147 -0
- package/scripts/vision_fp_probe.py +221 -0
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
// Tapp UI Map v1 — the platform-neutral, evidence-grounded graph shared by
|
|
2
|
+
// exploration, task/contract authoring, PR selection, CI, MCP, and desktop.
|
|
3
|
+
// Keep this dependency-free and deterministic: drivers only emit OCQA markers.
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
export const UI_MAP_SCHEMA_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
function stableId(prefix, value) {
|
|
11
|
+
return `${prefix}_${crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, 16)}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function cleanSpace(value) {
|
|
15
|
+
return String(value ?? "").normalize("NFKC").replace(/\s+/g, " ").trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function observedWebRoute(raw) {
|
|
19
|
+
const value = String(raw || "").trim();
|
|
20
|
+
if (!value) return null;
|
|
21
|
+
let parsed;
|
|
22
|
+
try { parsed = new URL(value, "http://tapp.invalid"); } catch { return null; }
|
|
23
|
+
const original = parsed.pathname || "/";
|
|
24
|
+
const redacted = original
|
|
25
|
+
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi, "<uuid>")
|
|
26
|
+
.replace(/\b(?:sk|pk|tok|token)[-_][A-Za-z0-9_-]{12,}\b/g, "<token>")
|
|
27
|
+
.replace(/\b\d{6,}\b/g, "<number>");
|
|
28
|
+
return {
|
|
29
|
+
platform: "web",
|
|
30
|
+
path: redacted,
|
|
31
|
+
replayable: redacted === original && !parsed.search,
|
|
32
|
+
status: "observed",
|
|
33
|
+
...(parsed.search ? { queryOmitted: true } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function redactUiText(value) {
|
|
38
|
+
return cleanSpace(value)
|
|
39
|
+
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "<email>")
|
|
40
|
+
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi, "<uuid>")
|
|
41
|
+
.replace(/\b(?:sk|pk|tok|token)[-_][A-Za-z0-9_-]{12,}\b/g, "<token>")
|
|
42
|
+
.replace(/\b\d{6,}\b/g, "<number>")
|
|
43
|
+
.slice(0, 160);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function semanticUiKey(value) {
|
|
47
|
+
return redactUiText(value)
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.replace(/<[^>]+>/g, " dynamic ")
|
|
50
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
51
|
+
.replace(/^-+|-+$/g, "") || "unknown";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function replayStepForEdge(edge, nodes, platform) {
|
|
55
|
+
if (!edge || !["observed", "validated"].includes(edge.status) || edge.confirmed === false) return null;
|
|
56
|
+
if (Array.isArray(edge.platforms) && edge.platforms.length && !edge.platforms.includes(platform)) return null;
|
|
57
|
+
if ((edge.actors || []).length || (edge.preconditions || []).length) return null;
|
|
58
|
+
const type = semanticUiKey(edge.action?.type || "");
|
|
59
|
+
if (!["tap", "back"].includes(type)) return null;
|
|
60
|
+
const observedTarget = redactUiText(edge.action?.target || "");
|
|
61
|
+
const descriptor = observedTarget.match(/^(id|label):(.+)$/i);
|
|
62
|
+
const target = redactUiText(descriptor?.[2] || observedTarget);
|
|
63
|
+
if (!target || /<[^>]+>|\{\{|tab_bar_pos|center_unexplored|_escape|drawer_probe|carousel/i.test(target)) return null;
|
|
64
|
+
const destination = nodes.get(edge.to);
|
|
65
|
+
if (!destination || !["observed", "validated"].includes(destination.status)) return null;
|
|
66
|
+
if (Array.isArray(destination.platforms) && destination.platforms.length && !destination.platforms.includes(platform)) return null;
|
|
67
|
+
const source = nodes.get(edge.from);
|
|
68
|
+
const matchedControl = (source?.controls || []).find((control) =>
|
|
69
|
+
[control.id, control.semanticKey, control.label, ...(control.selectors || []).map((selector) => selector.value)]
|
|
70
|
+
.some((value) => [semanticUiKey(target), semanticUiKey(observedTarget)].includes(semanticUiKey(value)))
|
|
71
|
+
);
|
|
72
|
+
const selectors = mergeUnique([
|
|
73
|
+
...(matchedControl?.selectors || []),
|
|
74
|
+
...(edge.action?.selectors || []),
|
|
75
|
+
...(descriptor ? [{ kind: descriptor[1].toLowerCase() === "id" ? (platform === "android" ? "resourceId" : "accessibilityId") : "label", value: target }] : []),
|
|
76
|
+
], (selector) => `${selector.kind}:${selector.value}`)
|
|
77
|
+
.filter((selector) => ["testId", "accessibilityId", "resourceId", "cssId", "label"].includes(selector.kind) && selector.value)
|
|
78
|
+
.slice(0, 8);
|
|
79
|
+
return {
|
|
80
|
+
edgeId: edge.id,
|
|
81
|
+
from: edge.from,
|
|
82
|
+
to: edge.to,
|
|
83
|
+
action: { type, target, selectors },
|
|
84
|
+
wait: edge.wait?.type === "condition"
|
|
85
|
+
? { type: "condition", timeoutMs: Math.max(250, Math.min(30_000, Number(edge.wait.timeoutMs) || 6000)) }
|
|
86
|
+
: { type: "condition", timeoutMs: 6000 },
|
|
87
|
+
expected: { type: "screen", value: destination.semanticKey, name: destination.name },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Produce a bounded deterministic route from the platform's observed navigation
|
|
92
|
+
// root to one UI Map node. This is execution infrastructure, not a claim that
|
|
93
|
+
// every observed edge is safely replayable: actor/precondition-dependent,
|
|
94
|
+
// dynamic, proposed, and unsupported actions are excluded before BFS.
|
|
95
|
+
export function replayableUiMapNavigation(map, targetNodeId, platform, { maxSteps = 8 } = {}) {
|
|
96
|
+
const limit = Math.max(0, Math.min(12, Number(maxSteps) || 0));
|
|
97
|
+
const nodes = new Map((map?.nodes || []).map((node) => [node.id, node]));
|
|
98
|
+
const target = nodes.get(targetNodeId);
|
|
99
|
+
if (!target) return { status: "blocked", reason: `UI Map target node is missing: ${targetNodeId}` };
|
|
100
|
+
const rootId = map?.app?.navigationRoots?.[platform] || map?.app?.entryNodes?.[platform] || "";
|
|
101
|
+
if (!rootId || !nodes.has(rootId)) return { status: "blocked", reason: `No observed ${platform} navigation root exists in the UI Map` };
|
|
102
|
+
if (rootId === targetNodeId) return {
|
|
103
|
+
status: "replayable", mode: "ui-map-path", provenance: "observed-ui-map",
|
|
104
|
+
entryNodeId: rootId, targetNodeId, steps: [], maxSteps: limit,
|
|
105
|
+
};
|
|
106
|
+
if (limit === 0) return { status: "blocked", reason: "UI Map navigation budget is zero" };
|
|
107
|
+
|
|
108
|
+
const adjacency = new Map();
|
|
109
|
+
for (const edge of map?.edges || []) {
|
|
110
|
+
const step = replayStepForEdge(edge, nodes, platform);
|
|
111
|
+
if (!step) continue;
|
|
112
|
+
const list = adjacency.get(edge.from) || [];
|
|
113
|
+
list.push(step);
|
|
114
|
+
adjacency.set(edge.from, list);
|
|
115
|
+
}
|
|
116
|
+
for (const list of adjacency.values()) list.sort((left, right) => left.edgeId.localeCompare(right.edgeId));
|
|
117
|
+
|
|
118
|
+
const queue = [{ nodeId: rootId, steps: [] }];
|
|
119
|
+
const visited = new Set([rootId]);
|
|
120
|
+
while (queue.length) {
|
|
121
|
+
const current = queue.shift();
|
|
122
|
+
if (current.steps.length >= limit) continue;
|
|
123
|
+
for (const step of adjacency.get(current.nodeId) || []) {
|
|
124
|
+
if (visited.has(step.to)) continue;
|
|
125
|
+
const steps = [...current.steps, step];
|
|
126
|
+
if (step.to === targetNodeId) return {
|
|
127
|
+
status: "replayable", mode: "ui-map-path", provenance: "observed-ui-map",
|
|
128
|
+
entryNodeId: rootId, targetNodeId, steps, maxSteps: limit,
|
|
129
|
+
};
|
|
130
|
+
visited.add(step.to);
|
|
131
|
+
queue.push({ nodeId: step.to, steps });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { status: "blocked", reason: `No bounded replayable ${platform} UI Map path reaches ${target.name || targetNodeId}` };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseMarkerLine(line) {
|
|
138
|
+
const match = String(line).trim().match(/^OCQA_([A-Z_]+):(\{.*\})$/);
|
|
139
|
+
if (!match) return null;
|
|
140
|
+
try { return { kind: match[1], value: JSON.parse(match[2]) }; } catch { return null; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function selectorList(control) {
|
|
144
|
+
const selectors = [];
|
|
145
|
+
const add = (kind, raw) => {
|
|
146
|
+
const value = redactUiText(raw);
|
|
147
|
+
if (!value || value === "<redacted>") return;
|
|
148
|
+
if (!selectors.some((selector) => selector.kind === kind && selector.value === value)) selectors.push({ kind, value });
|
|
149
|
+
};
|
|
150
|
+
add("testId", control.testId ?? control.selectors?.testId);
|
|
151
|
+
add("accessibilityId", control.accessibilityId ?? control.identifier ?? control.selectors?.accessibilityId);
|
|
152
|
+
add("resourceId", control.resourceId ?? control.id ?? control.selectors?.resourceId);
|
|
153
|
+
add("cssId", control.cssId ?? control.selectors?.cssId);
|
|
154
|
+
add("label", control.label ?? control.name ?? control.placeholder ?? control.selectors?.label);
|
|
155
|
+
add("role", control.role ?? control.selectors?.role);
|
|
156
|
+
return selectors;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeControl(raw, nodeId, platform) {
|
|
160
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
161
|
+
const secure = !!raw.secure;
|
|
162
|
+
const kind = cleanSpace(raw.kind || raw.type || (secure ? "secureField" : "control"));
|
|
163
|
+
const label = secure ? redactUiText(raw.label || raw.placeholder || "Password") : redactUiText(raw.label || raw.name || raw.text || raw.placeholder || raw.id || raw.identifier);
|
|
164
|
+
const selectors = selectorList({ ...raw, label });
|
|
165
|
+
const identity = selectors.find((selector) => selector.kind !== "role")?.value || label || kind;
|
|
166
|
+
if (!identity) return null;
|
|
167
|
+
return {
|
|
168
|
+
id: stableId("control", `${nodeId}|${semanticUiKey(kind)}|${semanticUiKey(identity)}`),
|
|
169
|
+
semanticKey: semanticUiKey(identity),
|
|
170
|
+
kind: kind || "control",
|
|
171
|
+
label: label || identity,
|
|
172
|
+
secure,
|
|
173
|
+
enabled: raw.enabled !== false,
|
|
174
|
+
hittable: raw.hittable !== false,
|
|
175
|
+
selectors,
|
|
176
|
+
platforms: [platform],
|
|
177
|
+
status: "observed",
|
|
178
|
+
sourcePaths: [],
|
|
179
|
+
coveredBy: { tasks: [], contracts: [] },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Visible titles are not unique state identities. Native navigation stacks in particular often
|
|
184
|
+
// keep the list title on a selected detail form. Derive a conservative structural landmark set
|
|
185
|
+
// from stable selectors and short semantic actions so same-title list/detail states can be split
|
|
186
|
+
// without treating dynamic rows, counters, customer content, or global tab chrome as new screens.
|
|
187
|
+
const GENERIC_STATE_ANCHORS = new Set([
|
|
188
|
+
"additionaldimmingoverlay", "backbutton", "checklist", "checkmark", "chevron-forward", "circle",
|
|
189
|
+
"gearshape", "gearshape-fill", "home", "house", "house-fill", "selected", "settings", "tasks",
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
function structuralStateAnchor(raw) {
|
|
193
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "";
|
|
194
|
+
const kind = semanticUiKey(raw.kind || raw.type || "control");
|
|
195
|
+
const selectors = Array.isArray(raw.selectors) ? raw.selectors : selectorList(raw);
|
|
196
|
+
const stableSelector = selectors.find((selector) =>
|
|
197
|
+
["testId", "accessibilityId", "resourceId", "cssId"].includes(selector.kind) && selector.value
|
|
198
|
+
);
|
|
199
|
+
const stableKey = semanticUiKey(stableSelector?.value || "");
|
|
200
|
+
if (stableKey && !GENERIC_STATE_ANCHORS.has(stableKey) && !/^(?:tab-bar-pos|dynamic|unknown)$/.test(stableKey)) {
|
|
201
|
+
return `${kind}:${stableKey}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const label = redactUiText(raw.label || raw.name || raw.placeholder || "");
|
|
205
|
+
const labelKey = semanticUiKey(label);
|
|
206
|
+
if (!label || label.length > 48 || /[,\d]|<[^>]+>/.test(label) || GENERIC_STATE_ANCHORS.has(labelKey)) return "";
|
|
207
|
+
if (!["button", "field", "securefield", "link", "switch", "toggle"].some((candidate) => kind.includes(candidate))) return "";
|
|
208
|
+
return `${kind}:${labelKey}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function structuralStateAnchorsFromRaw(raw) {
|
|
212
|
+
return [...new Set([
|
|
213
|
+
...(Array.isArray(raw?.controls) ? raw.controls : []),
|
|
214
|
+
...(Array.isArray(raw?.inputs) ? raw.inputs.map((input) => ({ ...input, kind: input.secure ? "secureField" : "field" })) : []),
|
|
215
|
+
].map(structuralStateAnchor).filter(Boolean))].sort();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function structuralStateAnchorsFromNode(node) {
|
|
219
|
+
return [...new Set((node?.controls || []).map(structuralStateAnchor).filter(Boolean))].sort();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function stateAnchorSimilarity(left, right) {
|
|
223
|
+
if (!left.length || !right.length) return { intersection: 0, score: 0 };
|
|
224
|
+
const leftSet = new Set(left);
|
|
225
|
+
const intersection = right.filter((item) => leftSet.has(item)).length;
|
|
226
|
+
const union = new Set([...left, ...right]).size;
|
|
227
|
+
return { intersection, score: union ? intersection / union : 0 };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function stateVariantKey(raw, anchors) {
|
|
231
|
+
const explicit = semanticUiKey(raw?.stateKey || raw?.variant || "");
|
|
232
|
+
if (explicit && explicit !== "unknown") return explicit;
|
|
233
|
+
const fields = anchors.filter((anchor) => anchor.startsWith("field:") || anchor.startsWith("securefield:"));
|
|
234
|
+
const field = fields.find((anchor) => /(?:^|-)field$/.test(anchor.split(":").slice(1).join(":"))) || fields[0];
|
|
235
|
+
if (field) return field.split(":").slice(1).join(":");
|
|
236
|
+
return `state-${crypto.createHash("sha256").update(anchors.join("|")).digest("hex").slice(0, 10)}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function stateVariantLabel(anchors, variant) {
|
|
240
|
+
const keys = anchors.map((anchor) => anchor.split(":").slice(1).join(":"));
|
|
241
|
+
const ordered = variant && !variant.startsWith("state-") ? [variant, ...keys.filter((key) => key !== variant)] : keys;
|
|
242
|
+
const words = ordered.slice(0, 3).map((key) => key.replace(/-/g, " "));
|
|
243
|
+
return words.map((value) => value.replace(/\b[a-z]/g, (letter) => letter.toUpperCase())).join(" / ");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function inferredControl(action, nodeId, platform) {
|
|
247
|
+
const type = cleanSpace(action?.type || action?.action).toLowerCase();
|
|
248
|
+
const observedTarget = redactUiText(action?.target || action?.label || action?.identifier || action?.direction);
|
|
249
|
+
const descriptor = observedTarget.match(/^(id|label):(.+)$/i);
|
|
250
|
+
const target = redactUiText(descriptor?.[2] || observedTarget);
|
|
251
|
+
if (!target || !["tap", "type", "typetext", "login_type"].some((candidate) => type.includes(candidate))) return null;
|
|
252
|
+
return normalizeControl({
|
|
253
|
+
kind: type.includes("type") ? "field" : "button",
|
|
254
|
+
label: target,
|
|
255
|
+
accessibilityId: descriptor?.[1]?.toLowerCase() === "id" ? target : action?.identifier,
|
|
256
|
+
}, nodeId, platform);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function preparationFromAction(action) {
|
|
260
|
+
const type = semanticUiKey(action?.type || action?.action || "");
|
|
261
|
+
if (!type.includes("type") || action?.status === "not_found" || action?.status === "failed") return null;
|
|
262
|
+
const target = redactUiText(action?.target || action?.identifier || action?.label || "");
|
|
263
|
+
if (!target) return null;
|
|
264
|
+
const targetKey = semanticUiKey(target);
|
|
265
|
+
const supplied = semanticUiKey(action?.valueSource || "");
|
|
266
|
+
const valueSource = ["test-email", "test-password", "generated-text"].includes(supplied)
|
|
267
|
+
? supplied
|
|
268
|
+
: targetKey.includes("password") ? "test-password" : targetKey.includes("email") ? "test-email" : "generated-text";
|
|
269
|
+
return { type: "type", target, valueSource };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function mergeUnique(items, key = (item) => JSON.stringify(item)) {
|
|
273
|
+
const seen = new Set();
|
|
274
|
+
return items.filter((item) => {
|
|
275
|
+
const identity = key(item);
|
|
276
|
+
if (seen.has(identity)) return false;
|
|
277
|
+
seen.add(identity);
|
|
278
|
+
return true;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function sortMap(map) {
|
|
283
|
+
map.nodes ||= [];
|
|
284
|
+
map.edges ||= [];
|
|
285
|
+
map.nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
286
|
+
for (const node of map.nodes) {
|
|
287
|
+
node.aliases ||= [];
|
|
288
|
+
node.platforms ||= [];
|
|
289
|
+
node.roles ||= [];
|
|
290
|
+
node.fingerprints ||= [];
|
|
291
|
+
node.controls ||= [];
|
|
292
|
+
node.routes ||= [];
|
|
293
|
+
node.aliases.sort();
|
|
294
|
+
node.platforms.sort();
|
|
295
|
+
node.roles.sort();
|
|
296
|
+
node.fingerprints.sort();
|
|
297
|
+
node.controls.sort((a, b) => a.id.localeCompare(b.id));
|
|
298
|
+
node.routes.sort((a, b) => `${a.platform}:${a.path}`.localeCompare(`${b.platform}:${b.path}`));
|
|
299
|
+
for (const control of node.controls) {
|
|
300
|
+
control.platforms ||= [];
|
|
301
|
+
control.selectors ||= [];
|
|
302
|
+
control.platforms.sort();
|
|
303
|
+
control.selectors.sort((a, b) => `${a.kind}:${a.value}`.localeCompare(`${b.kind}:${b.value}`));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
map.edges.sort((a, b) => a.id.localeCompare(b.id));
|
|
307
|
+
return map;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function observation(runId, observedAt, count = 1) {
|
|
311
|
+
return { runIds: runId ? [runId] : [], firstObservedAt: observedAt, lastObservedAt: observedAt, count };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function buildUiMapFromMarkers({ markersPath, platform = "ios", target = "", runId = "", observedAt, build = "" }) {
|
|
315
|
+
if (!markersPath || !fs.existsSync(markersPath)) throw new Error(`UI Map markers not found: ${markersPath || "(missing path)"}`);
|
|
316
|
+
const timestamp = observedAt || fs.statSync(markersPath).mtime.toISOString();
|
|
317
|
+
const parsed = fs.readFileSync(markersPath, "utf8").split(/\r?\n/).map(parseMarkerLine).filter(Boolean);
|
|
318
|
+
const nodeById = new Map();
|
|
319
|
+
const edgeById = new Map();
|
|
320
|
+
const actions = [];
|
|
321
|
+
let lastState = null;
|
|
322
|
+
let previousState = null;
|
|
323
|
+
let pendingTransition = null;
|
|
324
|
+
let actionsSinceState = 0;
|
|
325
|
+
let stateActions = [];
|
|
326
|
+
let entryState = null;
|
|
327
|
+
let navigationRoot = null;
|
|
328
|
+
|
|
329
|
+
const ensureNode = (raw = {}, recordStateObservation = false) => {
|
|
330
|
+
const name = redactUiText(raw.screen || raw.name || "Unknown");
|
|
331
|
+
if (!name || name === "Unknown") return null;
|
|
332
|
+
const baseSemanticKey = semanticUiKey(name);
|
|
333
|
+
const suppliedRole = cleanSpace(raw.role || "");
|
|
334
|
+
const role = semanticUiKey(suppliedRole || "screen");
|
|
335
|
+
const anchors = structuralStateAnchorsFromRaw(raw);
|
|
336
|
+
const candidates = [...nodeById.values()].filter((candidate) =>
|
|
337
|
+
(candidate.baseSemanticKey || candidate.semanticKey) === baseSemanticKey
|
|
338
|
+
);
|
|
339
|
+
let node = null;
|
|
340
|
+
if (candidates.length) {
|
|
341
|
+
// Marker-only transition endpoints do not carry controls. Resolve them to the established
|
|
342
|
+
// base identity; state-bearing markers below provide the evidence needed to select a variant.
|
|
343
|
+
if (!anchors.length) node = candidates.find((candidate) => !candidate.variant) || candidates[0];
|
|
344
|
+
else {
|
|
345
|
+
const ranked = candidates.map((candidate) => ({
|
|
346
|
+
candidate,
|
|
347
|
+
...stateAnchorSimilarity(anchors, structuralStateAnchorsFromNode(candidate)),
|
|
348
|
+
})).sort((left, right) => right.score - left.score || right.intersection - left.intersection || left.candidate.id.localeCompare(right.candidate.id));
|
|
349
|
+
const best = ranked[0];
|
|
350
|
+
const candidateAnchors = best ? structuralStateAnchorsFromNode(best.candidate) : [];
|
|
351
|
+
const strictSamePlatformSubset = !!best && (best.candidate.platforms || []).includes(platform)
|
|
352
|
+
&& best.intersection === Math.min(anchors.length, candidateAnchors.length)
|
|
353
|
+
&& anchors.length !== candidateAnchors.length;
|
|
354
|
+
const explicitVariant = cleanSpace(raw?.stateKey || raw?.variant || "");
|
|
355
|
+
const route = platform === "web" ? observedWebRoute(raw?.url || raw?.route || "") : null;
|
|
356
|
+
const sameWebRoute = !!best && platform === "web" && !explicitVariant && !!route
|
|
357
|
+
&& (best.candidate.routes || []).some((item) => item.platform === "web" && item.path === route.path);
|
|
358
|
+
// SPA forms commonly reveal fields/actions in-place while retaining the same heading and
|
|
359
|
+
// route. Treat that as one screen unless the driver supplies an explicit state identity.
|
|
360
|
+
// Native same-title subsets remain distinct because a disappeared prerequisite (for
|
|
361
|
+
// example Checkout on an empty Cart) materially changes what can be done.
|
|
362
|
+
if (best && (best.score >= 0.35 || best.intersection >= 2 || (sameWebRoute && best.intersection >= 1)) && (!strictSamePlatformSubset || sameWebRoute)) node = best.candidate;
|
|
363
|
+
// A first observation from another platform is a platform variant of the same semantic
|
|
364
|
+
// state, not evidence for a new state merely because its native controls differ.
|
|
365
|
+
else node = candidates.find((candidate) => !(candidate.platforms || []).includes(platform)) || null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const variant = node || !candidates.length || !anchors.length ? "" : stateVariantKey(raw, anchors);
|
|
369
|
+
const semanticKey = node?.semanticKey || (variant ? `${baseSemanticKey}--${variant}` : baseSemanticKey);
|
|
370
|
+
const id = node?.id || stableId("screen", semanticKey);
|
|
371
|
+
node ||= nodeById.get(id);
|
|
372
|
+
if (!node) {
|
|
373
|
+
node = {
|
|
374
|
+
id, semanticKey, name, aliases: [], roles: [role], platforms: [platform], status: "observed",
|
|
375
|
+
...(variant ? { baseSemanticKey, variant, stateLabel: `${name} · ${stateVariantLabel(anchors, variant)}` } : {}),
|
|
376
|
+
fingerprints: [], controls: [], routes: [], sourcePaths: [], coveredBy: { tasks: [], contracts: [] },
|
|
377
|
+
observation: observation(runId, timestamp, 0),
|
|
378
|
+
};
|
|
379
|
+
nodeById.set(id, node);
|
|
380
|
+
}
|
|
381
|
+
if (name !== node.name && !node.aliases.includes(name)) node.aliases.push(name);
|
|
382
|
+
if (!node.platforms.includes(platform)) node.platforms.push(platform);
|
|
383
|
+
if ((suppliedRole || node.roles.length === 0) && !node.roles.includes(role)) node.roles.push(role);
|
|
384
|
+
const fingerprint = redactUiText(raw.hash || raw.fingerprint || "");
|
|
385
|
+
if (fingerprint && !node.fingerprints.includes(fingerprint)) node.fingerprints.push(fingerprint);
|
|
386
|
+
if (platform === "web") {
|
|
387
|
+
const route = observedWebRoute(raw.url || raw.route || "");
|
|
388
|
+
if (route && !node.routes.some((item) => item.platform === route.platform && item.path === route.path)) node.routes.push(route);
|
|
389
|
+
}
|
|
390
|
+
if (recordStateObservation) node.observation.count += 1;
|
|
391
|
+
const rawControls = [
|
|
392
|
+
...(Array.isArray(raw.controls) ? raw.controls : []),
|
|
393
|
+
...(Array.isArray(raw.inputs) ? raw.inputs.map((input) => ({ ...input, kind: input.secure ? "secureField" : "field" })) : []),
|
|
394
|
+
];
|
|
395
|
+
for (const rawControl of rawControls) {
|
|
396
|
+
const control = normalizeControl(rawControl, id, platform);
|
|
397
|
+
if (!control) continue;
|
|
398
|
+
const prior = node.controls.find((item) => item.id === control.id || item.semanticKey === control.semanticKey || semanticUiKey(item.label) === semanticUiKey(control.label));
|
|
399
|
+
if (!prior) node.controls.push(control);
|
|
400
|
+
else {
|
|
401
|
+
prior.platforms = mergeUnique([...prior.platforms, ...control.platforms]);
|
|
402
|
+
prior.selectors = mergeUnique([...prior.selectors, ...control.selectors], (selector) => `${selector.kind}:${selector.value}`);
|
|
403
|
+
prior.enabled ||= control.enabled;
|
|
404
|
+
prior.hittable ||= control.hittable;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return node;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const addEdge = (raw, explicitFrom = null, explicitTo = null) => {
|
|
411
|
+
if (!raw || raw.changed === false || raw.to === "pending") return;
|
|
412
|
+
const from = explicitFrom || ensureNode({ screen: raw.from, role: raw.fromRole });
|
|
413
|
+
const to = explicitTo || ensureNode({ screen: raw.to, role: raw.toRole });
|
|
414
|
+
if (!from || !to || from.id === to.id) return;
|
|
415
|
+
const observedTarget = redactUiText(raw.action || raw.target || "transition");
|
|
416
|
+
const descriptor = observedTarget.match(/^(id|label):(.+)$/i);
|
|
417
|
+
const target = redactUiText(descriptor?.[2] || observedTarget);
|
|
418
|
+
const actionType = semanticUiKey(raw.type || (target.toLowerCase().includes("back") ? "back" : "tap"));
|
|
419
|
+
const id = stableId("edge", `${from.id}|${actionType}|${semanticUiKey(target)}|${to.id}`);
|
|
420
|
+
// Drivers may emit a state change before their explicit transition marker.
|
|
421
|
+
// Reconcile that inferred edge with the later confirmation by semantic
|
|
422
|
+
// target/endpoints; the marker's missing action type must not create a
|
|
423
|
+
// second edge for the same observed transition.
|
|
424
|
+
const existing = edgeById.get(id) || [...edgeById.values()].find((edge) =>
|
|
425
|
+
edge.from === from.id &&
|
|
426
|
+
edge.to === to.id &&
|
|
427
|
+
semanticUiKey(edge.action?.target) === semanticUiKey(target)
|
|
428
|
+
);
|
|
429
|
+
if (existing) {
|
|
430
|
+
existing.confirmed = true;
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const preparation = (raw.preparation || []).map(preparationFromAction).filter(Boolean);
|
|
434
|
+
edgeById.set(id, {
|
|
435
|
+
id, from: from.id, to: to.id, status: "observed",
|
|
436
|
+
platforms: [platform],
|
|
437
|
+
action: {
|
|
438
|
+
type: actionType,
|
|
439
|
+
target,
|
|
440
|
+
selectors: target ? [{ kind: descriptor?.[1]?.toLowerCase() === "id" ? (platform === "android" ? "resourceId" : "accessibilityId") : "label", value: target }] : [],
|
|
441
|
+
},
|
|
442
|
+
preconditions: preparation.map((action) => ({ type: "field-populated", target: action.target })), outcomes: [{ type: "screen", value: to.semanticKey }],
|
|
443
|
+
...(preparation.length ? { preparation } : {}),
|
|
444
|
+
wait: { type: "condition", timeoutMs: 6000 }, actors: [], sourcePaths: [],
|
|
445
|
+
coveredBy: { tasks: [], contracts: [] }, observation: observation(runId, timestamp),
|
|
446
|
+
...(raw.confirmed === true ? { confirmed: true } : {}),
|
|
447
|
+
});
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
for (const marker of parsed) {
|
|
451
|
+
if (marker.kind === "STATE") {
|
|
452
|
+
const node = ensureNode(marker.value, true);
|
|
453
|
+
if (node) {
|
|
454
|
+
if (pendingTransition) {
|
|
455
|
+
addEdge(pendingTransition.raw, pendingTransition.from, node);
|
|
456
|
+
pendingTransition = null;
|
|
457
|
+
}
|
|
458
|
+
entryState ||= node;
|
|
459
|
+
previousState = lastState;
|
|
460
|
+
if (!lastState || lastState.id !== node.id) stateActions = [];
|
|
461
|
+
lastState = node;
|
|
462
|
+
actionsSinceState = 0;
|
|
463
|
+
}
|
|
464
|
+
} else if (marker.kind === "NAVIGATION_ROOT") {
|
|
465
|
+
const node = ensureNode(marker.value, true);
|
|
466
|
+
if (node) {
|
|
467
|
+
if (pendingTransition) {
|
|
468
|
+
addEdge(pendingTransition.raw, pendingTransition.from, node);
|
|
469
|
+
pendingTransition = null;
|
|
470
|
+
}
|
|
471
|
+
previousState = lastState;
|
|
472
|
+
if (!lastState || lastState.id !== node.id) stateActions = [];
|
|
473
|
+
lastState = node;
|
|
474
|
+
navigationRoot = node;
|
|
475
|
+
actionsSinceState = 0;
|
|
476
|
+
}
|
|
477
|
+
} else if (marker.kind === "ACTION") {
|
|
478
|
+
const action = {
|
|
479
|
+
...marker.value,
|
|
480
|
+
// Browser BFS opens a route but records the semantic link label in
|
|
481
|
+
// `via`; map the user action, not the transport pathname.
|
|
482
|
+
target: marker.value.type === "open" && marker.value.via ? marker.value.via : marker.value.target,
|
|
483
|
+
screen: redactUiText(marker.value.screen || lastState?.name || ""),
|
|
484
|
+
};
|
|
485
|
+
actions.push(action);
|
|
486
|
+
stateActions.push(action);
|
|
487
|
+
actionsSinceState += 1;
|
|
488
|
+
const screenNode = lastState && semanticUiKey(lastState.name) === semanticUiKey(action.screen)
|
|
489
|
+
? lastState
|
|
490
|
+
: ensureNode({ screen: action.screen, role: lastState?.roles?.[0] });
|
|
491
|
+
const control = screenNode && inferredControl(action, screenNode.id, platform);
|
|
492
|
+
if (control) {
|
|
493
|
+
const existing = screenNode.controls.find((item) => item.id === control.id || item.semanticKey === control.semanticKey || semanticUiKey(item.label) === semanticUiKey(control.label));
|
|
494
|
+
if (!existing) screenNode.controls.push(control);
|
|
495
|
+
else existing.selectors = mergeUnique([...existing.selectors, ...control.selectors], (selector) => `${selector.kind}:${selector.value}`);
|
|
496
|
+
}
|
|
497
|
+
} else if (marker.kind === "TRANSITION" || marker.kind === "TRANSITION_RESOLVED") {
|
|
498
|
+
const targetKey = semanticUiKey(marker.value.action || marker.value.target || "transition");
|
|
499
|
+
const observedAction = [...actions].reverse().find((candidate) => semanticUiKey(candidate.target) === targetKey);
|
|
500
|
+
// Web BFS uses `open` as its transport, but an explicit transition with
|
|
501
|
+
// a semantic link label represents the user's tap on that link.
|
|
502
|
+
const observedType = observedAction?.type === "open" ? "tap" : observedAction?.type;
|
|
503
|
+
const preparation = stateActions.filter((candidate) => candidate !== observedAction).map(preparationFromAction).filter(Boolean);
|
|
504
|
+
const raw = { ...marker.value, type: marker.value.type || observedType, confirmed: true, ...(preparation.length ? { preparation } : {}) };
|
|
505
|
+
if (actionsSinceState > 0 && lastState && semanticUiKey(lastState.name) === semanticUiKey(raw.from)) {
|
|
506
|
+
// Native/iOS markers resolve the action immediately before emitting the destination
|
|
507
|
+
// OCQA_STATE. Defer endpoint binding so same-title structural variants remain distinct.
|
|
508
|
+
pendingTransition = { raw, from: lastState };
|
|
509
|
+
} else if (lastState && semanticUiKey(lastState.name) === semanticUiKey(raw.to)) {
|
|
510
|
+
addEdge(raw, previousState && semanticUiKey(previousState.name) === semanticUiKey(raw.from) ? previousState : null, lastState);
|
|
511
|
+
} else {
|
|
512
|
+
addEdge(raw);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const map = {
|
|
518
|
+
schemaVersion: UI_MAP_SCHEMA_VERSION,
|
|
519
|
+
app: {
|
|
520
|
+
target: redactUiText(target), platforms: [platform], sourceRoot: "",
|
|
521
|
+
entryNodes: entryState ? { [platform]: entryState.id } : {},
|
|
522
|
+
navigationRoots: (navigationRoot || entryState) ? { [platform]: (navigationRoot || entryState).id } : {},
|
|
523
|
+
},
|
|
524
|
+
provenance: { generatedBy: "tapp", runIds: runId ? [runId] : [], builds: build ? [redactUiText(build)] : [], firstObservedAt: timestamp, lastObservedAt: timestamp },
|
|
525
|
+
nodes: [...nodeById.values()], edges: [...edgeById.values()],
|
|
526
|
+
coverage: { tasks: [], contracts: [], uncoveredNodeIds: [], uncoveredEdgeIds: [] },
|
|
527
|
+
};
|
|
528
|
+
map.coverage.uncoveredNodeIds = map.nodes.map((node) => node.id);
|
|
529
|
+
map.coverage.uncoveredEdgeIds = map.edges.map((edge) => edge.id);
|
|
530
|
+
return sortMap(map);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function mergeObservation(a, b) {
|
|
534
|
+
return {
|
|
535
|
+
runIds: mergeUnique([...(a?.runIds || []), ...(b?.runIds || [])]).sort(),
|
|
536
|
+
firstObservedAt: [a?.firstObservedAt, b?.firstObservedAt].filter(Boolean).sort()[0] || "",
|
|
537
|
+
lastObservedAt: [a?.lastObservedAt, b?.lastObservedAt].filter(Boolean).sort().at(-1) || "",
|
|
538
|
+
count: Number(a?.count || 0) + Number(b?.count || 0),
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export function mergeUiMaps(existing, observed) {
|
|
543
|
+
if (!existing) return structuredClone(observed);
|
|
544
|
+
if (existing.schemaVersion !== UI_MAP_SCHEMA_VERSION || observed.schemaVersion !== UI_MAP_SCHEMA_VERSION) throw new Error("Unsupported UI Map schema version");
|
|
545
|
+
const result = structuredClone(existing);
|
|
546
|
+
result.app ||= { target: observed.app?.target || "", platforms: [], sourceRoot: "", entryNodes: {}, navigationRoots: {} };
|
|
547
|
+
// Preserve the established repository identity, but let a later target-aware
|
|
548
|
+
// observation fill metadata that an earlier capture could not know.
|
|
549
|
+
result.app.target ||= observed.app?.target || "";
|
|
550
|
+
result.app.sourceRoot ||= observed.app?.sourceRoot || "";
|
|
551
|
+
result.app.platforms = mergeUnique([...(existing.app?.platforms || []), ...(observed.app?.platforms || [])]).sort();
|
|
552
|
+
result.app.entryNodes = { ...(existing.app?.entryNodes || {}), ...(observed.app?.entryNodes || {}) };
|
|
553
|
+
result.app.navigationRoots = { ...(existing.app?.navigationRoots || {}), ...(observed.app?.navigationRoots || {}) };
|
|
554
|
+
result.provenance = {
|
|
555
|
+
...existing.provenance,
|
|
556
|
+
runIds: mergeUnique([...(existing.provenance?.runIds || []), ...(observed.provenance?.runIds || [])]).sort(),
|
|
557
|
+
builds: mergeUnique([...(existing.provenance?.builds || []), ...(observed.provenance?.builds || [])]).sort(),
|
|
558
|
+
firstObservedAt: [existing.provenance?.firstObservedAt, observed.provenance?.firstObservedAt].filter(Boolean).sort()[0] || "",
|
|
559
|
+
lastObservedAt: [existing.provenance?.lastObservedAt, observed.provenance?.lastObservedAt].filter(Boolean).sort().at(-1) || "",
|
|
560
|
+
};
|
|
561
|
+
for (const incoming of observed.nodes) {
|
|
562
|
+
const prior = result.nodes.find((node) => node.id === incoming.id);
|
|
563
|
+
if (!prior) { result.nodes.push(structuredClone(incoming)); continue; }
|
|
564
|
+
prior.aliases = mergeUnique([...prior.aliases, ...incoming.aliases]);
|
|
565
|
+
prior.platforms = mergeUnique([...prior.platforms, ...incoming.platforms]);
|
|
566
|
+
prior.roles = mergeUnique([...prior.roles, ...incoming.roles]);
|
|
567
|
+
prior.fingerprints = mergeUnique([...prior.fingerprints, ...incoming.fingerprints]);
|
|
568
|
+
prior.routes = mergeUnique([...(prior.routes || []), ...(incoming.routes || [])], (route) => `${route.platform}:${route.path}`);
|
|
569
|
+
prior.observation = mergeObservation(prior.observation, incoming.observation);
|
|
570
|
+
for (const control of incoming.controls) {
|
|
571
|
+
const oldControl = prior.controls.find((item) => item.id === control.id);
|
|
572
|
+
if (!oldControl) prior.controls.push(structuredClone(control));
|
|
573
|
+
else {
|
|
574
|
+
oldControl.platforms = mergeUnique([...oldControl.platforms, ...control.platforms]);
|
|
575
|
+
oldControl.selectors = mergeUnique([...oldControl.selectors, ...control.selectors], (selector) => `${selector.kind}:${selector.value}`);
|
|
576
|
+
oldControl.status = "observed";
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
prior.status = "observed";
|
|
580
|
+
}
|
|
581
|
+
for (const incoming of observed.edges) {
|
|
582
|
+
const prior = result.edges.find((edge) => edge.id === incoming.id);
|
|
583
|
+
if (!prior) result.edges.push(structuredClone(incoming));
|
|
584
|
+
else {
|
|
585
|
+
prior.observation = mergeObservation(prior.observation, incoming.observation);
|
|
586
|
+
prior.platforms = mergeUnique([...(prior.platforms || []), ...(incoming.platforms || [])]).sort();
|
|
587
|
+
prior.status = "observed";
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
result.coverage.uncoveredNodeIds = result.nodes.filter((node) => !(node.coveredBy?.tasks || []).length && !(node.coveredBy?.contracts || []).length).map((node) => node.id).sort();
|
|
591
|
+
result.coverage.uncoveredEdgeIds = result.edges.filter((edge) => !(edge.coveredBy?.tasks || []).length && !(edge.coveredBy?.contracts || []).length).map((edge) => edge.id).sort();
|
|
592
|
+
return sortMap(result);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export function diffUiMaps(previous, current, { comparableFullSweep = false } = {}) {
|
|
596
|
+
const prevNodes = new Map((previous?.nodes || []).map((node) => [node.id, node]));
|
|
597
|
+
const currNodes = new Map((current?.nodes || []).map((node) => [node.id, node]));
|
|
598
|
+
const prevEdges = new Map((previous?.edges || []).map((edge) => [edge.id, edge]));
|
|
599
|
+
const currEdges = new Map((current?.edges || []).map((edge) => [edge.id, edge]));
|
|
600
|
+
const addedNodes = [...currNodes.keys()].filter((id) => !prevNodes.has(id));
|
|
601
|
+
const notObservedNodes = [...prevNodes.keys()].filter((id) => !currNodes.has(id));
|
|
602
|
+
const addedEdges = [...currEdges.keys()].filter((id) => !prevEdges.has(id));
|
|
603
|
+
const notObservedEdges = [...prevEdges.keys()].filter((id) => !currEdges.has(id));
|
|
604
|
+
const changedControls = [];
|
|
605
|
+
for (const [id, currentNode] of currNodes) {
|
|
606
|
+
const prior = prevNodes.get(id);
|
|
607
|
+
if (!prior) continue;
|
|
608
|
+
const oldIds = new Set(prior.controls.map((control) => control.id));
|
|
609
|
+
const newIds = new Set(currentNode.controls.map((control) => control.id));
|
|
610
|
+
const added = [...newIds].filter((controlId) => !oldIds.has(controlId));
|
|
611
|
+
const notObserved = [...oldIds].filter((controlId) => !newIds.has(controlId));
|
|
612
|
+
if (added.length || notObserved.length) changedControls.push({ nodeId: id, added, notObserved });
|
|
613
|
+
}
|
|
614
|
+
return {
|
|
615
|
+
comparableFullSweep,
|
|
616
|
+
addedNodes: addedNodes.sort(),
|
|
617
|
+
notObservedNodes: notObservedNodes.sort(),
|
|
618
|
+
lostReachability: comparableFullSweep ? notObservedNodes.sort() : [],
|
|
619
|
+
addedEdges: addedEdges.sort(),
|
|
620
|
+
notObservedEdges: notObservedEdges.sort(),
|
|
621
|
+
lostTransitions: comparableFullSweep ? notObservedEdges.sort() : [],
|
|
622
|
+
changedControls,
|
|
623
|
+
requiresReview: changedControls.some((change) => change.notObserved.length > 0) || (comparableFullSweep && (notObservedNodes.length > 0 || notObservedEdges.length > 0)),
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export function validateUiMap(map) {
|
|
628
|
+
const errors = [];
|
|
629
|
+
if (!map || typeof map !== "object") return ["UI Map must be an object"];
|
|
630
|
+
if (map.schemaVersion !== UI_MAP_SCHEMA_VERSION) errors.push(`schemaVersion must be ${UI_MAP_SCHEMA_VERSION}`);
|
|
631
|
+
if (!Array.isArray(map.nodes)) errors.push("nodes must be an array");
|
|
632
|
+
if (!Array.isArray(map.edges)) errors.push("edges must be an array");
|
|
633
|
+
const nodeIds = new Set((map.nodes || []).map((node) => node.id));
|
|
634
|
+
if (nodeIds.size !== (map.nodes || []).length) errors.push("node ids must be unique");
|
|
635
|
+
const edgeIds = new Set();
|
|
636
|
+
for (const [kind, references] of [["entry", map.app?.entryNodes], ["navigation root", map.app?.navigationRoots]]) {
|
|
637
|
+
for (const [platform, nodeId] of Object.entries(references || {})) {
|
|
638
|
+
if (!platform || !nodeIds.has(nodeId)) errors.push(`${kind} for ${platform || "unknown platform"} references a missing node`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
for (const node of map.nodes || []) for (const route of node.routes || []) {
|
|
642
|
+
if (!route || typeof route !== "object" || !["web"].includes(route.platform) || typeof route.path !== "string" || !route.path.startsWith("/")) {
|
|
643
|
+
errors.push(`node ${node.id} has an invalid observed route`);
|
|
644
|
+
}
|
|
645
|
+
if (route.replayable !== true && route.replayable !== false) errors.push(`node ${node.id} route replayable must be boolean`);
|
|
646
|
+
}
|
|
647
|
+
for (const edge of map.edges || []) {
|
|
648
|
+
if (edgeIds.has(edge.id)) errors.push(`duplicate edge id: ${edge.id}`);
|
|
649
|
+
edgeIds.add(edge.id);
|
|
650
|
+
if (!nodeIds.has(edge.from) || !nodeIds.has(edge.to)) errors.push(`edge ${edge.id} references a missing node`);
|
|
651
|
+
}
|
|
652
|
+
return errors;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export function writeUiMap(outPath, map) {
|
|
656
|
+
const errors = validateUiMap(map);
|
|
657
|
+
if (errors.length) throw new Error(`Invalid UI Map: ${errors.join("; ")}`);
|
|
658
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
659
|
+
fs.writeFileSync(outPath, JSON.stringify(sortMap(structuredClone(map)), null, 2) + "\n");
|
|
660
|
+
return outPath;
|
|
661
|
+
}
|