@validate.qa/runner 1.0.12 → 1.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +928 -127
- package/dist/cli.mjs +928 -127
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -233,6 +233,9 @@ var require_ai_provider = __commonJS({
|
|
|
233
233
|
exports2.getNoThinkingRequestOptions = getNoThinkingRequestOptions;
|
|
234
234
|
exports2.getAIClient = getAIClient;
|
|
235
235
|
exports2.getClientForModel = getClientForModel;
|
|
236
|
+
exports2.shouldProxyAIThroughServerForLocalRunner = shouldProxyAIThroughServerForLocalRunner;
|
|
237
|
+
exports2.getRunnerAIClient = getRunnerAIClient;
|
|
238
|
+
exports2.getRunnerClientForModel = getRunnerClientForModel;
|
|
236
239
|
exports2.getModel = getModel;
|
|
237
240
|
exports2.resolveModelForTask = resolveModelForTask;
|
|
238
241
|
exports2.getAvailableModels = getAvailableModels;
|
|
@@ -510,6 +513,21 @@ var require_ai_provider = __commonJS({
|
|
|
510
513
|
function getClientForModel(modelId) {
|
|
511
514
|
return buildClientForProvider(getProviderForModel(modelId));
|
|
512
515
|
}
|
|
516
|
+
function shouldProxyAIThroughServerForLocalRunner() {
|
|
517
|
+
const serverUrl = (process.env.SERVER_URL ?? "").trim();
|
|
518
|
+
const token = (process.env.AUTH_TOKEN || process.env.RUNNER_TOKEN || "").trim();
|
|
519
|
+
return serverUrl.length > 0 && token.startsWith("urt_");
|
|
520
|
+
}
|
|
521
|
+
function getRunnerAIClient() {
|
|
522
|
+
if (shouldProxyAIThroughServerForLocalRunner())
|
|
523
|
+
return getServerProxiedAIClient();
|
|
524
|
+
return getAIClient();
|
|
525
|
+
}
|
|
526
|
+
function getRunnerClientForModel(modelId) {
|
|
527
|
+
if (shouldProxyAIThroughServerForLocalRunner())
|
|
528
|
+
return getServerProxiedClientForModel(modelId);
|
|
529
|
+
return getClientForModel(modelId);
|
|
530
|
+
}
|
|
513
531
|
function getModel(type) {
|
|
514
532
|
return MODEL_TASK_MAP[getActiveModelId()][type];
|
|
515
533
|
}
|
|
@@ -580,7 +598,7 @@ var require_ai_provider = __commonJS({
|
|
|
580
598
|
const create = async (params, options) => {
|
|
581
599
|
const modelId = typeof params.model === "string" ? params.model : "";
|
|
582
600
|
if (!modelId) {
|
|
583
|
-
throw new Error("server-proxied AI client requires params.model
|
|
601
|
+
throw new Error("server-proxied AI client requires params.model");
|
|
584
602
|
}
|
|
585
603
|
const body = {
|
|
586
604
|
modelId,
|
|
@@ -750,6 +768,21 @@ var require_mobile_source_parser = __commonJS({
|
|
|
750
768
|
const attrText = inner.slice(tag.length);
|
|
751
769
|
return { tag, attrs: parseAttributes(attrText), selfClosing };
|
|
752
770
|
}
|
|
771
|
+
function findTagEnd(xml, from) {
|
|
772
|
+
let quote = null;
|
|
773
|
+
for (let i = from; i < xml.length; i++) {
|
|
774
|
+
const ch = xml[i];
|
|
775
|
+
if (quote !== null) {
|
|
776
|
+
if (ch === quote)
|
|
777
|
+
quote = null;
|
|
778
|
+
} else if (ch === '"' || ch === "'") {
|
|
779
|
+
quote = ch;
|
|
780
|
+
} else if (ch === ">") {
|
|
781
|
+
return i;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return -1;
|
|
785
|
+
}
|
|
753
786
|
function tokenize(xml) {
|
|
754
787
|
if (!xml || typeof xml !== "string")
|
|
755
788
|
return null;
|
|
@@ -772,7 +805,7 @@ var require_mobile_source_parser = __commonJS({
|
|
|
772
805
|
i = end === -1 ? len : end + 3;
|
|
773
806
|
continue;
|
|
774
807
|
}
|
|
775
|
-
const gt = xml
|
|
808
|
+
const gt = findTagEnd(xml, lt + 1);
|
|
776
809
|
if (gt === -1)
|
|
777
810
|
break;
|
|
778
811
|
const tagBody = xml.slice(lt + 1, gt);
|
|
@@ -1558,6 +1591,10 @@ function buildAppiumCode(testName, target, steps) {
|
|
|
1558
1591
|
` }`,
|
|
1559
1592
|
`}`,
|
|
1560
1593
|
``,
|
|
1594
|
+
`async function __clearFocused(driver: WebdriverIO.Browser) {`,
|
|
1595
|
+
` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).clearValue();`,
|
|
1596
|
+
`}`,
|
|
1597
|
+
``,
|
|
1561
1598
|
`async function __textCandidates(el: WebdriverIO.Element) {`,
|
|
1562
1599
|
` const __values: string[] = [];`,
|
|
1563
1600
|
` const __push = (__value: unknown) => {`,
|
|
@@ -1651,6 +1688,12 @@ function buildAppiumCode(testName, target, steps) {
|
|
|
1651
1688
|
lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
|
|
1652
1689
|
} else if (step.action === "clear" && hasSelector) {
|
|
1653
1690
|
lines.push(` await (await findFirst(driver, ${candidatesLiteral})).clearValue();`);
|
|
1691
|
+
} else if (step.action === "clear" && bounds) {
|
|
1692
|
+
const x = Math.round(bounds.x + bounds.width / 2);
|
|
1693
|
+
const y = Math.round(bounds.y + bounds.height / 2);
|
|
1694
|
+
lines.push(` await __tapAt(driver, ${x}, ${y});`);
|
|
1695
|
+
lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
|
|
1696
|
+
lines.push(` await __clearFocused(driver);`);
|
|
1654
1697
|
} else if (step.action === "assertVisible" && hasSelector) {
|
|
1655
1698
|
lines.push(` if (!(await (await findFirst(driver, ${candidatesLiteral})).isDisplayed())) {`);
|
|
1656
1699
|
lines.push(` throw new Error('Assertion failed: element not displayed: ' + ${toJsStringLiteral(candidatesLiteral)});`);
|
|
@@ -1658,6 +1701,7 @@ function buildAppiumCode(testName, target, steps) {
|
|
|
1658
1701
|
} else if (step.action === "assertText" && hasSelector) {
|
|
1659
1702
|
lines.push(` {`);
|
|
1660
1703
|
lines.push(` const __expected = ${toJsStringLiteral(step.assertion ?? "")};`);
|
|
1704
|
+
lines.push(` if (__expected.length === 0) throw new Error('assertText step is missing expected text');`);
|
|
1661
1705
|
lines.push(` const __actual = await __textCandidates(await findFirst(driver, ${candidatesLiteral}));`);
|
|
1662
1706
|
lines.push(` if (!__actual.some((__value) => __value.includes(__expected))) {`);
|
|
1663
1707
|
lines.push(` throw new Error('Assertion failed: expected text to contain ' + JSON.stringify(__expected) + ' but got ' + JSON.stringify(__actual));`);
|
|
@@ -4366,6 +4410,7 @@ var require_mobile_observation = __commonJS({
|
|
|
4366
4410
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
4367
4411
|
exports2.renderMobileObservation = renderMobileObservation;
|
|
4368
4412
|
exports2.summarizeScreenForEvidence = summarizeScreenForEvidence;
|
|
4413
|
+
exports2.buildCollectedElementsForScreen = buildCollectedElementsForScreen;
|
|
4369
4414
|
var DEFAULT_MAX_ELEMENTS = 60;
|
|
4370
4415
|
var MAX_LABEL_LENGTH = 80;
|
|
4371
4416
|
var EVIDENCE_LABEL_LIMIT = 40;
|
|
@@ -4407,6 +4452,46 @@ var require_mobile_observation = __commonJS({
|
|
|
4407
4452
|
}
|
|
4408
4453
|
return { elementCount, actionableCount, labels };
|
|
4409
4454
|
}
|
|
4455
|
+
function buildCollectedElementsForScreen(elements, opts = {}) {
|
|
4456
|
+
const navigationRefs = opts.navigationRefs ?? EMPTY_REF_SET;
|
|
4457
|
+
const out = [];
|
|
4458
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4459
|
+
for (const element of elements) {
|
|
4460
|
+
if (!element.actionable)
|
|
4461
|
+
continue;
|
|
4462
|
+
const collected = toCollectedPageElement(element, navigationRefs.has(element.ref));
|
|
4463
|
+
const identity = `${collected.elementType}|${collected.testId ?? ""}|${collected.label ?? ""}`;
|
|
4464
|
+
if (seen.has(identity))
|
|
4465
|
+
continue;
|
|
4466
|
+
seen.add(identity);
|
|
4467
|
+
out.push(collected);
|
|
4468
|
+
}
|
|
4469
|
+
return out;
|
|
4470
|
+
}
|
|
4471
|
+
var EMPTY_REF_SET = /* @__PURE__ */ new Set();
|
|
4472
|
+
function toCollectedPageElement(element, triggersNavigation) {
|
|
4473
|
+
const label = element.label?.trim() || element.text?.trim() || void 0;
|
|
4474
|
+
const stableId = element.accessibilityId?.trim() || element.resourceId?.trim() || void 0;
|
|
4475
|
+
const locators = [];
|
|
4476
|
+
if (stableId)
|
|
4477
|
+
locators.push({ strategy: "getByTestId", value: stableId, confidence: 0.95 });
|
|
4478
|
+
const xpath = element.xpath?.trim();
|
|
4479
|
+
if (xpath)
|
|
4480
|
+
locators.push({ strategy: "css", value: xpath, confidence: 0.4 });
|
|
4481
|
+
const collected = {
|
|
4482
|
+
elementType: element.role || "View",
|
|
4483
|
+
triggersNavigation
|
|
4484
|
+
};
|
|
4485
|
+
if (label)
|
|
4486
|
+
collected.label = label;
|
|
4487
|
+
if (stableId)
|
|
4488
|
+
collected.testId = stableId;
|
|
4489
|
+
if (locators.length > 0)
|
|
4490
|
+
collected.locators = locators;
|
|
4491
|
+
if (element.enabled === false)
|
|
4492
|
+
collected.isDisabled = true;
|
|
4493
|
+
return collected;
|
|
4494
|
+
}
|
|
4410
4495
|
function buildHeaderLine(screen) {
|
|
4411
4496
|
const parts = ["screen:"];
|
|
4412
4497
|
const name = screenDisplayName(screen.screenSignal);
|
|
@@ -4855,7 +4940,7 @@ var require_mobile_explorer = __commonJS({
|
|
|
4855
4940
|
}
|
|
4856
4941
|
return sections.join("\n");
|
|
4857
4942
|
}
|
|
4858
|
-
function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId) {
|
|
4943
|
+
function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId, existingScreens) {
|
|
4859
4944
|
const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
|
|
4860
4945
|
const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
|
|
4861
4946
|
const surveyBody = `You are a mobile QA exploration agent driving a real ${platformLabel} app on a connected device. Your goal: MAP every reachable screen so downstream agents know what to test. You are NOT testing \u2014 you are building a complete screen map.
|
|
@@ -4901,13 +4986,18 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
|
|
|
4901
4986
|
## Application Brief
|
|
4902
4987
|
${appBrief}
|
|
4903
4988
|
Use this to prioritize which screens and flows to cover.` : "";
|
|
4904
|
-
|
|
4989
|
+
const existingScreensBlock = mode === "survey" && existingScreens && existingScreens.length > 0 ? `
|
|
4990
|
+
|
|
4991
|
+
## ALREADY DISCOVERED SCREENS (skip these; find new)
|
|
4992
|
+
These screens were mapped in a previous survey. Do NOT re-map them \u2014 use your budget to find NEW screens and flows not listed here. If you land on one of these, move on to something new.
|
|
4993
|
+
${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.name}"` : ""}${s.screenType ? ` [${s.screenType}]` : ""}`).join("\n")}` : "";
|
|
4994
|
+
return `${mode === "survey" ? surveyBody : deepBody}${briefBlock}${existingScreensBlock}`;
|
|
4905
4995
|
}
|
|
4906
4996
|
function buildKickoffMessage(mode) {
|
|
4907
4997
|
return mode === "survey" ? "Start by calling mobile_snapshot on the launch screen, then capture_screen. Map the entire app: open every tab, menu, and one row per list. Record transitions as you go." : "Start by calling mobile_snapshot on the launch screen. Then use the features: fill fields, submit forms, open menus, and observe outcomes. Record journeys after meaningful flows.";
|
|
4908
4998
|
}
|
|
4909
4999
|
async function runMobileExplorePhase(args) {
|
|
4910
|
-
const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot } = args;
|
|
5000
|
+
const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot, onScreenshotUpload, existingScreens } = args;
|
|
4911
5001
|
const logs2 = [];
|
|
4912
5002
|
const log2 = (line) => {
|
|
4913
5003
|
logs2.push(line);
|
|
@@ -4931,6 +5021,9 @@ Use this to prioritize which screens and flows to cover.` : "";
|
|
|
4931
5021
|
log2(`Budget: ${maxIterations} iterations | app: ${ctx.target?.appId ?? "(unknown)"}`);
|
|
4932
5022
|
const screenshots = [];
|
|
4933
5023
|
const pageScreenshots = {};
|
|
5024
|
+
const pageScreenshotKeys = {};
|
|
5025
|
+
const uploadedScreenIds = /* @__PURE__ */ new Set();
|
|
5026
|
+
const pendingScreenshotUploads = [];
|
|
4934
5027
|
const collectedTransitions = [];
|
|
4935
5028
|
const journeys = [];
|
|
4936
5029
|
const counters = {
|
|
@@ -4949,6 +5042,14 @@ Use this to prioritize which screens and flows to cover.` : "";
|
|
|
4949
5042
|
screenshots.push(base64);
|
|
4950
5043
|
if (screenId)
|
|
4951
5044
|
pageScreenshots[screenId] = base64;
|
|
5045
|
+
if (screenId && onScreenshotUpload && !uploadedScreenIds.has(screenId)) {
|
|
5046
|
+
uploadedScreenIds.add(screenId);
|
|
5047
|
+
pendingScreenshotUploads.push(onScreenshotUpload(screenId, base64).then((key) => {
|
|
5048
|
+
if (key)
|
|
5049
|
+
pageScreenshotKeys[screenId] = key;
|
|
5050
|
+
}).catch(() => {
|
|
5051
|
+
}));
|
|
5052
|
+
}
|
|
4952
5053
|
try {
|
|
4953
5054
|
onScreenshot?.(base64);
|
|
4954
5055
|
} catch {
|
|
@@ -5053,7 +5154,7 @@ Use this to prioritize which screens and flows to cover.` : "";
|
|
|
5053
5154
|
return null;
|
|
5054
5155
|
return { element, bounds: element.bounds };
|
|
5055
5156
|
};
|
|
5056
|
-
const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId);
|
|
5157
|
+
const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId, existingScreens);
|
|
5057
5158
|
const kickoff = buildKickoffMessage(mode);
|
|
5058
5159
|
const recentExchanges = [];
|
|
5059
5160
|
const transientDirectives = [];
|
|
@@ -5261,6 +5362,9 @@ Exploration complete: ${summary}`);
|
|
|
5261
5362
|
summary = `Explore phase aborted (${truncatedReason}) after mapping ${state2.getScreenCount()} screen(s).`;
|
|
5262
5363
|
}
|
|
5263
5364
|
}
|
|
5365
|
+
if (pendingScreenshotUploads.length > 0) {
|
|
5366
|
+
await Promise.allSettled(pendingScreenshotUploads);
|
|
5367
|
+
}
|
|
5264
5368
|
const collectedPages = state2.buildInMemoryScreenMap();
|
|
5265
5369
|
counters.pagesVisited = collectedPages.length;
|
|
5266
5370
|
if (counters.pagesDiscovered < collectedPages.length) {
|
|
@@ -5295,6 +5399,9 @@ Exploration complete: ${summary}`);
|
|
|
5295
5399
|
if (Object.keys(pageScreenshots).length > 0) {
|
|
5296
5400
|
result.pageScreenshots = pageScreenshots;
|
|
5297
5401
|
}
|
|
5402
|
+
if (Object.keys(pageScreenshotKeys).length > 0) {
|
|
5403
|
+
result.pageScreenshotKeys = pageScreenshotKeys;
|
|
5404
|
+
}
|
|
5298
5405
|
if (truncated) {
|
|
5299
5406
|
result.truncated = true;
|
|
5300
5407
|
result.truncatedReason = truncatedReason;
|
|
@@ -5796,6 +5903,44 @@ var require_snapshot_parser = __commonJS({
|
|
|
5796
5903
|
};
|
|
5797
5904
|
var SUBMIT_LABEL_RE = /^(submit|sign\s*in|sign\s*up|log\s*in|register|create|save|send|confirm|continue|next|apply|post|publish|add|update|go)$/i;
|
|
5798
5905
|
var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
|
|
5906
|
+
var RICH_CONTEXT_ROLES = /* @__PURE__ */ new Set(["table", "grid", "row", "radiogroup"]);
|
|
5907
|
+
var RICH_CONTEXT_LABEL_MAX = 60;
|
|
5908
|
+
var VALUE_BEARING_ROLES = /* @__PURE__ */ new Set(["textbox", "searchbox", "combobox", "spinbutton", "listbox", "slider"]);
|
|
5909
|
+
var SENSITIVE_VALUE_LABEL_RE = /\bpass(?:word|code|phrase)?\b|\bpassport\b|\bpwd\b|\bsecrets?\b|\btokens?\b|\botp\b|one[- ]?time|\bpin\b|\bcvv\b|\bcvc\b|card\s*number|\bssn\b|social\s*security|security\s*(?:answer|question)|api[-_ ]?key|private\s*key|\bauth\b|authoriz|authenticat|credential/i;
|
|
5910
|
+
var REDACTED_VALUE = "[redacted]";
|
|
5911
|
+
var VALUE_TEXT_MAX = 120;
|
|
5912
|
+
var SIGNAL_TEXT_RE = /\b(?:showing|displaying|page)\b[\s\S]*\b(?:of|results?|items?|entries)\b|\b(?:no|nothing)\b[\s\S]*\b(?:found|yet|available|results?|items?)\b|\bempty\b|\b(?:error|invalid|required|failed|expired?|success(?:fully)?|saved|updated|deleted|created|welcome)\b/i;
|
|
5913
|
+
var STATIC_MESSAGES_MAX = 12;
|
|
5914
|
+
var OPTIONS_PER_ELEMENT_MAX = 24;
|
|
5915
|
+
var URL_CHILD_LINE_RE = /^(\s*)- \/url:\s*(.+)$/;
|
|
5916
|
+
var MESSAGE_TOKEN_RE = /\b(?:sk|pk|rk|ak|key|tok)[-_][A-Za-z0-9_-]{8,}\b|\b[A-Za-z0-9_-]{24,}\b/g;
|
|
5917
|
+
function stripYamlQuotes(raw) {
|
|
5918
|
+
const trimmed = raw.trim();
|
|
5919
|
+
if (trimmed.length >= 2 && (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
5920
|
+
return trimmed.slice(1, -1);
|
|
5921
|
+
}
|
|
5922
|
+
return trimmed;
|
|
5923
|
+
}
|
|
5924
|
+
function extractTrailingText(attrs) {
|
|
5925
|
+
const lastBracket = attrs.lastIndexOf("]");
|
|
5926
|
+
const tail = lastBracket >= 0 ? attrs.slice(lastBracket + 1) : attrs;
|
|
5927
|
+
const match = /^\s*:\s+(.+)$/.exec(tail);
|
|
5928
|
+
if (!match)
|
|
5929
|
+
return null;
|
|
5930
|
+
const text = stripYamlQuotes(match[1]);
|
|
5931
|
+
if (!text || /^(?:\||\|-|>|>-)$/.test(text))
|
|
5932
|
+
return null;
|
|
5933
|
+
return text;
|
|
5934
|
+
}
|
|
5935
|
+
function isSensitiveValueField(name, placeholder) {
|
|
5936
|
+
return SENSITIVE_VALUE_LABEL_RE.test(`${name ?? ""} ${placeholder ?? ""}`);
|
|
5937
|
+
}
|
|
5938
|
+
function scrubMessageText(text) {
|
|
5939
|
+
return text.replace(MESSAGE_TOKEN_RE, REDACTED_VALUE);
|
|
5940
|
+
}
|
|
5941
|
+
function capText(text, max) {
|
|
5942
|
+
return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
|
|
5943
|
+
}
|
|
5799
5944
|
function isProbablyDynamicTestId(testId) {
|
|
5800
5945
|
if (testId.length < 6)
|
|
5801
5946
|
return false;
|
|
@@ -5871,9 +6016,12 @@ var require_snapshot_parser = __commonJS({
|
|
|
5871
6016
|
}
|
|
5872
6017
|
return e.name;
|
|
5873
6018
|
}
|
|
5874
|
-
function parseSnapshot(snapshotText) {
|
|
6019
|
+
function parseSnapshot(snapshotText, options = {}) {
|
|
6020
|
+
const richParse = options.richParse ?? process.env.DISCOVERY_SNAPSHOT_RICH_PARSE === "true";
|
|
5875
6021
|
const lines = snapshotText.split("\n");
|
|
5876
6022
|
const elements = [];
|
|
6023
|
+
const liveMessages = [];
|
|
6024
|
+
const textMessages = [];
|
|
5877
6025
|
const seenRefs = /* @__PURE__ */ new Set();
|
|
5878
6026
|
let parsedElementLineCount = 0;
|
|
5879
6027
|
let genericLabelCount = 0;
|
|
@@ -5882,6 +6030,21 @@ var require_snapshot_parser = __commonJS({
|
|
|
5882
6030
|
const contextStack = [];
|
|
5883
6031
|
const completedForms = [];
|
|
5884
6032
|
for (const line of lines) {
|
|
6033
|
+
if (richParse) {
|
|
6034
|
+
const urlMatch = URL_CHILD_LINE_RE.exec(line);
|
|
6035
|
+
if (urlMatch) {
|
|
6036
|
+
const urlIndent = urlMatch[1].length;
|
|
6037
|
+
for (let i = elements.length - 1; i >= 0; i--) {
|
|
6038
|
+
const el = elements[i];
|
|
6039
|
+
if (el.indentDepth < urlIndent) {
|
|
6040
|
+
if (el.role === "link" && !el.href)
|
|
6041
|
+
el.href = capText(stripYamlQuotes(urlMatch[2]), 300);
|
|
6042
|
+
break;
|
|
6043
|
+
}
|
|
6044
|
+
}
|
|
6045
|
+
continue;
|
|
6046
|
+
}
|
|
6047
|
+
}
|
|
5885
6048
|
const match = ELEMENT_LINE_RE.exec(line);
|
|
5886
6049
|
if (!match)
|
|
5887
6050
|
continue;
|
|
@@ -5903,7 +6066,7 @@ var require_snapshot_parser = __commonJS({
|
|
|
5903
6066
|
}
|
|
5904
6067
|
const headingLevelMatch = LEVEL_RE.exec(attrs);
|
|
5905
6068
|
const headingLevel = headingLevelMatch ? Number.parseInt(headingLevelMatch[1], 10) : null;
|
|
5906
|
-
const contextLabel = buildContextLabel(role, name ?? null, headingLevel);
|
|
6069
|
+
const contextLabel = buildContextLabel(role, name ?? null, headingLevel, richParse);
|
|
5907
6070
|
if (contextLabel) {
|
|
5908
6071
|
contextStack.push({ indent: indentDepth, label: contextLabel });
|
|
5909
6072
|
}
|
|
@@ -5916,6 +6079,40 @@ var require_snapshot_parser = __commonJS({
|
|
|
5916
6079
|
});
|
|
5917
6080
|
continue;
|
|
5918
6081
|
}
|
|
6082
|
+
if (richParse) {
|
|
6083
|
+
if (role === "option" && name) {
|
|
6084
|
+
for (let i = elements.length - 1; i >= 0; i--) {
|
|
6085
|
+
const el = elements[i];
|
|
6086
|
+
if (el.indentDepth < indentDepth) {
|
|
6087
|
+
if (el.role === "combobox" || el.role === "listbox") {
|
|
6088
|
+
el.options ??= [];
|
|
6089
|
+
if (el.options.length < OPTIONS_PER_ELEMENT_MAX) {
|
|
6090
|
+
el.options.push({ label: capText(name, 80), selected: parseBooleanState(SELECTED_RE.exec(attrs)) === true });
|
|
6091
|
+
}
|
|
6092
|
+
}
|
|
6093
|
+
break;
|
|
6094
|
+
}
|
|
6095
|
+
}
|
|
6096
|
+
}
|
|
6097
|
+
if (role === "alert" || role === "status" || role === "paragraph" || role === "text") {
|
|
6098
|
+
const text = extractTrailingText(attrs) ?? (name || null);
|
|
6099
|
+
const isLiveRegion = role === "alert" || role === "status";
|
|
6100
|
+
if (text && (isLiveRegion || SIGNAL_TEXT_RE.test(text))) {
|
|
6101
|
+
const capped = capText(scrubMessageText(text), VALUE_TEXT_MAX);
|
|
6102
|
+
const ref2 = REF_RE.exec(attrs)?.[1] ?? null;
|
|
6103
|
+
if (isLiveRegion) {
|
|
6104
|
+
const textDupIndex = textMessages.findIndex((m) => m.text === capped);
|
|
6105
|
+
if (textDupIndex >= 0)
|
|
6106
|
+
textMessages.splice(textDupIndex, 1);
|
|
6107
|
+
if (liveMessages.length < STATIC_MESSAGES_MAX && !liveMessages.some((m) => m.text === capped)) {
|
|
6108
|
+
liveMessages.push({ role, text: capped, ref: ref2 });
|
|
6109
|
+
}
|
|
6110
|
+
} else if (textMessages.length < STATIC_MESSAGES_MAX && !textMessages.some((m) => m.text === capped) && !liveMessages.some((m) => m.text === capped)) {
|
|
6111
|
+
textMessages.push({ role: "text", text: capped, ref: ref2 });
|
|
6112
|
+
}
|
|
6113
|
+
}
|
|
6114
|
+
}
|
|
6115
|
+
}
|
|
5919
6116
|
if (!INTERACTIVE_ROLES.has(role))
|
|
5920
6117
|
continue;
|
|
5921
6118
|
const refMatch = REF_RE.exec(attrs);
|
|
@@ -5948,6 +6145,13 @@ var require_snapshot_parser = __commonJS({
|
|
|
5948
6145
|
}
|
|
5949
6146
|
const isLink = role === "link";
|
|
5950
6147
|
const isSubmitButton = role === "button" && name != null && SUBMIT_LABEL_RE.test(name);
|
|
6148
|
+
let value = null;
|
|
6149
|
+
if (richParse && VALUE_BEARING_ROLES.has(role)) {
|
|
6150
|
+
const trailing = extractTrailingText(attrs);
|
|
6151
|
+
if (trailing) {
|
|
6152
|
+
value = isSensitiveValueField(name ?? null, placeholder) ? REDACTED_VALUE : capText(trailing, VALUE_TEXT_MAX);
|
|
6153
|
+
}
|
|
6154
|
+
}
|
|
5951
6155
|
const element = {
|
|
5952
6156
|
ref,
|
|
5953
6157
|
role,
|
|
@@ -5969,7 +6173,10 @@ var require_snapshot_parser = __commonJS({
|
|
|
5969
6173
|
pressed,
|
|
5970
6174
|
headingLevel,
|
|
5971
6175
|
currentState,
|
|
5972
|
-
containerPath
|
|
6176
|
+
containerPath,
|
|
6177
|
+
href: null,
|
|
6178
|
+
value,
|
|
6179
|
+
options: null
|
|
5973
6180
|
};
|
|
5974
6181
|
if (isGenericLabel(element.name))
|
|
5975
6182
|
genericLabelCount++;
|
|
@@ -5997,6 +6204,9 @@ var require_snapshot_parser = __commonJS({
|
|
|
5997
6204
|
return {
|
|
5998
6205
|
elements,
|
|
5999
6206
|
formGroups: completedForms,
|
|
6207
|
+
// live regions first: they carry their own reservation so an end-of-body toast can
|
|
6208
|
+
// never be starved out by signal-matching plain text earlier in the document.
|
|
6209
|
+
staticMessages: [...liveMessages, ...textMessages].slice(0, STATIC_MESSAGES_MAX),
|
|
6000
6210
|
rawLineCount: lines.length,
|
|
6001
6211
|
parsedElementLineCount,
|
|
6002
6212
|
genericLabelCount,
|
|
@@ -6107,12 +6317,17 @@ var require_snapshot_parser = __commonJS({
|
|
|
6107
6317
|
return "mixed";
|
|
6108
6318
|
return false;
|
|
6109
6319
|
}
|
|
6110
|
-
function buildContextLabel(role, name, headingLevel) {
|
|
6320
|
+
function buildContextLabel(role, name, headingLevel, richContext = false) {
|
|
6111
6321
|
if (role === "heading") {
|
|
6112
6322
|
if (!name)
|
|
6113
6323
|
return null;
|
|
6114
6324
|
return headingLevel ? `heading${headingLevel}:${name}` : `heading:${name}`;
|
|
6115
6325
|
}
|
|
6326
|
+
if (richContext && RICH_CONTEXT_ROLES.has(role)) {
|
|
6327
|
+
if (!name)
|
|
6328
|
+
return null;
|
|
6329
|
+
return `${role}:${capText(name.replace(/>/g, "\u203A"), RICH_CONTEXT_LABEL_MAX)}`;
|
|
6330
|
+
}
|
|
6116
6331
|
if (!CONTEXT_ROLES.has(role))
|
|
6117
6332
|
return null;
|
|
6118
6333
|
if (name)
|
|
@@ -7822,7 +8037,7 @@ Each entry is exactly one of:
|
|
|
7822
8037
|
const systemPrompt = options.systemPromptOverride ?? (context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports2.SCENARIO_SYSTEM_PROMPT);
|
|
7823
8038
|
const userPrompt = options.userPromptOverride ?? formatEvidenceForPlanner(compressed, evidence, context);
|
|
7824
8039
|
logs2.push(`AI planner: ~${Math.ceil(userPrompt.length / 3.5)} input tokens`);
|
|
7825
|
-
const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.
|
|
8040
|
+
const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
|
|
7826
8041
|
const messages = [
|
|
7827
8042
|
{ role: "system", content: systemPrompt },
|
|
7828
8043
|
{ role: "user", content: userPrompt }
|
|
@@ -7830,18 +8045,22 @@ Each entry is exactly one of:
|
|
|
7830
8045
|
const callStart = Date.now();
|
|
7831
8046
|
let response;
|
|
7832
8047
|
try {
|
|
7833
|
-
response = await (0, ai_retry_js_1.
|
|
7834
|
-
|
|
7835
|
-
|
|
7836
|
-
|
|
7837
|
-
|
|
7838
|
-
|
|
7839
|
-
|
|
7840
|
-
|
|
8048
|
+
response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
|
|
8049
|
+
arm();
|
|
8050
|
+
return aiClient.chat.completions.create({
|
|
8051
|
+
model: agentModel,
|
|
8052
|
+
messages,
|
|
8053
|
+
max_tokens: plannerMaxTokens,
|
|
8054
|
+
temperature: 0.3,
|
|
8055
|
+
...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
|
|
8056
|
+
...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
|
|
8057
|
+
}, { signal });
|
|
8058
|
+
}), {
|
|
7841
8059
|
label: "ai-planner",
|
|
7842
8060
|
baseDelayMs: 5e3,
|
|
7843
8061
|
maxRetries: 2,
|
|
7844
|
-
|
|
8062
|
+
deferTimeoutUntilArmed: true,
|
|
8063
|
+
onRetry: (attempt, reason, delay) => logs2.push(`AI planner call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
|
|
7845
8064
|
});
|
|
7846
8065
|
} catch (err) {
|
|
7847
8066
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -7907,18 +8126,25 @@ Each entry is exactly one of:
|
|
|
7907
8126
|
const retryStart = Date.now();
|
|
7908
8127
|
let retryResponse = null;
|
|
7909
8128
|
try {
|
|
7910
|
-
retryResponse = await (0, ai_retry_js_1.
|
|
7911
|
-
|
|
7912
|
-
|
|
7913
|
-
|
|
7914
|
-
|
|
7915
|
-
|
|
7916
|
-
|
|
7917
|
-
|
|
7918
|
-
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
|
|
8129
|
+
retryResponse = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
|
|
8130
|
+
arm();
|
|
8131
|
+
return aiClient.chat.completions.create({
|
|
8132
|
+
model: agentModel,
|
|
8133
|
+
messages: [
|
|
8134
|
+
...messages,
|
|
8135
|
+
{ role: "assistant", content: raw },
|
|
8136
|
+
{ role: "user", content: retryContext }
|
|
8137
|
+
],
|
|
8138
|
+
max_tokens: plannerMaxTokens,
|
|
8139
|
+
temperature: 0.3,
|
|
8140
|
+
...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
|
|
8141
|
+
...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
|
|
8142
|
+
}, { signal });
|
|
8143
|
+
}), {
|
|
8144
|
+
label: "ai-planner-retry",
|
|
8145
|
+
deferTimeoutUntilArmed: true,
|
|
8146
|
+
onRetry: (attempt, reason, delay) => logs2.push(`AI planner retry call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
|
|
8147
|
+
});
|
|
7922
8148
|
} catch (err) {
|
|
7923
8149
|
const message = err instanceof Error ? err.message : String(err);
|
|
7924
8150
|
logs2.push(`AI planner retry failed (${message}) \u2014 using first-pass result`);
|
|
@@ -8446,12 +8672,12 @@ ${constraints.join("\n")}`);
|
|
|
8446
8672
|
const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
|
|
8447
8673
|
const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
|
|
8448
8674
|
for (const state2 of orderedScreens) {
|
|
8449
|
-
const groupName = state2.screenType === "auth" ? "Authentication" : screenDisplayName(state2);
|
|
8675
|
+
const groupName = state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(state2);
|
|
8450
8676
|
const group = groups.get(groupName) ?? {
|
|
8451
8677
|
name: groupName,
|
|
8452
8678
|
description: `Native mobile screens related to ${groupName}.`,
|
|
8453
8679
|
routes: [],
|
|
8454
|
-
criticality: state2.screenType === "auth" ? "critical" : "medium",
|
|
8680
|
+
criticality: state2.screenType?.toLowerCase() === "auth" ? "critical" : "medium",
|
|
8455
8681
|
pages: [],
|
|
8456
8682
|
flows: []
|
|
8457
8683
|
};
|
|
@@ -8474,7 +8700,7 @@ ${constraints.join("\n")}`);
|
|
|
8474
8700
|
const to = graph.screens.get(transition.toScreenId);
|
|
8475
8701
|
if (!from || !to)
|
|
8476
8702
|
continue;
|
|
8477
|
-
const groupName = from.screenType === "auth" ? "Authentication" : screenDisplayName(from);
|
|
8703
|
+
const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(from);
|
|
8478
8704
|
const group = groups.get(groupName);
|
|
8479
8705
|
if (!group)
|
|
8480
8706
|
continue;
|
|
@@ -8519,9 +8745,9 @@ ${constraints.join("\n")}`);
|
|
|
8519
8745
|
scenarios.push({
|
|
8520
8746
|
name,
|
|
8521
8747
|
type: "HAPPY_PATH",
|
|
8522
|
-
priority: state2.screenType === "auth" ? 1 : 3,
|
|
8748
|
+
priority: state2.screenType?.toLowerCase() === "auth" ? 1 : 3,
|
|
8523
8749
|
variant: "happy",
|
|
8524
|
-
featureGroup: state2.screenType === "auth" ? "Authentication" : screenName,
|
|
8750
|
+
featureGroup: state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenName,
|
|
8525
8751
|
goal: `User can reach and inspect the ${screenName} screen`,
|
|
8526
8752
|
steps,
|
|
8527
8753
|
expectedOutcomes: [{
|
|
@@ -8667,6 +8893,7 @@ var require_mobile_generator = __commonJS({
|
|
|
8667
8893
|
var mobile_observation_js_1 = require_mobile_observation();
|
|
8668
8894
|
var mobile_boundary_js_1 = require_mobile_boundary();
|
|
8669
8895
|
var MAX_SCENARIO_ITERATIONS = 30;
|
|
8896
|
+
var MAX_CONSECUTIVE_INFRA_FAILURES = 3;
|
|
8670
8897
|
var MAX_RECENT_EXCHANGES = 24;
|
|
8671
8898
|
var OBSERVATION_MAX_ELEMENTS = 50;
|
|
8672
8899
|
function finalizeAction(input) {
|
|
@@ -9391,14 +9618,31 @@ ${statePacket}` },
|
|
|
9391
9618
|
if (!snapshot || snapshot.screen.elements.length === 0)
|
|
9392
9619
|
return null;
|
|
9393
9620
|
const elements = snapshot.screen.elements;
|
|
9394
|
-
const
|
|
9395
|
-
const
|
|
9621
|
+
const stableAnchors = elements.filter((el) => hasStableId(el) && !isAlwaysPresentContainer(el));
|
|
9622
|
+
const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
|
|
9623
|
+
if (contentfulNonButton)
|
|
9624
|
+
return contentfulNonButton;
|
|
9625
|
+
const nonButton = stableAnchors.find((el) => !/button/i.test(el.role));
|
|
9396
9626
|
if (nonButton)
|
|
9397
9627
|
return nonButton;
|
|
9398
|
-
if (
|
|
9399
|
-
return
|
|
9400
|
-
const labelled = elements.find((el) => nonEmpty2(el.label) || nonEmpty2(el.text));
|
|
9401
|
-
return labelled ?? elements[0];
|
|
9628
|
+
if (stableAnchors.length > 0)
|
|
9629
|
+
return stableAnchors[0];
|
|
9630
|
+
const labelled = elements.find((el) => !isAlwaysPresentContainer(el) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
|
|
9631
|
+
return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
|
|
9632
|
+
}
|
|
9633
|
+
var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
|
|
9634
|
+
var ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS = /* @__PURE__ */ new Set([
|
|
9635
|
+
"android:id/content",
|
|
9636
|
+
"android:id/decor",
|
|
9637
|
+
"android:id/action_bar_root",
|
|
9638
|
+
"android:id/statusBarBackground",
|
|
9639
|
+
"android:id/navigationBarBackground"
|
|
9640
|
+
]);
|
|
9641
|
+
function isAlwaysPresentContainer(element) {
|
|
9642
|
+
if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
|
|
9643
|
+
return true;
|
|
9644
|
+
const resourceId = element.resourceId?.trim();
|
|
9645
|
+
return !!resourceId && ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS.has(resourceId);
|
|
9402
9646
|
}
|
|
9403
9647
|
function appendHybridFloor(actions, recording, platform3) {
|
|
9404
9648
|
const anchor = pickLandingAnchor(recording.lastSnapshot);
|
|
@@ -9457,7 +9701,7 @@ ${statePacket}` },
|
|
|
9457
9701
|
return test;
|
|
9458
9702
|
}
|
|
9459
9703
|
async function runMobileGeneratePhase(args) {
|
|
9460
|
-
const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot } = args;
|
|
9704
|
+
const { scenarios: allScenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot, skipScenarioNames, onInfraStop } = args;
|
|
9461
9705
|
const log2 = (line) => {
|
|
9462
9706
|
try {
|
|
9463
9707
|
onLog?.(line);
|
|
@@ -9472,8 +9716,16 @@ ${statePacket}` },
|
|
|
9472
9716
|
const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
|
|
9473
9717
|
const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
|
|
9474
9718
|
const targetAppId = ctx.target?.appId?.trim() || void 0;
|
|
9719
|
+
const skipSet = new Set(skipScenarioNames ?? []);
|
|
9720
|
+
const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
|
|
9721
|
+
if (skipSet.size > 0) {
|
|
9722
|
+
log2(`Resume: skipping ${allScenarios.length - scenarios.length} already-generated scenario(s); ${scenarios.length} remaining to generate`);
|
|
9723
|
+
}
|
|
9475
9724
|
log2(`Mobile generator starting \u2014 platform: ${platform3}, provider: ${provider}, model: ${agentModel}, scenarios: ${scenarios.length}`);
|
|
9476
9725
|
const tests = [];
|
|
9726
|
+
const generatedScenarioNames = /* @__PURE__ */ new Set();
|
|
9727
|
+
let consecutiveInfraFailures = 0;
|
|
9728
|
+
let infraStopped = false;
|
|
9477
9729
|
for (let i = 0; i < scenarios.length; i++) {
|
|
9478
9730
|
const scenario = scenarios[i];
|
|
9479
9731
|
log2(`
|
|
@@ -9494,6 +9746,7 @@ ${statePacket}` },
|
|
|
9494
9746
|
targetAppId,
|
|
9495
9747
|
log: log2
|
|
9496
9748
|
});
|
|
9749
|
+
consecutiveInfraFailures = 0;
|
|
9497
9750
|
const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
|
|
9498
9751
|
if (!test) {
|
|
9499
9752
|
log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
|
|
@@ -9510,13 +9763,31 @@ ${statePacket}` },
|
|
|
9510
9763
|
log2(` onTestGenerated callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
|
|
9511
9764
|
}
|
|
9512
9765
|
tests.push(test);
|
|
9766
|
+
generatedScenarioNames.add(scenario.name);
|
|
9513
9767
|
} catch (err) {
|
|
9768
|
+
if ((0, ai_retry_js_1.isAIProviderError)(err)) {
|
|
9769
|
+
consecutiveInfraFailures += 1;
|
|
9770
|
+
log2(` scenario "${scenario.name}" failed \u2014 AI provider error (${consecutiveInfraFailures}/${MAX_CONSECUTIVE_INFRA_FAILURES}): ${err instanceof Error ? err.message : String(err)}`);
|
|
9771
|
+
if (consecutiveInfraFailures >= MAX_CONSECUTIVE_INFRA_FAILURES) {
|
|
9772
|
+
const remainingScenarioNames = scenarios.filter((s) => !generatedScenarioNames.has(s.name)).map((s) => s.name);
|
|
9773
|
+
infraStopped = true;
|
|
9774
|
+
log2(`
|
|
9775
|
+
\u26D4 Stopping mobile generation: ${consecutiveInfraFailures} consecutive AI provider failures \u2014 the provider appears overloaded/unavailable. ${tests.length}/${scenarios.length} tests generated and saved; the remaining ${remainingScenarioNames.length} scenario(s) will resume later.`);
|
|
9776
|
+
try {
|
|
9777
|
+
onInfraStop?.({ remainingScenarioNames, reason: "ai_rate_limit" });
|
|
9778
|
+
} catch (cbErr) {
|
|
9779
|
+
log2(` onInfraStop callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
|
|
9780
|
+
}
|
|
9781
|
+
break;
|
|
9782
|
+
}
|
|
9783
|
+
continue;
|
|
9784
|
+
}
|
|
9514
9785
|
log2(` scenario "${scenario.name}" failed: ${err instanceof Error ? err.message : String(err)} \u2014 skipping.`);
|
|
9515
9786
|
continue;
|
|
9516
9787
|
}
|
|
9517
9788
|
}
|
|
9518
9789
|
log2(`
|
|
9519
|
-
Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) generated (${tests.filter((t) => t.verified).length} verified).`);
|
|
9790
|
+
Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) generated (${tests.filter((t) => t.verified).length} verified)${infraStopped ? " \u2014 PAUSED on AI provider overload" : ""}.`);
|
|
9520
9791
|
return tests;
|
|
9521
9792
|
}
|
|
9522
9793
|
}
|
|
@@ -12795,6 +13066,10 @@ var require_snapshot_observation = __commonJS({
|
|
|
12795
13066
|
var snapshot_parser_js_1 = require_snapshot_parser();
|
|
12796
13067
|
var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
|
|
12797
13068
|
var NOISE_LABEL_RE = /^(skip\s+to|skip\s+nav|back\s+to\s+top|cookie|accept\s+(all\s+)?cookies)$/i;
|
|
13069
|
+
var DIALOG_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog\b/i;
|
|
13070
|
+
var FORM_SEGMENT_RE = /(?:^|>\s*)form\b/i;
|
|
13071
|
+
var TABPANEL_SEGMENT_RE = /(?:^|>\s*)tabpanel\b/i;
|
|
13072
|
+
var DIALOG_NAME_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog:([^>]+)/i;
|
|
12798
13073
|
var INTERACTIVE_ROLE_RE = /^(button|link|textbox|searchbox|checkbox|radio|switch|combobox|listbox|option|menuitem|tab|slider|spinbutton|textarea)$/i;
|
|
12799
13074
|
var INTERACTIVE_ELEMENT_TYPE_RE = /^(button|link|input|textarea|select|checkbox|radio|switch|tab|combobox|listbox|option|menuitem|slider|spinbutton)$/i;
|
|
12800
13075
|
function renderRetrievedSnapshotMemoryBlock(priorSnapshotEvidence, priorDiscoveryCheckpoints, options = {}) {
|
|
@@ -13039,10 +13314,12 @@ var require_snapshot_observation = __commonJS({
|
|
|
13039
13314
|
summary
|
|
13040
13315
|
}));
|
|
13041
13316
|
}
|
|
13317
|
+
var TEXT_DISAMBIGUATING_INPUT_TYPES = /* @__PURE__ */ new Set(["password", "text", "email", "tel", "number", "search", "url", "date", "datetime-local", "time", "month", "week"]);
|
|
13042
13318
|
function renderCompactSnapshotBlock(page, context = {}, options = {}) {
|
|
13043
13319
|
const metrics = page.snapshotMetrics ?? page.snapshotArtifact?.metrics;
|
|
13044
13320
|
const sourceKind = page.snapshotMeta?.sourceKind ?? page.snapshotArtifact?.sourceKind ?? "missing";
|
|
13045
13321
|
const artifact = page.snapshotArtifact;
|
|
13322
|
+
const richRender = options.richRender ?? process.env.DISCOVERY_SNAPSHOT_RICH_RENDER === "true";
|
|
13046
13323
|
const activeScope = inferActiveSnapshotScope(page);
|
|
13047
13324
|
const ranked = rankSnapshotElements(getScopedSnapshotElements(page, activeScope), withScopeContext(context, activeScope));
|
|
13048
13325
|
const allInteractive = ranked.filter(({ element }) => isInteractiveCandidate(element)).filter(({ element }) => {
|
|
@@ -13088,7 +13365,7 @@ var require_snapshot_observation = __commonJS({
|
|
|
13088
13365
|
if (activeScope.rootRef) {
|
|
13089
13366
|
lines.push(` root_ref: ${yamlScalar(activeScope.rootRef)}`);
|
|
13090
13367
|
}
|
|
13091
|
-
const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText);
|
|
13368
|
+
const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText, richRender);
|
|
13092
13369
|
if (scopePreview.length > 0) {
|
|
13093
13370
|
lines.push(" scope_snapshot_preview: |");
|
|
13094
13371
|
for (const previewLine of scopePreview) {
|
|
@@ -13130,6 +13407,19 @@ var require_snapshot_observation = __commonJS({
|
|
|
13130
13407
|
lines.push(` - ${yamlScalar(modal)}`);
|
|
13131
13408
|
}
|
|
13132
13409
|
}
|
|
13410
|
+
const staticMessages = page.staticMessages ?? [];
|
|
13411
|
+
if (richRender && staticMessages.length > 0) {
|
|
13412
|
+
const messageLimit = 6;
|
|
13413
|
+
const ordered = [...staticMessages].sort((a, b) => messageRolePriority(a.role) - messageRolePriority(b.role));
|
|
13414
|
+
lines.push("messages:");
|
|
13415
|
+
lines.push(" # Non-interactive page text worth asserting on (validation / toast / empty-state / counts). Text is the capture instant \u2014 toasts may have dismissed since.");
|
|
13416
|
+
for (const message of ordered.slice(0, messageLimit)) {
|
|
13417
|
+
lines.push(` - [${message.role}] ${yamlScalar(message.text)}`);
|
|
13418
|
+
}
|
|
13419
|
+
if (ordered.length > messageLimit) {
|
|
13420
|
+
lines.push(` # +${ordered.length - messageLimit} more message(s) not listed`);
|
|
13421
|
+
}
|
|
13422
|
+
}
|
|
13133
13423
|
lines.push("session_state:");
|
|
13134
13424
|
if (page.sessionState) {
|
|
13135
13425
|
lines.push(` authenticated: ${yamlScalar(page.sessionState.authenticated)}`);
|
|
@@ -13192,7 +13482,10 @@ var require_snapshot_observation = __commonJS({
|
|
|
13192
13482
|
}
|
|
13193
13483
|
const hasRowContext = group.rowContexts.some((rc) => !!rc);
|
|
13194
13484
|
if (hasRowContext) {
|
|
13195
|
-
|
|
13485
|
+
const firstRowRole = group.rowRoles[0] ?? null;
|
|
13486
|
+
const rowRole = firstRowRole && group.rowRoles.every((r) => r === firstRowRole) ? firstRowRole : null;
|
|
13487
|
+
const scopeHint = rowRole ? `getByRole(${yamlScalar(rowRole)}).filter({ has: getByText("<row>", { exact: true }) }).getByRole(${yamlScalar(group.role)}, { name: ${yamlScalar(group.name)} })` : `locate the container by its text (getByText("<row>", { exact: true })), then getByRole(${yamlScalar(group.role)}, { name: ${yamlScalar(group.name)} }) within it`;
|
|
13488
|
+
lines.push(` distinguish_by_row: # same name across rows \u2014 scope by the ROW/card container (not the control); nested same-role containers need an extra cell-level scope: ${scopeHint}`);
|
|
13196
13489
|
for (let i = 0; i < group.rowContexts.length; i++) {
|
|
13197
13490
|
const rowContext = group.rowContexts[i];
|
|
13198
13491
|
if (!rowContext)
|
|
@@ -13222,8 +13515,13 @@ var require_snapshot_observation = __commonJS({
|
|
|
13222
13515
|
lines.push(` placeholder: ${yamlScalar(rankedElement.element.placeholder)}`);
|
|
13223
13516
|
}
|
|
13224
13517
|
const stateTokens = buildStateSummary(rankedElement.element);
|
|
13518
|
+
const addedDisabled = richRender && !!rankedElement.element.isDisabled && !stateTokens.includes("disabled");
|
|
13519
|
+
if (addedDisabled) {
|
|
13520
|
+
stateTokens.unshift("disabled");
|
|
13521
|
+
}
|
|
13225
13522
|
if (stateTokens.length > 0) {
|
|
13226
|
-
|
|
13523
|
+
const note = addedDisabled ? " # disabled = captured instant; may enable after valid input \u2014 still exercise fill\u2192submit" : "";
|
|
13524
|
+
lines.push(` state: [${stateTokens.map((token) => yamlScalar(token)).join(", ")}]${note}`);
|
|
13227
13525
|
}
|
|
13228
13526
|
if (rankedElement.element.containerPath) {
|
|
13229
13527
|
lines.push(` container_path: ${yamlScalar(rankedElement.element.containerPath)}`);
|
|
@@ -13233,6 +13531,22 @@ var require_snapshot_observation = __commonJS({
|
|
|
13233
13531
|
lines.push(` locator: ${yamlScalar((0, snapshot_parser_js_1.formatLocator)(topLocator))}`);
|
|
13234
13532
|
}
|
|
13235
13533
|
const enr = rankedElement.element.enrichment;
|
|
13534
|
+
if (richRender && enr?.inputType && TEXT_DISAMBIGUATING_INPUT_TYPES.has(enr.inputType)) {
|
|
13535
|
+
lines.push(` type: ${yamlScalar(enr.inputType)}`);
|
|
13536
|
+
}
|
|
13537
|
+
if (richRender && rankedElement.element.href) {
|
|
13538
|
+
lines.push(` url: ${yamlScalar(rankedElement.element.href)}`);
|
|
13539
|
+
}
|
|
13540
|
+
if (richRender && rankedElement.element.value && enr?.inputType !== "password") {
|
|
13541
|
+
lines.push(` value: ${yamlScalar(rankedElement.element.value)}`);
|
|
13542
|
+
}
|
|
13543
|
+
const elementOptions = rankedElement.element.options ?? [];
|
|
13544
|
+
if (richRender && elementOptions.length > 0) {
|
|
13545
|
+
const optionLimit = 12;
|
|
13546
|
+
const rendered = elementOptions.slice(0, optionLimit).map((option) => yamlScalar(option.selected ? `${option.label} (selected)` : option.label));
|
|
13547
|
+
const overflow = elementOptions.length > optionLimit ? ` # +${elementOptions.length - optionLimit} more option(s)` : "";
|
|
13548
|
+
lines.push(` options: [${rendered.join(", ")}]${overflow}`);
|
|
13549
|
+
}
|
|
13236
13550
|
if (enr?.rowContext) {
|
|
13237
13551
|
lines.push(` row_context: ${yamlScalar(enr.rowContext)}`);
|
|
13238
13552
|
}
|
|
@@ -13245,6 +13559,12 @@ var require_snapshot_observation = __commonJS({
|
|
|
13245
13559
|
lines.push(` visibility: [${vis.map((token) => yamlScalar(token)).join(", ")}]`);
|
|
13246
13560
|
}
|
|
13247
13561
|
}
|
|
13562
|
+
if (richRender) {
|
|
13563
|
+
const hidden = allInteractive.length - interactive.length;
|
|
13564
|
+
if (hidden > 0) {
|
|
13565
|
+
lines.push(` # +${hidden} more interactive element(s) not individually listed (capped) \u2014 some may appear in the duplicates block above; if your target isn't here, narrow the scope or act on it by ref.`);
|
|
13566
|
+
}
|
|
13567
|
+
}
|
|
13248
13568
|
}
|
|
13249
13569
|
const recoveredClickables = options.recoveredClickables ?? [];
|
|
13250
13570
|
if (recoveredClickables.length > 0) {
|
|
@@ -13401,11 +13721,11 @@ var require_snapshot_observation = __commonJS({
|
|
|
13401
13721
|
score += 10;
|
|
13402
13722
|
if (element.isExpanded)
|
|
13403
13723
|
score += 8;
|
|
13404
|
-
if (element.containerPath
|
|
13724
|
+
if (element.containerPath && DIALOG_SEGMENT_RE.test(element.containerPath))
|
|
13405
13725
|
score += 12;
|
|
13406
|
-
if (element.containerPath
|
|
13726
|
+
if (element.containerPath && FORM_SEGMENT_RE.test(element.containerPath))
|
|
13407
13727
|
score += 10;
|
|
13408
|
-
if (element.containerPath
|
|
13728
|
+
if (element.containerPath && TABPANEL_SEGMENT_RE.test(element.containerPath))
|
|
13409
13729
|
score += 8;
|
|
13410
13730
|
if (element.inputType === "email" || element.inputType === "password" || element.inputType === "search")
|
|
13411
13731
|
score += 6;
|
|
@@ -13476,10 +13796,10 @@ var require_snapshot_observation = __commonJS({
|
|
|
13476
13796
|
}
|
|
13477
13797
|
return merged;
|
|
13478
13798
|
}
|
|
13479
|
-
function buildScopeSnapshotPreview(scopeSnapshotText) {
|
|
13799
|
+
function buildScopeSnapshotPreview(scopeSnapshotText, stripTrailingValues = false) {
|
|
13480
13800
|
if (!scopeSnapshotText)
|
|
13481
13801
|
return [];
|
|
13482
|
-
return scopeSnapshotText.split("\n").map((line) => line.trim()).filter(Boolean).slice(0, 6);
|
|
13802
|
+
return scopeSnapshotText.split("\n").map((line) => line.trim()).map((line) => stripTrailingValues ? line.replace(/\]\s*:\s.+$/, "]") : line).filter(Boolean).slice(0, 6);
|
|
13483
13803
|
}
|
|
13484
13804
|
function dedupeLines(lines) {
|
|
13485
13805
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -13606,11 +13926,12 @@ var require_snapshot_observation = __commonJS({
|
|
|
13606
13926
|
const key = `${role}\0${name}`;
|
|
13607
13927
|
let group = groups.get(key);
|
|
13608
13928
|
if (!group) {
|
|
13609
|
-
group = { role, name, containers: [], rowContexts: [], refs: [] };
|
|
13929
|
+
group = { role, name, containers: [], rowContexts: [], rowRoles: [], refs: [] };
|
|
13610
13930
|
groups.set(key, group);
|
|
13611
13931
|
}
|
|
13612
13932
|
group.containers.push(element.containerPath ?? null);
|
|
13613
13933
|
group.rowContexts.push(element.enrichment?.rowContext ?? null);
|
|
13934
|
+
group.rowRoles.push(element.enrichment?.rowRole ?? null);
|
|
13614
13935
|
group.refs.push(element.ref ?? null);
|
|
13615
13936
|
}
|
|
13616
13937
|
return [...groups.values()].filter((group) => group.containers.length > 1);
|
|
@@ -13620,7 +13941,7 @@ var require_snapshot_observation = __commonJS({
|
|
|
13620
13941
|
for (const element of elements) {
|
|
13621
13942
|
const role = element.role?.toLowerCase() ?? "";
|
|
13622
13943
|
const elementType = element.elementType?.toLowerCase() ?? "";
|
|
13623
|
-
const isDialog = role === "dialog" || elementType === "dialog" || (element.containerPath
|
|
13944
|
+
const isDialog = role === "dialog" || elementType === "dialog" || DIALOG_NAME_SEGMENT_RE.test(element.containerPath ?? "");
|
|
13624
13945
|
if (!isDialog)
|
|
13625
13946
|
continue;
|
|
13626
13947
|
const hint = qualifyElementLabel(element) ?? dialogNameFromContainerPath(element.containerPath) ?? element.label ?? element.testId ?? "dialog";
|
|
@@ -13633,7 +13954,7 @@ var require_snapshot_observation = __commonJS({
|
|
|
13633
13954
|
function dialogNameFromContainerPath(containerPath) {
|
|
13634
13955
|
if (!containerPath)
|
|
13635
13956
|
return null;
|
|
13636
|
-
const match = containerPath.match(
|
|
13957
|
+
const match = containerPath.match(DIALOG_NAME_SEGMENT_RE);
|
|
13637
13958
|
return match ? match[1].trim() : null;
|
|
13638
13959
|
}
|
|
13639
13960
|
function renderObservationCue(observation) {
|
|
@@ -13642,6 +13963,13 @@ var require_snapshot_observation = __commonJS({
|
|
|
13642
13963
|
const base2 = observation.outcomeType === "SUCCESS" && outcome ? outcome : `${action} -> ${outcome || observation.outcomeType}`;
|
|
13643
13964
|
return base2.length > 120 ? `${base2.slice(0, 117)}...` : base2;
|
|
13644
13965
|
}
|
|
13966
|
+
function messageRolePriority(role) {
|
|
13967
|
+
if (role === "alert")
|
|
13968
|
+
return 0;
|
|
13969
|
+
if (role === "status")
|
|
13970
|
+
return 1;
|
|
13971
|
+
return 2;
|
|
13972
|
+
}
|
|
13645
13973
|
function yamlScalar(value) {
|
|
13646
13974
|
if (value == null || value === "")
|
|
13647
13975
|
return "null";
|
|
@@ -20190,7 +20518,10 @@ ${testOpen}`;
|
|
|
20190
20518
|
pressed: se.pressed,
|
|
20191
20519
|
headingLevel: se.headingLevel,
|
|
20192
20520
|
currentState: se.currentState,
|
|
20193
|
-
containerPath: se.containerPath || void 0
|
|
20521
|
+
containerPath: se.containerPath || void 0,
|
|
20522
|
+
href: se.href || void 0,
|
|
20523
|
+
value: se.value || void 0,
|
|
20524
|
+
options: se.options && se.options.length > 0 ? se.options : void 0
|
|
20194
20525
|
}));
|
|
20195
20526
|
const structuredPage = {
|
|
20196
20527
|
route: pageUrl || "/",
|
|
@@ -20198,6 +20529,7 @@ ${testOpen}`;
|
|
|
20198
20529
|
pageType: "unknown",
|
|
20199
20530
|
elements: collectedElements,
|
|
20200
20531
|
formGroups: parsed.formGroups,
|
|
20532
|
+
staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
|
|
20201
20533
|
snapshotMetrics: {
|
|
20202
20534
|
rawLineCount: parsed.rawLineCount,
|
|
20203
20535
|
parsedElementLineCount: parsed.parsedElementLineCount,
|
|
@@ -21764,7 +22096,7 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
|
|
|
21764
22096
|
let stopped;
|
|
21765
22097
|
const agentModel = options?.modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(options.modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
|
|
21766
22098
|
const auditGroupId = `generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
|
|
21767
|
-
const aiClient = options?.modelOverride ? (0, ai_provider_js_1.
|
|
22099
|
+
const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
|
|
21768
22100
|
const credentials = variableContext.allTestVariables ?? variableContext.publicTestVariables;
|
|
21769
22101
|
logs2.push(`Phase 3 \u2014 Generate: ${(plan.scenarios ?? []).length} scenarios, max ${maxTests} tests`);
|
|
21770
22102
|
logs2.push(`Model: ${agentModel}`);
|
|
@@ -235729,7 +236061,7 @@ var require_mcp_healer = __commonJS({
|
|
|
235729
236061
|
messages.push(system, firstUser, { role: "user", content: lines.join("\n") }, ...tail);
|
|
235730
236062
|
}
|
|
235731
236063
|
var DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS = 6;
|
|
235732
|
-
function
|
|
236064
|
+
function readPositiveIntEnv2(env, name, fallback) {
|
|
235733
236065
|
const raw = env[name]?.trim();
|
|
235734
236066
|
if (!raw || !/^\d+$/.test(raw))
|
|
235735
236067
|
return fallback;
|
|
@@ -235737,7 +236069,7 @@ var require_mcp_healer = __commonJS({
|
|
|
235737
236069
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
235738
236070
|
}
|
|
235739
236071
|
function getScriptHealMaxAttempts(env = process.env) {
|
|
235740
|
-
return
|
|
236072
|
+
return readPositiveIntEnv2(env, "MCP_HEAL_SCRIPT_MAX_ATTEMPTS", DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS);
|
|
235741
236073
|
}
|
|
235742
236074
|
exports2.SCRIPT_HEAL_SYSTEM_PROMPT = `You are a QA engineer fixing a failing Playwright UI test. You will receive the test code, the error message, step-level results, and a screenshot of the failure state.
|
|
235743
236075
|
|
|
@@ -236073,7 +236405,7 @@ ${lines.join("\n")}
|
|
|
236073
236405
|
const landingViolation = (tags ?? []).includes("@landing-violation");
|
|
236074
236406
|
const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "strong") : (0, ai_provider_js_1.getModel)("strong");
|
|
236075
236407
|
const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
|
|
236076
|
-
const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.
|
|
236408
|
+
const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
|
|
236077
236409
|
const runNativeTest = options.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
|
|
236078
236410
|
const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
|
|
236079
236411
|
const groupId = `script-heal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -236895,10 +237227,10 @@ ${(verifyResult.logs ?? "").slice(0, 2e3)}`;
|
|
|
236895
237227
|
var DEFAULT_MAX_PHASE2_ITERATIONS = 4;
|
|
236896
237228
|
var DEFAULT_MAX_MCP_EXPLORE_ITERATIONS = 25;
|
|
236897
237229
|
function getMcpHealPhase2MaxIterations(env = process.env) {
|
|
236898
|
-
return
|
|
237230
|
+
return readPositiveIntEnv2(env, "MCP_HEAL_PHASE2_MAX_ITERATIONS", DEFAULT_MAX_PHASE2_ITERATIONS);
|
|
236899
237231
|
}
|
|
236900
237232
|
function getMcpHealExploreMaxIterations(env = process.env) {
|
|
236901
|
-
return
|
|
237233
|
+
return readPositiveIntEnv2(env, "MCP_HEAL_EXPLORE_MAX_ITERATIONS", DEFAULT_MAX_MCP_EXPLORE_ITERATIONS);
|
|
236902
237234
|
}
|
|
236903
237235
|
var DEFAULT_MCP_HEAL_MAX_WALL_CLOCK_MS = 10 * 60 * 1e3;
|
|
236904
237236
|
var DEFAULT_MCP_HEAL_MAX_TOTAL_AI_CALLS = 60;
|
|
@@ -236950,7 +237282,7 @@ Only explore the failing page and the area around the broken step.`;
|
|
|
236950
237282
|
let lastVideo;
|
|
236951
237283
|
const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
|
|
236952
237284
|
const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
|
|
236953
|
-
const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.
|
|
237285
|
+
const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
|
|
236954
237286
|
const runPhase2NativeTest = options?.phase2Harness?.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
|
|
236955
237287
|
const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
|
|
236956
237288
|
const groupId = `phase2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -237640,7 +237972,10 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
|
|
|
237640
237972
|
pressed: se.pressed,
|
|
237641
237973
|
headingLevel: se.headingLevel,
|
|
237642
237974
|
currentState: se.currentState,
|
|
237643
|
-
containerPath: se.containerPath || void 0
|
|
237975
|
+
containerPath: se.containerPath || void 0,
|
|
237976
|
+
href: se.href || void 0,
|
|
237977
|
+
value: se.value || void 0,
|
|
237978
|
+
options: se.options && se.options.length > 0 ? se.options : void 0
|
|
237644
237979
|
}));
|
|
237645
237980
|
const structuredPage = {
|
|
237646
237981
|
route: pageUrl || "/",
|
|
@@ -237648,6 +237983,7 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
|
|
|
237648
237983
|
pageType: "unknown",
|
|
237649
237984
|
elements: collectedElements,
|
|
237650
237985
|
formGroups: parsed.formGroups,
|
|
237986
|
+
staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
|
|
237651
237987
|
snapshotMetrics: {
|
|
237652
237988
|
rawLineCount: parsed.rawLineCount,
|
|
237653
237989
|
parsedElementLineCount: parsed.parsedElementLineCount,
|
|
@@ -238084,7 +238420,7 @@ ${testBlocks}
|
|
|
238084
238420
|
- By iteration ${Math.floor(maxIterations * 0.3)}: you should have completed 1-2 tests
|
|
238085
238421
|
- By iteration ${Math.floor(maxIterations * 0.7)}: you should be past the halfway point
|
|
238086
238422
|
- At iteration ${Math.floor(maxIterations * 0.9)}: wrap up and call finish_capture`;
|
|
238087
|
-
const aiClient = modelOverride ? (0, ai_provider_js_1.
|
|
238423
|
+
const aiClient = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
|
|
238088
238424
|
const messages = [
|
|
238089
238425
|
{ role: "system", content: systemPrompt },
|
|
238090
238426
|
{ role: "user", content: "Start executing the tests. Navigate to the base URL and begin with Test 1." }
|
|
@@ -238267,7 +238603,10 @@ ${testBlocks}
|
|
|
238267
238603
|
pressed: se.pressed,
|
|
238268
238604
|
headingLevel: se.headingLevel,
|
|
238269
238605
|
currentState: se.currentState,
|
|
238270
|
-
containerPath: se.containerPath || void 0
|
|
238606
|
+
containerPath: se.containerPath || void 0,
|
|
238607
|
+
href: se.href || void 0,
|
|
238608
|
+
value: se.value || void 0,
|
|
238609
|
+
options: se.options && se.options.length > 0 ? se.options : void 0
|
|
238271
238610
|
}));
|
|
238272
238611
|
const structuredPage = {
|
|
238273
238612
|
route: pageUrl || "/",
|
|
@@ -238275,6 +238614,7 @@ ${testBlocks}
|
|
|
238275
238614
|
pageType: "unknown",
|
|
238276
238615
|
elements: collectedElements,
|
|
238277
238616
|
formGroups: parsed.formGroups,
|
|
238617
|
+
staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
|
|
238278
238618
|
snapshotMetrics: {
|
|
238279
238619
|
rawLineCount: parsed.rawLineCount,
|
|
238280
238620
|
parsedElementLineCount: parsed.parsedElementLineCount,
|
|
@@ -300064,8 +300404,9 @@ var require_capture_page_normalizer = __commonJS({
|
|
|
300064
300404
|
let snapshotElements = [];
|
|
300065
300405
|
let formGroups = [];
|
|
300066
300406
|
let snapshotMetrics;
|
|
300407
|
+
let staticMessages;
|
|
300067
300408
|
if (normalizedSnapshotResult.snapshotText) {
|
|
300068
|
-
const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText);
|
|
300409
|
+
const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText, { richParse: context.richParse });
|
|
300069
300410
|
snapshotMetrics = {
|
|
300070
300411
|
rawLineCount: parsed.rawLineCount,
|
|
300071
300412
|
parsedElementLineCount: parsed.parsedElementLineCount,
|
|
@@ -300094,9 +300435,13 @@ var require_capture_page_normalizer = __commonJS({
|
|
|
300094
300435
|
pressed: se.pressed,
|
|
300095
300436
|
headingLevel: se.headingLevel,
|
|
300096
300437
|
currentState: se.currentState,
|
|
300097
|
-
containerPath: se.containerPath || void 0
|
|
300438
|
+
containerPath: se.containerPath || void 0,
|
|
300439
|
+
href: se.href || void 0,
|
|
300440
|
+
value: se.value || void 0,
|
|
300441
|
+
options: se.options && se.options.length > 0 ? se.options : void 0
|
|
300098
300442
|
}));
|
|
300099
300443
|
formGroups = parsed.formGroups;
|
|
300444
|
+
staticMessages = parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0;
|
|
300100
300445
|
}
|
|
300101
300446
|
const aiElements = Array.isArray(toolArgs.elements) ? toolArgs.elements.map(normalizeAiElement) : [];
|
|
300102
300447
|
const noYamlKinds = /* @__PURE__ */ new Set(["missing", "timeout", "error", "empty"]);
|
|
@@ -300192,6 +300537,7 @@ var require_capture_page_normalizer = __commonJS({
|
|
|
300192
300537
|
} : void 0,
|
|
300193
300538
|
readinessSignals,
|
|
300194
300539
|
viewportHints,
|
|
300540
|
+
staticMessages,
|
|
300195
300541
|
provenCommands: provenCmds,
|
|
300196
300542
|
observations: rawObservations,
|
|
300197
300543
|
routeCorrected,
|
|
@@ -300500,6 +300846,10 @@ var require_perception_enricher = __commonJS({
|
|
|
300500
300846
|
const e = {};
|
|
300501
300847
|
if (p.rowContext)
|
|
300502
300848
|
e.rowContext = p.rowContext;
|
|
300849
|
+
if (p.rowContext && p.rowRole)
|
|
300850
|
+
e.rowRole = p.rowRole;
|
|
300851
|
+
if (p.inputType)
|
|
300852
|
+
e.inputType = p.inputType;
|
|
300503
300853
|
if (p.position && p.position !== "in-view")
|
|
300504
300854
|
e.position = p.position;
|
|
300505
300855
|
if (p.occluded)
|
|
@@ -300749,6 +301099,23 @@ var require_perception_enricher = __commonJS({
|
|
|
300749
301099
|
const cls = (el.getAttribute("class") || "").toLowerCase();
|
|
300750
301100
|
return /(^|[\s_-])(card|row|item|listitem|cell|tile)([\s_-]|$)/.test(cls);
|
|
300751
301101
|
};
|
|
301102
|
+
const rowRoleOf = (el) => {
|
|
301103
|
+
const explicit = (el.getAttribute("role") || "").toLowerCase().trim();
|
|
301104
|
+
if (explicit) {
|
|
301105
|
+
return explicit === "row" || explicit === "listitem" || explicit === "article" || explicit === "option" ? explicit : null;
|
|
301106
|
+
}
|
|
301107
|
+
const tag = el.tagName;
|
|
301108
|
+
if (tag === "TR") {
|
|
301109
|
+
const tbl = el.closest("table");
|
|
301110
|
+
const tblRole = tbl ? (tbl.getAttribute("role") || "").toLowerCase().trim() : "";
|
|
301111
|
+
return tblRole === "presentation" || tblRole === "none" ? null : "row";
|
|
301112
|
+
}
|
|
301113
|
+
if (tag === "LI")
|
|
301114
|
+
return el.closest('ul:not([role]),ol:not([role]),menu:not([role]),[role="list"]') ? "listitem" : null;
|
|
301115
|
+
if (tag === "ARTICLE")
|
|
301116
|
+
return "article";
|
|
301117
|
+
return null;
|
|
301118
|
+
};
|
|
300752
301119
|
const rowContextOf = (el, ownNameLower) => {
|
|
300753
301120
|
let cur = el.parentElement;
|
|
300754
301121
|
let depth = 0;
|
|
@@ -300767,7 +301134,7 @@ var require_perception_enricher = __commonJS({
|
|
|
300767
301134
|
txt = txt.slice(0, 80);
|
|
300768
301135
|
const low = txt.toLowerCase();
|
|
300769
301136
|
if (txt && low !== ownNameLower && low.length > 1)
|
|
300770
|
-
return txt;
|
|
301137
|
+
return { text: txt, role: rowRoleOf(cur) };
|
|
300771
301138
|
return null;
|
|
300772
301139
|
}
|
|
300773
301140
|
cur = cur.parentElement;
|
|
@@ -300857,11 +301224,14 @@ var require_perception_enricher = __commonJS({
|
|
|
300857
301224
|
const name = accName(el);
|
|
300858
301225
|
const lname = lower(name);
|
|
300859
301226
|
const rect = vis.rect;
|
|
301227
|
+
const rc = rowContextOf(el, lname);
|
|
300860
301228
|
out.push({
|
|
300861
301229
|
domOrder: myOrder,
|
|
300862
301230
|
roleClass: roleClassOf(tag, roleAttr, inputType),
|
|
300863
301231
|
name: lname,
|
|
300864
|
-
rowContext:
|
|
301232
|
+
rowContext: rc ? rc.text : null,
|
|
301233
|
+
rowRole: rc ? rc.role : null,
|
|
301234
|
+
inputType: inputType || null,
|
|
300865
301235
|
position: positionOf(rect),
|
|
300866
301236
|
occluded: occlusionOf(el, rect),
|
|
300867
301237
|
cursorPointer,
|
|
@@ -300898,11 +301268,14 @@ var require_perception_enricher = __commonJS({
|
|
|
300898
301268
|
if (!name)
|
|
300899
301269
|
continue;
|
|
300900
301270
|
const lname = lower(name);
|
|
301271
|
+
const rc = rowContextOf(el, lname);
|
|
300901
301272
|
out.push({
|
|
300902
301273
|
domOrder: order++,
|
|
300903
301274
|
roleClass: "button",
|
|
300904
301275
|
name: lname,
|
|
300905
|
-
rowContext:
|
|
301276
|
+
rowContext: rc ? rc.text : null,
|
|
301277
|
+
rowRole: rc ? rc.role : null,
|
|
301278
|
+
inputType: null,
|
|
300906
301279
|
position: "in-view",
|
|
300907
301280
|
occluded: false,
|
|
300908
301281
|
cursorPointer: true,
|
|
@@ -301404,7 +301777,7 @@ var require_discover_explorer = __commonJS({
|
|
|
301404
301777
|
var DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP = 100;
|
|
301405
301778
|
var DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY = 100;
|
|
301406
301779
|
var DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL = 40;
|
|
301407
|
-
function
|
|
301780
|
+
function readPositiveIntEnv2(env, name, fallback) {
|
|
301408
301781
|
const raw = env[name]?.trim();
|
|
301409
301782
|
if (!raw || !/^\d+$/.test(raw))
|
|
301410
301783
|
return fallback;
|
|
@@ -301535,15 +301908,15 @@ var require_discover_explorer = __commonJS({
|
|
|
301535
301908
|
}
|
|
301536
301909
|
function getMaxIterations(mode, incremental, env = process.env) {
|
|
301537
301910
|
if (mode === "survey" && incremental) {
|
|
301538
|
-
return
|
|
301911
|
+
return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_INCREMENTAL", DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL);
|
|
301539
301912
|
}
|
|
301540
301913
|
if (mode === "survey") {
|
|
301541
|
-
return
|
|
301914
|
+
return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_SURVEY", DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY);
|
|
301542
301915
|
}
|
|
301543
301916
|
if (mode === "deep") {
|
|
301544
|
-
return
|
|
301917
|
+
return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEEP", DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP);
|
|
301545
301918
|
}
|
|
301546
|
-
return
|
|
301919
|
+
return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEFAULT", DEFAULT_MAX_EXPLORE_ITERATIONS_DEFAULT);
|
|
301547
301920
|
}
|
|
301548
301921
|
function updatePendingRouteScreenshot(pending, { imageData, detectedRoute, fallbackRoute }) {
|
|
301549
301922
|
if (imageData) {
|
|
@@ -302269,7 +302642,7 @@ ${inboxPromptBlock}`;
|
|
|
302269
302642
|
const browserCreds = variableContext.allTestVariables ?? variableContext.publicTestVariables;
|
|
302270
302643
|
const { secrets: browserSecretsForHint } = (0, credential_tools_js_1.partitionTestVariables)(browserCreds);
|
|
302271
302644
|
const hasLoginCredentials = Object.keys(browserSecretsForHint).length > 0;
|
|
302272
|
-
const aiClient = options?.modelOverride ? (0, ai_provider_js_1.
|
|
302645
|
+
const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
|
|
302273
302646
|
const isDeepMode = options?.mode === "deep";
|
|
302274
302647
|
const maxIterations = config?.maxIterationsOverride && config.maxIterationsOverride > 0 ? config.maxIterationsOverride : getMaxIterations(options?.mode ?? "full", options?.incremental);
|
|
302275
302648
|
const configKeepTail = isDeepMode ? Math.ceil(ctxConfig.keepTail * 1.5) : ctxConfig.keepTail;
|
|
@@ -304808,7 +305181,7 @@ var require_auth_capture_run = __commonJS({
|
|
|
304808
305181
|
var scrub_credentials_js_1 = require_scrub_credentials();
|
|
304809
305182
|
var credential_tools_js_1 = require_credential_tools();
|
|
304810
305183
|
var DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS = 12;
|
|
304811
|
-
function
|
|
305184
|
+
function readPositiveIntEnv2(env, name, fallback) {
|
|
304812
305185
|
const raw = env[name]?.trim();
|
|
304813
305186
|
if (!raw || !/^\d+$/.test(raw))
|
|
304814
305187
|
return fallback;
|
|
@@ -304816,7 +305189,7 @@ var require_auth_capture_run = __commonJS({
|
|
|
304816
305189
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
304817
305190
|
}
|
|
304818
305191
|
function getLoginCaptureMaxIterations(env = process.env) {
|
|
304819
|
-
return
|
|
305192
|
+
return readPositiveIntEnv2(env, "AUTH_CAPTURE_LOGIN_MAX_ITERATIONS", DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS);
|
|
304820
305193
|
}
|
|
304821
305194
|
var CAPTURE_RATE_LIMIT_MAX_RETRIES = 2;
|
|
304822
305195
|
var CAPTURE_RATE_LIMIT_MAX_DELAY_MS = 8e3;
|
|
@@ -305099,7 +305472,7 @@ ${keyLines || "- (none provided)"}
|
|
|
305099
305472
|
}
|
|
305100
305473
|
var DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS = 18;
|
|
305101
305474
|
function getRegisterCaptureMaxIterations(env = process.env) {
|
|
305102
|
-
return
|
|
305475
|
+
return readPositiveIntEnv2(env, "AUTH_CAPTURE_REGISTER_MAX_ITERATIONS", DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS);
|
|
305103
305476
|
}
|
|
305104
305477
|
function buildRegisterCaptureSystemPrompt(baseUrl, availableKeys, maxIterations = getRegisterCaptureMaxIterations()) {
|
|
305105
305478
|
const keyLines = availableKeys.map((k) => `- \`${k}\``).join("\n");
|
|
@@ -311329,6 +311702,7 @@ var require_mobile_exploration_state = __commonJS({
|
|
|
311329
311702
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
311330
311703
|
exports2.MobileExplorationState = void 0;
|
|
311331
311704
|
exports2.deriveScreenId = deriveScreenId;
|
|
311705
|
+
var mobile_observation_js_1 = require_mobile_observation();
|
|
311332
311706
|
var DEFAULT_STALE_THRESHOLD = 4;
|
|
311333
311707
|
function deriveScreenId(signal) {
|
|
311334
311708
|
const namespace = signal.activity ?? signal.bundleId;
|
|
@@ -311554,21 +311928,33 @@ var require_mobile_exploration_state = __commonJS({
|
|
|
311554
311928
|
* Build the planner evidence shape: one `CollectedPageData` per screen, with
|
|
311555
311929
|
* the screen-id carried in BOTH `route` (the on-the-wire positional field the
|
|
311556
311930
|
* server keys screenshots by) AND the explicit `screenId` field (per the
|
|
311557
|
-
* Phase-0 `CollectedPageData.screenId` contract). `elements`
|
|
311558
|
-
* the
|
|
311559
|
-
*
|
|
311560
|
-
*
|
|
311561
|
-
*
|
|
311931
|
+
* Phase-0 `CollectedPageData.screenId` contract). `elements` are mapped from
|
|
311932
|
+
* the screen's parsed actionable elements into the platform-neutral
|
|
311933
|
+
* `CollectedPageElement[]` shape the server persists as SiteElements — without
|
|
311934
|
+
* this, mobile screens land with zero elements (coverage always 0%, no element
|
|
311935
|
+
* intelligence). `triggersNavigation` is derived from transition-source
|
|
311936
|
+
* membership: an element is flagged when its ref drove a transition FROM this
|
|
311937
|
+
* screen. Insertion order matches first-seen order.
|
|
311562
311938
|
*/
|
|
311563
311939
|
buildInMemoryScreenMap() {
|
|
311564
311940
|
const pages = [];
|
|
311565
311941
|
const ordered = [...this.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
|
|
311942
|
+
const navRefsByScreen = /* @__PURE__ */ new Map();
|
|
311943
|
+
for (const transition of this.transitions) {
|
|
311944
|
+
if (!transition.viaRef)
|
|
311945
|
+
continue;
|
|
311946
|
+
const set = navRefsByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
|
|
311947
|
+
set.add(transition.viaRef);
|
|
311948
|
+
navRefsByScreen.set(transition.fromScreenId, set);
|
|
311949
|
+
}
|
|
311566
311950
|
for (const state2 of ordered) {
|
|
311567
311951
|
const page = {
|
|
311568
311952
|
route: state2.screenId,
|
|
311569
311953
|
screenId: state2.screenId,
|
|
311570
311954
|
pageType: state2.screenType ?? "UNKNOWN",
|
|
311571
|
-
elements:
|
|
311955
|
+
elements: (0, mobile_observation_js_1.buildCollectedElementsForScreen)(state2.elements, {
|
|
311956
|
+
navigationRefs: navRefsByScreen.get(state2.screenId)
|
|
311957
|
+
})
|
|
311572
311958
|
};
|
|
311573
311959
|
if (state2.screenName) {
|
|
311574
311960
|
page.title = state2.screenName;
|
|
@@ -311598,6 +311984,7 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311598
311984
|
"../runner-core/dist/services/mobile/mobile-discovery-agent.js"(exports2) {
|
|
311599
311985
|
"use strict";
|
|
311600
311986
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
311987
|
+
exports2.getMobileExploreBudget = getMobileExploreBudget;
|
|
311601
311988
|
exports2.runMobileDiscoveryOnRunner = runMobileDiscoveryOnRunner2;
|
|
311602
311989
|
var mobile_explorer_js_1 = require_mobile_explorer();
|
|
311603
311990
|
var mobile_discovery_prompts_js_1 = require_mobile_discovery_prompts();
|
|
@@ -311611,8 +311998,37 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311611
311998
|
const message = checkpointErrorMessage(err);
|
|
311612
311999
|
return /Discovery plan checkpoint rejected|Discovery run is .*not RUNNING|not RUNNING|cancelled/i.test(message);
|
|
311613
312000
|
}
|
|
311614
|
-
var
|
|
311615
|
-
var
|
|
312001
|
+
var MOBILE_EXPLORE_BUDGET_SURVEY_FIRST = 90;
|
|
312002
|
+
var MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL = 40;
|
|
312003
|
+
var MOBILE_EXPLORE_BUDGET_DEEP = 60;
|
|
312004
|
+
var MOBILE_EXPLORE_BUDGET_FULL = 90;
|
|
312005
|
+
function readPositiveIntEnv2(name, fallback, env = process.env) {
|
|
312006
|
+
const raw = env[name]?.trim();
|
|
312007
|
+
if (!raw || !/^\d+$/.test(raw))
|
|
312008
|
+
return fallback;
|
|
312009
|
+
const parsed = Number(raw);
|
|
312010
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
312011
|
+
}
|
|
312012
|
+
function getMobileExploreBudget(mode, incremental, env = process.env) {
|
|
312013
|
+
if (mode === "deep")
|
|
312014
|
+
return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_DEEP", MOBILE_EXPLORE_BUDGET_DEEP, env);
|
|
312015
|
+
if (mode === "full")
|
|
312016
|
+
return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_FULL", MOBILE_EXPLORE_BUDGET_FULL, env);
|
|
312017
|
+
if (incremental) {
|
|
312018
|
+
return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY_INCREMENTAL", MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL, env);
|
|
312019
|
+
}
|
|
312020
|
+
return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY", MOBILE_EXPLORE_BUDGET_SURVEY_FIRST, env);
|
|
312021
|
+
}
|
|
312022
|
+
function compactExistingScreens(siteMap) {
|
|
312023
|
+
if (!siteMap || !Array.isArray(siteMap.pages) || siteMap.pages.length === 0)
|
|
312024
|
+
return void 0;
|
|
312025
|
+
const screens = siteMap.pages.filter((p) => typeof p.route === "string" && p.route.trim().length > 0).map((p) => ({
|
|
312026
|
+
screenId: p.route,
|
|
312027
|
+
...p.title ? { name: p.title } : {},
|
|
312028
|
+
...p.pageType ? { screenType: p.pageType } : {}
|
|
312029
|
+
}));
|
|
312030
|
+
return screens.length > 0 ? screens : void 0;
|
|
312031
|
+
}
|
|
311616
312032
|
function platformForMedium(medium) {
|
|
311617
312033
|
return medium === "IOS_NATIVE" ? "IOS" : "ANDROID";
|
|
311618
312034
|
}
|
|
@@ -311631,6 +312047,9 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311631
312047
|
// Native screenshots are multi-megabyte PNGs. The live mirror streams them
|
|
311632
312048
|
// through heartbeat telemetry; discovery checkpoints keep the screen graph
|
|
311633
312049
|
// and transitions so large Android surveys cannot exceed server body limits.
|
|
312050
|
+
// D-D6 — pageScreenshotKeys is a small screen-id → storage-key map (NOT base64),
|
|
312051
|
+
// so it DOES ride the checkpoint: the server links each key to SitePage.screenshotKey.
|
|
312052
|
+
...explore.pageScreenshotKeys && Object.keys(explore.pageScreenshotKeys).length > 0 ? { pageScreenshotKeys: explore.pageScreenshotKeys } : {},
|
|
311634
312053
|
exploreStats: explore.exploreStats,
|
|
311635
312054
|
healthObservations: explore.healthObservations
|
|
311636
312055
|
};
|
|
@@ -311645,6 +312064,7 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311645
312064
|
const onLog = callbacks?.onLog;
|
|
311646
312065
|
const onAICall = callbacks?.onAICall;
|
|
311647
312066
|
const onScreenshot = callbacks?.onScreenshot;
|
|
312067
|
+
const onScreenshotUpload = callbacks?.onScreenshotUpload;
|
|
311648
312068
|
const runExplore = callbacks?.runExplore ?? mobile_explorer_js_1.runMobileExplorePhase;
|
|
311649
312069
|
const runPlan = callbacks?.runPlan ?? mobile_discovery_prompts_js_1.runMobileAIPlanner;
|
|
311650
312070
|
const runGenerate = callbacks?.runGenerate ?? mobile_generator_js_1.runMobileGeneratePhase;
|
|
@@ -311671,15 +312091,77 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311671
312091
|
elementsFound: 0,
|
|
311672
312092
|
transitionsFound: 0
|
|
311673
312093
|
};
|
|
312094
|
+
const resumeSavedScenarios = ctx.resume?.savedPlan?.scenarios ?? [];
|
|
312095
|
+
const isPlanReuseResume = mode !== "survey" && resumeSavedScenarios.length > 0;
|
|
311674
312096
|
try {
|
|
312097
|
+
if (isPlanReuseResume) {
|
|
312098
|
+
const alreadyGenerated = ctx.resume?.alreadyGeneratedScenarioNames ?? [];
|
|
312099
|
+
log2(`
|
|
312100
|
+
\u2500\u2500 Resume (plan-reuse): ${resumeSavedScenarios.length} saved scenario(s); ${alreadyGenerated.length} already generated. Skipping explore + plan; generating only the remainder. \u2500\u2500`);
|
|
312101
|
+
const scenarios2 = resumeSavedScenarios;
|
|
312102
|
+
const plans2 = toPlans(scenarios2);
|
|
312103
|
+
if (callbacks?.onPlanReady) {
|
|
312104
|
+
try {
|
|
312105
|
+
await callbacks.onPlanReady({ scenarios: scenarios2, featureGroups: void 0, fallbackUsed: false });
|
|
312106
|
+
log2("Plan checkpoint: reused scenario plan re-POSTed to server");
|
|
312107
|
+
} catch (err) {
|
|
312108
|
+
const message = checkpointErrorMessage(err);
|
|
312109
|
+
if (isTerminalDiscoveryCheckpointError(err)) {
|
|
312110
|
+
log2(`Plan checkpoint rejected for inactive discovery run: ${message}`);
|
|
312111
|
+
throw err;
|
|
312112
|
+
}
|
|
312113
|
+
log2(`Plan checkpoint POST failed (non-fatal): ${message}`);
|
|
312114
|
+
}
|
|
312115
|
+
}
|
|
312116
|
+
let paused = false;
|
|
312117
|
+
let pauseReason;
|
|
312118
|
+
let remainingScenarios;
|
|
312119
|
+
const tests2 = await runGenerate({
|
|
312120
|
+
scenarios: scenarios2,
|
|
312121
|
+
driver,
|
|
312122
|
+
ctx,
|
|
312123
|
+
onLog: (line) => log2(line),
|
|
312124
|
+
onAICall,
|
|
312125
|
+
onScreenshot,
|
|
312126
|
+
onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
|
|
312127
|
+
skipScenarioNames: alreadyGenerated,
|
|
312128
|
+
onInfraStop: (info) => {
|
|
312129
|
+
paused = true;
|
|
312130
|
+
pauseReason = info.reason;
|
|
312131
|
+
remainingScenarios = info.remainingScenarioNames;
|
|
312132
|
+
}
|
|
312133
|
+
});
|
|
312134
|
+
const duration2 = (Date.now() - startTime) / 1e3;
|
|
312135
|
+
const summary2 = `Mobile resume complete: generated ${tests2.length} test(s) from ${scenarios2.length} saved scenario(s) in ${duration2.toFixed(1)}s${paused ? " \u2014 PAUSED again on AI provider overload" : ""}`;
|
|
312136
|
+
log2(`
|
|
312137
|
+
=== Mobile Discovery Resume Complete (${duration2.toFixed(1)}s) ===`);
|
|
312138
|
+
log2(summary2);
|
|
312139
|
+
return {
|
|
312140
|
+
collectedPages: [],
|
|
312141
|
+
collectedTransitions: [],
|
|
312142
|
+
tests: tests2,
|
|
312143
|
+
plans: plans2,
|
|
312144
|
+
scenarios: scenarios2.length > 0 ? scenarios2 : void 0,
|
|
312145
|
+
logs: logs2.join("\n"),
|
|
312146
|
+
screenshots,
|
|
312147
|
+
summary: summary2,
|
|
312148
|
+
exploreStats: { pagesDiscovered: 0, pagesVisited: 0, elementsFound: 0, transitionsFound: 0 },
|
|
312149
|
+
duration: duration2,
|
|
312150
|
+
mode,
|
|
312151
|
+
...paused ? { paused: true, pauseReason, remainingScenarios } : {}
|
|
312152
|
+
// apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
|
|
312153
|
+
};
|
|
312154
|
+
}
|
|
311675
312155
|
log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
|
|
311676
312156
|
const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
|
|
311677
|
-
const maxIterations = mode
|
|
312157
|
+
const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
|
|
312158
|
+
const existingScreens = compactExistingScreens(ctx.existingSiteMap);
|
|
311678
312159
|
const explore = await runExplore({
|
|
311679
312160
|
ctx,
|
|
311680
312161
|
driver,
|
|
311681
312162
|
state: state2,
|
|
311682
312163
|
config: { maxIterations },
|
|
312164
|
+
existingScreens,
|
|
311683
312165
|
onLog: (line) => log2(line),
|
|
311684
312166
|
onAICall,
|
|
311685
312167
|
onScreenshot: (base64) => {
|
|
@@ -311689,7 +312171,8 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311689
312171
|
onScreenshot?.(base64);
|
|
311690
312172
|
} catch {
|
|
311691
312173
|
}
|
|
311692
|
-
}
|
|
312174
|
+
},
|
|
312175
|
+
onScreenshotUpload
|
|
311693
312176
|
});
|
|
311694
312177
|
const collectedTransitions = toCollectedTransitions(explore);
|
|
311695
312178
|
partialCollectedPages = explore.collectedPages;
|
|
@@ -311790,6 +312273,9 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
|
|
|
311790
312273
|
};
|
|
311791
312274
|
}
|
|
311792
312275
|
log2("\n\u2500\u2500 Phase 3: GENERATE \u2500\u2500");
|
|
312276
|
+
let generatePaused = false;
|
|
312277
|
+
let generatePauseReason;
|
|
312278
|
+
let generateRemainingScenarios;
|
|
311793
312279
|
const tests = await runGenerate({
|
|
311794
312280
|
scenarios,
|
|
311795
312281
|
driver,
|
|
@@ -311797,7 +312283,15 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
|
|
|
311797
312283
|
onLog: (line) => log2(line),
|
|
311798
312284
|
onAICall,
|
|
311799
312285
|
onScreenshot,
|
|
311800
|
-
onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" })
|
|
312286
|
+
onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
|
|
312287
|
+
// Legacy resume (no saved plan): still skip scenarios already generated by the
|
|
312288
|
+
// paused run so the AI isn't re-invoked for them. Undefined on a fresh run.
|
|
312289
|
+
skipScenarioNames: ctx.resume?.alreadyGeneratedScenarioNames,
|
|
312290
|
+
onInfraStop: (info) => {
|
|
312291
|
+
generatePaused = true;
|
|
312292
|
+
generatePauseReason = info.reason;
|
|
312293
|
+
generateRemainingScenarios = info.remainingScenarioNames;
|
|
312294
|
+
}
|
|
311801
312295
|
});
|
|
311802
312296
|
const generateElapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
311803
312297
|
log2(`
|
|
@@ -311813,7 +312307,7 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
|
|
|
311813
312307
|
};
|
|
311814
312308
|
}
|
|
311815
312309
|
const duration = (Date.now() - startTime) / 1e3;
|
|
311816
|
-
const summary = `Mobile discovery complete: explored ${explore.exploreStats.pagesDiscovered} screens, planned ${scenarios.length} scenarios, generated ${tests.length} tests in ${duration.toFixed(1)}s`;
|
|
312310
|
+
const summary = `Mobile discovery complete: explored ${explore.exploreStats.pagesDiscovered} screens, planned ${scenarios.length} scenarios, generated ${tests.length} tests in ${duration.toFixed(1)}s${generatePaused ? " \u2014 PAUSED on AI provider overload (resumable)" : ""}`;
|
|
311817
312311
|
log2(`
|
|
311818
312312
|
=== Mobile Discovery Pipeline Complete (${duration.toFixed(1)}s) ===`);
|
|
311819
312313
|
log2(summary);
|
|
@@ -311838,7 +312332,11 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
|
|
|
311838
312332
|
summary,
|
|
311839
312333
|
exploreStats: explore.exploreStats,
|
|
311840
312334
|
duration,
|
|
311841
|
-
mode
|
|
312335
|
+
mode,
|
|
312336
|
+
// D-D4 — a circuit-breaker trip PAUSES the run (resumable) instead of
|
|
312337
|
+
// finalizing COMPLETED/FAILED. The server's isDiscoveryPaused reads these
|
|
312338
|
+
// (forwarded by runner.ts buildFinalBody, byte-identical to the web path).
|
|
312339
|
+
...generatePaused ? { paused: true, pauseReason: generatePauseReason, remainingScenarios: generateRemainingScenarios } : {}
|
|
311842
312340
|
// apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
|
|
311843
312341
|
};
|
|
311844
312342
|
} catch (fatalErr) {
|
|
@@ -311884,6 +312382,7 @@ var require_discover_agent = __commonJS({
|
|
|
311884
312382
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
311885
312383
|
exports2.runMobileDiscoveryOnRunner = exports2.buildInMemorySiteMap = void 0;
|
|
311886
312384
|
exports2.runCanvasPreflight = runCanvasPreflight;
|
|
312385
|
+
exports2.getFatalExploreError = getFatalExploreError;
|
|
311887
312386
|
exports2.runSiteDiscoveryOnRunner = runSiteDiscoveryOnRunner2;
|
|
311888
312387
|
exports2.runFeatureDiscoveryOnRunner = runFeatureDiscoveryOnRunner2;
|
|
311889
312388
|
var browser_config_js_1 = require_browser_config();
|
|
@@ -312066,7 +312565,7 @@ Respond with ONLY valid JSON:
|
|
|
312066
312565
|
"keyFindings": "What was discovered - forms, validations, behaviors. Mark inferred items with '(inferred, not tested)'.",
|
|
312067
312566
|
"testingNotes": "Gotchas, validation behaviors, error messages. List visible-but-untested routes as candidates for future exploration."
|
|
312068
312567
|
}`;
|
|
312069
|
-
const client = modelOverride ? (0, ai_provider_js_1.
|
|
312568
|
+
const client = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
|
|
312070
312569
|
const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
|
|
312071
312570
|
const callStart = Date.now();
|
|
312072
312571
|
const response = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => client.chat.completions.create({
|
|
@@ -312197,6 +312696,10 @@ Respond with ONLY valid JSON:
|
|
|
312197
312696
|
});
|
|
312198
312697
|
return timed.catch(() => null);
|
|
312199
312698
|
}
|
|
312699
|
+
function getFatalExploreError(exploreResult) {
|
|
312700
|
+
const prefix = "Fatal error:";
|
|
312701
|
+
return exploreResult.summary.startsWith(prefix) ? exploreResult.summary.slice(prefix.length).trim() || exploreResult.summary : void 0;
|
|
312702
|
+
}
|
|
312200
312703
|
async function runSiteDiscoveryOnRunner2(ctx, onLog, onAICall, sharedBrowser, onScreenshot, callbackOptions) {
|
|
312201
312704
|
const logs2 = [];
|
|
312202
312705
|
const screenshots = [];
|
|
@@ -312381,6 +312884,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
|
|
|
312381
312884
|
if (loginRunP)
|
|
312382
312885
|
await loginRunP;
|
|
312383
312886
|
const duration2 = (Date.now() - startTime) / 1e3;
|
|
312887
|
+
const fatalExploreError = getFatalExploreError(exploreResult);
|
|
312384
312888
|
return {
|
|
312385
312889
|
collectedPages: exploreResult.collectedPages,
|
|
312386
312890
|
collectedTransitions: exploreResult.collectedTransitions,
|
|
@@ -312391,6 +312895,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
|
|
|
312391
312895
|
pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
|
|
312392
312896
|
pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
|
|
312393
312897
|
summary: exploreResult.summary || "Exploration completed but no page data captured.",
|
|
312898
|
+
error: fatalExploreError,
|
|
312394
312899
|
exploreStats: {
|
|
312395
312900
|
pagesDiscovered: exploreResult.pagesDiscovered,
|
|
312396
312901
|
pagesVisited: exploreResult.pagesVisited,
|
|
@@ -312932,6 +313437,7 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
|
|
|
312932
313437
|
discoveryApiCalls = earlyClose.apiCalls;
|
|
312933
313438
|
postCloseHealthSummary = earlyClose.healthSummary;
|
|
312934
313439
|
const duration2 = (Date.now() - startTime) / 1e3;
|
|
313440
|
+
const fatalExploreError = getFatalExploreError(exploreResult);
|
|
312935
313441
|
return {
|
|
312936
313442
|
collectedPages: exploreResult.collectedPages,
|
|
312937
313443
|
collectedTransitions: exploreResult.collectedTransitions,
|
|
@@ -312942,7 +313448,8 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
|
|
|
312942
313448
|
healthObservations: earlyClose.healthSummary,
|
|
312943
313449
|
pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
|
|
312944
313450
|
pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
|
|
312945
|
-
summary: "Feature exploration completed but no page data captured.",
|
|
313451
|
+
summary: fatalExploreError ? exploreResult.summary : "Feature exploration completed but no page data captured.",
|
|
313452
|
+
error: fatalExploreError,
|
|
312946
313453
|
exploreStats: {
|
|
312947
313454
|
pagesDiscovered: exploreResult.pagesDiscovered,
|
|
312948
313455
|
pagesVisited: exploreResult.pagesVisited,
|
|
@@ -326331,7 +326838,7 @@ var require_package4 = __commonJS({
|
|
|
326331
326838
|
"package.json"(exports2, module2) {
|
|
326332
326839
|
module2.exports = {
|
|
326333
326840
|
name: "@validate.qa/runner",
|
|
326334
|
-
version: "1.0.
|
|
326841
|
+
version: "1.0.13",
|
|
326335
326842
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
326336
326843
|
bin: {
|
|
326337
326844
|
"validate-runner": "dist/cli.js"
|
|
@@ -326339,7 +326846,7 @@ var require_package4 = __commonJS({
|
|
|
326339
326846
|
scripts: {
|
|
326340
326847
|
build: "tsup",
|
|
326341
326848
|
dev: "tsx src/cli.ts",
|
|
326342
|
-
"test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/services/mobile/mobile-network-capture.smoke.test.ts",
|
|
326849
|
+
"test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/services/mobile/mobile-network-capture.smoke.test.ts src/services/mobile/mobile-host-proxy.test.ts",
|
|
326343
326850
|
prepublishOnly: "npm run build"
|
|
326344
326851
|
},
|
|
326345
326852
|
files: [
|
|
@@ -326566,8 +327073,10 @@ async function startAppiumServer(options) {
|
|
|
326566
327073
|
return serverState;
|
|
326567
327074
|
}
|
|
326568
327075
|
const port = options?.port ?? APPIUM_PORT;
|
|
326569
|
-
const
|
|
326570
|
-
|
|
327076
|
+
const args = ["--port", String(port)];
|
|
327077
|
+
if (options?.drivers && options.drivers.length > 0) {
|
|
327078
|
+
args.push("--use-drivers", options.drivers.join(","));
|
|
327079
|
+
}
|
|
326571
327080
|
intentionalStop = false;
|
|
326572
327081
|
lastStartOptions = { ...lastStartOptions, ...options };
|
|
326573
327082
|
if (appiumGaveUp) {
|
|
@@ -326577,11 +327086,12 @@ async function startAppiumServer(options) {
|
|
|
326577
327086
|
killPort(port);
|
|
326578
327087
|
return new Promise((resolve, reject) => {
|
|
326579
327088
|
try {
|
|
326580
|
-
|
|
327089
|
+
const child = (0, import_child_process.spawn)("appium", args, {
|
|
326581
327090
|
stdio: ["pipe", "pipe", "pipe"],
|
|
326582
327091
|
env: { ...process.env },
|
|
326583
327092
|
detached: false
|
|
326584
327093
|
});
|
|
327094
|
+
appiumProcess = child;
|
|
326585
327095
|
serverState.pid = appiumProcess.pid ?? null;
|
|
326586
327096
|
serverState.port = port;
|
|
326587
327097
|
appiumProcess.stdout?.on("data", (data) => {
|
|
@@ -326598,7 +327108,11 @@ async function startAppiumServer(options) {
|
|
|
326598
327108
|
options?.onLog?.(line);
|
|
326599
327109
|
}
|
|
326600
327110
|
});
|
|
326601
|
-
|
|
327111
|
+
child.on("exit", (code, signal) => {
|
|
327112
|
+
if (appiumProcess !== child) {
|
|
327113
|
+
appendLog(`[appium] Ignoring exit (code=${code}, signal=${signal}) from a superseded process`);
|
|
327114
|
+
return;
|
|
327115
|
+
}
|
|
326602
327116
|
appendLog(`[appium] Process exited (code=${code}, signal=${signal})`);
|
|
326603
327117
|
const currentPort = serverState.port;
|
|
326604
327118
|
const currentRestartCount = serverState.restartCount;
|
|
@@ -326637,7 +327151,11 @@ async function startAppiumServer(options) {
|
|
|
326637
327151
|
startupAbort.abort();
|
|
326638
327152
|
reject(err);
|
|
326639
327153
|
};
|
|
326640
|
-
|
|
327154
|
+
child.on("error", (err) => {
|
|
327155
|
+
if (appiumProcess !== child) {
|
|
327156
|
+
appendLog(`[appium] Ignoring error from a superseded process: ${err.message}`);
|
|
327157
|
+
return;
|
|
327158
|
+
}
|
|
326641
327159
|
appendLog(`[appium] Process error: ${err.message}`);
|
|
326642
327160
|
resetServerState(port);
|
|
326643
327161
|
settleReject(new Error(`Failed to start Appium: ${err.message}. Is appium installed? Run: npm i -g appium`));
|
|
@@ -326756,6 +327274,7 @@ function runCommand(command, args, timeout = 1e4) {
|
|
|
326756
327274
|
}
|
|
326757
327275
|
var realIOSProfilerNegativeUntil = 0;
|
|
326758
327276
|
var REAL_IOS_PROFILER_TTL_MS = 6e4;
|
|
327277
|
+
var REAL_IOS_NO_UDID_HINT = "no paired UDID \u2014 install libimobiledevice (brew install libimobiledevice) and trust this computer on the device";
|
|
326759
327278
|
function discoverIOSSimulators() {
|
|
326760
327279
|
if ((0, import_os2.platform)() !== "darwin") return [];
|
|
326761
327280
|
const json = runCommand("xcrun", ["simctl", "list", "devices", "--json"]);
|
|
@@ -326765,7 +327284,8 @@ function discoverIOSSimulators() {
|
|
|
326765
327284
|
const devices = [];
|
|
326766
327285
|
for (const [runtime, runtimeDevices] of Object.entries(parsed.devices)) {
|
|
326767
327286
|
const versionMatch = runtime.match(/iOS[- ](\d+(?:[-.]\d+)*)/i);
|
|
326768
|
-
|
|
327287
|
+
if (!versionMatch) continue;
|
|
327288
|
+
const platformVersion = versionMatch[1].replace(/-/g, ".");
|
|
326769
327289
|
for (const device of runtimeDevices) {
|
|
326770
327290
|
const state2 = device.state === "Booted" ? "BOOTED" : device.state === "Shutdown" ? "SHUTDOWN" : "LOADING";
|
|
326771
327291
|
if (state2 === "BOOTED") {
|
|
@@ -326838,11 +327358,11 @@ function discoverRealIOSDevices() {
|
|
|
326838
327358
|
const version = runCommand("ideviceinfo", ["-u", serial, "-k", "ProductVersion"]) || "unknown";
|
|
326839
327359
|
devices.push({
|
|
326840
327360
|
id: serial,
|
|
326841
|
-
name
|
|
327361
|
+
name: `${name} (${REAL_IOS_NO_UDID_HINT})`,
|
|
326842
327362
|
platform: "IOS",
|
|
326843
327363
|
platformVersion: version,
|
|
326844
327364
|
state: "BOOTED",
|
|
326845
|
-
isAvailable:
|
|
327365
|
+
isAvailable: false,
|
|
326846
327366
|
isPhysical: true
|
|
326847
327367
|
});
|
|
326848
327368
|
}
|
|
@@ -326858,11 +327378,11 @@ function discoverRealIOSDevices() {
|
|
|
326858
327378
|
if (hasIPhone || hasIPad) {
|
|
326859
327379
|
devices.push({
|
|
326860
327380
|
id: "usb-ios-device",
|
|
326861
|
-
name: hasIPhone ? "iPhone (USB)" : "iPad (USB)"
|
|
327381
|
+
name: `${hasIPhone ? "iPhone (USB)" : "iPad (USB)"} (${REAL_IOS_NO_UDID_HINT})`,
|
|
326862
327382
|
platform: "IOS",
|
|
326863
327383
|
platformVersion: "unknown",
|
|
326864
327384
|
state: "BOOTED",
|
|
326865
|
-
isAvailable:
|
|
327385
|
+
isAvailable: false,
|
|
326866
327386
|
isPhysical: true
|
|
326867
327387
|
});
|
|
326868
327388
|
}
|
|
@@ -326940,7 +327460,9 @@ function discoverAndroidDevices() {
|
|
|
326940
327460
|
const adbDevices = parseAdbDevices();
|
|
326941
327461
|
return adbDevices.filter((d) => d.type === "device" || d.type === "unauthorized").map((d) => {
|
|
326942
327462
|
const isEmulator = d.serial.startsWith("emulator-");
|
|
326943
|
-
const
|
|
327463
|
+
const authorized = d.type === "device";
|
|
327464
|
+
const bootCompleted = authorized && getAndroidProp(d.serial, "sys.boot_completed") === "1";
|
|
327465
|
+
const isAvailable = authorized && bootCompleted;
|
|
326944
327466
|
const name = d.model || d.device || (isEmulator ? "Android Emulator" : "Android Device");
|
|
326945
327467
|
const platformVersion = isAvailable ? getAndroidProp(d.serial, "ro.build.version.release") || "unknown" : "unknown";
|
|
326946
327468
|
return {
|
|
@@ -326948,7 +327470,10 @@ function discoverAndroidDevices() {
|
|
|
326948
327470
|
name,
|
|
326949
327471
|
platform: "ANDROID",
|
|
326950
327472
|
platformVersion,
|
|
326951
|
-
|
|
327473
|
+
// LOADING = authorized but still booting; an unauthorized device stays
|
|
327474
|
+
// BOOTED (isAvailable false) so the dashboard shows it with an
|
|
327475
|
+
// "accept USB debugging" hint rather than hiding it.
|
|
327476
|
+
state: authorized && !bootCompleted ? "LOADING" : "BOOTED",
|
|
326952
327477
|
isAvailable,
|
|
326953
327478
|
// Mark if it's a real device vs emulator
|
|
326954
327479
|
...isEmulator ? {} : { isPhysical: true }
|
|
@@ -326994,15 +327519,25 @@ function discoverAllDevices(options) {
|
|
|
326994
327519
|
}
|
|
326995
327520
|
return [...iosDevices, ...androidDevices];
|
|
326996
327521
|
}
|
|
327522
|
+
var DEVICE_SNAPSHOT_TTL_MS = 8e3;
|
|
327523
|
+
var cachedDeviceSnapshot = null;
|
|
327524
|
+
function getCachedDeviceSnapshot() {
|
|
327525
|
+
const now = Date.now();
|
|
327526
|
+
if (cachedDeviceSnapshot && now - cachedDeviceSnapshot.at < DEVICE_SNAPSHOT_TTL_MS) {
|
|
327527
|
+
return cachedDeviceSnapshot.devices;
|
|
327528
|
+
}
|
|
327529
|
+
const devices = discoverAllDevices();
|
|
327530
|
+
cachedDeviceSnapshot = { at: now, devices };
|
|
327531
|
+
return devices;
|
|
327532
|
+
}
|
|
326997
327533
|
function getRunnerCapabilities() {
|
|
326998
|
-
const
|
|
326999
|
-
const androidDevices = discoverAndroidDevices();
|
|
327534
|
+
const devices = getCachedDeviceSnapshot();
|
|
327000
327535
|
const isReadyDevice = (device) => device.isAvailable && device.state === "BOOTED";
|
|
327001
327536
|
return {
|
|
327002
327537
|
web: true,
|
|
327003
327538
|
// Always capable of web testing via Playwright
|
|
327004
|
-
ios:
|
|
327005
|
-
android:
|
|
327539
|
+
ios: devices.some((d) => d.platform === "IOS" && isReadyDevice(d)),
|
|
327540
|
+
android: devices.some((d) => d.platform === "ANDROID" && isReadyDevice(d))
|
|
327006
327541
|
};
|
|
327007
327542
|
}
|
|
327008
327543
|
|
|
@@ -327138,6 +327673,9 @@ var MitmProxy = class {
|
|
|
327138
327673
|
await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
|
|
327139
327674
|
await server3.on("request", (req) => this.onRequest(req));
|
|
327140
327675
|
await server3.on("response", (res) => this.onResponse(res));
|
|
327676
|
+
await server3.on("abort", (req) => {
|
|
327677
|
+
this.inFlight.delete(req.id);
|
|
327678
|
+
});
|
|
327141
327679
|
await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
|
|
327142
327680
|
await server3.on("tls-passthrough-opened", (event) => this.onTlsPassthroughOpened(event));
|
|
327143
327681
|
await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
|
|
@@ -327434,6 +327972,107 @@ async function iosSimRemoveCa(deviceId, onLog) {
|
|
|
327434
327972
|
function errMsg(err) {
|
|
327435
327973
|
return err instanceof Error ? err.message : String(err);
|
|
327436
327974
|
}
|
|
327975
|
+
function parsePrimaryNetworkService(stdout) {
|
|
327976
|
+
for (const line of stdout.split("\n")) {
|
|
327977
|
+
const m = line.match(/^\(\d+\)\s+(.+)$/);
|
|
327978
|
+
if (m) return m[1].trim();
|
|
327979
|
+
}
|
|
327980
|
+
return null;
|
|
327981
|
+
}
|
|
327982
|
+
function parseWebProxyState(stdout) {
|
|
327983
|
+
const field = (label) => {
|
|
327984
|
+
const m = stdout.match(new RegExp(`^${label}:[ \\t]*(.*)$`, "im"));
|
|
327985
|
+
return m ? m[1].trim() : "";
|
|
327986
|
+
};
|
|
327987
|
+
return {
|
|
327988
|
+
enabled: field("Enabled").toLowerCase() === "yes",
|
|
327989
|
+
server: field("Server"),
|
|
327990
|
+
port: field("Port")
|
|
327991
|
+
};
|
|
327992
|
+
}
|
|
327993
|
+
function buildHostProxySetCommands(service, host, port) {
|
|
327994
|
+
const portStr = String(port);
|
|
327995
|
+
return [
|
|
327996
|
+
["-setwebproxy", service, host, portStr],
|
|
327997
|
+
["-setsecurewebproxy", service, host, portStr]
|
|
327998
|
+
];
|
|
327999
|
+
}
|
|
328000
|
+
function buildProxyRestoreArgs(setCmd, stateCmd, service, prior) {
|
|
328001
|
+
if (prior.server) {
|
|
328002
|
+
return [
|
|
328003
|
+
[setCmd, service, prior.server, prior.port || "0"],
|
|
328004
|
+
[stateCmd, service, prior.enabled ? "on" : "off"]
|
|
328005
|
+
];
|
|
328006
|
+
}
|
|
328007
|
+
return [[stateCmd, service, "off"]];
|
|
328008
|
+
}
|
|
328009
|
+
function buildHostProxyRestoreCommands(service, prior) {
|
|
328010
|
+
return [
|
|
328011
|
+
...buildProxyRestoreArgs("-setwebproxy", "-setwebproxystate", service, prior.web),
|
|
328012
|
+
...buildProxyRestoreArgs("-setsecurewebproxy", "-setsecurewebproxystate", service, prior.secure)
|
|
328013
|
+
];
|
|
328014
|
+
}
|
|
328015
|
+
function hostProxyPriorFromMarker(marker) {
|
|
328016
|
+
return {
|
|
328017
|
+
web: {
|
|
328018
|
+
enabled: Boolean(marker.hostWebProxyWasEnabled),
|
|
328019
|
+
server: marker.hostWebProxyPriorServer ?? "",
|
|
328020
|
+
port: marker.hostWebProxyPriorPort ?? ""
|
|
328021
|
+
},
|
|
328022
|
+
secure: {
|
|
328023
|
+
enabled: Boolean(marker.hostSecureProxyWasEnabled),
|
|
328024
|
+
server: marker.hostSecureProxyPriorServer ?? "",
|
|
328025
|
+
port: marker.hostSecureProxyPriorPort ?? ""
|
|
328026
|
+
}
|
|
328027
|
+
};
|
|
328028
|
+
}
|
|
328029
|
+
function hostProxyControlSupported(platform3, isPhysical, platformName = process.platform) {
|
|
328030
|
+
if (platform3 !== "IOS") return { supported: false };
|
|
328031
|
+
if (isPhysical) {
|
|
328032
|
+
return { supported: false, reason: "physical iOS device requires a manual Wi-Fi HTTP proxy; host proxy not applied" };
|
|
328033
|
+
}
|
|
328034
|
+
if (platformName !== "darwin") {
|
|
328035
|
+
return { supported: false, reason: `iOS simulator host proxy is only controllable on macOS (host is ${platformName})` };
|
|
328036
|
+
}
|
|
328037
|
+
return { supported: true };
|
|
328038
|
+
}
|
|
328039
|
+
async function iosSimConfigureHostProxy(host, port, onLog) {
|
|
328040
|
+
try {
|
|
328041
|
+
const { stdout: order } = await execFileAsync("networksetup", ["-listnetworkserviceorder"], {
|
|
328042
|
+
timeout: DEVICE_CMD_TIMEOUT_MS
|
|
328043
|
+
});
|
|
328044
|
+
const service = parsePrimaryNetworkService(order);
|
|
328045
|
+
if (!service) {
|
|
328046
|
+
return { configured: false, reason: "iOS simulator host proxy could not be configured: no active macOS network service found" };
|
|
328047
|
+
}
|
|
328048
|
+
const [{ stdout: webOut }, { stdout: secureOut }] = await Promise.all([
|
|
328049
|
+
execFileAsync("networksetup", ["-getwebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS }),
|
|
328050
|
+
execFileAsync("networksetup", ["-getsecurewebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS })
|
|
328051
|
+
]);
|
|
328052
|
+
const prior = { web: parseWebProxyState(webOut), secure: parseWebProxyState(secureOut) };
|
|
328053
|
+
for (const args of buildHostProxySetCommands(service, host, port)) {
|
|
328054
|
+
await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
|
|
328055
|
+
}
|
|
328056
|
+
onLog?.(`[mobile-net] macOS host proxy set on "${service}" \u2192 ${host}:${port} (iOS simulator shares the host network).`);
|
|
328057
|
+
return { configured: true, service, prior };
|
|
328058
|
+
} catch (err) {
|
|
328059
|
+
return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg(err)}` };
|
|
328060
|
+
}
|
|
328061
|
+
}
|
|
328062
|
+
async function iosSimRestoreHostProxy(service, prior, onLog) {
|
|
328063
|
+
const restore = prior ?? {
|
|
328064
|
+
web: { enabled: false, server: "", port: "" },
|
|
328065
|
+
secure: { enabled: false, server: "", port: "" }
|
|
328066
|
+
};
|
|
328067
|
+
for (const args of buildHostProxyRestoreCommands(service, restore)) {
|
|
328068
|
+
try {
|
|
328069
|
+
await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
|
|
328070
|
+
} catch (err) {
|
|
328071
|
+
onLog?.(`[mobile-net] networksetup restore (${args[0]}) failed: ${errMsg(err)}`);
|
|
328072
|
+
}
|
|
328073
|
+
}
|
|
328074
|
+
onLog?.(`[mobile-net] macOS host proxy restored on "${service}".`);
|
|
328075
|
+
}
|
|
327437
328076
|
var activeCleanups = /* @__PURE__ */ new Map();
|
|
327438
328077
|
var exitHandlersInstalled = false;
|
|
327439
328078
|
function markerPath(sessionKey) {
|
|
@@ -327468,6 +328107,14 @@ function restoreDeviceSync(marker, onLog) {
|
|
|
327468
328107
|
run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
|
|
327469
328108
|
}
|
|
327470
328109
|
}
|
|
328110
|
+
if (marker.platform === "IOS" && marker.hostProxySet && marker.hostProxyService && process.platform === "darwin") {
|
|
328111
|
+
for (const args of buildHostProxyRestoreCommands(marker.hostProxyService, hostProxyPriorFromMarker(marker))) {
|
|
328112
|
+
try {
|
|
328113
|
+
(0, import_node_child_process.execFileSync)("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS, stdio: "ignore" });
|
|
328114
|
+
} catch {
|
|
328115
|
+
}
|
|
328116
|
+
}
|
|
328117
|
+
}
|
|
327471
328118
|
onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
|
|
327472
328119
|
}
|
|
327473
328120
|
async function reconcilePendingCleanups(onLog) {
|
|
@@ -327479,6 +328126,8 @@ async function reconcilePendingCleanups(onLog) {
|
|
|
327479
328126
|
}
|
|
327480
328127
|
for (const file of files) {
|
|
327481
328128
|
if (!file.endsWith(".json")) continue;
|
|
328129
|
+
const sessionKey = file.slice(0, -".json".length);
|
|
328130
|
+
if (activeCleanups.has(sessionKey)) continue;
|
|
327482
328131
|
const full = (0, import_node_path.join)(PENDING_CLEANUP_DIR, file);
|
|
327483
328132
|
try {
|
|
327484
328133
|
const raw = await (0, import_promises.readFile)(full, "utf-8");
|
|
@@ -327508,8 +328157,10 @@ function ensureExitHandlers() {
|
|
|
327508
328157
|
for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
327509
328158
|
process.on(signal, () => {
|
|
327510
328159
|
drain();
|
|
327511
|
-
process.
|
|
327512
|
-
|
|
328160
|
+
if (process.listenerCount(signal) <= 1) {
|
|
328161
|
+
process.removeAllListeners(signal);
|
|
328162
|
+
process.kill(process.pid, signal);
|
|
328163
|
+
}
|
|
327513
328164
|
});
|
|
327514
328165
|
}
|
|
327515
328166
|
}
|
|
@@ -327559,6 +328210,9 @@ async function startMobileNetworkCapture(options) {
|
|
|
327559
328210
|
let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
|
|
327560
328211
|
let proxySet = false;
|
|
327561
328212
|
let caInstalledOnDevice = false;
|
|
328213
|
+
let hostProxySet = false;
|
|
328214
|
+
let hostProxyService;
|
|
328215
|
+
let hostProxyPrior;
|
|
327562
328216
|
try {
|
|
327563
328217
|
if (platform3 === "ANDROID") {
|
|
327564
328218
|
proxySet = await androidSetProxy(deviceId, hostPort, onLog);
|
|
@@ -327573,25 +328227,50 @@ async function startMobileNetworkCapture(options) {
|
|
|
327573
328227
|
}
|
|
327574
328228
|
} else {
|
|
327575
328229
|
if (!isPhysical && caCertPath) {
|
|
327576
|
-
const { trusted
|
|
328230
|
+
const { trusted } = await iosSimTrustCa(deviceId, caCertPath);
|
|
327577
328231
|
caInstalledOnDevice = trusted;
|
|
327578
|
-
|
|
328232
|
+
}
|
|
328233
|
+
const support = hostProxyControlSupported(platform3, isPhysical);
|
|
328234
|
+
if (support.supported) {
|
|
328235
|
+
const hp = await iosSimConfigureHostProxy(proxyHost, proxyPort, onLog);
|
|
328236
|
+
if (hp.configured) {
|
|
328237
|
+
hostProxySet = true;
|
|
328238
|
+
hostProxyService = hp.service;
|
|
328239
|
+
hostProxyPrior = hp.prior;
|
|
328240
|
+
wiringReason = "iOS simulator traffic routed via the macOS host proxy; HTTPS interception pending until the app completes a TLS handshake through the proxy" + (caCertPath ? ` (trust ${caCertPath} in the simulator if the app rejects our CA).` : ".");
|
|
328241
|
+
} else {
|
|
328242
|
+
wiringReason = hp.reason ?? "iOS simulator host proxy could not be configured";
|
|
328243
|
+
onLog?.(`[mobile-net] ${wiringReason}`);
|
|
328244
|
+
}
|
|
327579
328245
|
} else if (isPhysical) {
|
|
327580
328246
|
wiringReason = "Physical iOS HTTPS is tunneled without MITM by default. Set the Wi-Fi HTTP proxy and trust the CA manually, then run with VALIDATEQA_MOBILE_FORCE_TLS_MITM=1 to decrypt HTTPS bodies" + (caCertPath ? ` (${caCertPath}).` : ".");
|
|
328247
|
+
} else {
|
|
328248
|
+
wiringReason = support.reason ?? "iOS simulator host proxy is unsupported on this host";
|
|
328249
|
+
onLog?.(`[mobile-net] ${wiringReason}`);
|
|
327581
328250
|
}
|
|
327582
328251
|
}
|
|
327583
328252
|
} catch (err) {
|
|
327584
328253
|
wiringReason = `device wiring degraded: ${errMsg(err)}`;
|
|
327585
328254
|
onLog?.(`[mobile-net] ${wiringReason}`);
|
|
327586
328255
|
}
|
|
327587
|
-
if (proxySet || caInstalledOnDevice) {
|
|
328256
|
+
if (proxySet || caInstalledOnDevice || hostProxySet) {
|
|
327588
328257
|
const marker = {
|
|
327589
328258
|
platform: platform3,
|
|
327590
328259
|
deviceId,
|
|
327591
328260
|
isPhysical,
|
|
327592
328261
|
proxySet,
|
|
327593
328262
|
originalProxy,
|
|
327594
|
-
caInstalledOnDevice
|
|
328263
|
+
caInstalledOnDevice,
|
|
328264
|
+
...hostProxySet ? {
|
|
328265
|
+
hostProxySet: true,
|
|
328266
|
+
hostProxyService,
|
|
328267
|
+
hostWebProxyWasEnabled: hostProxyPrior?.web.enabled,
|
|
328268
|
+
hostWebProxyPriorServer: hostProxyPrior?.web.server,
|
|
328269
|
+
hostWebProxyPriorPort: hostProxyPrior?.web.port,
|
|
328270
|
+
hostSecureProxyWasEnabled: hostProxyPrior?.secure.enabled,
|
|
328271
|
+
hostSecureProxyPriorServer: hostProxyPrior?.secure.server,
|
|
328272
|
+
hostSecureProxyPriorPort: hostProxyPrior?.secure.port
|
|
328273
|
+
} : {}
|
|
327595
328274
|
};
|
|
327596
328275
|
activeCleanups.set(sessionKey, marker);
|
|
327597
328276
|
await writePendingCleanup(sessionKey, marker);
|
|
@@ -327611,6 +328290,9 @@ async function startMobileNetworkCapture(options) {
|
|
|
327611
328290
|
if (proxySet && platform3 === "ANDROID") {
|
|
327612
328291
|
await androidClearProxy(deviceId, originalProxy, onLog);
|
|
327613
328292
|
}
|
|
328293
|
+
if (hostProxySet && hostProxyService && platform3 === "IOS" && !isPhysical) {
|
|
328294
|
+
await iosSimRestoreHostProxy(hostProxyService, hostProxyPrior, onLog);
|
|
328295
|
+
}
|
|
327614
328296
|
if (caInstalledOnDevice && capturedCaCertPath) {
|
|
327615
328297
|
if (platform3 === "ANDROID") {
|
|
327616
328298
|
await androidRemoveCa(deviceId, onLog);
|
|
@@ -327646,6 +328328,9 @@ async function startMobileNetworkCapture(options) {
|
|
|
327646
328328
|
|
|
327647
328329
|
// src/services/appium-executor.ts
|
|
327648
328330
|
var DEFAULT_STEP_TIMEOUT_MS = 1e4;
|
|
328331
|
+
var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
|
|
328332
|
+
var WD_ACTION_TIMEOUT_MS = 3e4;
|
|
328333
|
+
var MAX_SESSION_CREATE_ATTEMPTS = 3;
|
|
327649
328334
|
var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
|
|
327650
328335
|
var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
|
|
327651
328336
|
var WebDriverTransportError = class extends Error {
|
|
@@ -327659,13 +328344,21 @@ function isTransportError(err) {
|
|
|
327659
328344
|
return err instanceof WebDriverTransportError || typeof err === "object" && err !== null && err.isTransport === true;
|
|
327660
328345
|
}
|
|
327661
328346
|
async function wdRequest(path, options) {
|
|
328347
|
+
const isSessionCreate = (options?.method ?? "GET").toUpperCase() === "POST" && path === "/session";
|
|
328348
|
+
const timeoutMs = isSessionCreate ? WD_SESSION_CREATE_TIMEOUT_MS : WD_ACTION_TIMEOUT_MS;
|
|
327662
328349
|
let res;
|
|
327663
328350
|
try {
|
|
327664
328351
|
res = await fetch(`http://localhost:${getAppiumState().port}${path}`, {
|
|
327665
328352
|
...options,
|
|
328353
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
327666
328354
|
headers: { "Content-Type": "application/json", ...options?.headers }
|
|
327667
328355
|
});
|
|
327668
328356
|
} catch (err) {
|
|
328357
|
+
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
|
|
328358
|
+
throw new WebDriverTransportError(
|
|
328359
|
+
`WebDriver request to ${path} timed out after ${timeoutMs}ms (device/Appium unresponsive)`
|
|
328360
|
+
);
|
|
328361
|
+
}
|
|
327669
328362
|
throw new WebDriverTransportError(`WebDriver connection error: ${err instanceof Error ? err.message : String(err)}`);
|
|
327670
328363
|
}
|
|
327671
328364
|
if (!res.ok) {
|
|
@@ -327750,6 +328443,31 @@ async function createSession(target) {
|
|
|
327750
328443
|
if (!sessionId) throw new Error("Appium session creation returned no session ID");
|
|
327751
328444
|
return sessionId;
|
|
327752
328445
|
}
|
|
328446
|
+
var TERMINAL_SESSION_START = /not installed|no such (app|file)|could not confirm|bundle ?id .*(not|missing)|xcodeorgid|signing|provisioning|not authorized|unauthorized/i;
|
|
328447
|
+
var RETRYABLE_SESSION_START = /timed out|timeout|webdriveragent|xcodebuild|instruments|could not connect|lockdownd|failed to (start|launch|create)|unable to launch|session is either terminated|unavailable|econnrefused|connection error|xcuitest|\b50[234]\b/i;
|
|
328448
|
+
function isRetryableSessionStartError(err) {
|
|
328449
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
328450
|
+
if (TERMINAL_SESSION_START.test(message)) return false;
|
|
328451
|
+
if (isTransportError(err)) return true;
|
|
328452
|
+
return RETRYABLE_SESSION_START.test(message);
|
|
328453
|
+
}
|
|
328454
|
+
async function createSessionWithRetry(target, onRetry) {
|
|
328455
|
+
let lastErr;
|
|
328456
|
+
for (let attempt = 1; attempt <= MAX_SESSION_CREATE_ATTEMPTS; attempt++) {
|
|
328457
|
+
try {
|
|
328458
|
+
return await createSession(target);
|
|
328459
|
+
} catch (err) {
|
|
328460
|
+
lastErr = err;
|
|
328461
|
+
if (attempt >= MAX_SESSION_CREATE_ATTEMPTS || !isRetryableSessionStartError(err)) throw err;
|
|
328462
|
+
const delayMs = Math.pow(2, attempt) * 1e3;
|
|
328463
|
+
onRetry?.(
|
|
328464
|
+
`Appium session start failed (attempt ${attempt}/${MAX_SESSION_CREATE_ATTEMPTS}, retrying in ${delayMs}ms): ${err instanceof Error ? err.message : String(err)}`
|
|
328465
|
+
);
|
|
328466
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
328467
|
+
}
|
|
328468
|
+
}
|
|
328469
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
328470
|
+
}
|
|
327753
328471
|
async function openDeepLink(sessionId, platform3, deeplink, appId) {
|
|
327754
328472
|
const args = platform3 === "IOS" ? [{ url: deeplink, bundleId: appId }] : [{ url: deeplink, package: appId }];
|
|
327755
328473
|
await wdRequest(`/session/${sessionId}/execute/sync`, {
|
|
@@ -327851,6 +328569,61 @@ async function takeScreenshot(sessionId) {
|
|
|
327851
328569
|
return null;
|
|
327852
328570
|
}
|
|
327853
328571
|
}
|
|
328572
|
+
function mobileRecordingEnabled() {
|
|
328573
|
+
const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
|
|
328574
|
+
return value !== "0" && value !== "false";
|
|
328575
|
+
}
|
|
328576
|
+
function readPositiveIntEnv(name, fallback, min, max) {
|
|
328577
|
+
const raw = process.env[name];
|
|
328578
|
+
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
|
328579
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
328580
|
+
return Math.max(min, Math.min(max, parsed));
|
|
328581
|
+
}
|
|
328582
|
+
async function startScreenRecording(sessionId, platform3, log2) {
|
|
328583
|
+
if (!mobileRecordingEnabled()) {
|
|
328584
|
+
log2("[mobile-video] recording disabled by VALIDATEQA_MOBILE_RUN_VIDEO=0");
|
|
328585
|
+
return false;
|
|
328586
|
+
}
|
|
328587
|
+
const timeLimit = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_SECONDS", 180, 5, 180);
|
|
328588
|
+
const options = {
|
|
328589
|
+
timeLimit: String(timeLimit),
|
|
328590
|
+
forceRestart: true
|
|
328591
|
+
};
|
|
328592
|
+
if (platform3 === "ANDROID") {
|
|
328593
|
+
options.bitRate = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_BITRATE", 75e4, 1e5, 4e6);
|
|
328594
|
+
} else {
|
|
328595
|
+
options.videoQuality = process.env.VALIDATEQA_MOBILE_RECORDING_IOS_QUALITY || "low";
|
|
328596
|
+
}
|
|
328597
|
+
try {
|
|
328598
|
+
await wdRequest(`/session/${sessionId}/appium/start_recording_screen`, {
|
|
328599
|
+
method: "POST",
|
|
328600
|
+
body: JSON.stringify({ options })
|
|
328601
|
+
});
|
|
328602
|
+
log2(`[mobile-video] screen recording started (${timeLimit}s max)`);
|
|
328603
|
+
return true;
|
|
328604
|
+
} catch (err) {
|
|
328605
|
+
log2(`[mobile-video] screen recording unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
328606
|
+
return false;
|
|
328607
|
+
}
|
|
328608
|
+
}
|
|
328609
|
+
async function stopScreenRecording(sessionId, log2) {
|
|
328610
|
+
try {
|
|
328611
|
+
const result = await wdRequest(`/session/${sessionId}/appium/stop_recording_screen`, {
|
|
328612
|
+
method: "POST",
|
|
328613
|
+
body: JSON.stringify({ options: {} })
|
|
328614
|
+
});
|
|
328615
|
+
const value = typeof result?.value === "string" ? result.value.trim() : "";
|
|
328616
|
+
if (!value) {
|
|
328617
|
+
log2("[mobile-video] screen recording stopped but Appium returned no video");
|
|
328618
|
+
return void 0;
|
|
328619
|
+
}
|
|
328620
|
+
log2(`[mobile-video] screen recording captured (${Math.round(value.length / 1024)}KB base64)`);
|
|
328621
|
+
return value;
|
|
328622
|
+
} catch (err) {
|
|
328623
|
+
log2(`[mobile-video] screen recording stop failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
328624
|
+
return void 0;
|
|
328625
|
+
}
|
|
328626
|
+
}
|
|
327854
328627
|
function boundsFromStep(step) {
|
|
327855
328628
|
const bounds = step.metadata?.bounds;
|
|
327856
328629
|
if (bounds && typeof bounds === "object" && !Array.isArray(bounds) && typeof bounds.x === "number" && typeof bounds.y === "number" && typeof bounds.width === "number" && typeof bounds.height === "number") {
|
|
@@ -327970,7 +328743,8 @@ async function sendKeysToFocusedInput(sessionId, value) {
|
|
|
327970
328743
|
});
|
|
327971
328744
|
}
|
|
327972
328745
|
function encodeAndroidInputText(value) {
|
|
327973
|
-
|
|
328746
|
+
const inputEncoded = value.replace(/%/g, "%25").replace(/\s/g, "%s");
|
|
328747
|
+
return `'${inputEncoded.replace(/'/g, `'\\''`)}'`;
|
|
327974
328748
|
}
|
|
327975
328749
|
async function sendAndroidTextToFocusedInput(deviceId, value) {
|
|
327976
328750
|
if (!deviceId) {
|
|
@@ -328228,6 +329002,9 @@ async function executeMobileTest(options) {
|
|
|
328228
329002
|
let sessionId = null;
|
|
328229
329003
|
let capture = null;
|
|
328230
329004
|
let captureStopped = false;
|
|
329005
|
+
let recordingStarted = false;
|
|
329006
|
+
let recordingStopped = false;
|
|
329007
|
+
let video;
|
|
328231
329008
|
const stopCapture = async () => {
|
|
328232
329009
|
if (!capture || captureStopped) return apiCalls;
|
|
328233
329010
|
captureStopped = true;
|
|
@@ -328242,6 +329019,15 @@ async function executeMobileTest(options) {
|
|
|
328242
329019
|
}
|
|
328243
329020
|
return apiCalls;
|
|
328244
329021
|
};
|
|
329022
|
+
const stopRecording = async () => {
|
|
329023
|
+
if (!sessionId || !recordingStarted || recordingStopped) return video;
|
|
329024
|
+
recordingStopped = true;
|
|
329025
|
+
const captured = await stopScreenRecording(sessionId, log2);
|
|
329026
|
+
if (captured) {
|
|
329027
|
+
video = captured;
|
|
329028
|
+
}
|
|
329029
|
+
return video;
|
|
329030
|
+
};
|
|
328245
329031
|
try {
|
|
328246
329032
|
log2("Checking Appium server health...");
|
|
328247
329033
|
const health = await checkAppiumHealth();
|
|
@@ -328295,13 +329081,13 @@ async function executeMobileTest(options) {
|
|
|
328295
329081
|
}
|
|
328296
329082
|
log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
|
|
328297
329083
|
try {
|
|
328298
|
-
sessionId = await
|
|
329084
|
+
sessionId = await createSessionWithRetry({
|
|
328299
329085
|
appId: options.target.appId,
|
|
328300
329086
|
platform: options.target.platform,
|
|
328301
329087
|
deviceId: selectedDevice.id,
|
|
328302
329088
|
platformVersion: options.target.platformVersion ?? selectedDevice.platformVersion,
|
|
328303
329089
|
isPhysical: selectedDevice.isPhysical
|
|
328304
|
-
});
|
|
329090
|
+
}, (msg) => log2(msg));
|
|
328305
329091
|
} catch (err) {
|
|
328306
329092
|
const base2 = err instanceof Error ? err.message : String(err);
|
|
328307
329093
|
if (selectedDevice.isPhysical) {
|
|
@@ -328316,6 +329102,7 @@ async function executeMobileTest(options) {
|
|
|
328316
329102
|
throw err;
|
|
328317
329103
|
}
|
|
328318
329104
|
log2(`Session created: ${sessionId}`);
|
|
329105
|
+
recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
|
|
328319
329106
|
if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
|
|
328320
329107
|
try {
|
|
328321
329108
|
log2(`Opening deep link: ${options.target.deeplink}`);
|
|
@@ -328376,7 +329163,9 @@ async function executeMobileTest(options) {
|
|
|
328376
329163
|
break;
|
|
328377
329164
|
}
|
|
328378
329165
|
}
|
|
328379
|
-
|
|
329166
|
+
if (step.action !== "home" && step.action !== "back") {
|
|
329167
|
+
await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
|
|
329168
|
+
}
|
|
328380
329169
|
} catch (err) {
|
|
328381
329170
|
status = "failed";
|
|
328382
329171
|
stepError = err instanceof Error ? err.message : String(err);
|
|
@@ -328405,10 +329194,12 @@ async function executeMobileTest(options) {
|
|
|
328405
329194
|
const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
|
|
328406
329195
|
const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
|
|
328407
329196
|
await stopCapture();
|
|
329197
|
+
await stopRecording();
|
|
328408
329198
|
return {
|
|
328409
329199
|
passed: allPassed,
|
|
328410
329200
|
stepResults,
|
|
328411
329201
|
screenshots,
|
|
329202
|
+
...video ? { video, videoMimeType: "video/mp4" } : {},
|
|
328412
329203
|
apiCalls,
|
|
328413
329204
|
logs: logLines.join("\n"),
|
|
328414
329205
|
error: firstError,
|
|
@@ -328419,10 +329210,12 @@ async function executeMobileTest(options) {
|
|
|
328419
329210
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
328420
329211
|
log2(`Fatal error: ${errorMsg}`);
|
|
328421
329212
|
await stopCapture();
|
|
329213
|
+
await stopRecording();
|
|
328422
329214
|
return {
|
|
328423
329215
|
passed: false,
|
|
328424
329216
|
stepResults,
|
|
328425
329217
|
screenshots,
|
|
329218
|
+
...video ? { video, videoMimeType: "video/mp4" } : {},
|
|
328426
329219
|
apiCalls,
|
|
328427
329220
|
logs: logLines.join("\n"),
|
|
328428
329221
|
error: errorMsg,
|
|
@@ -328434,6 +329227,7 @@ async function executeMobileTest(options) {
|
|
|
328434
329227
|
};
|
|
328435
329228
|
} finally {
|
|
328436
329229
|
await stopCapture();
|
|
329230
|
+
await stopRecording();
|
|
328437
329231
|
if (sessionId) {
|
|
328438
329232
|
log2("Tearing down Appium session...");
|
|
328439
329233
|
await deleteSession(sessionId);
|
|
@@ -328474,7 +329268,7 @@ async function createMobileDiscoverySession(target, options) {
|
|
|
328474
329268
|
isPhysical: selectedDevice.isPhysical === true,
|
|
328475
329269
|
platformVersion: selectedDevice.platformVersion
|
|
328476
329270
|
});
|
|
328477
|
-
const sessionId = await
|
|
329271
|
+
const sessionId = await createSessionWithRetry({
|
|
328478
329272
|
appId: target.appId,
|
|
328479
329273
|
platform: target.platform,
|
|
328480
329274
|
deviceId: selectedDevice.id,
|
|
@@ -329285,7 +330079,7 @@ function stopSessionReaper() {
|
|
|
329285
330079
|
}
|
|
329286
330080
|
}
|
|
329287
330081
|
async function reapIdleSession() {
|
|
329288
|
-
if (!state.appiumSessionId || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
|
|
330082
|
+
if (!state.appiumSessionId || mirrorSessionOwner !== "recording" || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
|
|
329289
330083
|
return;
|
|
329290
330084
|
}
|
|
329291
330085
|
const sessionId = state.appiumSessionId;
|
|
@@ -331802,6 +332596,11 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
331802
332596
|
});
|
|
331803
332597
|
requestLiveBrowserHeartbeat();
|
|
331804
332598
|
},
|
|
332599
|
+
// D-D6 — upload ONE baseline PNG per newly-observed screen through the
|
|
332600
|
+
// same streaming /page-screenshot relay the web path uses (mobile has no
|
|
332601
|
+
// route, so the screen-id is the key). The returned storage key rides the
|
|
332602
|
+
// explore checkpoint so the server links it to SitePage.screenshotKey.
|
|
332603
|
+
onScreenshotUpload: (screenId, base64) => onScreenshot(screenId, base64),
|
|
331805
332604
|
onExploreComplete,
|
|
331806
332605
|
onTestGenerated,
|
|
331807
332606
|
onPlanReady
|
|
@@ -332144,6 +332943,8 @@ ${result2.logs}`);
|
|
|
332144
332943
|
status,
|
|
332145
332944
|
stepResults: result2.stepResults,
|
|
332146
332945
|
screenshots: result2.screenshots,
|
|
332946
|
+
video: result2.video,
|
|
332947
|
+
videoMimeType: result2.videoMimeType,
|
|
332147
332948
|
apiCalls: result2.apiCalls,
|
|
332148
332949
|
logs: result2.logs,
|
|
332149
332950
|
error: reportedError,
|
|
@@ -332455,7 +333256,7 @@ async function startRunner(opts) {
|
|
|
332455
333256
|
};
|
|
332456
333257
|
const sendHeartbeat = async () => {
|
|
332457
333258
|
try {
|
|
332458
|
-
const devices = enableMobile ?
|
|
333259
|
+
const devices = enableMobile ? getCachedDeviceSnapshot() : [];
|
|
332459
333260
|
const capabilities = enableMobile ? {
|
|
332460
333261
|
web: true,
|
|
332461
333262
|
ios: devices.some((d) => d.platform === "IOS" && d.isAvailable && d.state === "BOOTED"),
|