@veolab/discoverylab 1.2.1 → 1.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/.mcp.json +2 -2
- package/README.md +182 -0
- package/dist/{chunk-TAODYZ52.js → chunk-3QRQEDWR.js} +510 -213
- package/dist/{chunk-L4SA5F5W.js → chunk-4L76GPRC.js} +1162 -58
- package/dist/chunk-6EGBXRDK.js +30 -0
- package/dist/{chunk-I6YD3QFM.js → chunk-FIL7IWEL.js} +5 -3
- package/dist/{chunk-4KLG6DDE.js → chunk-FNUN7EPB.js} +6 -6
- package/dist/chunk-GAKEFJ5T.js +481 -0
- package/dist/chunk-LB3RNE3O.js +109 -0
- package/dist/chunk-N6JJ2RGV.js +2680 -0
- package/dist/{chunk-XUKWS2CE.js → chunk-VRM42PML.js} +3546 -926
- package/dist/{chunk-TJ3H23LL.js → chunk-VVIOB362.js} +3 -1
- package/dist/{chunk-W3WJGYR6.js → chunk-XFVDP332.js} +8 -2
- package/dist/{chunk-QJXXHOV7.js → chunk-XKX6NBHF.js} +5 -1
- package/dist/cli.js +405 -11
- package/dist/{db-ADBEBNH6.js → db-6WLEVKUV.js} +3 -1
- package/dist/esvp-GSISVXLC.js +52 -0
- package/dist/esvp-mobile-GC7MAGMI.js +20 -0
- package/dist/index.d.ts +123 -1
- package/dist/index.html +11689 -8690
- package/dist/index.js +67 -11
- package/dist/{ocr-UTWC7537.js → ocr-QDYNCSPE.js} +1 -1
- package/dist/{playwright-R7Y5HREH.js → playwright-VZ7PXDC5.js} +2 -2
- package/dist/runtime/esvp-host-runtime/darwin-arm64/esvp-host-runtime +0 -0
- package/dist/runtime/esvp-host-runtime/manifest.json +10 -0
- package/dist/{server-3FBHBA7L.js → server-6N3KIEGP.js} +2 -1
- package/dist/server-FO3UVUZU.js +22 -0
- package/dist/{setup-27CQAX6K.js → setup-2SQC5UHJ.js} +4 -3
- package/dist/{tools-L6PKKQPY.js → tools-OCRMOQ4U.js} +63 -8
- package/package.json +36 -5
- package/dist/chunk-22OCFYHG.js +0 -6283
- package/dist/chunk-24VARQVO.js +0 -7818
- package/dist/chunk-2OGZX6C4.js +0 -588
- package/dist/chunk-2WCNIFRO.js +0 -6191
- package/dist/chunk-43U6UYV7.js +0 -590
- package/dist/chunk-4H2E3K2G.js +0 -7638
- package/dist/chunk-4MS6YW2B.js +0 -6490
- package/dist/chunk-4NNTRJOI.js +0 -7791
- package/dist/chunk-5F76VWME.js +0 -6397
- package/dist/chunk-5NEFN42O.js +0 -7791
- package/dist/chunk-63MEQ6UH.js +0 -7673
- package/dist/chunk-6H3NXFX3.js +0 -6861
- package/dist/chunk-7IDQLLBW.js +0 -311
- package/dist/chunk-7NP64TGJ.js +0 -6822
- package/dist/chunk-AATLY4KT.js +0 -6505
- package/dist/chunk-C7QUR7XX.js +0 -6397
- package/dist/chunk-CGKCE6MC.js +0 -6279
- package/dist/chunk-D25V6IWE.js +0 -6487
- package/dist/chunk-EQOZSXAT.js +0 -6822
- package/dist/chunk-FPHD7HSQ.js +0 -6812
- package/dist/chunk-GGJJUCFK.js +0 -7160
- package/dist/chunk-GLHOY3NN.js +0 -7805
- package/dist/chunk-GML5MKQA.js +0 -6398
- package/dist/chunk-GOL6FUJL.js +0 -6045
- package/dist/chunk-GSWHWEYC.js +0 -1346
- package/dist/chunk-HDKEQOF5.js +0 -7788
- package/dist/chunk-HZGSWVVS.js +0 -7111
- package/dist/chunk-IGZ5TICZ.js +0 -334
- package/dist/chunk-IRKQG33A.js +0 -7054
- package/dist/chunk-JFTBF4JR.js +0 -6040
- package/dist/chunk-JVLVBPUJ.js +0 -6180
- package/dist/chunk-JY3KC67R.js +0 -6504
- package/dist/chunk-KUFBCBNJ.js +0 -6815
- package/dist/chunk-KV7KDJ43.js +0 -7639
- package/dist/chunk-L5IJZV5F.js +0 -6822
- package/dist/chunk-MFFPQLU4.js +0 -7102
- package/dist/chunk-MJS2YKNR.js +0 -6397
- package/dist/chunk-MN6LCZHZ.js +0 -1320
- package/dist/chunk-NBAUZ7X2.js +0 -1336
- package/dist/chunk-NDBW6ELQ.js +0 -7638
- package/dist/chunk-O2HBSDI2.js +0 -6175
- package/dist/chunk-OFFIUYMG.js +0 -6341
- package/dist/chunk-OVCQGF2J.js +0 -1321
- package/dist/chunk-P4S7ZY6G.js +0 -7638
- package/dist/chunk-PBHUHSC3.js +0 -6002
- package/dist/chunk-PC4LR4ZI.js +0 -6359
- package/dist/chunk-PMTGGZ7R.js +0 -6397
- package/dist/chunk-PTXSB3UV.js +0 -497
- package/dist/chunk-PYUCY3U6.js +0 -1340
- package/dist/chunk-RDZDSOAL.js +0 -7750
- package/dist/chunk-RLW2OI2L.js +0 -6383
- package/dist/chunk-RUGHHO4K.js +0 -6395
- package/dist/chunk-SIOQVM2E.js +0 -6819
- package/dist/chunk-SR67SRIT.js +0 -1336
- package/dist/chunk-SSRXIO2V.js +0 -6822
- package/dist/chunk-SWSEKFON.js +0 -6487
- package/dist/chunk-TBG76CYG.js +0 -6395
- package/dist/chunk-V3CBINLD.js +0 -6812
- package/dist/chunk-VPYSLEGM.js +0 -6710
- package/dist/chunk-VY3BLXBW.js +0 -329
- package/dist/chunk-WTFOGVJQ.js +0 -6365
- package/dist/chunk-X64SFUT5.js +0 -6099
- package/dist/chunk-XIBF5LBD.js +0 -6395
- package/dist/chunk-Y5VDMSYC.js +0 -6701
- package/dist/chunk-YUBL36H4.js +0 -6605
- package/dist/chunk-YWVXFVSW.js +0 -6456
- package/dist/chunk-ZXZACOLD.js +0 -6822
- package/dist/db-IWIL65EX.js +0 -33
- package/dist/gridCompositor-ENKLFPWR.js +0 -409
- package/dist/playwright-A3OGSDRG.js +0 -38
- package/dist/playwright-ATDC4NYW.js +0 -38
- package/dist/playwright-E6EUFIJG.js +0 -38
- package/dist/server-2DXLKLFM.js +0 -13
- package/dist/server-2ICEWJVK.js +0 -13
- package/dist/server-2MQV3FNY.js +0 -13
- package/dist/server-2NGD7GE3.js +0 -13
- package/dist/server-2VKO76UK.js +0 -14
- package/dist/server-3BK2VFU7.js +0 -13
- package/dist/server-4LDOB3NX.js +0 -13
- package/dist/server-4YI44KDR.js +0 -13
- package/dist/server-64XMXA5P.js +0 -13
- package/dist/server-6IPHVUYT.js +0 -14
- package/dist/server-73ORHMJN.js +0 -13
- package/dist/server-73P7M3QB.js +0 -14
- package/dist/server-BPVRW5LJ.js +0 -14
- package/dist/server-BW4RKZIX.js +0 -13
- package/dist/server-CFS5SM5K.js +0 -13
- package/dist/server-DX7VYHHM.js +0 -13
- package/dist/server-F3YPX6ET.js +0 -13
- package/dist/server-FUXTR33I.js +0 -13
- package/dist/server-G2SY3DOS.js +0 -13
- package/dist/server-G32U7VOQ.js +0 -13
- package/dist/server-IOOZK4NP.js +0 -14
- package/dist/server-J52LMTBT.js +0 -13
- package/dist/server-JG7UKFGK.js +0 -14
- package/dist/server-JSCHEBOD.js +0 -13
- package/dist/server-K6KC4ZOM.js +0 -13
- package/dist/server-KJVRGWFE.js +0 -13
- package/dist/server-LCPB2L4U.js +0 -13
- package/dist/server-M7LDYKAJ.js +0 -13
- package/dist/server-MKVK6ZQQ.js +0 -13
- package/dist/server-MU52LCXT.js +0 -13
- package/dist/server-NM5CKDUU.js +0 -13
- package/dist/server-NPZN3FWO.js +0 -14
- package/dist/server-O5FIAHSY.js +0 -14
- package/dist/server-OESJUEYC.js +0 -13
- package/dist/server-ONSKQO4W.js +0 -13
- package/dist/server-P27BZXBL.js +0 -14
- package/dist/server-Q4FBWQUA.js +0 -13
- package/dist/server-RNQ7VUAL.js +0 -13
- package/dist/server-S6B5WUBT.js +0 -14
- package/dist/server-SRYNSGSP.js +0 -14
- package/dist/server-SUN3W2YK.js +0 -13
- package/dist/server-UA62LHZB.js +0 -13
- package/dist/server-UJB44EW5.js +0 -13
- package/dist/server-X3TLP6DX.js +0 -14
- package/dist/server-YT2UGEZK.js +0 -13
- package/dist/server-ZBPQ33V6.js +0 -14
- package/dist/setup-AQX4JQVR.js +0 -17
- package/dist/setup-EQTU7FI6.js +0 -17
- package/dist/tools-2KPB37GK.js +0 -178
- package/dist/tools-3H6IOWXV.js +0 -178
- package/dist/tools-3KYHPDCJ.js +0 -178
- package/dist/tools-75BAPCUM.js +0 -177
- package/dist/tools-BUVCUCRL.js +0 -178
- package/dist/tools-HDNODRS6.js +0 -178
- package/dist/tools-HP5MNY3D.js +0 -177
- package/dist/tools-N5N2IO7V.js +0 -178
- package/dist/tools-NFJEZ2FF.js +0 -177
- package/dist/tools-TLCKABUW.js +0 -178
|
@@ -0,0 +1,2680 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildAppLabNetworkProfile,
|
|
3
|
+
inferAppLabExternalProxyHost
|
|
4
|
+
} from "./chunk-LB3RNE3O.js";
|
|
5
|
+
import {
|
|
6
|
+
attachESVPNetworkTrace,
|
|
7
|
+
clearESVPNetwork,
|
|
8
|
+
configureESVPNetwork,
|
|
9
|
+
createESVPSession,
|
|
10
|
+
finishESVPSession,
|
|
11
|
+
getESVPArtifactContent,
|
|
12
|
+
getESVPConnection,
|
|
13
|
+
getESVPReplayConsistency,
|
|
14
|
+
getESVPSessionNetwork,
|
|
15
|
+
inspectESVPSession,
|
|
16
|
+
replayESVPSession,
|
|
17
|
+
runESVPActions
|
|
18
|
+
} from "./chunk-GAKEFJ5T.js";
|
|
19
|
+
import {
|
|
20
|
+
redactSensitiveTestInput
|
|
21
|
+
} from "./chunk-SLNJEF32.js";
|
|
22
|
+
import {
|
|
23
|
+
recognizeText
|
|
24
|
+
} from "./chunk-XFVDP332.js";
|
|
25
|
+
|
|
26
|
+
// src/core/analyze/aiActionDetector.ts
|
|
27
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
28
|
+
import { readFileSync, readdirSync } from "fs";
|
|
29
|
+
import { join } from "path";
|
|
30
|
+
function buildAnalysisPrompt(screenshotCount) {
|
|
31
|
+
return `You are analyzing a sequence of ${screenshotCount} mobile app screenshots taken during a user testing session. Your task is to detect what actions the user performed between each screenshot.
|
|
32
|
+
|
|
33
|
+
Analyze the visual changes between consecutive screenshots and identify:
|
|
34
|
+
1. Taps on buttons, links, or UI elements
|
|
35
|
+
2. Text input in fields
|
|
36
|
+
3. Scroll/swipe gestures
|
|
37
|
+
4. Navigation actions (back, etc.)
|
|
38
|
+
5. App launches or screen transitions
|
|
39
|
+
|
|
40
|
+
For each detected action, determine:
|
|
41
|
+
- The type of action (tap, type, scroll, swipe, back, launch, wait)
|
|
42
|
+
- A short human description of what happened
|
|
43
|
+
- For tap actions, set "element" to the shortest exact visible label on screen when possible. Examples: "Looks", "View Plans", "Continue". Do not use descriptive phrases like "Looks tab in bottom navigation" unless that full phrase is literally visible.
|
|
44
|
+
- The approximate screen coordinates if it's a tap (as percentage of screen, e.g., x: 50, y: 30 means center-top)
|
|
45
|
+
- Any text that was typed
|
|
46
|
+
- Scroll/swipe direction
|
|
47
|
+
|
|
48
|
+
Respond in JSON format:
|
|
49
|
+
{
|
|
50
|
+
"appName": "detected app name or null",
|
|
51
|
+
"actions": [
|
|
52
|
+
{
|
|
53
|
+
"type": "tap|type|scroll|swipe|back|launch|wait",
|
|
54
|
+
"description": "what the action does",
|
|
55
|
+
"element": "button text or element description",
|
|
56
|
+
"text": "typed text if type action",
|
|
57
|
+
"coordinates": {"x": 50, "y": 75},
|
|
58
|
+
"direction": "up|down|left|right for scroll/swipe",
|
|
59
|
+
"confidence": 0.0-1.0
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
"summary": "brief summary of the user flow"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
Be conservative - only include actions you're confident about (confidence > 0.7).
|
|
66
|
+
Focus on meaningful interactions, not every tiny change.
|
|
67
|
+
Return ONLY valid JSON (no Markdown, no code fences, no extra commentary).`;
|
|
68
|
+
}
|
|
69
|
+
function buildVisibleActionSelectionPrompt(candidates) {
|
|
70
|
+
return `You are looking at a single mobile app screenshot.
|
|
71
|
+
|
|
72
|
+
Choose which of the candidate UI actions is currently visible and tappable on screen right now.
|
|
73
|
+
|
|
74
|
+
Rules:
|
|
75
|
+
- Prefer exact visible button/link/tab labels.
|
|
76
|
+
- If none of the candidates are clearly visible, return null.
|
|
77
|
+
- Do not guess based on what might happen next.
|
|
78
|
+
|
|
79
|
+
Respond in JSON only:
|
|
80
|
+
{
|
|
81
|
+
"selectedLabel": "one of the candidate labels exactly as provided, or null",
|
|
82
|
+
"confidence": 0.0-1.0,
|
|
83
|
+
"reason": "brief explanation"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
Candidates:
|
|
87
|
+
${candidates.map((candidate, index) => `${index + 1}. ${candidate}`).join("\n")}`;
|
|
88
|
+
}
|
|
89
|
+
function normalizeWhitespace(value) {
|
|
90
|
+
return String(value || "").replace(/\s+/g, " ").trim();
|
|
91
|
+
}
|
|
92
|
+
function extractQuotedLabel(value) {
|
|
93
|
+
const text = normalizeWhitespace(value);
|
|
94
|
+
if (!text) return "";
|
|
95
|
+
const match = text.match(/["“”']([^"“”']{2,80})["“”']/);
|
|
96
|
+
return normalizeWhitespace(match?.[1]);
|
|
97
|
+
}
|
|
98
|
+
function normalizeTapElementLabel(value) {
|
|
99
|
+
const raw = normalizeWhitespace(value);
|
|
100
|
+
if (!raw) return "";
|
|
101
|
+
const quoted = extractQuotedLabel(raw);
|
|
102
|
+
if (quoted) return quoted;
|
|
103
|
+
const variants = [];
|
|
104
|
+
const seen = /* @__PURE__ */ new Set();
|
|
105
|
+
const push = (candidate) => {
|
|
106
|
+
const normalized = normalizeWhitespace(candidate);
|
|
107
|
+
if (!normalized || seen.has(normalized)) return;
|
|
108
|
+
seen.add(normalized);
|
|
109
|
+
variants.push(normalized);
|
|
110
|
+
};
|
|
111
|
+
push(raw);
|
|
112
|
+
push(raw.replace(/\s+(?:in|inside|within|from|on)\s+.+$/i, ""));
|
|
113
|
+
push(raw.replace(/\s+(?:button|tab|icon|link|banner|card|item|field|input|modal|sheet|screen|section|row)$/i, ""));
|
|
114
|
+
push(
|
|
115
|
+
raw.split(/\s+/).filter((part) => !["my", "the", "a", "an", "to", "for", "of"].includes(part.toLowerCase())).join(" ")
|
|
116
|
+
);
|
|
117
|
+
return variants[variants.length - 1] || variants[0] || raw;
|
|
118
|
+
}
|
|
119
|
+
function normalizeDetectedAction(action) {
|
|
120
|
+
const normalized = {
|
|
121
|
+
...action,
|
|
122
|
+
description: normalizeWhitespace(action.description),
|
|
123
|
+
element: normalizeWhitespace(action.element),
|
|
124
|
+
text: normalizeWhitespace(action.text)
|
|
125
|
+
};
|
|
126
|
+
if (normalized.type === "tap") {
|
|
127
|
+
const literalElement = normalizeTapElementLabel(normalized.element || normalized.description);
|
|
128
|
+
if (literalElement) {
|
|
129
|
+
normalized.element = literalElement;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return normalized;
|
|
133
|
+
}
|
|
134
|
+
function normalizeDetectedActions(actions) {
|
|
135
|
+
return (actions || []).map(normalizeDetectedAction);
|
|
136
|
+
}
|
|
137
|
+
function sanitizePreview(text, maxLength = 400) {
|
|
138
|
+
const normalized = String(text || "").replace(/\s+/g, " ").trim();
|
|
139
|
+
if (!normalized) return "(empty response)";
|
|
140
|
+
return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized;
|
|
141
|
+
}
|
|
142
|
+
function extractCodeFenceCandidates(text) {
|
|
143
|
+
const candidates = [];
|
|
144
|
+
const fenceRegex = /```(?:json)?\s*([\s\S]*?)```/gi;
|
|
145
|
+
let match = null;
|
|
146
|
+
while ((match = fenceRegex.exec(text)) !== null) {
|
|
147
|
+
if (match[1]?.trim()) candidates.push(match[1].trim());
|
|
148
|
+
}
|
|
149
|
+
return candidates;
|
|
150
|
+
}
|
|
151
|
+
function extractBraceObjectCandidates(text) {
|
|
152
|
+
const candidates = [];
|
|
153
|
+
let depth = 0;
|
|
154
|
+
let start = -1;
|
|
155
|
+
let inString = false;
|
|
156
|
+
let escaped = false;
|
|
157
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
158
|
+
const ch = text[i];
|
|
159
|
+
if (escaped) {
|
|
160
|
+
escaped = false;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (ch === "\\" && inString) {
|
|
164
|
+
escaped = true;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (ch === '"') {
|
|
168
|
+
inString = !inString;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (inString) continue;
|
|
172
|
+
if (ch === "{") {
|
|
173
|
+
if (depth === 0) start = i;
|
|
174
|
+
depth += 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (ch === "}") {
|
|
178
|
+
if (depth > 0) depth -= 1;
|
|
179
|
+
if (depth === 0 && start >= 0) {
|
|
180
|
+
const candidate = text.slice(start, i + 1).trim();
|
|
181
|
+
if (candidate) candidates.push(candidate);
|
|
182
|
+
start = -1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return candidates;
|
|
187
|
+
}
|
|
188
|
+
function coerceAnalysisResult(parsed) {
|
|
189
|
+
if (!parsed || typeof parsed !== "object") {
|
|
190
|
+
throw new Error("Parsed response is not an object");
|
|
191
|
+
}
|
|
192
|
+
const obj = parsed;
|
|
193
|
+
return {
|
|
194
|
+
actions: Array.isArray(obj.actions) ? obj.actions : [],
|
|
195
|
+
appName: typeof obj.appName === "string" ? obj.appName : void 0,
|
|
196
|
+
summary: typeof obj.summary === "string" ? obj.summary : "",
|
|
197
|
+
skipped: typeof obj.skipped === "boolean" ? obj.skipped : void 0
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function parseAnalysisResponse(text) {
|
|
201
|
+
const raw = String(text || "").trim();
|
|
202
|
+
if (!raw) {
|
|
203
|
+
throw new Error("Could not parse JSON from response (empty response)");
|
|
204
|
+
}
|
|
205
|
+
const candidates = [raw, ...extractCodeFenceCandidates(raw), ...extractBraceObjectCandidates(raw)];
|
|
206
|
+
const seen = /* @__PURE__ */ new Set();
|
|
207
|
+
let lastError = null;
|
|
208
|
+
for (const candidate of candidates) {
|
|
209
|
+
const normalized = candidate.trim();
|
|
210
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
211
|
+
seen.add(normalized);
|
|
212
|
+
try {
|
|
213
|
+
return coerceAnalysisResult(JSON.parse(normalized));
|
|
214
|
+
} catch (error) {
|
|
215
|
+
lastError = error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const detail = lastError instanceof Error ? lastError.message : "unknown parse error";
|
|
219
|
+
throw new Error(`Could not parse JSON from response (${detail}). Preview: ${sanitizePreview(raw)}`);
|
|
220
|
+
}
|
|
221
|
+
function parseVisibleActionSelectionResponse(text) {
|
|
222
|
+
const raw = String(text || "").trim();
|
|
223
|
+
if (!raw) {
|
|
224
|
+
throw new Error("Could not parse visible action selection (empty response)");
|
|
225
|
+
}
|
|
226
|
+
const candidates = [raw, ...extractCodeFenceCandidates(raw), ...extractBraceObjectCandidates(raw)];
|
|
227
|
+
const seen = /* @__PURE__ */ new Set();
|
|
228
|
+
let lastError = null;
|
|
229
|
+
for (const candidate of candidates) {
|
|
230
|
+
const normalized = candidate.trim();
|
|
231
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
232
|
+
seen.add(normalized);
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(normalized);
|
|
235
|
+
return {
|
|
236
|
+
selectedLabel: typeof parsed.selectedLabel === "string" && parsed.selectedLabel.trim() ? parsed.selectedLabel.trim() : null,
|
|
237
|
+
confidence: typeof parsed.confidence === "number" ? parsed.confidence : void 0,
|
|
238
|
+
reason: typeof parsed.reason === "string" ? parsed.reason : void 0
|
|
239
|
+
};
|
|
240
|
+
} catch (error) {
|
|
241
|
+
lastError = error;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const detail = lastError instanceof Error ? lastError.message : "unknown parse error";
|
|
245
|
+
throw new Error(`Could not parse visible action selection (${detail}). Preview: ${sanitizePreview(raw)}`);
|
|
246
|
+
}
|
|
247
|
+
async function analyzeWithAnthropicVision(apiKey, screenshotPaths, prompt) {
|
|
248
|
+
const anthropic = new Anthropic({ apiKey });
|
|
249
|
+
const imageContents = screenshotPaths.map((imagePath) => {
|
|
250
|
+
const imageData = readFileSync(imagePath);
|
|
251
|
+
const base64 = imageData.toString("base64");
|
|
252
|
+
return {
|
|
253
|
+
type: "image",
|
|
254
|
+
source: {
|
|
255
|
+
type: "base64",
|
|
256
|
+
media_type: "image/png",
|
|
257
|
+
data: base64
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
});
|
|
261
|
+
const response = await anthropic.messages.create({
|
|
262
|
+
model: "claude-sonnet-4-20250514",
|
|
263
|
+
max_tokens: 4096,
|
|
264
|
+
messages: [
|
|
265
|
+
{
|
|
266
|
+
role: "user",
|
|
267
|
+
content: [
|
|
268
|
+
...imageContents,
|
|
269
|
+
{ type: "text", text: prompt }
|
|
270
|
+
]
|
|
271
|
+
}
|
|
272
|
+
]
|
|
273
|
+
});
|
|
274
|
+
const textContent = response.content.find((c) => c.type === "text");
|
|
275
|
+
if (!textContent || textContent.type !== "text") {
|
|
276
|
+
throw new Error("No text response from Claude");
|
|
277
|
+
}
|
|
278
|
+
return parseAnalysisResponse(textContent.text);
|
|
279
|
+
}
|
|
280
|
+
async function selectVisibleActionWithAnthropicVision(apiKey, screenshotPath, prompt) {
|
|
281
|
+
const anthropic = new Anthropic({ apiKey });
|
|
282
|
+
const imageData = readFileSync(screenshotPath);
|
|
283
|
+
const base64 = imageData.toString("base64");
|
|
284
|
+
const response = await anthropic.messages.create({
|
|
285
|
+
model: "claude-sonnet-4-20250514",
|
|
286
|
+
max_tokens: 1024,
|
|
287
|
+
messages: [
|
|
288
|
+
{
|
|
289
|
+
role: "user",
|
|
290
|
+
content: [
|
|
291
|
+
{
|
|
292
|
+
type: "image",
|
|
293
|
+
source: {
|
|
294
|
+
type: "base64",
|
|
295
|
+
media_type: "image/png",
|
|
296
|
+
data: base64
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
{ type: "text", text: prompt }
|
|
300
|
+
]
|
|
301
|
+
}
|
|
302
|
+
]
|
|
303
|
+
});
|
|
304
|
+
const textContent = response.content.find((content) => content.type === "text");
|
|
305
|
+
if (!textContent || textContent.type !== "text") {
|
|
306
|
+
throw new Error("No text response from Claude");
|
|
307
|
+
}
|
|
308
|
+
return parseVisibleActionSelectionResponse(textContent.text);
|
|
309
|
+
}
|
|
310
|
+
async function analyzeScreenshotsForActions(screenshotsDir, maxScreenshots = 20, provider) {
|
|
311
|
+
const files = readdirSync(screenshotsDir).filter((f) => f.endsWith(".png")).sort();
|
|
312
|
+
if (files.length < 2) {
|
|
313
|
+
return {
|
|
314
|
+
actions: [],
|
|
315
|
+
summary: "Not enough screenshots for action detection"
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
const selectedFiles = files.length > maxScreenshots ? sampleArray(files, maxScreenshots) : files;
|
|
319
|
+
const screenshotPaths = selectedFiles.map((file) => join(screenshotsDir, file));
|
|
320
|
+
const prompt = buildAnalysisPrompt(selectedFiles.length);
|
|
321
|
+
const providerCandidates = (Array.isArray(provider) ? provider : [provider]).filter(
|
|
322
|
+
(p) => !!p
|
|
323
|
+
);
|
|
324
|
+
console.log(`[AIActionDetector] Analyzing ${selectedFiles.length} screenshots...`);
|
|
325
|
+
const strategyErrors = [];
|
|
326
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
327
|
+
if (apiKey) {
|
|
328
|
+
try {
|
|
329
|
+
console.log("[AIActionDetector] Using Anthropic API vision (fast path)");
|
|
330
|
+
const result = await analyzeWithAnthropicVision(apiKey, screenshotPaths, prompt);
|
|
331
|
+
result.actions = normalizeDetectedActions(result.actions);
|
|
332
|
+
result.actionDetectionProvider = "anthropic-api vision (claude-sonnet-4-20250514)";
|
|
333
|
+
console.log(`[AIActionDetector] Detected ${result.actions.length} actions`);
|
|
334
|
+
return result;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
337
|
+
strategyErrors.push(`anthropic-api vision: ${message}`);
|
|
338
|
+
console.warn(`[AIActionDetector] Anthropic vision failed: ${message}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
for (const candidate of providerCandidates) {
|
|
342
|
+
if (!candidate.sendMessageWithImages) continue;
|
|
343
|
+
try {
|
|
344
|
+
console.log(`[AIActionDetector] Using provider: ${candidate.name} (image support)`);
|
|
345
|
+
const response = await candidate.sendMessageWithImages(prompt, screenshotPaths);
|
|
346
|
+
const result = parseAnalysisResponse(response);
|
|
347
|
+
result.actions = normalizeDetectedActions(result.actions);
|
|
348
|
+
result.actionDetectionProvider = candidate.name;
|
|
349
|
+
console.log(`[AIActionDetector] Detected ${result.actions.length} actions`);
|
|
350
|
+
return result;
|
|
351
|
+
} catch (error) {
|
|
352
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
353
|
+
strategyErrors.push(`${candidate.name}: ${message}`);
|
|
354
|
+
console.warn(`[AIActionDetector] Provider failed (${candidate.name}): ${message}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (strategyErrors.length > 0) {
|
|
358
|
+
const combined = strategyErrors.slice(0, 3).join(" | ");
|
|
359
|
+
console.error("[AIActionDetector] Analysis failed across all vision strategies:", combined);
|
|
360
|
+
return {
|
|
361
|
+
actions: [],
|
|
362
|
+
summary: `AI analysis failed: ${combined}`
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
console.warn("[AIActionDetector] \u26A0\uFE0F No vision-capable provider available");
|
|
366
|
+
console.warn("[AIActionDetector] Set ANTHROPIC_API_KEY, ensure Claude CLI is available, or configure a vision-capable Ollama model");
|
|
367
|
+
return {
|
|
368
|
+
actions: [],
|
|
369
|
+
summary: "No vision-capable AI provider available",
|
|
370
|
+
skipped: true
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
async function selectVisibleActionFromScreenshot(screenshotPath, candidateLabels, provider) {
|
|
374
|
+
const normalizedCandidates = Array.from(
|
|
375
|
+
new Set(
|
|
376
|
+
(candidateLabels || []).map((candidate) => normalizeWhitespace(candidate)).filter(Boolean)
|
|
377
|
+
)
|
|
378
|
+
);
|
|
379
|
+
if (normalizedCandidates.length === 0) {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
const prompt = buildVisibleActionSelectionPrompt(normalizedCandidates);
|
|
383
|
+
const providerCandidates = (Array.isArray(provider) ? provider : [provider]).filter(
|
|
384
|
+
(p) => !!p
|
|
385
|
+
);
|
|
386
|
+
const strategyErrors = [];
|
|
387
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
388
|
+
if (apiKey) {
|
|
389
|
+
try {
|
|
390
|
+
const result = await selectVisibleActionWithAnthropicVision(apiKey, screenshotPath, prompt);
|
|
391
|
+
return {
|
|
392
|
+
...result,
|
|
393
|
+
providerName: "anthropic-api vision (claude-sonnet-4-20250514)"
|
|
394
|
+
};
|
|
395
|
+
} catch (error) {
|
|
396
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
397
|
+
strategyErrors.push(`anthropic-api vision: ${message}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
for (const candidate of providerCandidates) {
|
|
401
|
+
if (!candidate.sendMessageWithImages) continue;
|
|
402
|
+
try {
|
|
403
|
+
const response = await candidate.sendMessageWithImages(prompt, [screenshotPath]);
|
|
404
|
+
const result = parseVisibleActionSelectionResponse(response);
|
|
405
|
+
return {
|
|
406
|
+
...result,
|
|
407
|
+
providerName: candidate.name
|
|
408
|
+
};
|
|
409
|
+
} catch (error) {
|
|
410
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
411
|
+
strategyErrors.push(`${candidate.name}: ${message}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (strategyErrors.length > 0) {
|
|
415
|
+
console.warn("[AIActionDetector] Visible action selection failed:", strategyErrors.slice(0, 2).join(" | "));
|
|
416
|
+
}
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
function generateMaestroYaml(actions, appId, appName, actionDetectionProvider) {
|
|
420
|
+
const lines = [
|
|
421
|
+
`# Auto-generated Maestro test flow`,
|
|
422
|
+
`# Generated by DiscoveryLab AI Action Detector`,
|
|
423
|
+
`# ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
424
|
+
""
|
|
425
|
+
];
|
|
426
|
+
if (actionDetectionProvider) {
|
|
427
|
+
lines.push(`# AI Action Detection Provider: ${actionDetectionProvider}`);
|
|
428
|
+
lines.push("");
|
|
429
|
+
}
|
|
430
|
+
lines.push("appId: " + (appId || "com.example.app # TODO: Set your app ID"));
|
|
431
|
+
lines.push("");
|
|
432
|
+
if (appName) {
|
|
433
|
+
lines.push(`# App: ${appName}`);
|
|
434
|
+
lines.push("");
|
|
435
|
+
}
|
|
436
|
+
lines.push("---");
|
|
437
|
+
lines.push("");
|
|
438
|
+
for (const action of actions) {
|
|
439
|
+
if (action.confidence < 0.7) continue;
|
|
440
|
+
let commentDescription = action.description;
|
|
441
|
+
switch (action.type) {
|
|
442
|
+
case "launch":
|
|
443
|
+
lines.push(`- launchApp`);
|
|
444
|
+
break;
|
|
445
|
+
case "tap":
|
|
446
|
+
if (action.element) {
|
|
447
|
+
const literalElement = normalizeTapElementLabel(action.element || commentDescription);
|
|
448
|
+
lines.push(`- tapOn:`);
|
|
449
|
+
lines.push(` text: "${escapeYaml(literalElement)}"`);
|
|
450
|
+
} else if (action.coordinates) {
|
|
451
|
+
lines.push(`- tapOn:`);
|
|
452
|
+
lines.push(` point: "${action.coordinates.x}%,${action.coordinates.y}%"`);
|
|
453
|
+
}
|
|
454
|
+
break;
|
|
455
|
+
case "type":
|
|
456
|
+
if (action.text) {
|
|
457
|
+
const safeInputText = redactSensitiveTestInput(action.text, {
|
|
458
|
+
actionType: "inputText",
|
|
459
|
+
description: action.description,
|
|
460
|
+
fieldHint: action.element
|
|
461
|
+
});
|
|
462
|
+
if (safeInputText !== action.text && commentDescription) {
|
|
463
|
+
commentDescription = commentDescription.split(action.text).join(safeInputText);
|
|
464
|
+
}
|
|
465
|
+
lines.push(`- inputText: "${escapeYaml(safeInputText)}"`);
|
|
466
|
+
}
|
|
467
|
+
break;
|
|
468
|
+
case "scroll":
|
|
469
|
+
case "swipe":
|
|
470
|
+
if (action.direction) {
|
|
471
|
+
const direction = action.direction === "up" ? "DOWN" : action.direction === "down" ? "UP" : action.direction === "left" ? "RIGHT" : "LEFT";
|
|
472
|
+
lines.push(`- scroll:`);
|
|
473
|
+
lines.push(` direction: ${direction}`);
|
|
474
|
+
}
|
|
475
|
+
break;
|
|
476
|
+
case "back":
|
|
477
|
+
lines.push(`- pressKey: back`);
|
|
478
|
+
break;
|
|
479
|
+
case "wait":
|
|
480
|
+
lines.push(`- extendedWaitUntil:`);
|
|
481
|
+
lines.push(` visible: ".*"`);
|
|
482
|
+
lines.push(` timeout: 5000`);
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
if (commentDescription) {
|
|
486
|
+
lines[lines.length - 1] += ` # ${commentDescription}`;
|
|
487
|
+
}
|
|
488
|
+
lines.push("");
|
|
489
|
+
}
|
|
490
|
+
return lines.join("\n");
|
|
491
|
+
}
|
|
492
|
+
function sampleArray(arr, count) {
|
|
493
|
+
if (arr.length <= count) return arr;
|
|
494
|
+
const result = [];
|
|
495
|
+
const step = (arr.length - 1) / (count - 1);
|
|
496
|
+
for (let i = 0; i < count; i++) {
|
|
497
|
+
const index = Math.round(i * step);
|
|
498
|
+
result.push(arr[index]);
|
|
499
|
+
}
|
|
500
|
+
return result;
|
|
501
|
+
}
|
|
502
|
+
function escapeYaml(str) {
|
|
503
|
+
return str.replace(/"/g, '\\"').replace(/\n/g, "\\n");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// src/core/integrations/local-network-proxy.ts
|
|
507
|
+
import { networkInterfaces } from "os";
|
|
508
|
+
|
|
509
|
+
// src/core/integrations/esvp-host-runtime.ts
|
|
510
|
+
import { spawn } from "child_process";
|
|
511
|
+
import { existsSync } from "fs";
|
|
512
|
+
import { createServer } from "net";
|
|
513
|
+
import { homedir } from "os";
|
|
514
|
+
import { dirname, join as join2, resolve } from "path";
|
|
515
|
+
import { fileURLToPath } from "url";
|
|
516
|
+
var MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
517
|
+
var PROJECT_ROOT = resolve(MODULE_DIR, "../../..");
|
|
518
|
+
var runtimeState = null;
|
|
519
|
+
var runtimeStartPromise = null;
|
|
520
|
+
async function startHostRuntimeCaptureSession(input) {
|
|
521
|
+
const runtime = await ensureRuntimeProcess();
|
|
522
|
+
return runtimeRequest(runtime, "/sessions/start", {
|
|
523
|
+
method: "POST",
|
|
524
|
+
body: JSON.stringify({
|
|
525
|
+
sessionId: input.sessionId,
|
|
526
|
+
advertiseHost: input.advertiseHost,
|
|
527
|
+
bindHost: input.bindHost,
|
|
528
|
+
captureMode: input.captureMode || "external-proxy",
|
|
529
|
+
maxDurationMs: input.maxDurationMs ?? null,
|
|
530
|
+
maxBodyCaptureBytes: input.maxBodyCaptureBytes ?? 16384,
|
|
531
|
+
meta: input.meta || null
|
|
532
|
+
})
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
async function drainHostRuntimeCaptureSession(sessionId) {
|
|
536
|
+
const runtime = getRunningRuntimeProcess();
|
|
537
|
+
if (!runtime) {
|
|
538
|
+
throw new Error("ESVP host runtime is not running.");
|
|
539
|
+
}
|
|
540
|
+
return runtimeRequest(runtime, `/sessions/${encodeURIComponent(sessionId)}/drain`, {
|
|
541
|
+
method: "POST",
|
|
542
|
+
body: JSON.stringify({ reason: "manual-stop" })
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
async function panicStopHostRuntime(reason = "manual-emergency-stop") {
|
|
546
|
+
const runtime = getRunningRuntimeProcess();
|
|
547
|
+
if (!runtime) {
|
|
548
|
+
return {
|
|
549
|
+
stopped: 0,
|
|
550
|
+
sessions: []
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
return runtimeRequest(runtime, "/panic-stop", {
|
|
554
|
+
method: "POST",
|
|
555
|
+
body: JSON.stringify({ reason })
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
async function shutdownHostRuntime() {
|
|
559
|
+
const runtime = runtimeState;
|
|
560
|
+
runtimeState = null;
|
|
561
|
+
runtimeStartPromise = null;
|
|
562
|
+
if (!runtime) return;
|
|
563
|
+
try {
|
|
564
|
+
runtime.child.stdin?.end();
|
|
565
|
+
} catch {
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
const exited = await waitForChildExit(runtime.child, 750);
|
|
569
|
+
if (exited) return;
|
|
570
|
+
} catch {
|
|
571
|
+
}
|
|
572
|
+
try {
|
|
573
|
+
runtime.child.kill("SIGTERM");
|
|
574
|
+
} catch {
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
async function ensureRuntimeProcess() {
|
|
578
|
+
if (isRuntimeProcessAlive(runtimeState)) {
|
|
579
|
+
return runtimeState;
|
|
580
|
+
}
|
|
581
|
+
if (runtimeStartPromise) return runtimeStartPromise;
|
|
582
|
+
runtimeStartPromise = startRuntimeProcess().finally(() => {
|
|
583
|
+
runtimeStartPromise = null;
|
|
584
|
+
});
|
|
585
|
+
runtimeState = await runtimeStartPromise;
|
|
586
|
+
return runtimeState;
|
|
587
|
+
}
|
|
588
|
+
async function startRuntimeProcess() {
|
|
589
|
+
const port = await findFreePort();
|
|
590
|
+
const token = randomToken();
|
|
591
|
+
const launch = resolveRuntimeLaunchCommand();
|
|
592
|
+
const args = [
|
|
593
|
+
...launch.args,
|
|
594
|
+
"--host",
|
|
595
|
+
"127.0.0.1",
|
|
596
|
+
"--port",
|
|
597
|
+
String(port),
|
|
598
|
+
"--token",
|
|
599
|
+
token
|
|
600
|
+
];
|
|
601
|
+
const child = spawn(launch.command, args, {
|
|
602
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
603
|
+
env: process.env
|
|
604
|
+
});
|
|
605
|
+
const state = {
|
|
606
|
+
child,
|
|
607
|
+
command: launch,
|
|
608
|
+
token,
|
|
609
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
610
|
+
port
|
|
611
|
+
};
|
|
612
|
+
child.stdout?.on("data", (chunk) => {
|
|
613
|
+
const text = String(chunk || "").trim();
|
|
614
|
+
if (text) console.log(`[esvp-host-runtime] ${text}`);
|
|
615
|
+
});
|
|
616
|
+
child.stderr?.on("data", (chunk) => {
|
|
617
|
+
const text = String(chunk || "").trim();
|
|
618
|
+
if (text) console.error(`[esvp-host-runtime] ${text}`);
|
|
619
|
+
});
|
|
620
|
+
child.once("exit", () => {
|
|
621
|
+
if (runtimeState?.child === child) {
|
|
622
|
+
runtimeState = null;
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
child.once("error", () => {
|
|
626
|
+
if (runtimeState?.child === child) {
|
|
627
|
+
runtimeState = null;
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
try {
|
|
631
|
+
await waitForRuntimeHealth(state);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
try {
|
|
634
|
+
child.kill("SIGTERM");
|
|
635
|
+
} catch {
|
|
636
|
+
}
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
return state;
|
|
640
|
+
}
|
|
641
|
+
function resolveRuntimeLaunchCommand() {
|
|
642
|
+
const binary = resolveBundledOrBuiltRuntimeBinary();
|
|
643
|
+
if (binary) {
|
|
644
|
+
return {
|
|
645
|
+
kind: "binary",
|
|
646
|
+
command: binary,
|
|
647
|
+
args: []
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
for (const root of resolveProjectRoots()) {
|
|
651
|
+
const manifestPath = join2(root, "runtime", "Cargo.toml");
|
|
652
|
+
if (existsSync(manifestPath)) {
|
|
653
|
+
return {
|
|
654
|
+
kind: "cargo",
|
|
655
|
+
command: "cargo",
|
|
656
|
+
args: ["run", "--quiet", "--release", "--manifest-path", manifestPath, "-p", "esvp-host-runtime", "--"]
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
throw new Error(
|
|
661
|
+
"ESVP host runtime was not found. Build the Rust runtime or set DISCOVERYLAB_ESVP_HOST_RUNTIME_BIN."
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
function resolveBundledOrBuiltRuntimeBinary() {
|
|
665
|
+
const explicit = normalizeOptionalString(process.env.DISCOVERYLAB_ESVP_HOST_RUNTIME_BIN);
|
|
666
|
+
if (explicit && existsSync(explicit)) return explicit;
|
|
667
|
+
const target = resolveTargetTriple();
|
|
668
|
+
const binaryName = process.platform === "win32" ? "esvp-host-runtime.exe" : "esvp-host-runtime";
|
|
669
|
+
const rustTarget = resolveRustTargetTriple(target);
|
|
670
|
+
const candidates = [
|
|
671
|
+
join2(homedir(), ".discoverylab", "runtime", "esvp-host-runtime", target, binaryName),
|
|
672
|
+
...resolveProjectRoots().flatMap((root) => [
|
|
673
|
+
join2(root, "dist", "runtime", "esvp-host-runtime", target, binaryName),
|
|
674
|
+
join2(root, "runtime", "esvp-host-runtime", target, binaryName),
|
|
675
|
+
join2(root, "runtime", "target", rustTarget, "release", binaryName),
|
|
676
|
+
join2(root, "runtime", "target", "release", binaryName)
|
|
677
|
+
])
|
|
678
|
+
];
|
|
679
|
+
for (const candidate of candidates) {
|
|
680
|
+
if (existsSync(candidate)) return candidate;
|
|
681
|
+
}
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
function resolveTargetTriple() {
|
|
685
|
+
const platform = process.platform;
|
|
686
|
+
const arch = normalizeTargetArch(process.arch);
|
|
687
|
+
if (platform === "darwin" && arch === "arm64") return "darwin-arm64";
|
|
688
|
+
if (platform === "darwin" && arch === "x64") return "darwin-x64";
|
|
689
|
+
if (platform === "linux" && arch === "arm64") return "linux-arm64";
|
|
690
|
+
if (platform === "linux" && arch === "x64") return "linux-x64";
|
|
691
|
+
if (platform === "win32" && arch === "x64") return "win32-x64";
|
|
692
|
+
throw new Error(`Unsupported platform/arch for ESVP host runtime: ${platform}/${arch}`);
|
|
693
|
+
}
|
|
694
|
+
function resolveRustTargetTriple(target) {
|
|
695
|
+
const mapping = {
|
|
696
|
+
"darwin-arm64": "aarch64-apple-darwin",
|
|
697
|
+
"darwin-x64": "x86_64-apple-darwin",
|
|
698
|
+
"linux-arm64": "aarch64-unknown-linux-gnu",
|
|
699
|
+
"linux-x64": "x86_64-unknown-linux-gnu",
|
|
700
|
+
"win32-x64": "x86_64-pc-windows-msvc"
|
|
701
|
+
};
|
|
702
|
+
const resolved = mapping[target];
|
|
703
|
+
if (!resolved) {
|
|
704
|
+
throw new Error(`Unsupported Rust target for ESVP host runtime: ${target}`);
|
|
705
|
+
}
|
|
706
|
+
return resolved;
|
|
707
|
+
}
|
|
708
|
+
async function waitForRuntimeHealth(runtime) {
|
|
709
|
+
let lastError = null;
|
|
710
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
711
|
+
if (runtime.child.exitCode != null) {
|
|
712
|
+
throw new Error(`ESVP host runtime exited before becoming healthy (code ${runtime.child.exitCode}).`);
|
|
713
|
+
}
|
|
714
|
+
try {
|
|
715
|
+
await runtimeRequest(runtime, "/health", { method: "GET" }, 1500);
|
|
716
|
+
return;
|
|
717
|
+
} catch (error) {
|
|
718
|
+
lastError = error;
|
|
719
|
+
await sleep(250);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
throw new Error(`ESVP host runtime failed health checks: ${safeErrorMessage(lastError)}`);
|
|
723
|
+
}
|
|
724
|
+
async function runtimeRequest(runtime, path, init, timeoutMs = 5e3) {
|
|
725
|
+
const controller = new AbortController();
|
|
726
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
727
|
+
try {
|
|
728
|
+
const response = await fetch(`${runtime.baseUrl}${path}`, {
|
|
729
|
+
...init,
|
|
730
|
+
headers: {
|
|
731
|
+
Authorization: `Bearer ${runtime.token}`,
|
|
732
|
+
"Content-Type": "application/json",
|
|
733
|
+
...init.headers || {}
|
|
734
|
+
},
|
|
735
|
+
signal: controller.signal
|
|
736
|
+
});
|
|
737
|
+
const text = await response.text();
|
|
738
|
+
const payload = text.trim() ? tryParseJson(text) : null;
|
|
739
|
+
if (!response.ok) {
|
|
740
|
+
const message = (payload && typeof payload === "object" && payload !== null ? payload.error || payload.message : null) || `Host runtime request failed (${response.status})`;
|
|
741
|
+
throw new Error(String(message));
|
|
742
|
+
}
|
|
743
|
+
return payload;
|
|
744
|
+
} catch (error) {
|
|
745
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
746
|
+
throw new Error(`ESVP host runtime request timed out after ${timeoutMs}ms: ${path}`);
|
|
747
|
+
}
|
|
748
|
+
throw error;
|
|
749
|
+
} finally {
|
|
750
|
+
clearTimeout(timeout);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
function tryParseJson(text) {
|
|
754
|
+
try {
|
|
755
|
+
return JSON.parse(text);
|
|
756
|
+
} catch {
|
|
757
|
+
return { raw: text };
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
async function findFreePort() {
|
|
761
|
+
return new Promise((resolve2, reject) => {
|
|
762
|
+
const server = createServer();
|
|
763
|
+
server.unref();
|
|
764
|
+
server.once("error", reject);
|
|
765
|
+
server.listen(0, "127.0.0.1", () => {
|
|
766
|
+
const address = server.address();
|
|
767
|
+
const port = address && typeof address === "object" ? Number(address.port) : NaN;
|
|
768
|
+
server.close((error) => {
|
|
769
|
+
if (error) {
|
|
770
|
+
reject(error);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
774
|
+
reject(new Error("Failed to allocate a free port for the ESVP host runtime."));
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
resolve2(port);
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
function randomToken() {
|
|
783
|
+
return `rt_${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
|
784
|
+
}
|
|
785
|
+
function resolveProjectRoots() {
|
|
786
|
+
return [.../* @__PURE__ */ new Set([PROJECT_ROOT, process.cwd()])];
|
|
787
|
+
}
|
|
788
|
+
function normalizeTargetArch(arch) {
|
|
789
|
+
if (arch === "x64") return "x64";
|
|
790
|
+
return arch;
|
|
791
|
+
}
|
|
792
|
+
function getRunningRuntimeProcess() {
|
|
793
|
+
return isRuntimeProcessAlive(runtimeState) ? runtimeState : null;
|
|
794
|
+
}
|
|
795
|
+
function isRuntimeProcessAlive(runtime) {
|
|
796
|
+
return Boolean(runtime && runtime.child.exitCode == null && !runtime.child.killed);
|
|
797
|
+
}
|
|
798
|
+
function waitForChildExit(child, timeoutMs) {
|
|
799
|
+
return new Promise((resolve2) => {
|
|
800
|
+
const timeout = setTimeout(() => {
|
|
801
|
+
cleanup();
|
|
802
|
+
resolve2(false);
|
|
803
|
+
}, timeoutMs);
|
|
804
|
+
const cleanup = () => {
|
|
805
|
+
clearTimeout(timeout);
|
|
806
|
+
child.off("exit", onExit);
|
|
807
|
+
child.off("error", onError);
|
|
808
|
+
};
|
|
809
|
+
const onExit = () => {
|
|
810
|
+
cleanup();
|
|
811
|
+
resolve2(true);
|
|
812
|
+
};
|
|
813
|
+
const onError = () => {
|
|
814
|
+
cleanup();
|
|
815
|
+
resolve2(true);
|
|
816
|
+
};
|
|
817
|
+
child.once("exit", onExit);
|
|
818
|
+
child.once("error", onError);
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
function sleep(ms) {
|
|
822
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
823
|
+
}
|
|
824
|
+
function normalizeOptionalString(value) {
|
|
825
|
+
if (typeof value !== "string") return null;
|
|
826
|
+
const trimmed = value.trim();
|
|
827
|
+
return trimmed || null;
|
|
828
|
+
}
|
|
829
|
+
function safeErrorMessage(error) {
|
|
830
|
+
return error instanceof Error ? error.message : String(error);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// src/core/integrations/local-network-proxy.ts
|
|
834
|
+
var activeProxies = /* @__PURE__ */ new Map();
|
|
835
|
+
var finalizationsInFlight = /* @__PURE__ */ new Map();
|
|
836
|
+
var cleanupRegistered = false;
|
|
837
|
+
async function ensureLocalCaptureProxyProfile(input) {
|
|
838
|
+
const profile = cloneProfile(input.profile);
|
|
839
|
+
if (!profile) {
|
|
840
|
+
return {
|
|
841
|
+
profile,
|
|
842
|
+
captureProxy: null,
|
|
843
|
+
usesExternalProxy: false,
|
|
844
|
+
appLabOwnedProxy: false
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
const captureMode = String(profile.capture?.mode || "").trim().toLowerCase();
|
|
848
|
+
const usesExternalProxy = captureMode === "external-proxy";
|
|
849
|
+
if (!usesExternalProxy) {
|
|
850
|
+
return {
|
|
851
|
+
profile,
|
|
852
|
+
captureProxy: null,
|
|
853
|
+
usesExternalProxy,
|
|
854
|
+
appLabOwnedProxy: false
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
if (hasExplicitProxy(profile)) {
|
|
858
|
+
return {
|
|
859
|
+
profile,
|
|
860
|
+
captureProxy: null,
|
|
861
|
+
usesExternalProxy,
|
|
862
|
+
appLabOwnedProxy: false
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
if (input.allowAppLabOwnedProxy === false) {
|
|
866
|
+
throw new Error(
|
|
867
|
+
"App Lab proxy emergency lock is enabled. Unlock it in Settings or provide an explicit external proxy host/port."
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
const proxy = await startLocalCaptureProxy({
|
|
871
|
+
sessionId: input.sessionId,
|
|
872
|
+
platform: input.platform,
|
|
873
|
+
deviceId: input.deviceId,
|
|
874
|
+
captureMode: resolveAppLabCaptureMode(profile),
|
|
875
|
+
lifecycle: input.lifecycle || null
|
|
876
|
+
});
|
|
877
|
+
profile.proxy = {
|
|
878
|
+
host: proxy.host,
|
|
879
|
+
port: proxy.port,
|
|
880
|
+
protocol: "http"
|
|
881
|
+
};
|
|
882
|
+
return {
|
|
883
|
+
profile,
|
|
884
|
+
captureProxy: proxy,
|
|
885
|
+
usesExternalProxy,
|
|
886
|
+
appLabOwnedProxy: true
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
async function stopLocalCaptureProxy(sessionId) {
|
|
890
|
+
const existing = activeProxies.get(sessionId) || null;
|
|
891
|
+
if (!existing) {
|
|
892
|
+
return {
|
|
893
|
+
captureProxy: null,
|
|
894
|
+
trace: null
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
activeProxies.delete(sessionId);
|
|
898
|
+
const drained = await drainHostRuntimeCaptureSession(sessionId).catch(() => null);
|
|
899
|
+
return {
|
|
900
|
+
captureProxy: normalizeCaptureProxyState(drained?.captureProxy, existing.captureProxy, false),
|
|
901
|
+
trace: normalizeTracePayload(drained?.trace || null)
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
function listLocalCaptureProxyStates() {
|
|
905
|
+
return [...activeProxies.values()].map((record) => normalizeCaptureProxyState(record.captureProxy, null, true)).filter(Boolean);
|
|
906
|
+
}
|
|
907
|
+
async function finalizeAllLocalCaptureProxySessions(input = {}) {
|
|
908
|
+
const sessionIds = [...activeProxies.keys()];
|
|
909
|
+
const reason = normalizeOptionalString2(input.reason) || "manual-emergency-stop";
|
|
910
|
+
const results = await Promise.all(
|
|
911
|
+
sessionIds.map(async (sessionId) => {
|
|
912
|
+
try {
|
|
913
|
+
return {
|
|
914
|
+
sessionId,
|
|
915
|
+
result: await finalizeLocalCaptureProxySession({
|
|
916
|
+
sourceSessionId: sessionId,
|
|
917
|
+
...activeProxies.get(sessionId)?.lifecycle || {},
|
|
918
|
+
clearNetwork: true,
|
|
919
|
+
cleanupMeta: {
|
|
920
|
+
...activeProxies.get(sessionId)?.lifecycle?.cleanupMeta || {},
|
|
921
|
+
finalize_reason: reason
|
|
922
|
+
}
|
|
923
|
+
})
|
|
924
|
+
};
|
|
925
|
+
} catch (error) {
|
|
926
|
+
return {
|
|
927
|
+
sessionId,
|
|
928
|
+
result: {
|
|
929
|
+
captureProxy: normalizeCaptureProxyState(activeProxies.get(sessionId)?.captureProxy || null, null, false),
|
|
930
|
+
traceAttached: false,
|
|
931
|
+
cleanupSessionId: null,
|
|
932
|
+
clearResult: null,
|
|
933
|
+
clearedAt: null,
|
|
934
|
+
finishResult: null,
|
|
935
|
+
errors: [safeErrorMessage2(error)]
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
})
|
|
940
|
+
);
|
|
941
|
+
return {
|
|
942
|
+
total: sessionIds.length,
|
|
943
|
+
finalized: results.length,
|
|
944
|
+
results
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
async function finalizeLocalCaptureProxySession(input) {
|
|
948
|
+
const existing = finalizationsInFlight.get(input.sourceSessionId);
|
|
949
|
+
if (existing) return existing;
|
|
950
|
+
const promise = finalizeLocalCaptureProxySessionInternal(input).finally(() => {
|
|
951
|
+
finalizationsInFlight.delete(input.sourceSessionId);
|
|
952
|
+
});
|
|
953
|
+
finalizationsInFlight.set(input.sourceSessionId, promise);
|
|
954
|
+
return promise;
|
|
955
|
+
}
|
|
956
|
+
async function finalizeLocalCaptureProxySessionInternal(input) {
|
|
957
|
+
const errors = [];
|
|
958
|
+
let clearResult = null;
|
|
959
|
+
let cleanupSessionId = null;
|
|
960
|
+
let clearedAt = null;
|
|
961
|
+
if (input.clearNetwork !== false) {
|
|
962
|
+
try {
|
|
963
|
+
clearResult = await clearESVPNetwork(input.sourceSessionId, input.serverUrl);
|
|
964
|
+
clearedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
965
|
+
} catch (error) {
|
|
966
|
+
errors.push(safeErrorMessage2(error));
|
|
967
|
+
const cleanup = await runFallbackNetworkCleanupSession({
|
|
968
|
+
executor: input.executor,
|
|
969
|
+
deviceId: input.deviceId,
|
|
970
|
+
sourceSessionId: input.sourceSessionId,
|
|
971
|
+
serverUrl: input.serverUrl,
|
|
972
|
+
cleanupMeta: input.cleanupMeta
|
|
973
|
+
});
|
|
974
|
+
cleanupSessionId = cleanup.cleanupSessionId;
|
|
975
|
+
if (cleanup.clearResult) {
|
|
976
|
+
clearResult = cleanup.clearResult;
|
|
977
|
+
clearedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
978
|
+
errors.length = 0;
|
|
979
|
+
}
|
|
980
|
+
if (cleanup.errors.length > 0) {
|
|
981
|
+
errors.splice(0, errors.length, ...cleanup.errors);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
const stopped = await stopLocalCaptureProxy(input.sourceSessionId).catch((error) => {
|
|
986
|
+
errors.push(safeErrorMessage2(error));
|
|
987
|
+
return {
|
|
988
|
+
captureProxy: null,
|
|
989
|
+
trace: null
|
|
990
|
+
};
|
|
991
|
+
});
|
|
992
|
+
let traceAttached = false;
|
|
993
|
+
if (stopped.trace) {
|
|
994
|
+
try {
|
|
995
|
+
await attachESVPNetworkTrace(input.sourceSessionId, stopped.trace, input.serverUrl);
|
|
996
|
+
traceAttached = true;
|
|
997
|
+
} catch (error) {
|
|
998
|
+
errors.push(safeErrorMessage2(error));
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
const finishResult = await finishESVPSession(
|
|
1002
|
+
input.sourceSessionId,
|
|
1003
|
+
{
|
|
1004
|
+
captureLogcat: input.captureLogcat
|
|
1005
|
+
},
|
|
1006
|
+
input.serverUrl
|
|
1007
|
+
).catch((error) => {
|
|
1008
|
+
errors.push(safeErrorMessage2(error));
|
|
1009
|
+
return null;
|
|
1010
|
+
});
|
|
1011
|
+
return {
|
|
1012
|
+
captureProxy: stopped.captureProxy,
|
|
1013
|
+
traceAttached,
|
|
1014
|
+
cleanupSessionId,
|
|
1015
|
+
clearResult,
|
|
1016
|
+
clearedAt,
|
|
1017
|
+
finishResult: isObjectRecord(finishResult) ? finishResult : null,
|
|
1018
|
+
errors
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
function cloneProfile(profile) {
|
|
1022
|
+
if (!profile || typeof profile !== "object") return profile;
|
|
1023
|
+
return JSON.parse(JSON.stringify(profile));
|
|
1024
|
+
}
|
|
1025
|
+
function hasExplicitProxy(profile) {
|
|
1026
|
+
const proxy = profile.proxy;
|
|
1027
|
+
if (!proxy || typeof proxy !== "object" || Array.isArray(proxy)) return false;
|
|
1028
|
+
const normalizedProxy = proxy;
|
|
1029
|
+
const host = typeof normalizedProxy.host === "string" ? normalizedProxy.host.trim() : "";
|
|
1030
|
+
const port = Number(normalizedProxy.port);
|
|
1031
|
+
return Boolean(host) && Number.isFinite(port) && port > 0;
|
|
1032
|
+
}
|
|
1033
|
+
async function startLocalCaptureProxy(input) {
|
|
1034
|
+
const existing = activeProxies.get(input.sessionId);
|
|
1035
|
+
if (existing) return existing.captureProxy;
|
|
1036
|
+
const advertiseHost = resolveAdvertiseHost(input);
|
|
1037
|
+
const bindHost = resolveBindHost(advertiseHost);
|
|
1038
|
+
const maxDurationMs = input.lifecycle?.maxDurationMs ?? readProxyMaxDurationMs();
|
|
1039
|
+
const started = await startHostRuntimeCaptureSession({
|
|
1040
|
+
sessionId: input.sessionId,
|
|
1041
|
+
advertiseHost,
|
|
1042
|
+
bindHost,
|
|
1043
|
+
captureMode: input.captureMode || "external-proxy",
|
|
1044
|
+
maxDurationMs,
|
|
1045
|
+
maxBodyCaptureBytes: 16384,
|
|
1046
|
+
meta: {
|
|
1047
|
+
platform: normalizeOptionalString2(input.platform) || null,
|
|
1048
|
+
deviceId: normalizeOptionalString2(input.deviceId) || null,
|
|
1049
|
+
source: "applab-discovery"
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
1052
|
+
const captureProxy = normalizeCaptureProxyState(started.captureProxy, null, true);
|
|
1053
|
+
if (!captureProxy) {
|
|
1054
|
+
throw new Error("Host runtime did not return a capture proxy state.");
|
|
1055
|
+
}
|
|
1056
|
+
captureProxy.mitm = normalizeMitmState(started.mitm || null, null);
|
|
1057
|
+
activeProxies.set(input.sessionId, {
|
|
1058
|
+
sessionId: input.sessionId,
|
|
1059
|
+
captureProxy,
|
|
1060
|
+
lifecycle: input.lifecycle || null
|
|
1061
|
+
});
|
|
1062
|
+
registerCleanup();
|
|
1063
|
+
return captureProxy;
|
|
1064
|
+
}
|
|
1065
|
+
function resolveAdvertiseHost(input) {
|
|
1066
|
+
const explicit = normalizeOptionalString2(process.env.DISCOVERYLAB_NETWORK_PROXY_HOST);
|
|
1067
|
+
if (explicit) return explicit;
|
|
1068
|
+
const platform = String(input.platform || "").trim().toLowerCase();
|
|
1069
|
+
if (platform === "ios") return "127.0.0.1";
|
|
1070
|
+
if (platform === "android") {
|
|
1071
|
+
const deviceId = String(input.deviceId || "").trim();
|
|
1072
|
+
if (deviceId.startsWith("emulator-")) return "10.0.2.2";
|
|
1073
|
+
return inferLanIpAddress() || "127.0.0.1";
|
|
1074
|
+
}
|
|
1075
|
+
return "127.0.0.1";
|
|
1076
|
+
}
|
|
1077
|
+
function resolveBindHost(advertiseHost) {
|
|
1078
|
+
const explicit = normalizeOptionalString2(process.env.DISCOVERYLAB_NETWORK_PROXY_BIND_HOST);
|
|
1079
|
+
if (explicit) return explicit;
|
|
1080
|
+
if (advertiseHost === "127.0.0.1" || advertiseHost === "10.0.2.2") return "127.0.0.1";
|
|
1081
|
+
return "0.0.0.0";
|
|
1082
|
+
}
|
|
1083
|
+
function inferLanIpAddress() {
|
|
1084
|
+
const interfaces = networkInterfaces();
|
|
1085
|
+
for (const entries of Object.values(interfaces)) {
|
|
1086
|
+
for (const entry of entries || []) {
|
|
1087
|
+
if (!entry || entry.internal) continue;
|
|
1088
|
+
if (entry.family === "IPv4") return entry.address;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
return null;
|
|
1092
|
+
}
|
|
1093
|
+
function registerCleanup() {
|
|
1094
|
+
if (cleanupRegistered) return;
|
|
1095
|
+
cleanupRegistered = true;
|
|
1096
|
+
const cleanup = async (reason) => {
|
|
1097
|
+
const sessionIds = [...activeProxies.keys()];
|
|
1098
|
+
await Promise.allSettled(
|
|
1099
|
+
sessionIds.map((sessionId) => {
|
|
1100
|
+
const lifecycle = activeProxies.get(sessionId)?.lifecycle || null;
|
|
1101
|
+
if (lifecycle) {
|
|
1102
|
+
return finalizeLocalCaptureProxySession({
|
|
1103
|
+
sourceSessionId: sessionId,
|
|
1104
|
+
executor: lifecycle.executor,
|
|
1105
|
+
deviceId: lifecycle.deviceId,
|
|
1106
|
+
serverUrl: lifecycle.serverUrl,
|
|
1107
|
+
captureLogcat: lifecycle.captureLogcat,
|
|
1108
|
+
clearNetwork: true,
|
|
1109
|
+
cleanupMeta: {
|
|
1110
|
+
...lifecycle.cleanupMeta || {},
|
|
1111
|
+
finalize_reason: reason
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
return stopLocalCaptureProxy(sessionId).catch(() => null);
|
|
1116
|
+
})
|
|
1117
|
+
);
|
|
1118
|
+
activeProxies.clear();
|
|
1119
|
+
await panicStopHostRuntime(reason).catch(() => null);
|
|
1120
|
+
await shutdownHostRuntime().catch(() => null);
|
|
1121
|
+
};
|
|
1122
|
+
process.once("beforeExit", () => {
|
|
1123
|
+
void cleanup("process-exit");
|
|
1124
|
+
});
|
|
1125
|
+
process.once("SIGINT", () => {
|
|
1126
|
+
void cleanup("sigint").finally(() => process.exit(0));
|
|
1127
|
+
});
|
|
1128
|
+
process.once("SIGTERM", () => {
|
|
1129
|
+
void cleanup("sigterm").finally(() => process.exit(0));
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
function normalizeCaptureProxyState(value, fallback, active) {
|
|
1133
|
+
const record = isObjectRecord(value) ? value : null;
|
|
1134
|
+
const host = normalizeOptionalString2(record?.host) || fallback?.host || null;
|
|
1135
|
+
const port = Number(record?.port ?? fallback?.port ?? NaN);
|
|
1136
|
+
if (!host || !Number.isFinite(port) || port <= 0) {
|
|
1137
|
+
return fallback ? {
|
|
1138
|
+
...fallback,
|
|
1139
|
+
active,
|
|
1140
|
+
port: Number.isFinite(fallback.port) ? fallback.port : null
|
|
1141
|
+
} : null;
|
|
1142
|
+
}
|
|
1143
|
+
return {
|
|
1144
|
+
id: normalizeOptionalString2(record?.id) || fallback?.id || `runtime-${Math.random().toString(36).slice(2, 10)}`,
|
|
1145
|
+
sessionId: normalizeOptionalString2(record?.sessionId) || fallback?.sessionId || "",
|
|
1146
|
+
active,
|
|
1147
|
+
bindHost: normalizeOptionalString2(record?.bindHost) || fallback?.bindHost || host,
|
|
1148
|
+
host,
|
|
1149
|
+
port,
|
|
1150
|
+
url: normalizeOptionalString2(record?.url) || fallback?.url || `http://${host}:${port}`,
|
|
1151
|
+
startedAt: normalizeOptionalString2(record?.startedAt) || fallback?.startedAt || (/* @__PURE__ */ new Date()).toISOString(),
|
|
1152
|
+
entryCount: clampInt(record?.entryCount ?? fallback?.entryCount ?? 0, 0, Number.MAX_SAFE_INTEGER, 0),
|
|
1153
|
+
captureMode: normalizeOptionalString2(record?.captureMode) === "external-mitm" || fallback?.captureMode === "external-mitm" ? "external-mitm" : "external-proxy",
|
|
1154
|
+
source: normalizeOptionalString2(record?.source) === "applab-external-mitm" || fallback?.source === "applab-external-mitm" ? "applab-external-mitm" : "applab-external-proxy",
|
|
1155
|
+
mitm: normalizeMitmState(record?.mitm, fallback?.mitm || null)
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
function resolveAppLabCaptureMode(profile) {
|
|
1159
|
+
const capture = profile.capture;
|
|
1160
|
+
if (!capture || typeof capture !== "object" || Array.isArray(capture)) return "external-proxy";
|
|
1161
|
+
const applabMode = String(capture.applabMode || "").trim().toLowerCase();
|
|
1162
|
+
return applabMode === "external-mitm" ? "external-mitm" : "external-proxy";
|
|
1163
|
+
}
|
|
1164
|
+
function normalizeTracePayload(value) {
|
|
1165
|
+
if (!isObjectRecord(value)) return null;
|
|
1166
|
+
return value;
|
|
1167
|
+
}
|
|
1168
|
+
function normalizeMitmState(value, fallback) {
|
|
1169
|
+
const record = isObjectRecord(value) ? value : null;
|
|
1170
|
+
if (!record) return fallback || null;
|
|
1171
|
+
return {
|
|
1172
|
+
enabled: record.enabled === true || fallback?.enabled === true,
|
|
1173
|
+
rootCertPath: normalizeOptionalString2(record.rootCertPath) || fallback?.rootCertPath || null,
|
|
1174
|
+
platform: normalizeOptionalString2(record.platform) || fallback?.platform || null,
|
|
1175
|
+
deviceId: normalizeOptionalString2(record.deviceId) || fallback?.deviceId || null,
|
|
1176
|
+
certificateInstalled: typeof record.certificateInstalled === "boolean" ? record.certificateInstalled : fallback?.certificateInstalled,
|
|
1177
|
+
certificateInstallMethod: normalizeOptionalString2(record.certificateInstallMethod) || fallback?.certificateInstallMethod || null,
|
|
1178
|
+
warnings: Array.isArray(record.warnings) ? record.warnings.map((item) => String(item)).filter(Boolean) : fallback?.warnings || [],
|
|
1179
|
+
errors: Array.isArray(record.errors) ? record.errors.map((item) => String(item)).filter(Boolean) : fallback?.errors || []
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
function normalizeOptionalString2(value) {
|
|
1183
|
+
if (typeof value !== "string") return null;
|
|
1184
|
+
const trimmed = value.trim();
|
|
1185
|
+
return trimmed ? trimmed : null;
|
|
1186
|
+
}
|
|
1187
|
+
function clampInt(value, min, max, fallback) {
|
|
1188
|
+
const numeric = Number(value);
|
|
1189
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
1190
|
+
return Math.max(min, Math.min(max, Math.round(numeric)));
|
|
1191
|
+
}
|
|
1192
|
+
function readProxyMaxDurationMs() {
|
|
1193
|
+
const raw = normalizeOptionalString2(process.env.DISCOVERYLAB_NETWORK_PROXY_MAX_DURATION_MS);
|
|
1194
|
+
if (raw == null) return 15 * 60 * 1e3;
|
|
1195
|
+
const durationMs = Number(raw);
|
|
1196
|
+
if (!Number.isFinite(durationMs)) return 15 * 60 * 1e3;
|
|
1197
|
+
if (durationMs <= 0) return null;
|
|
1198
|
+
return clampInt(durationMs, 3e4, 24 * 60 * 60 * 1e3, 15 * 60 * 1e3);
|
|
1199
|
+
}
|
|
1200
|
+
async function runFallbackNetworkCleanupSession(input) {
|
|
1201
|
+
const errors = [];
|
|
1202
|
+
const deviceId = normalizeOptionalString2(input.deviceId);
|
|
1203
|
+
const executor = normalizeOptionalExecutor(input.executor);
|
|
1204
|
+
if (!deviceId || !executor) {
|
|
1205
|
+
return {
|
|
1206
|
+
cleanupSessionId: null,
|
|
1207
|
+
clearResult: null,
|
|
1208
|
+
errors
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
let cleanupSessionId = null;
|
|
1212
|
+
try {
|
|
1213
|
+
const created = await createESVPSession(
|
|
1214
|
+
{
|
|
1215
|
+
executor,
|
|
1216
|
+
deviceId,
|
|
1217
|
+
meta: {
|
|
1218
|
+
source: "applab-discovery-network-cleanup",
|
|
1219
|
+
cleanup_for_session_id: input.sourceSessionId,
|
|
1220
|
+
...input.cleanupMeta || {}
|
|
1221
|
+
}
|
|
1222
|
+
},
|
|
1223
|
+
input.serverUrl
|
|
1224
|
+
);
|
|
1225
|
+
cleanupSessionId = normalizeOptionalString2(created?.session?.id || created?.id);
|
|
1226
|
+
if (!cleanupSessionId) {
|
|
1227
|
+
throw new Error("Failed to create a cleanup ESVP session.");
|
|
1228
|
+
}
|
|
1229
|
+
const cleared = await clearESVPNetwork(cleanupSessionId, input.serverUrl);
|
|
1230
|
+
await finishESVPSession(cleanupSessionId, { captureLogcat: false }, input.serverUrl).catch(() => null);
|
|
1231
|
+
return {
|
|
1232
|
+
cleanupSessionId,
|
|
1233
|
+
clearResult: isObjectRecord(cleared) ? cleared : null,
|
|
1234
|
+
errors
|
|
1235
|
+
};
|
|
1236
|
+
} catch (error) {
|
|
1237
|
+
errors.push(safeErrorMessage2(error));
|
|
1238
|
+
if (cleanupSessionId) {
|
|
1239
|
+
await finishESVPSession(cleanupSessionId, { captureLogcat: false }, input.serverUrl).catch(() => null);
|
|
1240
|
+
}
|
|
1241
|
+
return {
|
|
1242
|
+
cleanupSessionId,
|
|
1243
|
+
clearResult: null,
|
|
1244
|
+
errors
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
function normalizeOptionalExecutor(value) {
|
|
1249
|
+
const normalized = normalizeOptionalString2(value);
|
|
1250
|
+
if (!normalized) return null;
|
|
1251
|
+
if (normalized === "adb" || normalized === "ios-sim" || normalized === "maestro-ios" || normalized === "fake") {
|
|
1252
|
+
return normalized;
|
|
1253
|
+
}
|
|
1254
|
+
return null;
|
|
1255
|
+
}
|
|
1256
|
+
function safeErrorMessage2(error) {
|
|
1257
|
+
return error instanceof Error ? error.message : String(error);
|
|
1258
|
+
}
|
|
1259
|
+
function isObjectRecord(value) {
|
|
1260
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// src/core/integrations/local-app-http-trace.ts
|
|
1264
|
+
import { randomBytes, randomUUID } from "crypto";
|
|
1265
|
+
var activeCollectorsBySession = /* @__PURE__ */ new Map();
|
|
1266
|
+
var activeCollectorsById = /* @__PURE__ */ new Map();
|
|
1267
|
+
function startLocalAppHttpTraceCollector(input) {
|
|
1268
|
+
const existing = activeCollectorsBySession.get(input.sessionId);
|
|
1269
|
+
if (existing) {
|
|
1270
|
+
return existing.state;
|
|
1271
|
+
}
|
|
1272
|
+
const collectorId = randomUUID();
|
|
1273
|
+
const host = inferAppLabExternalProxyHost({
|
|
1274
|
+
platform: input.platform,
|
|
1275
|
+
deviceId: input.deviceId,
|
|
1276
|
+
explicitHost: null
|
|
1277
|
+
}) || "127.0.0.1";
|
|
1278
|
+
const ingestPath = `/api/testing/mobile/recordings/${encodeURIComponent(input.recordingId)}/esvp/app-http-trace/${encodeURIComponent(collectorId)}`;
|
|
1279
|
+
const bootstrapPath = `/api/testing/mobile/app-http-trace/bootstrap?appId=${encodeURIComponent(String(input.appId || ""))}&recordingId=${encodeURIComponent(input.recordingId)}`;
|
|
1280
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1281
|
+
const state = {
|
|
1282
|
+
id: collectorId,
|
|
1283
|
+
sessionId: input.sessionId,
|
|
1284
|
+
recordingId: input.recordingId,
|
|
1285
|
+
appId: normalizeOptionalString3(input.appId),
|
|
1286
|
+
platform: normalizePlatform(input.platform),
|
|
1287
|
+
deviceId: normalizeOptionalString3(input.deviceId),
|
|
1288
|
+
active: true,
|
|
1289
|
+
host,
|
|
1290
|
+
port: input.serverPort,
|
|
1291
|
+
bootstrapPath,
|
|
1292
|
+
bootstrapUrl: `http://${host}:${input.serverPort}${bootstrapPath}`,
|
|
1293
|
+
ingestPath,
|
|
1294
|
+
ingestUrl: `http://${host}:${input.serverPort}${ingestPath}`,
|
|
1295
|
+
startedAt,
|
|
1296
|
+
entryCount: 0,
|
|
1297
|
+
maxBodyCaptureBytes: normalizePositiveNumber(input.maxBodyCaptureBytes) || 16384,
|
|
1298
|
+
source: "applab-local-app-http-trace",
|
|
1299
|
+
traceKind: "app_http_trace"
|
|
1300
|
+
};
|
|
1301
|
+
activeCollectorsBySession.set(input.sessionId, {
|
|
1302
|
+
state,
|
|
1303
|
+
token: randomBytes(18).toString("hex"),
|
|
1304
|
+
entries: [],
|
|
1305
|
+
createdAt: Date.now()
|
|
1306
|
+
});
|
|
1307
|
+
activeCollectorsById.set(state.id, input.sessionId);
|
|
1308
|
+
return state;
|
|
1309
|
+
}
|
|
1310
|
+
function resolveLocalAppHttpTraceCollectorById(collectorId) {
|
|
1311
|
+
const sessionId = activeCollectorsById.get(collectorId);
|
|
1312
|
+
if (!sessionId) return null;
|
|
1313
|
+
return activeCollectorsBySession.get(sessionId)?.state || null;
|
|
1314
|
+
}
|
|
1315
|
+
function getLocalAppHttpTraceBootstrap(input) {
|
|
1316
|
+
const appId = normalizeOptionalString3(input.appId);
|
|
1317
|
+
const recordingId = normalizeOptionalString3(input.recordingId);
|
|
1318
|
+
let bestMatch = null;
|
|
1319
|
+
for (const record of activeCollectorsBySession.values()) {
|
|
1320
|
+
if (!record.state.active) continue;
|
|
1321
|
+
if (appId && record.state.appId === appId) {
|
|
1322
|
+
if (!bestMatch || record.createdAt > bestMatch.createdAt) bestMatch = record;
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1325
|
+
if (!appId && recordingId && record.state.recordingId === recordingId) {
|
|
1326
|
+
if (!bestMatch || record.createdAt > bestMatch.createdAt) bestMatch = record;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
if (!bestMatch) return null;
|
|
1330
|
+
return {
|
|
1331
|
+
...bestMatch.state,
|
|
1332
|
+
bootstrap: buildBootstrapConfig(bestMatch)
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
function ingestLocalAppHttpTrace(input) {
|
|
1336
|
+
const sessionId = activeCollectorsById.get(input.collectorId);
|
|
1337
|
+
if (!sessionId) {
|
|
1338
|
+
return {
|
|
1339
|
+
collector: null,
|
|
1340
|
+
accepted: 0
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
const record = activeCollectorsBySession.get(sessionId);
|
|
1344
|
+
if (!record || !record.state.active) {
|
|
1345
|
+
return {
|
|
1346
|
+
collector: null,
|
|
1347
|
+
accepted: 0
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
if (record.token !== String(input.authToken || "").trim()) {
|
|
1351
|
+
return {
|
|
1352
|
+
collector: null,
|
|
1353
|
+
accepted: 0
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
const entries = extractEntries(input.payload);
|
|
1357
|
+
if (entries.length > 0) {
|
|
1358
|
+
record.entries.push(...entries);
|
|
1359
|
+
record.state = {
|
|
1360
|
+
...record.state,
|
|
1361
|
+
entryCount: record.entries.length
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
activeCollectorsBySession.set(sessionId, record);
|
|
1365
|
+
return {
|
|
1366
|
+
collector: record.state,
|
|
1367
|
+
accepted: entries.length
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
async function finalizeLocalAppHttpTraceCollector(input) {
|
|
1371
|
+
const record = activeCollectorsBySession.get(input.sourceSessionId);
|
|
1372
|
+
if (!record) {
|
|
1373
|
+
return {
|
|
1374
|
+
collector: null,
|
|
1375
|
+
traceAttached: false,
|
|
1376
|
+
errors: []
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
activeCollectorsBySession.delete(input.sourceSessionId);
|
|
1380
|
+
activeCollectorsById.delete(record.state.id);
|
|
1381
|
+
const finalState = {
|
|
1382
|
+
...record.state,
|
|
1383
|
+
active: false,
|
|
1384
|
+
entryCount: record.entries.length
|
|
1385
|
+
};
|
|
1386
|
+
if (record.entries.length === 0) {
|
|
1387
|
+
return {
|
|
1388
|
+
collector: finalState,
|
|
1389
|
+
traceAttached: false,
|
|
1390
|
+
errors: []
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
try {
|
|
1394
|
+
await attachESVPNetworkTrace(
|
|
1395
|
+
input.sourceSessionId,
|
|
1396
|
+
{
|
|
1397
|
+
trace_kind: "app_http_trace",
|
|
1398
|
+
label: "App Lab Local App HTTP Trace",
|
|
1399
|
+
format: "json",
|
|
1400
|
+
source: finalState.source,
|
|
1401
|
+
payload: {
|
|
1402
|
+
session_id: input.sourceSessionId,
|
|
1403
|
+
collector_id: finalState.id,
|
|
1404
|
+
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1405
|
+
entries: record.entries
|
|
1406
|
+
},
|
|
1407
|
+
artifactMeta: {
|
|
1408
|
+
trace_kind: "app_http_trace",
|
|
1409
|
+
collector_id: finalState.id,
|
|
1410
|
+
entry_count: record.entries.length,
|
|
1411
|
+
source: finalState.source
|
|
1412
|
+
}
|
|
1413
|
+
},
|
|
1414
|
+
input.serverUrl
|
|
1415
|
+
);
|
|
1416
|
+
return {
|
|
1417
|
+
collector: finalState,
|
|
1418
|
+
traceAttached: true,
|
|
1419
|
+
errors: []
|
|
1420
|
+
};
|
|
1421
|
+
} catch (error) {
|
|
1422
|
+
return {
|
|
1423
|
+
collector: finalState,
|
|
1424
|
+
traceAttached: false,
|
|
1425
|
+
errors: [error instanceof Error ? error.message : String(error)]
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
function buildBootstrapConfig(record) {
|
|
1430
|
+
return {
|
|
1431
|
+
traceKind: "app_http_trace",
|
|
1432
|
+
source: "applab-local-app-http-trace",
|
|
1433
|
+
sessionId: record.state.sessionId,
|
|
1434
|
+
recordingId: record.state.recordingId,
|
|
1435
|
+
appId: record.state.appId,
|
|
1436
|
+
ingestUrl: record.state.ingestUrl,
|
|
1437
|
+
ingestPath: record.state.ingestPath,
|
|
1438
|
+
authHeader: "x-applab-trace-token",
|
|
1439
|
+
token: record.token,
|
|
1440
|
+
maxBodyCaptureBytes: record.state.maxBodyCaptureBytes
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
function extractEntries(payload) {
|
|
1444
|
+
if (Array.isArray(payload)) {
|
|
1445
|
+
return payload.filter(isObject);
|
|
1446
|
+
}
|
|
1447
|
+
if (!isObject(payload)) {
|
|
1448
|
+
return [];
|
|
1449
|
+
}
|
|
1450
|
+
const nestedCandidates = [
|
|
1451
|
+
payload,
|
|
1452
|
+
isObject(payload.payload) ? payload.payload : null,
|
|
1453
|
+
isObject(payload.data) ? payload.data : null,
|
|
1454
|
+
isObject(payload.trace) ? payload.trace : null,
|
|
1455
|
+
isObject(payload.result) ? payload.result : null
|
|
1456
|
+
].filter(Boolean);
|
|
1457
|
+
for (const candidate of nestedCandidates) {
|
|
1458
|
+
for (const key of ["entries", "events", "requests", "items"]) {
|
|
1459
|
+
if (Array.isArray(candidate[key])) {
|
|
1460
|
+
return candidate[key].filter(isObject);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
return [payload];
|
|
1465
|
+
}
|
|
1466
|
+
function normalizePositiveNumber(value) {
|
|
1467
|
+
const parsed = Number(value);
|
|
1468
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
1469
|
+
return Math.round(parsed);
|
|
1470
|
+
}
|
|
1471
|
+
function normalizeOptionalString3(value) {
|
|
1472
|
+
if (typeof value !== "string") return null;
|
|
1473
|
+
const normalized = value.trim();
|
|
1474
|
+
return normalized || null;
|
|
1475
|
+
}
|
|
1476
|
+
function normalizePlatform(value) {
|
|
1477
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
1478
|
+
if (normalized === "ios" || normalized === "android") return normalized;
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1481
|
+
function isObject(value) {
|
|
1482
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// src/core/integrations/esvp-mobile.ts
|
|
1486
|
+
function createMobileNetworkCaptureMeta(input) {
|
|
1487
|
+
const resourceTypes = Array.isArray(input?.resourceTypes) && input.resourceTypes.length > 0 ? input.resourceTypes.map((value) => String(value)) : ["mobile-http-trace"];
|
|
1488
|
+
return {
|
|
1489
|
+
truncated: input?.truncated === true,
|
|
1490
|
+
maxEntries: Number.isFinite(input?.maxEntries) ? Number(input?.maxEntries) : 1200,
|
|
1491
|
+
resourceTypes,
|
|
1492
|
+
...input
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
function diagnoseESVPNetworkState(opts) {
|
|
1496
|
+
if (!opts.sourceSessionId) {
|
|
1497
|
+
return {
|
|
1498
|
+
status: "no_session",
|
|
1499
|
+
message: "No ESVP session attached to this project."
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
if (opts.networkSupported === false) {
|
|
1503
|
+
const executorLabel = opts.executor === "maestro-ios" ? "iOS (Maestro)" : opts.executor || "this executor";
|
|
1504
|
+
return {
|
|
1505
|
+
status: "not_supported",
|
|
1506
|
+
message: `Network capture not supported for ${executorLabel}. Use external proxy or attach HAR manually.`
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
if (!opts.configuredAt) {
|
|
1510
|
+
return {
|
|
1511
|
+
status: "not_configured",
|
|
1512
|
+
message: "Network profile was never configured for this session. Network capture requires configuring a profile before running actions."
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
const proxyEntryCount = opts.managedProxy?.entry_count ?? 0;
|
|
1516
|
+
const traceCount = opts.traceCount ?? 0;
|
|
1517
|
+
if ((proxyEntryCount > 0 || traceCount > 0) && (!opts.entryCount || opts.entryCount === 0)) {
|
|
1518
|
+
const count = proxyEntryCount || traceCount;
|
|
1519
|
+
return {
|
|
1520
|
+
status: "has_traces_need_sync",
|
|
1521
|
+
message: `ESVP session has ${count} captured request${count !== 1 ? "s" : ""}. Click 'Sync Network Trace' to pull them.`,
|
|
1522
|
+
traceCount: count
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
if (proxyEntryCount === 0 && traceCount === 0 && (!opts.entryCount || opts.entryCount === 0)) {
|
|
1526
|
+
return {
|
|
1527
|
+
status: "proxy_no_traffic",
|
|
1528
|
+
message: opts.captureMode === "external-proxy" ? "External proxy capture is configured but no network_trace has been attached yet." : "Managed proxy was active but captured zero requests. The device may not have routed traffic through the proxy.",
|
|
1529
|
+
detail: opts.captureMode === "external-proxy" ? void 0 : opts.managedProxy ? `Proxy bound to ${opts.managedProxy.bind_host || "?"}:${opts.managedProxy.port || "?"}` : void 0
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
if (opts.entryCount && opts.entryCount > 0) {
|
|
1533
|
+
return {
|
|
1534
|
+
status: "synced",
|
|
1535
|
+
message: `${opts.entryCount} network entries synced.`,
|
|
1536
|
+
entryCount: opts.entryCount
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
return {
|
|
1540
|
+
status: "unknown",
|
|
1541
|
+
message: "Unable to determine network capture state."
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
async function collectESVPSessionNetworkData(sessionId, serverUrl) {
|
|
1545
|
+
const inspection = await inspectESVPSession(
|
|
1546
|
+
sessionId,
|
|
1547
|
+
{
|
|
1548
|
+
includeArtifacts: true,
|
|
1549
|
+
includeTranscript: false
|
|
1550
|
+
},
|
|
1551
|
+
serverUrl
|
|
1552
|
+
);
|
|
1553
|
+
const artifacts = Array.isArray(inspection?.artifacts) ? inspection.artifacts : [];
|
|
1554
|
+
const networkState = await getESVPSessionNetwork(sessionId, serverUrl).catch(() => null);
|
|
1555
|
+
const publicNetworkState = isObject2(networkState?.network) ? networkState.network : null;
|
|
1556
|
+
const normalized = await normalizeESVPNetworkArtifacts(sessionId, artifacts, serverUrl);
|
|
1557
|
+
const traceKinds = Array.isArray(publicNetworkState?.trace_kinds) ? publicNetworkState.trace_kinds.map((value) => String(value)) : normalized.traceKinds;
|
|
1558
|
+
const networkCapture = createMobileNetworkCaptureMeta({
|
|
1559
|
+
...normalized.networkCapture,
|
|
1560
|
+
traceKinds,
|
|
1561
|
+
source: "esvp-mobile",
|
|
1562
|
+
sessionId,
|
|
1563
|
+
managedProxy: publicNetworkState?.managed_proxy || null,
|
|
1564
|
+
effectiveProfile: publicNetworkState?.effective_profile || null
|
|
1565
|
+
});
|
|
1566
|
+
return {
|
|
1567
|
+
networkEntries: normalized.networkEntries,
|
|
1568
|
+
networkCapture,
|
|
1569
|
+
traceKinds,
|
|
1570
|
+
networkState: publicNetworkState
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
async function validateMaestroRecordingWithESVP(recording, optionsOrServerUrl) {
|
|
1574
|
+
const options = normalizeValidationOptions(optionsOrServerUrl);
|
|
1575
|
+
const executor = recording.platform === "android" ? "adb" : "maestro-ios";
|
|
1576
|
+
const connection = await getESVPConnection(options.serverUrl);
|
|
1577
|
+
const normalizedAppId = normalizeRecordingAppId(recording.appId);
|
|
1578
|
+
const bootstrap = await buildRecordingBootstrapActions(recording, {
|
|
1579
|
+
appId: normalizedAppId,
|
|
1580
|
+
screenshotPath: options.bootstrapScreenshotPath
|
|
1581
|
+
});
|
|
1582
|
+
const effectiveRecordingActions = [...bootstrap.actions, ...recording.actions || []];
|
|
1583
|
+
const translated = translateMaestroActionsToESVP(effectiveRecordingActions, { appId: normalizedAppId });
|
|
1584
|
+
const shouldCaptureLogcat = resolveCaptureLogcat(executor, options.captureLogcat);
|
|
1585
|
+
const requestedNetworkProfile = normalizeRequestedNetworkProfile(options.network, {
|
|
1586
|
+
platform: recording.platform,
|
|
1587
|
+
deviceId: recording.deviceId
|
|
1588
|
+
});
|
|
1589
|
+
if (translated.actions.length === 0) {
|
|
1590
|
+
return {
|
|
1591
|
+
supported: false,
|
|
1592
|
+
reason: "No actions compatible with the public ESVP contract were found in this Maestro recording.",
|
|
1593
|
+
connectionMode: connection.mode,
|
|
1594
|
+
serverUrl: connection.serverUrl,
|
|
1595
|
+
executor,
|
|
1596
|
+
translatedActions: translated.actions,
|
|
1597
|
+
skippedActions: translated.skipped,
|
|
1598
|
+
networkEntries: [],
|
|
1599
|
+
networkCapture: createMobileNetworkCaptureMeta({
|
|
1600
|
+
source: "esvp-mobile",
|
|
1601
|
+
sessionId: null
|
|
1602
|
+
}),
|
|
1603
|
+
traceKinds: [],
|
|
1604
|
+
bootstrap: bootstrap.summary,
|
|
1605
|
+
recovery: null
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
let translatedActions = translated.actions;
|
|
1609
|
+
let skippedActions = [...translated.skipped];
|
|
1610
|
+
let recovery = null;
|
|
1611
|
+
let execution = await runValidationSourceSession(
|
|
1612
|
+
recording,
|
|
1613
|
+
{
|
|
1614
|
+
executor,
|
|
1615
|
+
appId: normalizedAppId,
|
|
1616
|
+
translatedActions,
|
|
1617
|
+
requestedNetworkProfile,
|
|
1618
|
+
captureLogcat: shouldCaptureLogcat,
|
|
1619
|
+
serverUrl: options.serverUrl,
|
|
1620
|
+
appTraceServerPort: options.appTraceServerPort,
|
|
1621
|
+
metaSource: "applab-discovery-maestro-validation",
|
|
1622
|
+
allowAppLabOwnedProxyAutostart: options.allowAppLabOwnedProxyAutostart
|
|
1623
|
+
}
|
|
1624
|
+
);
|
|
1625
|
+
const initialSourceSessionId = execution.sourceSessionId;
|
|
1626
|
+
if (executor === "maestro-ios" && didESVPRunFail(execution.run)) {
|
|
1627
|
+
const recoveryPlan = await buildIOSVisibleTextRecoveryPlan(
|
|
1628
|
+
execution.sourceSessionId,
|
|
1629
|
+
translatedActions,
|
|
1630
|
+
options.serverUrl,
|
|
1631
|
+
options.recoveryVisionProvider
|
|
1632
|
+
);
|
|
1633
|
+
if (recoveryPlan) {
|
|
1634
|
+
const retryExecution = await runValidationSourceSession(
|
|
1635
|
+
recording,
|
|
1636
|
+
{
|
|
1637
|
+
executor,
|
|
1638
|
+
appId: normalizedAppId,
|
|
1639
|
+
translatedActions: recoveryPlan.prunedActions,
|
|
1640
|
+
requestedNetworkProfile,
|
|
1641
|
+
captureLogcat: shouldCaptureLogcat,
|
|
1642
|
+
serverUrl: options.serverUrl,
|
|
1643
|
+
appTraceServerPort: options.appTraceServerPort,
|
|
1644
|
+
metaSource: "applab-discovery-maestro-validation-recovered",
|
|
1645
|
+
extraMeta: {
|
|
1646
|
+
recovery_strategy: recoveryPlan.strategy,
|
|
1647
|
+
recovery_source_session_id: execution.sourceSessionId,
|
|
1648
|
+
recovery_pruned_action_count: recoveryPlan.prunedSkipped.length
|
|
1649
|
+
},
|
|
1650
|
+
allowAppLabOwnedProxyAutostart: options.allowAppLabOwnedProxyAutostart
|
|
1651
|
+
}
|
|
1652
|
+
);
|
|
1653
|
+
if (shouldPreferRecoveredExecution(execution.run, retryExecution.run)) {
|
|
1654
|
+
execution = retryExecution;
|
|
1655
|
+
translatedActions = recoveryPlan.prunedActions;
|
|
1656
|
+
skippedActions = [...skippedActions, ...recoveryPlan.prunedSkipped];
|
|
1657
|
+
recovery = {
|
|
1658
|
+
applied: true,
|
|
1659
|
+
strategy: recoveryPlan.strategy,
|
|
1660
|
+
initialSourceSessionId,
|
|
1661
|
+
finalSourceSessionId: retryExecution.sourceSessionId,
|
|
1662
|
+
prunedActionCount: recoveryPlan.prunedSkipped.length,
|
|
1663
|
+
resumedFromActionIndex: recoveryPlan.resumeFromIndex,
|
|
1664
|
+
matchedActionIndex: recoveryPlan.matchedActionIndex,
|
|
1665
|
+
matchedActionLabel: recoveryPlan.matchedActionLabel,
|
|
1666
|
+
checkpointScreenshotPath: recoveryPlan.checkpointScreenshotPath,
|
|
1667
|
+
visibleTextPreview: recoveryPlan.visibleTextPreview
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
const sourceSessionId = execution.sourceSessionId;
|
|
1673
|
+
const replay = options.replay === false ? null : await replayESVPSession(
|
|
1674
|
+
sourceSessionId,
|
|
1675
|
+
{
|
|
1676
|
+
executor,
|
|
1677
|
+
deviceId: recording.deviceId,
|
|
1678
|
+
captureLogcat: shouldCaptureLogcat,
|
|
1679
|
+
meta: {
|
|
1680
|
+
source: "applab-discovery-maestro-replay",
|
|
1681
|
+
recording_id: recording.id,
|
|
1682
|
+
...recovery?.applied ? { recovery_strategy: recovery.strategy } : {}
|
|
1683
|
+
}
|
|
1684
|
+
},
|
|
1685
|
+
options.serverUrl
|
|
1686
|
+
);
|
|
1687
|
+
const replaySessionId = replay?.replay_session?.id ? String(replay.replay_session.id) : null;
|
|
1688
|
+
const replayConsistency = replaySessionId ? await getESVPReplayConsistency(replaySessionId, options.serverUrl).catch(() => null) : null;
|
|
1689
|
+
const networkData = await collectESVPSessionNetworkData(sourceSessionId, options.serverUrl).catch(() => ({
|
|
1690
|
+
networkEntries: [],
|
|
1691
|
+
networkCapture: createMobileNetworkCaptureMeta({
|
|
1692
|
+
source: "esvp-mobile",
|
|
1693
|
+
sessionId: sourceSessionId
|
|
1694
|
+
}),
|
|
1695
|
+
traceKinds: [],
|
|
1696
|
+
networkState: null
|
|
1697
|
+
}));
|
|
1698
|
+
const networkState = mergeNetworkState(
|
|
1699
|
+
networkData.networkState,
|
|
1700
|
+
extractNetworkStateFromConfig(execution.networkConfigResult)
|
|
1701
|
+
);
|
|
1702
|
+
if (networkState && execution.cleanupError && !networkState.last_error) {
|
|
1703
|
+
networkState.last_error = execution.cleanupError;
|
|
1704
|
+
}
|
|
1705
|
+
if (networkState && execution.clearedAt && !networkState.cleared_at) {
|
|
1706
|
+
networkState.cleared_at = execution.clearedAt;
|
|
1707
|
+
}
|
|
1708
|
+
return {
|
|
1709
|
+
supported: true,
|
|
1710
|
+
connectionMode: connection.mode,
|
|
1711
|
+
serverUrl: connection.serverUrl,
|
|
1712
|
+
executor,
|
|
1713
|
+
translatedActions,
|
|
1714
|
+
skippedActions,
|
|
1715
|
+
sourceSessionId,
|
|
1716
|
+
replaySessionId,
|
|
1717
|
+
runSummary: execution.run,
|
|
1718
|
+
checkpointComparison: replay?.checkpoint_comparison || null,
|
|
1719
|
+
replayConsistency: replayConsistency?.replay_consistency || null,
|
|
1720
|
+
networkEntries: networkData.networkEntries,
|
|
1721
|
+
networkCapture: networkData.networkCapture,
|
|
1722
|
+
traceKinds: networkData.traceKinds,
|
|
1723
|
+
networkState,
|
|
1724
|
+
managedProxy: networkState?.managed_proxy || null,
|
|
1725
|
+
captureProxy: execution.captureProxy || null,
|
|
1726
|
+
appTraceCollector: execution.appTraceCollector || null,
|
|
1727
|
+
networkProfileApplied: isObject2(execution.networkConfigResult?.applied) ? execution.networkConfigResult?.applied : null,
|
|
1728
|
+
bootstrap: bootstrap.summary,
|
|
1729
|
+
recovery
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
async function runValidationSourceSession(recording, input) {
|
|
1733
|
+
const created = await createESVPSession(
|
|
1734
|
+
{
|
|
1735
|
+
executor: input.executor,
|
|
1736
|
+
deviceId: recording.deviceId,
|
|
1737
|
+
meta: {
|
|
1738
|
+
source: input.metaSource,
|
|
1739
|
+
recording_id: recording.id,
|
|
1740
|
+
recording_name: recording.name,
|
|
1741
|
+
recording_platform: recording.platform,
|
|
1742
|
+
recording_device_name: recording.deviceName || null,
|
|
1743
|
+
...input.appId ? { appId: input.appId, app_id: input.appId } : {},
|
|
1744
|
+
...input.extraMeta || {}
|
|
1745
|
+
}
|
|
1746
|
+
},
|
|
1747
|
+
input.serverUrl
|
|
1748
|
+
);
|
|
1749
|
+
const sourceSessionId = String(created?.session?.id || created?.id || "");
|
|
1750
|
+
if (!sourceSessionId) {
|
|
1751
|
+
throw new Error("Failed to create an ESVP session for Maestro validation.");
|
|
1752
|
+
}
|
|
1753
|
+
const appTraceMode = getRequestedAppLabCaptureMode(input.requestedNetworkProfile) === "app-http-trace";
|
|
1754
|
+
const preparedNetworkProfile = appTraceMode ? {
|
|
1755
|
+
profile: input.requestedNetworkProfile,
|
|
1756
|
+
captureProxy: null,
|
|
1757
|
+
usesExternalProxy: false,
|
|
1758
|
+
appLabOwnedProxy: false
|
|
1759
|
+
} : await ensureLocalCaptureProxyProfile({
|
|
1760
|
+
sessionId: sourceSessionId,
|
|
1761
|
+
profile: input.requestedNetworkProfile,
|
|
1762
|
+
platform: recording.platform,
|
|
1763
|
+
deviceId: recording.deviceId,
|
|
1764
|
+
allowAppLabOwnedProxy: input.allowAppLabOwnedProxyAutostart,
|
|
1765
|
+
lifecycle: {
|
|
1766
|
+
executor: input.executor,
|
|
1767
|
+
deviceId: recording.deviceId,
|
|
1768
|
+
serverUrl: input.serverUrl,
|
|
1769
|
+
captureLogcat: input.captureLogcat,
|
|
1770
|
+
cleanupMeta: {
|
|
1771
|
+
recording_id: recording.id,
|
|
1772
|
+
recording_name: recording.name,
|
|
1773
|
+
recording_platform: recording.platform
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
});
|
|
1777
|
+
const appTraceCollector = appTraceMode ? startLocalAppHttpTraceCollector({
|
|
1778
|
+
sessionId: sourceSessionId,
|
|
1779
|
+
recordingId: recording.id,
|
|
1780
|
+
appId: input.appId || recording.appId,
|
|
1781
|
+
platform: recording.platform,
|
|
1782
|
+
deviceId: recording.deviceId,
|
|
1783
|
+
serverPort: input.appTraceServerPort || 3847
|
|
1784
|
+
}) : null;
|
|
1785
|
+
let networkConfigResult = null;
|
|
1786
|
+
if (preparedNetworkProfile.profile && !appTraceMode) {
|
|
1787
|
+
networkConfigResult = await configureESVPNetwork(
|
|
1788
|
+
sourceSessionId,
|
|
1789
|
+
preparedNetworkProfile.profile,
|
|
1790
|
+
input.serverUrl
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
let run = null;
|
|
1794
|
+
let runError = null;
|
|
1795
|
+
try {
|
|
1796
|
+
run = await runESVPActions(
|
|
1797
|
+
sourceSessionId,
|
|
1798
|
+
{
|
|
1799
|
+
actions: input.translatedActions,
|
|
1800
|
+
finish: false,
|
|
1801
|
+
captureLogcat: input.captureLogcat,
|
|
1802
|
+
checkpointAfterEach: true
|
|
1803
|
+
},
|
|
1804
|
+
input.serverUrl
|
|
1805
|
+
);
|
|
1806
|
+
} catch (error) {
|
|
1807
|
+
runError = error;
|
|
1808
|
+
}
|
|
1809
|
+
const finalization = appTraceMode ? await finalizeLocalAppHttpTraceCollector({
|
|
1810
|
+
sourceSessionId,
|
|
1811
|
+
serverUrl: input.serverUrl
|
|
1812
|
+
}) : await finalizeLocalCaptureProxySession({
|
|
1813
|
+
sourceSessionId,
|
|
1814
|
+
executor: input.executor,
|
|
1815
|
+
deviceId: recording.deviceId,
|
|
1816
|
+
serverUrl: input.serverUrl,
|
|
1817
|
+
captureLogcat: input.captureLogcat,
|
|
1818
|
+
clearNetwork: Boolean(preparedNetworkProfile.profile),
|
|
1819
|
+
cleanupMeta: {
|
|
1820
|
+
recording_id: recording.id,
|
|
1821
|
+
recording_name: recording.name,
|
|
1822
|
+
recording_platform: recording.platform
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
const finalizationCollector = "collector" in finalization ? finalization.collector || null : null;
|
|
1826
|
+
const finalizationClearedAt = "clearedAt" in finalization ? finalization.clearedAt : null;
|
|
1827
|
+
if (runError) {
|
|
1828
|
+
throw runError;
|
|
1829
|
+
}
|
|
1830
|
+
return {
|
|
1831
|
+
sourceSessionId,
|
|
1832
|
+
run,
|
|
1833
|
+
networkConfigResult,
|
|
1834
|
+
captureProxy: preparedNetworkProfile.captureProxy,
|
|
1835
|
+
appTraceCollector: finalizationCollector || appTraceCollector,
|
|
1836
|
+
cleanupError: finalization.errors[0] || null,
|
|
1837
|
+
clearedAt: finalizationClearedAt
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
function didESVPRunFail(run) {
|
|
1841
|
+
return Boolean(run?.failed || run?.session?.status === "failed");
|
|
1842
|
+
}
|
|
1843
|
+
function getESVPRunCheckpointCount(run) {
|
|
1844
|
+
return Number.isFinite(run?.session?.checkpoint_count) ? Number(run.session.checkpoint_count) : 0;
|
|
1845
|
+
}
|
|
1846
|
+
function shouldPreferRecoveredExecution(initialRun, retryRun) {
|
|
1847
|
+
if (!didESVPRunFail(retryRun)) return true;
|
|
1848
|
+
return getESVPRunCheckpointCount(retryRun) > getESVPRunCheckpointCount(initialRun);
|
|
1849
|
+
}
|
|
1850
|
+
function getTranslatedActionId(action, index) {
|
|
1851
|
+
if (typeof action?.checkpointLabel === "string" && action.checkpointLabel.trim()) {
|
|
1852
|
+
return action.checkpointLabel.trim();
|
|
1853
|
+
}
|
|
1854
|
+
return `esvp_action_${String(index + 1).padStart(3, "0")}`;
|
|
1855
|
+
}
|
|
1856
|
+
function normalizeMatchText(value) {
|
|
1857
|
+
return String(value || "").normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
|
|
1858
|
+
}
|
|
1859
|
+
function normalizeCandidateLabel(value) {
|
|
1860
|
+
return String(value || "").replace(/\s+/g, " ").trim();
|
|
1861
|
+
}
|
|
1862
|
+
function isLikelyBootstrapAnchorLabel(value) {
|
|
1863
|
+
const label = normalizeCandidateLabel(value);
|
|
1864
|
+
if (!label) return false;
|
|
1865
|
+
if (label.length > 24) return false;
|
|
1866
|
+
if (/@|https?:\/\/|www\./i.test(label)) return false;
|
|
1867
|
+
if (/\d/.test(label)) return false;
|
|
1868
|
+
const words = label.split(/\s+/).filter(Boolean);
|
|
1869
|
+
if (words.length === 0 || words.length > 3) return false;
|
|
1870
|
+
const lowered = label.toLowerCase();
|
|
1871
|
+
if (["preferred name", "phone number", "enable notifications", "tap to see subscription options"].includes(lowered)) {
|
|
1872
|
+
return false;
|
|
1873
|
+
}
|
|
1874
|
+
return true;
|
|
1875
|
+
}
|
|
1876
|
+
async function inferBootstrapAnchorFromScreenshot(screenshotPath) {
|
|
1877
|
+
const targetPath = normalizeCandidateLabel(screenshotPath);
|
|
1878
|
+
if (!targetPath) return null;
|
|
1879
|
+
const ocrResult = await recognizeText(targetPath, {
|
|
1880
|
+
recognitionLevel: "accurate",
|
|
1881
|
+
languages: ["en-US", "pt-BR"]
|
|
1882
|
+
}).catch(() => null);
|
|
1883
|
+
const text = typeof ocrResult?.text === "string" ? ocrResult.text : "";
|
|
1884
|
+
if (!ocrResult?.success || !text.trim()) {
|
|
1885
|
+
return null;
|
|
1886
|
+
}
|
|
1887
|
+
const lines = text.split(/\n+/).map((line) => normalizeCandidateLabel(line)).filter(Boolean);
|
|
1888
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1889
|
+
const ordered = [];
|
|
1890
|
+
for (const line of lines) {
|
|
1891
|
+
if (!isLikelyBootstrapAnchorLabel(line)) continue;
|
|
1892
|
+
const normalized = normalizeMatchText(line);
|
|
1893
|
+
if (!normalized) continue;
|
|
1894
|
+
counts.set(normalized, (counts.get(normalized) || 0) + 1);
|
|
1895
|
+
if (!ordered.includes(normalized)) {
|
|
1896
|
+
ordered.push(normalized);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
for (const normalized of ordered) {
|
|
1900
|
+
if ((counts.get(normalized) || 0) < 2) continue;
|
|
1901
|
+
const original = lines.find((line) => normalizeMatchText(line) === normalized);
|
|
1902
|
+
if (original) {
|
|
1903
|
+
return original;
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
return null;
|
|
1907
|
+
}
|
|
1908
|
+
async function buildRecordingBootstrapActions(recording, options) {
|
|
1909
|
+
const actions = [];
|
|
1910
|
+
const launchAppId = normalizeRecordingAppId(options.appId || recording.appId);
|
|
1911
|
+
const hasExplicitLaunch = (recording.actions || []).some((action) => action?.type === "launch");
|
|
1912
|
+
if (launchAppId && !hasExplicitLaunch) {
|
|
1913
|
+
actions.push({
|
|
1914
|
+
id: "bootstrap_launch",
|
|
1915
|
+
type: "launch",
|
|
1916
|
+
timestamp: Date.now(),
|
|
1917
|
+
description: `Launch ${launchAppId}`,
|
|
1918
|
+
text: launchAppId,
|
|
1919
|
+
appId: launchAppId
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
const initialAnchorLabel = await inferBootstrapAnchorFromScreenshot(options.screenshotPath);
|
|
1923
|
+
const firstActionLabel = normalizeMatchText((recording.actions || [])[0]?.text);
|
|
1924
|
+
if (initialAnchorLabel && normalizeMatchText(initialAnchorLabel) !== firstActionLabel) {
|
|
1925
|
+
actions.push({
|
|
1926
|
+
id: "bootstrap_anchor_001",
|
|
1927
|
+
type: "tap",
|
|
1928
|
+
timestamp: Date.now(),
|
|
1929
|
+
description: `Return to ${initialAnchorLabel} screen`,
|
|
1930
|
+
text: initialAnchorLabel
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
return {
|
|
1934
|
+
actions,
|
|
1935
|
+
summary: {
|
|
1936
|
+
applied: actions.length > 0,
|
|
1937
|
+
launchInserted: actions.some((action) => action.type === "launch"),
|
|
1938
|
+
initialAnchorLabel: initialAnchorLabel || null,
|
|
1939
|
+
actionCount: actions.length,
|
|
1940
|
+
screenshotPath: options.screenshotPath || null
|
|
1941
|
+
}
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
function previewVisibleText(value, maxLength = 220) {
|
|
1945
|
+
const normalized = String(value || "").replace(/\s+/g, " ").trim();
|
|
1946
|
+
if (!normalized) return "";
|
|
1947
|
+
return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized;
|
|
1948
|
+
}
|
|
1949
|
+
function getActionTextCandidates(action) {
|
|
1950
|
+
const args = isObject2(action?.args) ? action.args : {};
|
|
1951
|
+
const candidates = [];
|
|
1952
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1953
|
+
const push = (value) => {
|
|
1954
|
+
const normalized = String(value || "").trim();
|
|
1955
|
+
if (!normalized || seen.has(normalized)) return;
|
|
1956
|
+
seen.add(normalized);
|
|
1957
|
+
candidates.push(normalized);
|
|
1958
|
+
};
|
|
1959
|
+
push(args.text);
|
|
1960
|
+
push(args.selector);
|
|
1961
|
+
if (Array.isArray(args.selectors)) {
|
|
1962
|
+
for (const selector of args.selectors) {
|
|
1963
|
+
if (!isObject2(selector)) continue;
|
|
1964
|
+
push(selector.selector);
|
|
1965
|
+
push(selector.label);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
return candidates;
|
|
1969
|
+
}
|
|
1970
|
+
function scoreActionAgainstVisibleText(action, visibleText) {
|
|
1971
|
+
const haystack = normalizeMatchText(visibleText);
|
|
1972
|
+
if (!haystack) return 0;
|
|
1973
|
+
let bestScore = 0;
|
|
1974
|
+
for (const candidate of getActionTextCandidates(action)) {
|
|
1975
|
+
const needle = normalizeMatchText(candidate);
|
|
1976
|
+
if (!needle) continue;
|
|
1977
|
+
if (haystack.includes(needle)) {
|
|
1978
|
+
bestScore = Math.max(bestScore, 1);
|
|
1979
|
+
continue;
|
|
1980
|
+
}
|
|
1981
|
+
const tokens = needle.split(" ").filter((part) => part.length > 1);
|
|
1982
|
+
if (tokens.length === 0) continue;
|
|
1983
|
+
const matchedTokens = tokens.filter((part) => haystack.includes(part)).length;
|
|
1984
|
+
if (!matchedTokens) continue;
|
|
1985
|
+
const score = matchedTokens / tokens.length * 0.45;
|
|
1986
|
+
if (score > bestScore) {
|
|
1987
|
+
bestScore = score;
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
return bestScore;
|
|
1991
|
+
}
|
|
1992
|
+
function getLatestScreenshotArtifactPath(artifacts) {
|
|
1993
|
+
for (let index = artifacts.length - 1; index >= 0; index -= 1) {
|
|
1994
|
+
const artifact = artifacts[index];
|
|
1995
|
+
if (artifact?.kind !== "screenshot") continue;
|
|
1996
|
+
if (typeof artifact.abs_path === "string" && artifact.abs_path.trim()) {
|
|
1997
|
+
return artifact.abs_path.trim();
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
return null;
|
|
2001
|
+
}
|
|
2002
|
+
async function buildIOSVisibleTextRecoveryPlan(sourceSessionId, translatedActions, serverUrl, recoveryVisionProvider) {
|
|
2003
|
+
const inspection = await inspectESVPSession(
|
|
2004
|
+
sourceSessionId,
|
|
2005
|
+
{
|
|
2006
|
+
includeArtifacts: true,
|
|
2007
|
+
includeTranscript: false
|
|
2008
|
+
},
|
|
2009
|
+
serverUrl
|
|
2010
|
+
).catch(() => null);
|
|
2011
|
+
const completedCount = Number.isFinite(inspection?.session?.checkpoint_count) ? Number(inspection.session.checkpoint_count) : 0;
|
|
2012
|
+
if (completedCount <= 0 || completedCount >= translatedActions.length) {
|
|
2013
|
+
return null;
|
|
2014
|
+
}
|
|
2015
|
+
const artifacts = Array.isArray(inspection?.artifacts) ? inspection.artifacts : [];
|
|
2016
|
+
const checkpointScreenshotPath = getLatestScreenshotArtifactPath(artifacts);
|
|
2017
|
+
if (!checkpointScreenshotPath) {
|
|
2018
|
+
return null;
|
|
2019
|
+
}
|
|
2020
|
+
const ocrResult = await recognizeText(checkpointScreenshotPath, {
|
|
2021
|
+
recognitionLevel: "accurate",
|
|
2022
|
+
languages: ["en-US", "pt-BR"]
|
|
2023
|
+
}).catch(() => null);
|
|
2024
|
+
const visibleText = typeof ocrResult?.text === "string" ? ocrResult.text : "";
|
|
2025
|
+
if (!ocrResult?.success || !visibleText.trim()) {
|
|
2026
|
+
return null;
|
|
2027
|
+
}
|
|
2028
|
+
const currentIndex = completedCount;
|
|
2029
|
+
const currentScore = scoreActionAgainstVisibleText(translatedActions[currentIndex], visibleText);
|
|
2030
|
+
if (currentScore >= 0.7) {
|
|
2031
|
+
return null;
|
|
2032
|
+
}
|
|
2033
|
+
const maxLookahead = Math.min(translatedActions.length, currentIndex + 3);
|
|
2034
|
+
let matchedActionIndex = -1;
|
|
2035
|
+
let matchedActionScore = 0;
|
|
2036
|
+
for (let index = currentIndex + 1; index < maxLookahead; index += 1) {
|
|
2037
|
+
const score = scoreActionAgainstVisibleText(translatedActions[index], visibleText);
|
|
2038
|
+
if (score > matchedActionScore) {
|
|
2039
|
+
matchedActionScore = score;
|
|
2040
|
+
matchedActionIndex = index;
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
if (matchedActionIndex <= currentIndex || matchedActionScore < 0.7) {
|
|
2044
|
+
const visibleActionCandidates = translatedActions.slice(currentIndex, maxLookahead).map((action) => getActionTextCandidates(action)[0] || String(action?.name || "action")).filter(Boolean);
|
|
2045
|
+
const visionSelection = await selectVisibleActionFromScreenshot(
|
|
2046
|
+
checkpointScreenshotPath,
|
|
2047
|
+
visibleActionCandidates,
|
|
2048
|
+
recoveryVisionProvider
|
|
2049
|
+
).catch(() => null);
|
|
2050
|
+
if (!visionSelection?.selectedLabel) {
|
|
2051
|
+
return null;
|
|
2052
|
+
}
|
|
2053
|
+
const selectedNormalized = normalizeMatchText(visionSelection.selectedLabel);
|
|
2054
|
+
matchedActionIndex = translatedActions.findIndex((action, index) => {
|
|
2055
|
+
if (index < currentIndex || index >= maxLookahead) return false;
|
|
2056
|
+
return getActionTextCandidates(action).some((candidate) => normalizeMatchText(candidate) === selectedNormalized);
|
|
2057
|
+
});
|
|
2058
|
+
if (matchedActionIndex <= currentIndex) {
|
|
2059
|
+
return null;
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
const prunedActions = translatedActions.filter((_, index) => index < currentIndex || index >= matchedActionIndex);
|
|
2063
|
+
if (prunedActions.length === translatedActions.length) {
|
|
2064
|
+
return null;
|
|
2065
|
+
}
|
|
2066
|
+
const prunedSkipped = translatedActions.slice(currentIndex, matchedActionIndex).map((action, offset) => ({
|
|
2067
|
+
actionId: getTranslatedActionId(action, currentIndex + offset),
|
|
2068
|
+
type: String(action?.name || "action"),
|
|
2069
|
+
reason: "pruned after checkpoint OCR matched a later visible UI action"
|
|
2070
|
+
}));
|
|
2071
|
+
return {
|
|
2072
|
+
strategy: "ios-visible-text-reconciliation",
|
|
2073
|
+
prunedActions,
|
|
2074
|
+
prunedSkipped,
|
|
2075
|
+
checkpointScreenshotPath,
|
|
2076
|
+
visibleTextPreview: previewVisibleText(visibleText),
|
|
2077
|
+
resumeFromIndex: currentIndex,
|
|
2078
|
+
matchedActionIndex,
|
|
2079
|
+
matchedActionLabel: getActionTextCandidates(translatedActions[matchedActionIndex])[0] || null
|
|
2080
|
+
};
|
|
2081
|
+
}
|
|
2082
|
+
function normalizeValidationOptions(input) {
|
|
2083
|
+
if (typeof input === "string") {
|
|
2084
|
+
return {
|
|
2085
|
+
serverUrl: input
|
|
2086
|
+
};
|
|
2087
|
+
}
|
|
2088
|
+
return input || {};
|
|
2089
|
+
}
|
|
2090
|
+
function normalizeRecordingAppId(appId) {
|
|
2091
|
+
const value = String(appId || "").trim();
|
|
2092
|
+
if (!value) return void 0;
|
|
2093
|
+
if (value === "com.example.app") return void 0;
|
|
2094
|
+
if (value.includes("# TODO")) return void 0;
|
|
2095
|
+
return value;
|
|
2096
|
+
}
|
|
2097
|
+
function buildTextQueryVariants(value) {
|
|
2098
|
+
const raw = String(value || "").trim().replace(/\s+/g, " ");
|
|
2099
|
+
if (!raw) return [];
|
|
2100
|
+
const variants = [];
|
|
2101
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2102
|
+
const push = (candidate) => {
|
|
2103
|
+
const normalized = String(candidate || "").trim().replace(/\s+/g, " ");
|
|
2104
|
+
if (!normalized || seen.has(normalized)) return;
|
|
2105
|
+
seen.add(normalized);
|
|
2106
|
+
variants.push(normalized);
|
|
2107
|
+
};
|
|
2108
|
+
push(raw);
|
|
2109
|
+
const withoutContext = raw.replace(/\s+(?:in|inside|within|from|on)\s+.+$/i, "").trim();
|
|
2110
|
+
push(withoutContext);
|
|
2111
|
+
const withoutUiNoun = withoutContext.replace(/\s+(?:button|tab|icon|link|banner|card|item|field|input|modal|sheet|screen|section|row)$/i, "").trim();
|
|
2112
|
+
push(withoutUiNoun);
|
|
2113
|
+
const withoutCommonWords = withoutUiNoun.split(/\s+/).filter((part) => !["my", "the", "a", "an", "to", "for", "of"].includes(part.toLowerCase())).join(" ");
|
|
2114
|
+
push(withoutCommonWords);
|
|
2115
|
+
const firstSegment = withoutUiNoun.split(/\s*[\/>|-]\s*/)[0]?.trim();
|
|
2116
|
+
push(firstSegment);
|
|
2117
|
+
return variants;
|
|
2118
|
+
}
|
|
2119
|
+
function choosePrimaryTextQuery(value) {
|
|
2120
|
+
const variants = buildTextQueryVariants(value);
|
|
2121
|
+
if (variants.length === 0) return "";
|
|
2122
|
+
return variants[variants.length - 1] || variants[0] || "";
|
|
2123
|
+
}
|
|
2124
|
+
function resolveCaptureLogcat(executor, captureLogcat) {
|
|
2125
|
+
if (typeof captureLogcat === "boolean") return captureLogcat;
|
|
2126
|
+
return executor === "adb";
|
|
2127
|
+
}
|
|
2128
|
+
function normalizeRequestedNetworkProfile(input, context) {
|
|
2129
|
+
return buildAppLabNetworkProfile(input, context);
|
|
2130
|
+
}
|
|
2131
|
+
function getRequestedAppLabCaptureMode(profile) {
|
|
2132
|
+
if (!profile || !isObject2(profile.capture)) return "";
|
|
2133
|
+
const capture = profile.capture;
|
|
2134
|
+
const applabMode = typeof capture.applabMode === "string" ? capture.applabMode.trim().toLowerCase() : "";
|
|
2135
|
+
if (applabMode) return applabMode;
|
|
2136
|
+
return typeof capture.mode === "string" ? capture.mode.trim().toLowerCase() : "";
|
|
2137
|
+
}
|
|
2138
|
+
function extractNetworkStateFromConfig(config) {
|
|
2139
|
+
if (!isObject2(config?.network)) return null;
|
|
2140
|
+
return config.network;
|
|
2141
|
+
}
|
|
2142
|
+
function mergeNetworkState(primary, fallback) {
|
|
2143
|
+
if (!primary) return fallback;
|
|
2144
|
+
if (!fallback) return primary;
|
|
2145
|
+
return {
|
|
2146
|
+
...fallback,
|
|
2147
|
+
...primary,
|
|
2148
|
+
active_profile: primary.active_profile ?? fallback.active_profile ?? null,
|
|
2149
|
+
effective_profile: primary.effective_profile ?? fallback.effective_profile ?? null,
|
|
2150
|
+
configured_at: primary.configured_at ?? fallback.configured_at ?? null,
|
|
2151
|
+
managed_proxy: primary.managed_proxy ?? fallback.managed_proxy ?? null
|
|
2152
|
+
};
|
|
2153
|
+
}
|
|
2154
|
+
function translateMaestroActionsToESVP(actions, options = {}) {
|
|
2155
|
+
const translated = [];
|
|
2156
|
+
const skipped = [];
|
|
2157
|
+
const pushTranslated = (action, source, reason) => {
|
|
2158
|
+
if (action) {
|
|
2159
|
+
translated.push(action);
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
skipped.push({
|
|
2163
|
+
actionId: source.id,
|
|
2164
|
+
type: source.type,
|
|
2165
|
+
reason: reason || "unsupported"
|
|
2166
|
+
});
|
|
2167
|
+
};
|
|
2168
|
+
for (const action of actions || []) {
|
|
2169
|
+
switch (action.type) {
|
|
2170
|
+
case "launch": {
|
|
2171
|
+
const appId = String(action.appId || action.text || options.appId || "").trim();
|
|
2172
|
+
pushTranslated(
|
|
2173
|
+
appId ? {
|
|
2174
|
+
name: "launch",
|
|
2175
|
+
args: { appId },
|
|
2176
|
+
checkpointAfter: true,
|
|
2177
|
+
checkpointLabel: `launch:${appId}`
|
|
2178
|
+
} : null,
|
|
2179
|
+
action,
|
|
2180
|
+
"launch is missing an appId"
|
|
2181
|
+
);
|
|
2182
|
+
break;
|
|
2183
|
+
}
|
|
2184
|
+
case "tap": {
|
|
2185
|
+
if (typeof action.x === "number" && typeof action.y === "number") {
|
|
2186
|
+
pushTranslated(
|
|
2187
|
+
{
|
|
2188
|
+
name: "tap",
|
|
2189
|
+
args: {
|
|
2190
|
+
x: Math.round(action.x),
|
|
2191
|
+
y: Math.round(action.y)
|
|
2192
|
+
},
|
|
2193
|
+
checkpointAfter: true,
|
|
2194
|
+
checkpointLabel: action.id
|
|
2195
|
+
},
|
|
2196
|
+
action
|
|
2197
|
+
);
|
|
2198
|
+
} else if (typeof action.text === "string" && action.text.trim()) {
|
|
2199
|
+
const selector = choosePrimaryTextQuery(action.text);
|
|
2200
|
+
const selectorVariants = buildTextQueryVariants(action.text);
|
|
2201
|
+
pushTranslated(
|
|
2202
|
+
{
|
|
2203
|
+
name: "tap",
|
|
2204
|
+
args: {
|
|
2205
|
+
text: selector,
|
|
2206
|
+
selector,
|
|
2207
|
+
selectors: selectorVariants.map((candidate) => ({
|
|
2208
|
+
selector: candidate,
|
|
2209
|
+
label: candidate
|
|
2210
|
+
}))
|
|
2211
|
+
},
|
|
2212
|
+
checkpointAfter: true,
|
|
2213
|
+
checkpointLabel: action.id
|
|
2214
|
+
},
|
|
2215
|
+
action
|
|
2216
|
+
);
|
|
2217
|
+
} else {
|
|
2218
|
+
pushTranslated(null, action, "tap is missing coordinates or a selector that can be translated to ESVP");
|
|
2219
|
+
}
|
|
2220
|
+
break;
|
|
2221
|
+
}
|
|
2222
|
+
case "swipe":
|
|
2223
|
+
case "scroll": {
|
|
2224
|
+
if (typeof action.x === "number" && typeof action.y === "number" && typeof action.endX === "number" && typeof action.endY === "number") {
|
|
2225
|
+
pushTranslated(
|
|
2226
|
+
{
|
|
2227
|
+
name: "swipe",
|
|
2228
|
+
args: {
|
|
2229
|
+
x1: Math.round(action.x),
|
|
2230
|
+
y1: Math.round(action.y),
|
|
2231
|
+
x2: Math.round(action.endX),
|
|
2232
|
+
y2: Math.round(action.endY),
|
|
2233
|
+
durationMs: Math.max(120, Math.round(action.duration || 280))
|
|
2234
|
+
},
|
|
2235
|
+
checkpointAfter: true,
|
|
2236
|
+
checkpointLabel: action.id
|
|
2237
|
+
},
|
|
2238
|
+
action
|
|
2239
|
+
);
|
|
2240
|
+
} else if (action.direction) {
|
|
2241
|
+
pushTranslated(
|
|
2242
|
+
{
|
|
2243
|
+
name: "swipe",
|
|
2244
|
+
args: {
|
|
2245
|
+
direction: String(action.direction).toLowerCase()
|
|
2246
|
+
},
|
|
2247
|
+
checkpointAfter: true,
|
|
2248
|
+
checkpointLabel: action.id
|
|
2249
|
+
},
|
|
2250
|
+
action
|
|
2251
|
+
);
|
|
2252
|
+
} else {
|
|
2253
|
+
pushTranslated(null, action, "swipe/scroll is missing coordinates or direction");
|
|
2254
|
+
}
|
|
2255
|
+
break;
|
|
2256
|
+
}
|
|
2257
|
+
case "input": {
|
|
2258
|
+
const text = typeof action.text === "string" ? action.text : "";
|
|
2259
|
+
pushTranslated(
|
|
2260
|
+
text ? {
|
|
2261
|
+
name: "type",
|
|
2262
|
+
args: { text },
|
|
2263
|
+
checkpointAfter: true,
|
|
2264
|
+
checkpointLabel: action.id
|
|
2265
|
+
} : null,
|
|
2266
|
+
action,
|
|
2267
|
+
"input is missing text"
|
|
2268
|
+
);
|
|
2269
|
+
break;
|
|
2270
|
+
}
|
|
2271
|
+
case "back":
|
|
2272
|
+
pushTranslated(
|
|
2273
|
+
{
|
|
2274
|
+
name: "back",
|
|
2275
|
+
checkpointAfter: true,
|
|
2276
|
+
checkpointLabel: action.id
|
|
2277
|
+
},
|
|
2278
|
+
action
|
|
2279
|
+
);
|
|
2280
|
+
break;
|
|
2281
|
+
case "home":
|
|
2282
|
+
pushTranslated(
|
|
2283
|
+
{
|
|
2284
|
+
name: "home",
|
|
2285
|
+
checkpointAfter: true,
|
|
2286
|
+
checkpointLabel: action.id
|
|
2287
|
+
},
|
|
2288
|
+
action
|
|
2289
|
+
);
|
|
2290
|
+
break;
|
|
2291
|
+
case "pressKey": {
|
|
2292
|
+
const key = typeof action.text === "string" ? action.text : "";
|
|
2293
|
+
pushTranslated(
|
|
2294
|
+
key ? {
|
|
2295
|
+
name: "keyevent",
|
|
2296
|
+
args: { key },
|
|
2297
|
+
checkpointAfter: true,
|
|
2298
|
+
checkpointLabel: action.id
|
|
2299
|
+
} : null,
|
|
2300
|
+
action,
|
|
2301
|
+
"pressKey is missing a key value"
|
|
2302
|
+
);
|
|
2303
|
+
break;
|
|
2304
|
+
}
|
|
2305
|
+
case "wait": {
|
|
2306
|
+
const ms = typeof action.seconds === "number" ? Math.max(0, Math.round(action.seconds * 1e3)) : 1e3;
|
|
2307
|
+
pushTranslated(
|
|
2308
|
+
{
|
|
2309
|
+
name: "wait",
|
|
2310
|
+
args: { ms },
|
|
2311
|
+
checkpointAfter: true,
|
|
2312
|
+
checkpointLabel: action.id
|
|
2313
|
+
},
|
|
2314
|
+
action
|
|
2315
|
+
);
|
|
2316
|
+
break;
|
|
2317
|
+
}
|
|
2318
|
+
default:
|
|
2319
|
+
pushTranslated(null, action, `action type "${action.type}" is not supported by the ESVP adapter yet`);
|
|
2320
|
+
break;
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
return {
|
|
2324
|
+
actions: translated,
|
|
2325
|
+
skipped
|
|
2326
|
+
};
|
|
2327
|
+
}
|
|
2328
|
+
async function normalizeESVPNetworkArtifacts(sessionId, artifacts, serverUrl) {
|
|
2329
|
+
const networkArtifacts = (artifacts || []).filter((artifact) => artifact?.kind === "network_trace" && typeof artifact?.path === "string");
|
|
2330
|
+
const traceKinds = Array.from(
|
|
2331
|
+
new Set(
|
|
2332
|
+
networkArtifacts.map((artifact) => artifact?.meta && typeof artifact.meta === "object" ? String(artifact.meta.trace_kind || "") : "").filter(Boolean)
|
|
2333
|
+
)
|
|
2334
|
+
);
|
|
2335
|
+
const entries = [];
|
|
2336
|
+
for (const artifact of networkArtifacts) {
|
|
2337
|
+
const artifactPath = String(artifact.path || "");
|
|
2338
|
+
if (!artifactPath) continue;
|
|
2339
|
+
try {
|
|
2340
|
+
const payload = await getESVPArtifactContent(sessionId, artifactPath, serverUrl);
|
|
2341
|
+
const meta = artifact.meta && typeof artifact.meta === "object" ? artifact.meta : {};
|
|
2342
|
+
const traceKind = typeof meta.trace_kind === "string" ? meta.trace_kind : null;
|
|
2343
|
+
entries.push(...normalizeNetworkPayloadToEntries(payload, {
|
|
2344
|
+
traceKind,
|
|
2345
|
+
artifactPath,
|
|
2346
|
+
artifactMeta: meta
|
|
2347
|
+
}));
|
|
2348
|
+
} catch {
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
return {
|
|
2352
|
+
networkEntries: entries.sort((a, b) => (a.startedAt || 0) - (b.startedAt || 0)),
|
|
2353
|
+
networkCapture: createMobileNetworkCaptureMeta({
|
|
2354
|
+
source: "esvp-mobile",
|
|
2355
|
+
truncated: false,
|
|
2356
|
+
maxEntries: Math.max(entries.length, 1200),
|
|
2357
|
+
resourceTypes: traceKinds.length > 0 ? traceKinds : ["mobile-http-trace"]
|
|
2358
|
+
}),
|
|
2359
|
+
traceKinds
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
2362
|
+
function normalizeNetworkPayloadToEntries(payload, context) {
|
|
2363
|
+
const payloadRecord = parseEmbeddedObject(payload);
|
|
2364
|
+
const nestedPayloadRecord = payloadRecord ? parseEmbeddedObject(payloadRecord.payload) : null;
|
|
2365
|
+
const payloadLog = payloadRecord ? parseEmbeddedObject(payloadRecord.log) : null;
|
|
2366
|
+
const nestedPayloadLog = nestedPayloadRecord ? parseEmbeddedObject(nestedPayloadRecord.log) : null;
|
|
2367
|
+
if (context.traceKind === "har") {
|
|
2368
|
+
if (payloadLog && Array.isArray(payloadLog.entries)) {
|
|
2369
|
+
return normalizeHarEntries(payloadLog.entries, context);
|
|
2370
|
+
}
|
|
2371
|
+
if (nestedPayloadLog && Array.isArray(nestedPayloadLog.entries)) {
|
|
2372
|
+
return normalizeHarEntries(nestedPayloadLog.entries, context);
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
const candidates = extractTraceCandidates(payload);
|
|
2376
|
+
const normalized = candidates.map((candidate, index) => normalizeHttpTraceCandidate(candidate, index, context)).filter((entry) => entry !== null);
|
|
2377
|
+
return normalized;
|
|
2378
|
+
}
|
|
2379
|
+
function normalizeHarEntries(entries, context) {
|
|
2380
|
+
const normalized = [];
|
|
2381
|
+
for (const [index, entry] of (entries || []).entries()) {
|
|
2382
|
+
const requestUrl = String(entry?.request?.url || "").trim();
|
|
2383
|
+
if (!requestUrl) continue;
|
|
2384
|
+
const parsed = parseRequestUrl(requestUrl);
|
|
2385
|
+
const startedAt = Date.parse(String(entry?.startedDateTime || ""));
|
|
2386
|
+
const durationMs = numberOrNull(entry?.time);
|
|
2387
|
+
const finishedAt = Number.isFinite(startedAt) && durationMs != null ? startedAt + durationMs : null;
|
|
2388
|
+
const responseSize = numberOrNull(entry?.response?.content?.size) ?? numberOrNull(entry?.response?.bodySize);
|
|
2389
|
+
const requestHeaders = toHeaderMap(entry?.request?.headers);
|
|
2390
|
+
const responseHeaders = toHeaderMap(entry?.response?.headers);
|
|
2391
|
+
normalized.push({
|
|
2392
|
+
id: `net_${sanitizeIdentifier(context.artifactPath)}_${String(index + 1).padStart(4, "0")}`,
|
|
2393
|
+
url: parsed.url,
|
|
2394
|
+
origin: parsed.origin,
|
|
2395
|
+
hostname: parsed.hostname,
|
|
2396
|
+
pathname: parsed.pathname,
|
|
2397
|
+
routeKey: parsed.routeKey,
|
|
2398
|
+
method: String(entry?.request?.method || "GET").toUpperCase(),
|
|
2399
|
+
resourceType: "har",
|
|
2400
|
+
startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(),
|
|
2401
|
+
finishedAt,
|
|
2402
|
+
durationMs,
|
|
2403
|
+
status: numberOrNull(entry?.response?.status),
|
|
2404
|
+
ok: typeof entry?.response?.status === "number" ? entry.response.status < 400 : null,
|
|
2405
|
+
queryKeys: parsed.queryKeys,
|
|
2406
|
+
requestContentType: requestHeaders["content-type"] || null,
|
|
2407
|
+
responseContentType: responseHeaders["content-type"] || null,
|
|
2408
|
+
responseSize,
|
|
2409
|
+
failureText: typeof entry?.response?._error === "string" ? entry.response._error : null,
|
|
2410
|
+
requestHeaders,
|
|
2411
|
+
responseHeaders,
|
|
2412
|
+
requestBodyPreview: null,
|
|
2413
|
+
responseBodyPreview: null,
|
|
2414
|
+
requestBodyBytes: null,
|
|
2415
|
+
responseBodyBytes: responseSize
|
|
2416
|
+
});
|
|
2417
|
+
}
|
|
2418
|
+
return normalized;
|
|
2419
|
+
}
|
|
2420
|
+
function normalizeHttpTraceCandidate(candidate, index, context) {
|
|
2421
|
+
if (!isObject2(candidate)) return null;
|
|
2422
|
+
const request = isObject2(candidate.request) ? candidate.request : candidate;
|
|
2423
|
+
const response = isObject2(candidate.response) ? candidate.response : null;
|
|
2424
|
+
const url = firstString(
|
|
2425
|
+
candidate.url,
|
|
2426
|
+
request.url,
|
|
2427
|
+
context.artifactMeta.url
|
|
2428
|
+
);
|
|
2429
|
+
if (!url) return null;
|
|
2430
|
+
const parsed = parseRequestUrl(url);
|
|
2431
|
+
const method = firstString(candidate.method, request.method, context.artifactMeta.method) || "GET";
|
|
2432
|
+
const status = firstNumber(
|
|
2433
|
+
candidate.status,
|
|
2434
|
+
candidate.statusCode,
|
|
2435
|
+
response?.status,
|
|
2436
|
+
response?.statusCode,
|
|
2437
|
+
context.artifactMeta.status_code
|
|
2438
|
+
);
|
|
2439
|
+
const durationMs = firstNumber(
|
|
2440
|
+
candidate.durationMs,
|
|
2441
|
+
candidate.duration_ms,
|
|
2442
|
+
response?.durationMs,
|
|
2443
|
+
response?.duration_ms,
|
|
2444
|
+
response?.time_ms,
|
|
2445
|
+
candidate.timeMs
|
|
2446
|
+
);
|
|
2447
|
+
const startedAt = firstTimestamp(
|
|
2448
|
+
candidate.startedAt,
|
|
2449
|
+
candidate.started_at,
|
|
2450
|
+
candidate.timestamp,
|
|
2451
|
+
candidate.ts,
|
|
2452
|
+
request.startedAt,
|
|
2453
|
+
request.timestamp,
|
|
2454
|
+
request.ts
|
|
2455
|
+
) ?? Date.now();
|
|
2456
|
+
const finishedAt = durationMs != null ? startedAt + durationMs : firstTimestamp(candidate.finishedAt, candidate.finished_at) ?? null;
|
|
2457
|
+
const requestHeaders = mergeHeaderObjects(
|
|
2458
|
+
candidate.requestHeaders,
|
|
2459
|
+
candidate.request_headers,
|
|
2460
|
+
request.headers
|
|
2461
|
+
);
|
|
2462
|
+
const responseHeaders = mergeHeaderObjects(
|
|
2463
|
+
candidate.responseHeaders,
|
|
2464
|
+
candidate.response_headers,
|
|
2465
|
+
response?.headers
|
|
2466
|
+
);
|
|
2467
|
+
const requestBodyPreview = firstString(
|
|
2468
|
+
candidate.requestBodyPreview,
|
|
2469
|
+
candidate.request_body_preview,
|
|
2470
|
+
request.bodyPreview,
|
|
2471
|
+
request.body_preview
|
|
2472
|
+
);
|
|
2473
|
+
const responseBodyPreview = firstString(
|
|
2474
|
+
candidate.responseBodyPreview,
|
|
2475
|
+
candidate.response_body_preview,
|
|
2476
|
+
response?.bodyPreview,
|
|
2477
|
+
response?.body_preview
|
|
2478
|
+
);
|
|
2479
|
+
const requestBodyBytes = firstNumber(
|
|
2480
|
+
candidate.requestBodyBytes,
|
|
2481
|
+
candidate.request_body_bytes,
|
|
2482
|
+
request.bodyBytes,
|
|
2483
|
+
request.body_bytes
|
|
2484
|
+
);
|
|
2485
|
+
const responseSize = firstNumber(
|
|
2486
|
+
candidate.responseBodyBytes,
|
|
2487
|
+
candidate.response_body_bytes,
|
|
2488
|
+
candidate.responseSize,
|
|
2489
|
+
candidate.response_size,
|
|
2490
|
+
response?.size,
|
|
2491
|
+
response?.bodyBytes,
|
|
2492
|
+
response?.body_bytes,
|
|
2493
|
+
response?.contentLength,
|
|
2494
|
+
responseHeaders["content-length"]
|
|
2495
|
+
);
|
|
2496
|
+
const failureText = firstString(
|
|
2497
|
+
candidate.failureText,
|
|
2498
|
+
candidate.error,
|
|
2499
|
+
response?.error,
|
|
2500
|
+
response?.failureText
|
|
2501
|
+
);
|
|
2502
|
+
return {
|
|
2503
|
+
id: `net_${sanitizeIdentifier(context.artifactPath)}_${String(index + 1).padStart(4, "0")}`,
|
|
2504
|
+
url: parsed.url,
|
|
2505
|
+
origin: parsed.origin,
|
|
2506
|
+
hostname: parsed.hostname,
|
|
2507
|
+
pathname: parsed.pathname,
|
|
2508
|
+
routeKey: parsed.routeKey,
|
|
2509
|
+
method: String(method).toUpperCase(),
|
|
2510
|
+
resourceType: firstString(candidate.resourceType, candidate.kind, context.traceKind) || "request",
|
|
2511
|
+
startedAt,
|
|
2512
|
+
finishedAt,
|
|
2513
|
+
durationMs,
|
|
2514
|
+
status,
|
|
2515
|
+
ok: status != null ? status < 400 : null,
|
|
2516
|
+
queryKeys: parsed.queryKeys,
|
|
2517
|
+
requestContentType: firstString(
|
|
2518
|
+
candidate.requestContentType,
|
|
2519
|
+
request.contentType,
|
|
2520
|
+
requestHeaders["content-type"]
|
|
2521
|
+
),
|
|
2522
|
+
responseContentType: firstString(
|
|
2523
|
+
candidate.responseContentType,
|
|
2524
|
+
response?.contentType,
|
|
2525
|
+
responseHeaders["content-type"]
|
|
2526
|
+
),
|
|
2527
|
+
responseSize,
|
|
2528
|
+
failureText,
|
|
2529
|
+
requestHeaders,
|
|
2530
|
+
responseHeaders,
|
|
2531
|
+
requestBodyPreview,
|
|
2532
|
+
responseBodyPreview,
|
|
2533
|
+
requestBodyBytes,
|
|
2534
|
+
responseBodyBytes: responseSize
|
|
2535
|
+
};
|
|
2536
|
+
}
|
|
2537
|
+
function extractTraceCandidates(payload) {
|
|
2538
|
+
if (Array.isArray(payload)) return payload;
|
|
2539
|
+
const payloadRecord = parseEmbeddedObject(payload);
|
|
2540
|
+
if (!payloadRecord) return [];
|
|
2541
|
+
const nestedCandidates = [
|
|
2542
|
+
payloadRecord,
|
|
2543
|
+
parseEmbeddedObject(payloadRecord.payload),
|
|
2544
|
+
parseEmbeddedObject(payloadRecord.data),
|
|
2545
|
+
parseEmbeddedObject(payloadRecord.trace),
|
|
2546
|
+
parseEmbeddedObject(payloadRecord.result)
|
|
2547
|
+
].filter(Boolean);
|
|
2548
|
+
for (const candidate of nestedCandidates) {
|
|
2549
|
+
if (Array.isArray(candidate.entries)) return candidate.entries;
|
|
2550
|
+
if (Array.isArray(candidate.requests)) return candidate.requests;
|
|
2551
|
+
if (Array.isArray(candidate.events)) return candidate.events;
|
|
2552
|
+
if (Array.isArray(candidate.items)) return candidate.items;
|
|
2553
|
+
}
|
|
2554
|
+
return [payload];
|
|
2555
|
+
}
|
|
2556
|
+
function parseRequestUrl(rawUrl) {
|
|
2557
|
+
try {
|
|
2558
|
+
const parsed = new URL(rawUrl);
|
|
2559
|
+
const pathname = parsed.pathname || "/";
|
|
2560
|
+
const queryKeys = Array.from(new Set(Array.from(parsed.searchParams.keys()).filter(Boolean))).sort();
|
|
2561
|
+
return {
|
|
2562
|
+
url: buildDisplayUrl(parsed.origin, pathname),
|
|
2563
|
+
origin: parsed.origin,
|
|
2564
|
+
hostname: parsed.hostname,
|
|
2565
|
+
pathname,
|
|
2566
|
+
routeKey: `${parsed.hostname}${pathname}`,
|
|
2567
|
+
queryKeys
|
|
2568
|
+
};
|
|
2569
|
+
} catch {
|
|
2570
|
+
const [baseWithoutQuery] = String(rawUrl).split("?");
|
|
2571
|
+
const [baseWithoutFragment] = String(baseWithoutQuery || rawUrl).split("#");
|
|
2572
|
+
return {
|
|
2573
|
+
url: baseWithoutFragment || rawUrl,
|
|
2574
|
+
origin: "",
|
|
2575
|
+
hostname: "unknown host",
|
|
2576
|
+
pathname: "/",
|
|
2577
|
+
routeKey: rawUrl,
|
|
2578
|
+
queryKeys: []
|
|
2579
|
+
};
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
function buildDisplayUrl(origin, pathname) {
|
|
2583
|
+
if (origin) return `${origin}${pathname || "/"}`;
|
|
2584
|
+
return pathname || "/";
|
|
2585
|
+
}
|
|
2586
|
+
function sanitizeIdentifier(value) {
|
|
2587
|
+
return String(value || "").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40) || "trace";
|
|
2588
|
+
}
|
|
2589
|
+
function toHeaderMap(entries) {
|
|
2590
|
+
if (!Array.isArray(entries)) return {};
|
|
2591
|
+
return entries.reduce((acc, item) => {
|
|
2592
|
+
if (!isObject2(item)) return acc;
|
|
2593
|
+
const key = firstString(item.name, item.key);
|
|
2594
|
+
if (!key) return acc;
|
|
2595
|
+
const value = firstString(item.value) || "";
|
|
2596
|
+
acc[key.toLowerCase()] = value;
|
|
2597
|
+
return acc;
|
|
2598
|
+
}, {});
|
|
2599
|
+
}
|
|
2600
|
+
function headerObject(value) {
|
|
2601
|
+
if (!value) return {};
|
|
2602
|
+
if (Array.isArray(value)) return toHeaderMap(value);
|
|
2603
|
+
if (!isObject2(value)) return {};
|
|
2604
|
+
return Object.entries(value).reduce((acc, [key, entryValue]) => {
|
|
2605
|
+
acc[String(key).toLowerCase()] = String(entryValue);
|
|
2606
|
+
return acc;
|
|
2607
|
+
}, {});
|
|
2608
|
+
}
|
|
2609
|
+
function mergeHeaderObjects(...values) {
|
|
2610
|
+
return values.reduce((acc, value) => {
|
|
2611
|
+
const headers = headerObject(value);
|
|
2612
|
+
if (!headers || Object.keys(headers).length === 0) return acc;
|
|
2613
|
+
return {
|
|
2614
|
+
...acc,
|
|
2615
|
+
...headers
|
|
2616
|
+
};
|
|
2617
|
+
}, {});
|
|
2618
|
+
}
|
|
2619
|
+
function firstString(...values) {
|
|
2620
|
+
for (const value of values) {
|
|
2621
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
2622
|
+
}
|
|
2623
|
+
return null;
|
|
2624
|
+
}
|
|
2625
|
+
function firstNumber(...values) {
|
|
2626
|
+
for (const value of values) {
|
|
2627
|
+
const numeric = numberOrNull(value);
|
|
2628
|
+
if (numeric != null) return numeric;
|
|
2629
|
+
}
|
|
2630
|
+
return null;
|
|
2631
|
+
}
|
|
2632
|
+
function firstTimestamp(...values) {
|
|
2633
|
+
for (const value of values) {
|
|
2634
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
2635
|
+
if (typeof value === "string" && value.trim()) {
|
|
2636
|
+
const direct = Number(value);
|
|
2637
|
+
if (Number.isFinite(direct)) return direct;
|
|
2638
|
+
const parsed = Date.parse(value);
|
|
2639
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
return null;
|
|
2643
|
+
}
|
|
2644
|
+
function numberOrNull(value) {
|
|
2645
|
+
if (value == null || value === "") return null;
|
|
2646
|
+
const numeric = Number(value);
|
|
2647
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
2648
|
+
}
|
|
2649
|
+
function parseEmbeddedObject(value) {
|
|
2650
|
+
if (isObject2(value)) return value;
|
|
2651
|
+
if (typeof value !== "string") return null;
|
|
2652
|
+
try {
|
|
2653
|
+
const parsed = JSON.parse(value);
|
|
2654
|
+
return isObject2(parsed) ? parsed : null;
|
|
2655
|
+
} catch {
|
|
2656
|
+
return null;
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
function isObject2(value) {
|
|
2660
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
export {
|
|
2664
|
+
analyzeScreenshotsForActions,
|
|
2665
|
+
generateMaestroYaml,
|
|
2666
|
+
ensureLocalCaptureProxyProfile,
|
|
2667
|
+
listLocalCaptureProxyStates,
|
|
2668
|
+
finalizeAllLocalCaptureProxySessions,
|
|
2669
|
+
finalizeLocalCaptureProxySession,
|
|
2670
|
+
startLocalAppHttpTraceCollector,
|
|
2671
|
+
resolveLocalAppHttpTraceCollectorById,
|
|
2672
|
+
getLocalAppHttpTraceBootstrap,
|
|
2673
|
+
ingestLocalAppHttpTrace,
|
|
2674
|
+
finalizeLocalAppHttpTraceCollector,
|
|
2675
|
+
createMobileNetworkCaptureMeta,
|
|
2676
|
+
diagnoseESVPNetworkState,
|
|
2677
|
+
collectESVPSessionNetworkData,
|
|
2678
|
+
validateMaestroRecordingWithESVP,
|
|
2679
|
+
translateMaestroActionsToESVP
|
|
2680
|
+
};
|