@validate.qa/runner 1.0.12 → 1.0.14
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 +1068 -133
- package/dist/cli.mjs +1068 -133
- package/package.json +2 -2
package/dist/cli.mjs
CHANGED
|
@@ -238,6 +238,9 @@ var require_ai_provider = __commonJS({
|
|
|
238
238
|
exports.getNoThinkingRequestOptions = getNoThinkingRequestOptions;
|
|
239
239
|
exports.getAIClient = getAIClient;
|
|
240
240
|
exports.getClientForModel = getClientForModel;
|
|
241
|
+
exports.shouldProxyAIThroughServerForLocalRunner = shouldProxyAIThroughServerForLocalRunner;
|
|
242
|
+
exports.getRunnerAIClient = getRunnerAIClient;
|
|
243
|
+
exports.getRunnerClientForModel = getRunnerClientForModel;
|
|
241
244
|
exports.getModel = getModel;
|
|
242
245
|
exports.resolveModelForTask = resolveModelForTask;
|
|
243
246
|
exports.getAvailableModels = getAvailableModels;
|
|
@@ -515,6 +518,21 @@ var require_ai_provider = __commonJS({
|
|
|
515
518
|
function getClientForModel(modelId) {
|
|
516
519
|
return buildClientForProvider(getProviderForModel(modelId));
|
|
517
520
|
}
|
|
521
|
+
function shouldProxyAIThroughServerForLocalRunner() {
|
|
522
|
+
const serverUrl = (process.env.SERVER_URL ?? "").trim();
|
|
523
|
+
const token = (process.env.AUTH_TOKEN || process.env.RUNNER_TOKEN || "").trim();
|
|
524
|
+
return serverUrl.length > 0 && token.startsWith("urt_");
|
|
525
|
+
}
|
|
526
|
+
function getRunnerAIClient() {
|
|
527
|
+
if (shouldProxyAIThroughServerForLocalRunner())
|
|
528
|
+
return getServerProxiedAIClient();
|
|
529
|
+
return getAIClient();
|
|
530
|
+
}
|
|
531
|
+
function getRunnerClientForModel(modelId) {
|
|
532
|
+
if (shouldProxyAIThroughServerForLocalRunner())
|
|
533
|
+
return getServerProxiedClientForModel(modelId);
|
|
534
|
+
return getClientForModel(modelId);
|
|
535
|
+
}
|
|
518
536
|
function getModel(type) {
|
|
519
537
|
return MODEL_TASK_MAP[getActiveModelId()][type];
|
|
520
538
|
}
|
|
@@ -585,7 +603,7 @@ var require_ai_provider = __commonJS({
|
|
|
585
603
|
const create = async (params, options) => {
|
|
586
604
|
const modelId = typeof params.model === "string" ? params.model : "";
|
|
587
605
|
if (!modelId) {
|
|
588
|
-
throw new Error("server-proxied AI client requires params.model
|
|
606
|
+
throw new Error("server-proxied AI client requires params.model");
|
|
589
607
|
}
|
|
590
608
|
const body = {
|
|
591
609
|
modelId,
|
|
@@ -755,6 +773,21 @@ var require_mobile_source_parser = __commonJS({
|
|
|
755
773
|
const attrText = inner.slice(tag.length);
|
|
756
774
|
return { tag, attrs: parseAttributes(attrText), selfClosing };
|
|
757
775
|
}
|
|
776
|
+
function findTagEnd(xml, from) {
|
|
777
|
+
let quote = null;
|
|
778
|
+
for (let i = from; i < xml.length; i++) {
|
|
779
|
+
const ch = xml[i];
|
|
780
|
+
if (quote !== null) {
|
|
781
|
+
if (ch === quote)
|
|
782
|
+
quote = null;
|
|
783
|
+
} else if (ch === '"' || ch === "'") {
|
|
784
|
+
quote = ch;
|
|
785
|
+
} else if (ch === ">") {
|
|
786
|
+
return i;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return -1;
|
|
790
|
+
}
|
|
758
791
|
function tokenize(xml) {
|
|
759
792
|
if (!xml || typeof xml !== "string")
|
|
760
793
|
return null;
|
|
@@ -777,7 +810,7 @@ var require_mobile_source_parser = __commonJS({
|
|
|
777
810
|
i = end === -1 ? len : end + 3;
|
|
778
811
|
continue;
|
|
779
812
|
}
|
|
780
|
-
const gt = xml
|
|
813
|
+
const gt = findTagEnd(xml, lt + 1);
|
|
781
814
|
if (gt === -1)
|
|
782
815
|
break;
|
|
783
816
|
const tagBody = xml.slice(lt + 1, gt);
|
|
@@ -1329,6 +1362,9 @@ var init_constants = __esm({
|
|
|
1329
1362
|
function toJsStringLiteral(value) {
|
|
1330
1363
|
return JSON.stringify(value);
|
|
1331
1364
|
}
|
|
1365
|
+
function toInlineCommentText(value) {
|
|
1366
|
+
return value.replace(/[\r\n\u2028\u2029]+/g, " ");
|
|
1367
|
+
}
|
|
1332
1368
|
function isPlainObject(value) {
|
|
1333
1369
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1334
1370
|
}
|
|
@@ -1534,7 +1570,7 @@ function buildAppiumCode(testName, target, steps) {
|
|
|
1534
1570
|
`// 3. A booted device/simulator matching the capabilities below`,
|
|
1535
1571
|
`// This is NOT a WDIO testrunner spec: it uses the standalone remote() client`,
|
|
1536
1572
|
`// (driver.$, no auto-injected $/expect/describe globals) so it has no harness`,
|
|
1537
|
-
`// dependency and runs with a plain \`tsx ${testName}.spec.ts\`.`,
|
|
1573
|
+
`// dependency and runs with a plain \`tsx ${toInlineCommentText(testName)}.spec.ts\`.`,
|
|
1538
1574
|
`import { remote } from 'webdriverio';`,
|
|
1539
1575
|
``,
|
|
1540
1576
|
`// Resolve the first selector that exists, mirroring the runner's locator`,
|
|
@@ -1563,6 +1599,10 @@ function buildAppiumCode(testName, target, steps) {
|
|
|
1563
1599
|
` }`,
|
|
1564
1600
|
`}`,
|
|
1565
1601
|
``,
|
|
1602
|
+
`async function __clearFocused(driver: WebdriverIO.Browser) {`,
|
|
1603
|
+
` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).clearValue();`,
|
|
1604
|
+
`}`,
|
|
1605
|
+
``,
|
|
1566
1606
|
`async function __textCandidates(el: WebdriverIO.Element) {`,
|
|
1567
1607
|
` const __values: string[] = [];`,
|
|
1568
1608
|
` const __push = (__value: unknown) => {`,
|
|
@@ -1613,7 +1653,7 @@ function buildAppiumCode(testName, target, steps) {
|
|
|
1613
1653
|
` // Canonical execution source: steps JSON`
|
|
1614
1654
|
];
|
|
1615
1655
|
const stepLines = steps.flatMap((step) => {
|
|
1616
|
-
const lines = [` // Step ${step.order + 1}: ${step.description}`];
|
|
1656
|
+
const lines = [` // Step ${step.order + 1}: ${toInlineCommentText(step.description)}`];
|
|
1617
1657
|
const candidates = selectorCandidatesForStep(step);
|
|
1618
1658
|
const candidatesLiteral = `[${candidates.map(toJsStringLiteral).join(", ")}]`;
|
|
1619
1659
|
const hasSelector = candidates.length > 0;
|
|
@@ -1656,6 +1696,12 @@ function buildAppiumCode(testName, target, steps) {
|
|
|
1656
1696
|
lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
|
|
1657
1697
|
} else if (step.action === "clear" && hasSelector) {
|
|
1658
1698
|
lines.push(` await (await findFirst(driver, ${candidatesLiteral})).clearValue();`);
|
|
1699
|
+
} else if (step.action === "clear" && bounds) {
|
|
1700
|
+
const x = Math.round(bounds.x + bounds.width / 2);
|
|
1701
|
+
const y = Math.round(bounds.y + bounds.height / 2);
|
|
1702
|
+
lines.push(` await __tapAt(driver, ${x}, ${y});`);
|
|
1703
|
+
lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
|
|
1704
|
+
lines.push(` await __clearFocused(driver);`);
|
|
1659
1705
|
} else if (step.action === "assertVisible" && hasSelector) {
|
|
1660
1706
|
lines.push(` if (!(await (await findFirst(driver, ${candidatesLiteral})).isDisplayed())) {`);
|
|
1661
1707
|
lines.push(` throw new Error('Assertion failed: element not displayed: ' + ${toJsStringLiteral(candidatesLiteral)});`);
|
|
@@ -1663,6 +1709,7 @@ function buildAppiumCode(testName, target, steps) {
|
|
|
1663
1709
|
} else if (step.action === "assertText" && hasSelector) {
|
|
1664
1710
|
lines.push(` {`);
|
|
1665
1711
|
lines.push(` const __expected = ${toJsStringLiteral(step.assertion ?? "")};`);
|
|
1712
|
+
lines.push(` if (__expected.length === 0) throw new Error('assertText step is missing expected text');`);
|
|
1666
1713
|
lines.push(` const __actual = await __textCandidates(await findFirst(driver, ${candidatesLiteral}));`);
|
|
1667
1714
|
lines.push(` if (!__actual.some((__value) => __value.includes(__expected))) {`);
|
|
1668
1715
|
lines.push(` throw new Error('Assertion failed: expected text to contain ' + JSON.stringify(__expected) + ' but got ' + JSON.stringify(__actual));`);
|
|
@@ -4371,6 +4418,7 @@ var require_mobile_observation = __commonJS({
|
|
|
4371
4418
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4372
4419
|
exports.renderMobileObservation = renderMobileObservation;
|
|
4373
4420
|
exports.summarizeScreenForEvidence = summarizeScreenForEvidence;
|
|
4421
|
+
exports.buildCollectedElementsForScreen = buildCollectedElementsForScreen;
|
|
4374
4422
|
var DEFAULT_MAX_ELEMENTS = 60;
|
|
4375
4423
|
var MAX_LABEL_LENGTH = 80;
|
|
4376
4424
|
var EVIDENCE_LABEL_LIMIT = 40;
|
|
@@ -4412,6 +4460,46 @@ var require_mobile_observation = __commonJS({
|
|
|
4412
4460
|
}
|
|
4413
4461
|
return { elementCount, actionableCount, labels };
|
|
4414
4462
|
}
|
|
4463
|
+
function buildCollectedElementsForScreen(elements, opts = {}) {
|
|
4464
|
+
const navigationRefs = opts.navigationRefs ?? EMPTY_REF_SET;
|
|
4465
|
+
const out = [];
|
|
4466
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4467
|
+
for (const element of elements) {
|
|
4468
|
+
if (!element.actionable)
|
|
4469
|
+
continue;
|
|
4470
|
+
const collected = toCollectedPageElement(element, navigationRefs.has(element.ref));
|
|
4471
|
+
const identity = `${collected.elementType}|${collected.testId ?? ""}|${collected.label ?? ""}`;
|
|
4472
|
+
if (seen.has(identity))
|
|
4473
|
+
continue;
|
|
4474
|
+
seen.add(identity);
|
|
4475
|
+
out.push(collected);
|
|
4476
|
+
}
|
|
4477
|
+
return out;
|
|
4478
|
+
}
|
|
4479
|
+
var EMPTY_REF_SET = /* @__PURE__ */ new Set();
|
|
4480
|
+
function toCollectedPageElement(element, triggersNavigation) {
|
|
4481
|
+
const label = element.label?.trim() || element.text?.trim() || void 0;
|
|
4482
|
+
const stableId = element.accessibilityId?.trim() || element.resourceId?.trim() || void 0;
|
|
4483
|
+
const locators = [];
|
|
4484
|
+
if (stableId)
|
|
4485
|
+
locators.push({ strategy: "getByTestId", value: stableId, confidence: 0.95 });
|
|
4486
|
+
const xpath = element.xpath?.trim();
|
|
4487
|
+
if (xpath)
|
|
4488
|
+
locators.push({ strategy: "css", value: xpath, confidence: 0.4 });
|
|
4489
|
+
const collected = {
|
|
4490
|
+
elementType: element.role || "View",
|
|
4491
|
+
triggersNavigation
|
|
4492
|
+
};
|
|
4493
|
+
if (label)
|
|
4494
|
+
collected.label = label;
|
|
4495
|
+
if (stableId)
|
|
4496
|
+
collected.testId = stableId;
|
|
4497
|
+
if (locators.length > 0)
|
|
4498
|
+
collected.locators = locators;
|
|
4499
|
+
if (element.enabled === false)
|
|
4500
|
+
collected.isDisabled = true;
|
|
4501
|
+
return collected;
|
|
4502
|
+
}
|
|
4415
4503
|
function buildHeaderLine(screen) {
|
|
4416
4504
|
const parts = ["screen:"];
|
|
4417
4505
|
const name = screenDisplayName(screen.screenSignal);
|
|
@@ -4860,7 +4948,7 @@ var require_mobile_explorer = __commonJS({
|
|
|
4860
4948
|
}
|
|
4861
4949
|
return sections.join("\n");
|
|
4862
4950
|
}
|
|
4863
|
-
function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId) {
|
|
4951
|
+
function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId, existingScreens) {
|
|
4864
4952
|
const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
|
|
4865
4953
|
const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
|
|
4866
4954
|
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.
|
|
@@ -4906,13 +4994,18 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
|
|
|
4906
4994
|
## Application Brief
|
|
4907
4995
|
${appBrief}
|
|
4908
4996
|
Use this to prioritize which screens and flows to cover.` : "";
|
|
4909
|
-
|
|
4997
|
+
const existingScreensBlock = mode === "survey" && existingScreens && existingScreens.length > 0 ? `
|
|
4998
|
+
|
|
4999
|
+
## ALREADY DISCOVERED SCREENS (skip these; find new)
|
|
5000
|
+
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.
|
|
5001
|
+
${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.name}"` : ""}${s.screenType ? ` [${s.screenType}]` : ""}`).join("\n")}` : "";
|
|
5002
|
+
return `${mode === "survey" ? surveyBody : deepBody}${briefBlock}${existingScreensBlock}`;
|
|
4910
5003
|
}
|
|
4911
5004
|
function buildKickoffMessage(mode) {
|
|
4912
5005
|
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.";
|
|
4913
5006
|
}
|
|
4914
5007
|
async function runMobileExplorePhase(args) {
|
|
4915
|
-
const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot } = args;
|
|
5008
|
+
const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot, onScreenshotUpload, existingScreens } = args;
|
|
4916
5009
|
const logs2 = [];
|
|
4917
5010
|
const log2 = (line) => {
|
|
4918
5011
|
logs2.push(line);
|
|
@@ -4936,6 +5029,9 @@ Use this to prioritize which screens and flows to cover.` : "";
|
|
|
4936
5029
|
log2(`Budget: ${maxIterations} iterations | app: ${ctx.target?.appId ?? "(unknown)"}`);
|
|
4937
5030
|
const screenshots = [];
|
|
4938
5031
|
const pageScreenshots = {};
|
|
5032
|
+
const pageScreenshotKeys = {};
|
|
5033
|
+
const uploadedScreenIds = /* @__PURE__ */ new Set();
|
|
5034
|
+
const pendingScreenshotUploads = [];
|
|
4939
5035
|
const collectedTransitions = [];
|
|
4940
5036
|
const journeys = [];
|
|
4941
5037
|
const counters = {
|
|
@@ -4954,6 +5050,14 @@ Use this to prioritize which screens and flows to cover.` : "";
|
|
|
4954
5050
|
screenshots.push(base64);
|
|
4955
5051
|
if (screenId)
|
|
4956
5052
|
pageScreenshots[screenId] = base64;
|
|
5053
|
+
if (screenId && onScreenshotUpload && !uploadedScreenIds.has(screenId)) {
|
|
5054
|
+
uploadedScreenIds.add(screenId);
|
|
5055
|
+
pendingScreenshotUploads.push(onScreenshotUpload(screenId, base64).then((key) => {
|
|
5056
|
+
if (key)
|
|
5057
|
+
pageScreenshotKeys[screenId] = key;
|
|
5058
|
+
}).catch(() => {
|
|
5059
|
+
}));
|
|
5060
|
+
}
|
|
4957
5061
|
try {
|
|
4958
5062
|
onScreenshot?.(base64);
|
|
4959
5063
|
} catch {
|
|
@@ -5058,7 +5162,7 @@ Use this to prioritize which screens and flows to cover.` : "";
|
|
|
5058
5162
|
return null;
|
|
5059
5163
|
return { element, bounds: element.bounds };
|
|
5060
5164
|
};
|
|
5061
|
-
const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId);
|
|
5165
|
+
const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId, existingScreens);
|
|
5062
5166
|
const kickoff = buildKickoffMessage(mode);
|
|
5063
5167
|
const recentExchanges = [];
|
|
5064
5168
|
const transientDirectives = [];
|
|
@@ -5266,6 +5370,9 @@ Exploration complete: ${summary}`);
|
|
|
5266
5370
|
summary = `Explore phase aborted (${truncatedReason}) after mapping ${state2.getScreenCount()} screen(s).`;
|
|
5267
5371
|
}
|
|
5268
5372
|
}
|
|
5373
|
+
if (pendingScreenshotUploads.length > 0) {
|
|
5374
|
+
await Promise.allSettled(pendingScreenshotUploads);
|
|
5375
|
+
}
|
|
5269
5376
|
const collectedPages = state2.buildInMemoryScreenMap();
|
|
5270
5377
|
counters.pagesVisited = collectedPages.length;
|
|
5271
5378
|
if (counters.pagesDiscovered < collectedPages.length) {
|
|
@@ -5300,6 +5407,9 @@ Exploration complete: ${summary}`);
|
|
|
5300
5407
|
if (Object.keys(pageScreenshots).length > 0) {
|
|
5301
5408
|
result.pageScreenshots = pageScreenshots;
|
|
5302
5409
|
}
|
|
5410
|
+
if (Object.keys(pageScreenshotKeys).length > 0) {
|
|
5411
|
+
result.pageScreenshotKeys = pageScreenshotKeys;
|
|
5412
|
+
}
|
|
5303
5413
|
if (truncated) {
|
|
5304
5414
|
result.truncated = true;
|
|
5305
5415
|
result.truncatedReason = truncatedReason;
|
|
@@ -5801,6 +5911,44 @@ var require_snapshot_parser = __commonJS({
|
|
|
5801
5911
|
};
|
|
5802
5912
|
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;
|
|
5803
5913
|
var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
|
|
5914
|
+
var RICH_CONTEXT_ROLES = /* @__PURE__ */ new Set(["table", "grid", "row", "radiogroup"]);
|
|
5915
|
+
var RICH_CONTEXT_LABEL_MAX = 60;
|
|
5916
|
+
var VALUE_BEARING_ROLES = /* @__PURE__ */ new Set(["textbox", "searchbox", "combobox", "spinbutton", "listbox", "slider"]);
|
|
5917
|
+
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;
|
|
5918
|
+
var REDACTED_VALUE = "[redacted]";
|
|
5919
|
+
var VALUE_TEXT_MAX = 120;
|
|
5920
|
+
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;
|
|
5921
|
+
var STATIC_MESSAGES_MAX = 12;
|
|
5922
|
+
var OPTIONS_PER_ELEMENT_MAX = 24;
|
|
5923
|
+
var URL_CHILD_LINE_RE = /^(\s*)- \/url:\s*(.+)$/;
|
|
5924
|
+
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;
|
|
5925
|
+
function stripYamlQuotes(raw) {
|
|
5926
|
+
const trimmed = raw.trim();
|
|
5927
|
+
if (trimmed.length >= 2 && (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
5928
|
+
return trimmed.slice(1, -1);
|
|
5929
|
+
}
|
|
5930
|
+
return trimmed;
|
|
5931
|
+
}
|
|
5932
|
+
function extractTrailingText(attrs) {
|
|
5933
|
+
const lastBracket = attrs.lastIndexOf("]");
|
|
5934
|
+
const tail = lastBracket >= 0 ? attrs.slice(lastBracket + 1) : attrs;
|
|
5935
|
+
const match = /^\s*:\s+(.+)$/.exec(tail);
|
|
5936
|
+
if (!match)
|
|
5937
|
+
return null;
|
|
5938
|
+
const text = stripYamlQuotes(match[1]);
|
|
5939
|
+
if (!text || /^(?:\||\|-|>|>-)$/.test(text))
|
|
5940
|
+
return null;
|
|
5941
|
+
return text;
|
|
5942
|
+
}
|
|
5943
|
+
function isSensitiveValueField(name, placeholder) {
|
|
5944
|
+
return SENSITIVE_VALUE_LABEL_RE.test(`${name ?? ""} ${placeholder ?? ""}`);
|
|
5945
|
+
}
|
|
5946
|
+
function scrubMessageText(text) {
|
|
5947
|
+
return text.replace(MESSAGE_TOKEN_RE, REDACTED_VALUE);
|
|
5948
|
+
}
|
|
5949
|
+
function capText(text, max) {
|
|
5950
|
+
return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
|
|
5951
|
+
}
|
|
5804
5952
|
function isProbablyDynamicTestId(testId) {
|
|
5805
5953
|
if (testId.length < 6)
|
|
5806
5954
|
return false;
|
|
@@ -5876,9 +6024,12 @@ var require_snapshot_parser = __commonJS({
|
|
|
5876
6024
|
}
|
|
5877
6025
|
return e.name;
|
|
5878
6026
|
}
|
|
5879
|
-
function parseSnapshot(snapshotText) {
|
|
6027
|
+
function parseSnapshot(snapshotText, options = {}) {
|
|
6028
|
+
const richParse = options.richParse ?? process.env.DISCOVERY_SNAPSHOT_RICH_PARSE === "true";
|
|
5880
6029
|
const lines = snapshotText.split("\n");
|
|
5881
6030
|
const elements = [];
|
|
6031
|
+
const liveMessages = [];
|
|
6032
|
+
const textMessages = [];
|
|
5882
6033
|
const seenRefs = /* @__PURE__ */ new Set();
|
|
5883
6034
|
let parsedElementLineCount = 0;
|
|
5884
6035
|
let genericLabelCount = 0;
|
|
@@ -5887,6 +6038,21 @@ var require_snapshot_parser = __commonJS({
|
|
|
5887
6038
|
const contextStack = [];
|
|
5888
6039
|
const completedForms = [];
|
|
5889
6040
|
for (const line of lines) {
|
|
6041
|
+
if (richParse) {
|
|
6042
|
+
const urlMatch = URL_CHILD_LINE_RE.exec(line);
|
|
6043
|
+
if (urlMatch) {
|
|
6044
|
+
const urlIndent = urlMatch[1].length;
|
|
6045
|
+
for (let i = elements.length - 1; i >= 0; i--) {
|
|
6046
|
+
const el = elements[i];
|
|
6047
|
+
if (el.indentDepth < urlIndent) {
|
|
6048
|
+
if (el.role === "link" && !el.href)
|
|
6049
|
+
el.href = capText(stripYamlQuotes(urlMatch[2]), 300);
|
|
6050
|
+
break;
|
|
6051
|
+
}
|
|
6052
|
+
}
|
|
6053
|
+
continue;
|
|
6054
|
+
}
|
|
6055
|
+
}
|
|
5890
6056
|
const match = ELEMENT_LINE_RE.exec(line);
|
|
5891
6057
|
if (!match)
|
|
5892
6058
|
continue;
|
|
@@ -5908,7 +6074,7 @@ var require_snapshot_parser = __commonJS({
|
|
|
5908
6074
|
}
|
|
5909
6075
|
const headingLevelMatch = LEVEL_RE.exec(attrs);
|
|
5910
6076
|
const headingLevel = headingLevelMatch ? Number.parseInt(headingLevelMatch[1], 10) : null;
|
|
5911
|
-
const contextLabel = buildContextLabel(role, name ?? null, headingLevel);
|
|
6077
|
+
const contextLabel = buildContextLabel(role, name ?? null, headingLevel, richParse);
|
|
5912
6078
|
if (contextLabel) {
|
|
5913
6079
|
contextStack.push({ indent: indentDepth, label: contextLabel });
|
|
5914
6080
|
}
|
|
@@ -5921,6 +6087,40 @@ var require_snapshot_parser = __commonJS({
|
|
|
5921
6087
|
});
|
|
5922
6088
|
continue;
|
|
5923
6089
|
}
|
|
6090
|
+
if (richParse) {
|
|
6091
|
+
if (role === "option" && name) {
|
|
6092
|
+
for (let i = elements.length - 1; i >= 0; i--) {
|
|
6093
|
+
const el = elements[i];
|
|
6094
|
+
if (el.indentDepth < indentDepth) {
|
|
6095
|
+
if (el.role === "combobox" || el.role === "listbox") {
|
|
6096
|
+
el.options ??= [];
|
|
6097
|
+
if (el.options.length < OPTIONS_PER_ELEMENT_MAX) {
|
|
6098
|
+
el.options.push({ label: capText(name, 80), selected: parseBooleanState(SELECTED_RE.exec(attrs)) === true });
|
|
6099
|
+
}
|
|
6100
|
+
}
|
|
6101
|
+
break;
|
|
6102
|
+
}
|
|
6103
|
+
}
|
|
6104
|
+
}
|
|
6105
|
+
if (role === "alert" || role === "status" || role === "paragraph" || role === "text") {
|
|
6106
|
+
const text = extractTrailingText(attrs) ?? (name || null);
|
|
6107
|
+
const isLiveRegion = role === "alert" || role === "status";
|
|
6108
|
+
if (text && (isLiveRegion || SIGNAL_TEXT_RE.test(text))) {
|
|
6109
|
+
const capped = capText(scrubMessageText(text), VALUE_TEXT_MAX);
|
|
6110
|
+
const ref2 = REF_RE.exec(attrs)?.[1] ?? null;
|
|
6111
|
+
if (isLiveRegion) {
|
|
6112
|
+
const textDupIndex = textMessages.findIndex((m) => m.text === capped);
|
|
6113
|
+
if (textDupIndex >= 0)
|
|
6114
|
+
textMessages.splice(textDupIndex, 1);
|
|
6115
|
+
if (liveMessages.length < STATIC_MESSAGES_MAX && !liveMessages.some((m) => m.text === capped)) {
|
|
6116
|
+
liveMessages.push({ role, text: capped, ref: ref2 });
|
|
6117
|
+
}
|
|
6118
|
+
} else if (textMessages.length < STATIC_MESSAGES_MAX && !textMessages.some((m) => m.text === capped) && !liveMessages.some((m) => m.text === capped)) {
|
|
6119
|
+
textMessages.push({ role: "text", text: capped, ref: ref2 });
|
|
6120
|
+
}
|
|
6121
|
+
}
|
|
6122
|
+
}
|
|
6123
|
+
}
|
|
5924
6124
|
if (!INTERACTIVE_ROLES.has(role))
|
|
5925
6125
|
continue;
|
|
5926
6126
|
const refMatch = REF_RE.exec(attrs);
|
|
@@ -5953,6 +6153,13 @@ var require_snapshot_parser = __commonJS({
|
|
|
5953
6153
|
}
|
|
5954
6154
|
const isLink = role === "link";
|
|
5955
6155
|
const isSubmitButton = role === "button" && name != null && SUBMIT_LABEL_RE.test(name);
|
|
6156
|
+
let value = null;
|
|
6157
|
+
if (richParse && VALUE_BEARING_ROLES.has(role)) {
|
|
6158
|
+
const trailing = extractTrailingText(attrs);
|
|
6159
|
+
if (trailing) {
|
|
6160
|
+
value = isSensitiveValueField(name ?? null, placeholder) ? REDACTED_VALUE : capText(trailing, VALUE_TEXT_MAX);
|
|
6161
|
+
}
|
|
6162
|
+
}
|
|
5956
6163
|
const element = {
|
|
5957
6164
|
ref,
|
|
5958
6165
|
role,
|
|
@@ -5974,7 +6181,10 @@ var require_snapshot_parser = __commonJS({
|
|
|
5974
6181
|
pressed,
|
|
5975
6182
|
headingLevel,
|
|
5976
6183
|
currentState,
|
|
5977
|
-
containerPath
|
|
6184
|
+
containerPath,
|
|
6185
|
+
href: null,
|
|
6186
|
+
value,
|
|
6187
|
+
options: null
|
|
5978
6188
|
};
|
|
5979
6189
|
if (isGenericLabel(element.name))
|
|
5980
6190
|
genericLabelCount++;
|
|
@@ -6002,6 +6212,9 @@ var require_snapshot_parser = __commonJS({
|
|
|
6002
6212
|
return {
|
|
6003
6213
|
elements,
|
|
6004
6214
|
formGroups: completedForms,
|
|
6215
|
+
// live regions first: they carry their own reservation so an end-of-body toast can
|
|
6216
|
+
// never be starved out by signal-matching plain text earlier in the document.
|
|
6217
|
+
staticMessages: [...liveMessages, ...textMessages].slice(0, STATIC_MESSAGES_MAX),
|
|
6005
6218
|
rawLineCount: lines.length,
|
|
6006
6219
|
parsedElementLineCount,
|
|
6007
6220
|
genericLabelCount,
|
|
@@ -6112,12 +6325,17 @@ var require_snapshot_parser = __commonJS({
|
|
|
6112
6325
|
return "mixed";
|
|
6113
6326
|
return false;
|
|
6114
6327
|
}
|
|
6115
|
-
function buildContextLabel(role, name, headingLevel) {
|
|
6328
|
+
function buildContextLabel(role, name, headingLevel, richContext = false) {
|
|
6116
6329
|
if (role === "heading") {
|
|
6117
6330
|
if (!name)
|
|
6118
6331
|
return null;
|
|
6119
6332
|
return headingLevel ? `heading${headingLevel}:${name}` : `heading:${name}`;
|
|
6120
6333
|
}
|
|
6334
|
+
if (richContext && RICH_CONTEXT_ROLES.has(role)) {
|
|
6335
|
+
if (!name)
|
|
6336
|
+
return null;
|
|
6337
|
+
return `${role}:${capText(name.replace(/>/g, "\u203A"), RICH_CONTEXT_LABEL_MAX)}`;
|
|
6338
|
+
}
|
|
6121
6339
|
if (!CONTEXT_ROLES.has(role))
|
|
6122
6340
|
return null;
|
|
6123
6341
|
if (name)
|
|
@@ -7827,7 +8045,7 @@ Each entry is exactly one of:
|
|
|
7827
8045
|
const systemPrompt = options.systemPromptOverride ?? (context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports.SCENARIO_SYSTEM_PROMPT);
|
|
7828
8046
|
const userPrompt = options.userPromptOverride ?? formatEvidenceForPlanner(compressed, evidence, context);
|
|
7829
8047
|
logs2.push(`AI planner: ~${Math.ceil(userPrompt.length / 3.5)} input tokens`);
|
|
7830
|
-
const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.
|
|
8048
|
+
const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
|
|
7831
8049
|
const messages = [
|
|
7832
8050
|
{ role: "system", content: systemPrompt },
|
|
7833
8051
|
{ role: "user", content: userPrompt }
|
|
@@ -7835,18 +8053,22 @@ Each entry is exactly one of:
|
|
|
7835
8053
|
const callStart = Date.now();
|
|
7836
8054
|
let response;
|
|
7837
8055
|
try {
|
|
7838
|
-
response = await (0, ai_retry_js_1.
|
|
7839
|
-
|
|
7840
|
-
|
|
7841
|
-
|
|
7842
|
-
|
|
7843
|
-
|
|
7844
|
-
|
|
7845
|
-
|
|
8056
|
+
response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
|
|
8057
|
+
arm();
|
|
8058
|
+
return aiClient.chat.completions.create({
|
|
8059
|
+
model: agentModel,
|
|
8060
|
+
messages,
|
|
8061
|
+
max_tokens: plannerMaxTokens,
|
|
8062
|
+
temperature: 0.3,
|
|
8063
|
+
...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
|
|
8064
|
+
...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
|
|
8065
|
+
}, { signal });
|
|
8066
|
+
}), {
|
|
7846
8067
|
label: "ai-planner",
|
|
7847
8068
|
baseDelayMs: 5e3,
|
|
7848
8069
|
maxRetries: 2,
|
|
7849
|
-
|
|
8070
|
+
deferTimeoutUntilArmed: true,
|
|
8071
|
+
onRetry: (attempt, reason, delay) => logs2.push(`AI planner call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
|
|
7850
8072
|
});
|
|
7851
8073
|
} catch (err) {
|
|
7852
8074
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -7912,18 +8134,25 @@ Each entry is exactly one of:
|
|
|
7912
8134
|
const retryStart = Date.now();
|
|
7913
8135
|
let retryResponse = null;
|
|
7914
8136
|
try {
|
|
7915
|
-
retryResponse = await (0, ai_retry_js_1.
|
|
7916
|
-
|
|
7917
|
-
|
|
7918
|
-
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
8137
|
+
retryResponse = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
|
|
8138
|
+
arm();
|
|
8139
|
+
return aiClient.chat.completions.create({
|
|
8140
|
+
model: agentModel,
|
|
8141
|
+
messages: [
|
|
8142
|
+
...messages,
|
|
8143
|
+
{ role: "assistant", content: raw },
|
|
8144
|
+
{ role: "user", content: retryContext }
|
|
8145
|
+
],
|
|
8146
|
+
max_tokens: plannerMaxTokens,
|
|
8147
|
+
temperature: 0.3,
|
|
8148
|
+
...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
|
|
8149
|
+
...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
|
|
8150
|
+
}, { signal });
|
|
8151
|
+
}), {
|
|
8152
|
+
label: "ai-planner-retry",
|
|
8153
|
+
deferTimeoutUntilArmed: true,
|
|
8154
|
+
onRetry: (attempt, reason, delay) => logs2.push(`AI planner retry call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
|
|
8155
|
+
});
|
|
7927
8156
|
} catch (err) {
|
|
7928
8157
|
const message = err instanceof Error ? err.message : String(err);
|
|
7929
8158
|
logs2.push(`AI planner retry failed (${message}) \u2014 using first-pass result`);
|
|
@@ -8451,12 +8680,12 @@ ${constraints.join("\n")}`);
|
|
|
8451
8680
|
const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
|
|
8452
8681
|
const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
|
|
8453
8682
|
for (const state2 of orderedScreens) {
|
|
8454
|
-
const groupName = state2.screenType === "auth" ? "Authentication" : screenDisplayName(state2);
|
|
8683
|
+
const groupName = state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(state2);
|
|
8455
8684
|
const group = groups.get(groupName) ?? {
|
|
8456
8685
|
name: groupName,
|
|
8457
8686
|
description: `Native mobile screens related to ${groupName}.`,
|
|
8458
8687
|
routes: [],
|
|
8459
|
-
criticality: state2.screenType === "auth" ? "critical" : "medium",
|
|
8688
|
+
criticality: state2.screenType?.toLowerCase() === "auth" ? "critical" : "medium",
|
|
8460
8689
|
pages: [],
|
|
8461
8690
|
flows: []
|
|
8462
8691
|
};
|
|
@@ -8479,7 +8708,7 @@ ${constraints.join("\n")}`);
|
|
|
8479
8708
|
const to = graph.screens.get(transition.toScreenId);
|
|
8480
8709
|
if (!from || !to)
|
|
8481
8710
|
continue;
|
|
8482
|
-
const groupName = from.screenType === "auth" ? "Authentication" : screenDisplayName(from);
|
|
8711
|
+
const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(from);
|
|
8483
8712
|
const group = groups.get(groupName);
|
|
8484
8713
|
if (!group)
|
|
8485
8714
|
continue;
|
|
@@ -8524,9 +8753,9 @@ ${constraints.join("\n")}`);
|
|
|
8524
8753
|
scenarios.push({
|
|
8525
8754
|
name,
|
|
8526
8755
|
type: "HAPPY_PATH",
|
|
8527
|
-
priority: state2.screenType === "auth" ? 1 : 3,
|
|
8756
|
+
priority: state2.screenType?.toLowerCase() === "auth" ? 1 : 3,
|
|
8528
8757
|
variant: "happy",
|
|
8529
|
-
featureGroup: state2.screenType === "auth" ? "Authentication" : screenName,
|
|
8758
|
+
featureGroup: state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenName,
|
|
8530
8759
|
goal: `User can reach and inspect the ${screenName} screen`,
|
|
8531
8760
|
steps,
|
|
8532
8761
|
expectedOutcomes: [{
|
|
@@ -8672,6 +8901,7 @@ var require_mobile_generator = __commonJS({
|
|
|
8672
8901
|
var mobile_observation_js_1 = require_mobile_observation();
|
|
8673
8902
|
var mobile_boundary_js_1 = require_mobile_boundary();
|
|
8674
8903
|
var MAX_SCENARIO_ITERATIONS = 30;
|
|
8904
|
+
var MAX_CONSECUTIVE_INFRA_FAILURES = 3;
|
|
8675
8905
|
var MAX_RECENT_EXCHANGES = 24;
|
|
8676
8906
|
var OBSERVATION_MAX_ELEMENTS = 50;
|
|
8677
8907
|
function finalizeAction(input) {
|
|
@@ -9396,14 +9626,31 @@ ${statePacket}` },
|
|
|
9396
9626
|
if (!snapshot || snapshot.screen.elements.length === 0)
|
|
9397
9627
|
return null;
|
|
9398
9628
|
const elements = snapshot.screen.elements;
|
|
9399
|
-
const
|
|
9400
|
-
const
|
|
9629
|
+
const stableAnchors = elements.filter((el) => hasStableId(el) && !isAlwaysPresentContainer(el));
|
|
9630
|
+
const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
|
|
9631
|
+
if (contentfulNonButton)
|
|
9632
|
+
return contentfulNonButton;
|
|
9633
|
+
const nonButton = stableAnchors.find((el) => !/button/i.test(el.role));
|
|
9401
9634
|
if (nonButton)
|
|
9402
9635
|
return nonButton;
|
|
9403
|
-
if (
|
|
9404
|
-
return
|
|
9405
|
-
const labelled = elements.find((el) => nonEmpty2(el.label) || nonEmpty2(el.text));
|
|
9406
|
-
return labelled ?? elements[0];
|
|
9636
|
+
if (stableAnchors.length > 0)
|
|
9637
|
+
return stableAnchors[0];
|
|
9638
|
+
const labelled = elements.find((el) => !isAlwaysPresentContainer(el) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
|
|
9639
|
+
return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
|
|
9640
|
+
}
|
|
9641
|
+
var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
|
|
9642
|
+
var ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS = /* @__PURE__ */ new Set([
|
|
9643
|
+
"android:id/content",
|
|
9644
|
+
"android:id/decor",
|
|
9645
|
+
"android:id/action_bar_root",
|
|
9646
|
+
"android:id/statusBarBackground",
|
|
9647
|
+
"android:id/navigationBarBackground"
|
|
9648
|
+
]);
|
|
9649
|
+
function isAlwaysPresentContainer(element) {
|
|
9650
|
+
if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
|
|
9651
|
+
return true;
|
|
9652
|
+
const resourceId = element.resourceId?.trim();
|
|
9653
|
+
return !!resourceId && ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS.has(resourceId);
|
|
9407
9654
|
}
|
|
9408
9655
|
function appendHybridFloor(actions, recording, platform3) {
|
|
9409
9656
|
const anchor = pickLandingAnchor(recording.lastSnapshot);
|
|
@@ -9457,12 +9704,31 @@ ${statePacket}` },
|
|
|
9457
9704
|
steps: compiled.steps,
|
|
9458
9705
|
playwrightCode: "",
|
|
9459
9706
|
verified,
|
|
9460
|
-
tags
|
|
9707
|
+
tags,
|
|
9708
|
+
// Carry the planner scenario identity so incremental/resume can match an
|
|
9709
|
+
// already-generated scenario reliably (scenarioRef.name === the plan's
|
|
9710
|
+
// scenario name). Without this the resume skip-set falls back to the
|
|
9711
|
+
// model-chosen test name and may regenerate completed scenarios. Mobile has
|
|
9712
|
+
// no live command/locator ledger, so evidence is an empty (but valid) bundle
|
|
9713
|
+
// — scenarioRef is the load-bearing field for the skip-set.
|
|
9714
|
+
healingContext: {
|
|
9715
|
+
scenarioRef: {
|
|
9716
|
+
name: scenario.name,
|
|
9717
|
+
featureGroup: scenario.featureGroup,
|
|
9718
|
+
goal: scenario.goal,
|
|
9719
|
+
type: scenario.type,
|
|
9720
|
+
variant: scenario.variant,
|
|
9721
|
+
entryRoute: scenario.entryRoute,
|
|
9722
|
+
startFromLanding: scenario.startFromLanding,
|
|
9723
|
+
targetPages: [...scenario.targetPages]
|
|
9724
|
+
},
|
|
9725
|
+
evidence: { observedLocators: [], observedTransitions: [] }
|
|
9726
|
+
}
|
|
9461
9727
|
};
|
|
9462
9728
|
return test;
|
|
9463
9729
|
}
|
|
9464
9730
|
async function runMobileGeneratePhase(args) {
|
|
9465
|
-
const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot } = args;
|
|
9731
|
+
const { scenarios: allScenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot, skipScenarioNames, onInfraStop } = args;
|
|
9466
9732
|
const log2 = (line) => {
|
|
9467
9733
|
try {
|
|
9468
9734
|
onLog?.(line);
|
|
@@ -9477,8 +9743,16 @@ ${statePacket}` },
|
|
|
9477
9743
|
const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
|
|
9478
9744
|
const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
|
|
9479
9745
|
const targetAppId = ctx.target?.appId?.trim() || void 0;
|
|
9746
|
+
const skipSet = new Set(skipScenarioNames ?? []);
|
|
9747
|
+
const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
|
|
9748
|
+
if (skipSet.size > 0) {
|
|
9749
|
+
log2(`Resume: skipping ${allScenarios.length - scenarios.length} already-generated scenario(s); ${scenarios.length} remaining to generate`);
|
|
9750
|
+
}
|
|
9480
9751
|
log2(`Mobile generator starting \u2014 platform: ${platform3}, provider: ${provider}, model: ${agentModel}, scenarios: ${scenarios.length}`);
|
|
9481
9752
|
const tests = [];
|
|
9753
|
+
const generatedScenarioNames = /* @__PURE__ */ new Set();
|
|
9754
|
+
let consecutiveInfraFailures = 0;
|
|
9755
|
+
let infraStopped = false;
|
|
9482
9756
|
for (let i = 0; i < scenarios.length; i++) {
|
|
9483
9757
|
const scenario = scenarios[i];
|
|
9484
9758
|
log2(`
|
|
@@ -9499,6 +9773,7 @@ ${statePacket}` },
|
|
|
9499
9773
|
targetAppId,
|
|
9500
9774
|
log: log2
|
|
9501
9775
|
});
|
|
9776
|
+
consecutiveInfraFailures = 0;
|
|
9502
9777
|
const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
|
|
9503
9778
|
if (!test) {
|
|
9504
9779
|
log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
|
|
@@ -9515,13 +9790,31 @@ ${statePacket}` },
|
|
|
9515
9790
|
log2(` onTestGenerated callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
|
|
9516
9791
|
}
|
|
9517
9792
|
tests.push(test);
|
|
9793
|
+
generatedScenarioNames.add(scenario.name);
|
|
9518
9794
|
} catch (err) {
|
|
9795
|
+
if ((0, ai_retry_js_1.isAIProviderError)(err)) {
|
|
9796
|
+
consecutiveInfraFailures += 1;
|
|
9797
|
+
log2(` scenario "${scenario.name}" failed \u2014 AI provider error (${consecutiveInfraFailures}/${MAX_CONSECUTIVE_INFRA_FAILURES}): ${err instanceof Error ? err.message : String(err)}`);
|
|
9798
|
+
if (consecutiveInfraFailures >= MAX_CONSECUTIVE_INFRA_FAILURES) {
|
|
9799
|
+
const remainingScenarioNames = scenarios.filter((s) => !generatedScenarioNames.has(s.name)).map((s) => s.name);
|
|
9800
|
+
infraStopped = true;
|
|
9801
|
+
log2(`
|
|
9802
|
+
\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.`);
|
|
9803
|
+
try {
|
|
9804
|
+
onInfraStop?.({ remainingScenarioNames, reason: "ai_rate_limit" });
|
|
9805
|
+
} catch (cbErr) {
|
|
9806
|
+
log2(` onInfraStop callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
|
|
9807
|
+
}
|
|
9808
|
+
break;
|
|
9809
|
+
}
|
|
9810
|
+
continue;
|
|
9811
|
+
}
|
|
9519
9812
|
log2(` scenario "${scenario.name}" failed: ${err instanceof Error ? err.message : String(err)} \u2014 skipping.`);
|
|
9520
9813
|
continue;
|
|
9521
9814
|
}
|
|
9522
9815
|
}
|
|
9523
9816
|
log2(`
|
|
9524
|
-
Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) generated (${tests.filter((t) => t.verified).length} verified).`);
|
|
9817
|
+
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" : ""}.`);
|
|
9525
9818
|
return tests;
|
|
9526
9819
|
}
|
|
9527
9820
|
}
|
|
@@ -12800,6 +13093,10 @@ var require_snapshot_observation = __commonJS({
|
|
|
12800
13093
|
var snapshot_parser_js_1 = require_snapshot_parser();
|
|
12801
13094
|
var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
|
|
12802
13095
|
var NOISE_LABEL_RE = /^(skip\s+to|skip\s+nav|back\s+to\s+top|cookie|accept\s+(all\s+)?cookies)$/i;
|
|
13096
|
+
var DIALOG_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog\b/i;
|
|
13097
|
+
var FORM_SEGMENT_RE = /(?:^|>\s*)form\b/i;
|
|
13098
|
+
var TABPANEL_SEGMENT_RE = /(?:^|>\s*)tabpanel\b/i;
|
|
13099
|
+
var DIALOG_NAME_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog:([^>]+)/i;
|
|
12803
13100
|
var INTERACTIVE_ROLE_RE = /^(button|link|textbox|searchbox|checkbox|radio|switch|combobox|listbox|option|menuitem|tab|slider|spinbutton|textarea)$/i;
|
|
12804
13101
|
var INTERACTIVE_ELEMENT_TYPE_RE = /^(button|link|input|textarea|select|checkbox|radio|switch|tab|combobox|listbox|option|menuitem|slider|spinbutton)$/i;
|
|
12805
13102
|
function renderRetrievedSnapshotMemoryBlock(priorSnapshotEvidence, priorDiscoveryCheckpoints, options = {}) {
|
|
@@ -13044,10 +13341,12 @@ var require_snapshot_observation = __commonJS({
|
|
|
13044
13341
|
summary
|
|
13045
13342
|
}));
|
|
13046
13343
|
}
|
|
13344
|
+
var TEXT_DISAMBIGUATING_INPUT_TYPES = /* @__PURE__ */ new Set(["password", "text", "email", "tel", "number", "search", "url", "date", "datetime-local", "time", "month", "week"]);
|
|
13047
13345
|
function renderCompactSnapshotBlock(page, context = {}, options = {}) {
|
|
13048
13346
|
const metrics = page.snapshotMetrics ?? page.snapshotArtifact?.metrics;
|
|
13049
13347
|
const sourceKind = page.snapshotMeta?.sourceKind ?? page.snapshotArtifact?.sourceKind ?? "missing";
|
|
13050
13348
|
const artifact = page.snapshotArtifact;
|
|
13349
|
+
const richRender = options.richRender ?? process.env.DISCOVERY_SNAPSHOT_RICH_RENDER === "true";
|
|
13051
13350
|
const activeScope = inferActiveSnapshotScope(page);
|
|
13052
13351
|
const ranked = rankSnapshotElements(getScopedSnapshotElements(page, activeScope), withScopeContext(context, activeScope));
|
|
13053
13352
|
const allInteractive = ranked.filter(({ element }) => isInteractiveCandidate(element)).filter(({ element }) => {
|
|
@@ -13093,7 +13392,7 @@ var require_snapshot_observation = __commonJS({
|
|
|
13093
13392
|
if (activeScope.rootRef) {
|
|
13094
13393
|
lines.push(` root_ref: ${yamlScalar(activeScope.rootRef)}`);
|
|
13095
13394
|
}
|
|
13096
|
-
const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText);
|
|
13395
|
+
const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText, richRender);
|
|
13097
13396
|
if (scopePreview.length > 0) {
|
|
13098
13397
|
lines.push(" scope_snapshot_preview: |");
|
|
13099
13398
|
for (const previewLine of scopePreview) {
|
|
@@ -13135,6 +13434,19 @@ var require_snapshot_observation = __commonJS({
|
|
|
13135
13434
|
lines.push(` - ${yamlScalar(modal)}`);
|
|
13136
13435
|
}
|
|
13137
13436
|
}
|
|
13437
|
+
const staticMessages = page.staticMessages ?? [];
|
|
13438
|
+
if (richRender && staticMessages.length > 0) {
|
|
13439
|
+
const messageLimit = 6;
|
|
13440
|
+
const ordered = [...staticMessages].sort((a, b) => messageRolePriority(a.role) - messageRolePriority(b.role));
|
|
13441
|
+
lines.push("messages:");
|
|
13442
|
+
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.");
|
|
13443
|
+
for (const message of ordered.slice(0, messageLimit)) {
|
|
13444
|
+
lines.push(` - [${message.role}] ${yamlScalar(message.text)}`);
|
|
13445
|
+
}
|
|
13446
|
+
if (ordered.length > messageLimit) {
|
|
13447
|
+
lines.push(` # +${ordered.length - messageLimit} more message(s) not listed`);
|
|
13448
|
+
}
|
|
13449
|
+
}
|
|
13138
13450
|
lines.push("session_state:");
|
|
13139
13451
|
if (page.sessionState) {
|
|
13140
13452
|
lines.push(` authenticated: ${yamlScalar(page.sessionState.authenticated)}`);
|
|
@@ -13197,7 +13509,10 @@ var require_snapshot_observation = __commonJS({
|
|
|
13197
13509
|
}
|
|
13198
13510
|
const hasRowContext = group.rowContexts.some((rc) => !!rc);
|
|
13199
13511
|
if (hasRowContext) {
|
|
13200
|
-
|
|
13512
|
+
const firstRowRole = group.rowRoles[0] ?? null;
|
|
13513
|
+
const rowRole = firstRowRole && group.rowRoles.every((r) => r === firstRowRole) ? firstRowRole : null;
|
|
13514
|
+
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`;
|
|
13515
|
+
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}`);
|
|
13201
13516
|
for (let i = 0; i < group.rowContexts.length; i++) {
|
|
13202
13517
|
const rowContext = group.rowContexts[i];
|
|
13203
13518
|
if (!rowContext)
|
|
@@ -13227,8 +13542,13 @@ var require_snapshot_observation = __commonJS({
|
|
|
13227
13542
|
lines.push(` placeholder: ${yamlScalar(rankedElement.element.placeholder)}`);
|
|
13228
13543
|
}
|
|
13229
13544
|
const stateTokens = buildStateSummary(rankedElement.element);
|
|
13545
|
+
const addedDisabled = richRender && !!rankedElement.element.isDisabled && !stateTokens.includes("disabled");
|
|
13546
|
+
if (addedDisabled) {
|
|
13547
|
+
stateTokens.unshift("disabled");
|
|
13548
|
+
}
|
|
13230
13549
|
if (stateTokens.length > 0) {
|
|
13231
|
-
|
|
13550
|
+
const note = addedDisabled ? " # disabled = captured instant; may enable after valid input \u2014 still exercise fill\u2192submit" : "";
|
|
13551
|
+
lines.push(` state: [${stateTokens.map((token) => yamlScalar(token)).join(", ")}]${note}`);
|
|
13232
13552
|
}
|
|
13233
13553
|
if (rankedElement.element.containerPath) {
|
|
13234
13554
|
lines.push(` container_path: ${yamlScalar(rankedElement.element.containerPath)}`);
|
|
@@ -13238,6 +13558,22 @@ var require_snapshot_observation = __commonJS({
|
|
|
13238
13558
|
lines.push(` locator: ${yamlScalar((0, snapshot_parser_js_1.formatLocator)(topLocator))}`);
|
|
13239
13559
|
}
|
|
13240
13560
|
const enr = rankedElement.element.enrichment;
|
|
13561
|
+
if (richRender && enr?.inputType && TEXT_DISAMBIGUATING_INPUT_TYPES.has(enr.inputType)) {
|
|
13562
|
+
lines.push(` type: ${yamlScalar(enr.inputType)}`);
|
|
13563
|
+
}
|
|
13564
|
+
if (richRender && rankedElement.element.href) {
|
|
13565
|
+
lines.push(` url: ${yamlScalar(rankedElement.element.href)}`);
|
|
13566
|
+
}
|
|
13567
|
+
if (richRender && rankedElement.element.value && enr?.inputType !== "password") {
|
|
13568
|
+
lines.push(` value: ${yamlScalar(rankedElement.element.value)}`);
|
|
13569
|
+
}
|
|
13570
|
+
const elementOptions = rankedElement.element.options ?? [];
|
|
13571
|
+
if (richRender && elementOptions.length > 0) {
|
|
13572
|
+
const optionLimit = 12;
|
|
13573
|
+
const rendered = elementOptions.slice(0, optionLimit).map((option) => yamlScalar(option.selected ? `${option.label} (selected)` : option.label));
|
|
13574
|
+
const overflow = elementOptions.length > optionLimit ? ` # +${elementOptions.length - optionLimit} more option(s)` : "";
|
|
13575
|
+
lines.push(` options: [${rendered.join(", ")}]${overflow}`);
|
|
13576
|
+
}
|
|
13241
13577
|
if (enr?.rowContext) {
|
|
13242
13578
|
lines.push(` row_context: ${yamlScalar(enr.rowContext)}`);
|
|
13243
13579
|
}
|
|
@@ -13250,6 +13586,12 @@ var require_snapshot_observation = __commonJS({
|
|
|
13250
13586
|
lines.push(` visibility: [${vis.map((token) => yamlScalar(token)).join(", ")}]`);
|
|
13251
13587
|
}
|
|
13252
13588
|
}
|
|
13589
|
+
if (richRender) {
|
|
13590
|
+
const hidden = allInteractive.length - interactive.length;
|
|
13591
|
+
if (hidden > 0) {
|
|
13592
|
+
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.`);
|
|
13593
|
+
}
|
|
13594
|
+
}
|
|
13253
13595
|
}
|
|
13254
13596
|
const recoveredClickables = options.recoveredClickables ?? [];
|
|
13255
13597
|
if (recoveredClickables.length > 0) {
|
|
@@ -13406,11 +13748,11 @@ var require_snapshot_observation = __commonJS({
|
|
|
13406
13748
|
score += 10;
|
|
13407
13749
|
if (element.isExpanded)
|
|
13408
13750
|
score += 8;
|
|
13409
|
-
if (element.containerPath
|
|
13751
|
+
if (element.containerPath && DIALOG_SEGMENT_RE.test(element.containerPath))
|
|
13410
13752
|
score += 12;
|
|
13411
|
-
if (element.containerPath
|
|
13753
|
+
if (element.containerPath && FORM_SEGMENT_RE.test(element.containerPath))
|
|
13412
13754
|
score += 10;
|
|
13413
|
-
if (element.containerPath
|
|
13755
|
+
if (element.containerPath && TABPANEL_SEGMENT_RE.test(element.containerPath))
|
|
13414
13756
|
score += 8;
|
|
13415
13757
|
if (element.inputType === "email" || element.inputType === "password" || element.inputType === "search")
|
|
13416
13758
|
score += 6;
|
|
@@ -13481,10 +13823,10 @@ var require_snapshot_observation = __commonJS({
|
|
|
13481
13823
|
}
|
|
13482
13824
|
return merged;
|
|
13483
13825
|
}
|
|
13484
|
-
function buildScopeSnapshotPreview(scopeSnapshotText) {
|
|
13826
|
+
function buildScopeSnapshotPreview(scopeSnapshotText, stripTrailingValues = false) {
|
|
13485
13827
|
if (!scopeSnapshotText)
|
|
13486
13828
|
return [];
|
|
13487
|
-
return scopeSnapshotText.split("\n").map((line) => line.trim()).filter(Boolean).slice(0, 6);
|
|
13829
|
+
return scopeSnapshotText.split("\n").map((line) => line.trim()).map((line) => stripTrailingValues ? line.replace(/\]\s*:\s.+$/, "]") : line).filter(Boolean).slice(0, 6);
|
|
13488
13830
|
}
|
|
13489
13831
|
function dedupeLines(lines) {
|
|
13490
13832
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -13611,11 +13953,12 @@ var require_snapshot_observation = __commonJS({
|
|
|
13611
13953
|
const key = `${role}\0${name}`;
|
|
13612
13954
|
let group = groups.get(key);
|
|
13613
13955
|
if (!group) {
|
|
13614
|
-
group = { role, name, containers: [], rowContexts: [], refs: [] };
|
|
13956
|
+
group = { role, name, containers: [], rowContexts: [], rowRoles: [], refs: [] };
|
|
13615
13957
|
groups.set(key, group);
|
|
13616
13958
|
}
|
|
13617
13959
|
group.containers.push(element.containerPath ?? null);
|
|
13618
13960
|
group.rowContexts.push(element.enrichment?.rowContext ?? null);
|
|
13961
|
+
group.rowRoles.push(element.enrichment?.rowRole ?? null);
|
|
13619
13962
|
group.refs.push(element.ref ?? null);
|
|
13620
13963
|
}
|
|
13621
13964
|
return [...groups.values()].filter((group) => group.containers.length > 1);
|
|
@@ -13625,7 +13968,7 @@ var require_snapshot_observation = __commonJS({
|
|
|
13625
13968
|
for (const element of elements) {
|
|
13626
13969
|
const role = element.role?.toLowerCase() ?? "";
|
|
13627
13970
|
const elementType = element.elementType?.toLowerCase() ?? "";
|
|
13628
|
-
const isDialog = role === "dialog" || elementType === "dialog" || (element.containerPath
|
|
13971
|
+
const isDialog = role === "dialog" || elementType === "dialog" || DIALOG_NAME_SEGMENT_RE.test(element.containerPath ?? "");
|
|
13629
13972
|
if (!isDialog)
|
|
13630
13973
|
continue;
|
|
13631
13974
|
const hint = qualifyElementLabel(element) ?? dialogNameFromContainerPath(element.containerPath) ?? element.label ?? element.testId ?? "dialog";
|
|
@@ -13638,7 +13981,7 @@ var require_snapshot_observation = __commonJS({
|
|
|
13638
13981
|
function dialogNameFromContainerPath(containerPath) {
|
|
13639
13982
|
if (!containerPath)
|
|
13640
13983
|
return null;
|
|
13641
|
-
const match = containerPath.match(
|
|
13984
|
+
const match = containerPath.match(DIALOG_NAME_SEGMENT_RE);
|
|
13642
13985
|
return match ? match[1].trim() : null;
|
|
13643
13986
|
}
|
|
13644
13987
|
function renderObservationCue(observation) {
|
|
@@ -13647,6 +13990,13 @@ var require_snapshot_observation = __commonJS({
|
|
|
13647
13990
|
const base2 = observation.outcomeType === "SUCCESS" && outcome ? outcome : `${action} -> ${outcome || observation.outcomeType}`;
|
|
13648
13991
|
return base2.length > 120 ? `${base2.slice(0, 117)}...` : base2;
|
|
13649
13992
|
}
|
|
13993
|
+
function messageRolePriority(role) {
|
|
13994
|
+
if (role === "alert")
|
|
13995
|
+
return 0;
|
|
13996
|
+
if (role === "status")
|
|
13997
|
+
return 1;
|
|
13998
|
+
return 2;
|
|
13999
|
+
}
|
|
13650
14000
|
function yamlScalar(value) {
|
|
13651
14001
|
if (value == null || value === "")
|
|
13652
14002
|
return "null";
|
|
@@ -20195,7 +20545,10 @@ ${testOpen}`;
|
|
|
20195
20545
|
pressed: se.pressed,
|
|
20196
20546
|
headingLevel: se.headingLevel,
|
|
20197
20547
|
currentState: se.currentState,
|
|
20198
|
-
containerPath: se.containerPath || void 0
|
|
20548
|
+
containerPath: se.containerPath || void 0,
|
|
20549
|
+
href: se.href || void 0,
|
|
20550
|
+
value: se.value || void 0,
|
|
20551
|
+
options: se.options && se.options.length > 0 ? se.options : void 0
|
|
20199
20552
|
}));
|
|
20200
20553
|
const structuredPage = {
|
|
20201
20554
|
route: pageUrl || "/",
|
|
@@ -20203,6 +20556,7 @@ ${testOpen}`;
|
|
|
20203
20556
|
pageType: "unknown",
|
|
20204
20557
|
elements: collectedElements,
|
|
20205
20558
|
formGroups: parsed.formGroups,
|
|
20559
|
+
staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
|
|
20206
20560
|
snapshotMetrics: {
|
|
20207
20561
|
rawLineCount: parsed.rawLineCount,
|
|
20208
20562
|
parsedElementLineCount: parsed.parsedElementLineCount,
|
|
@@ -21769,7 +22123,7 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
|
|
|
21769
22123
|
let stopped;
|
|
21770
22124
|
const agentModel = options?.modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(options.modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
|
|
21771
22125
|
const auditGroupId = `generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
|
|
21772
|
-
const aiClient = options?.modelOverride ? (0, ai_provider_js_1.
|
|
22126
|
+
const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
|
|
21773
22127
|
const credentials = variableContext.allTestVariables ?? variableContext.publicTestVariables;
|
|
21774
22128
|
logs2.push(`Phase 3 \u2014 Generate: ${(plan.scenarios ?? []).length} scenarios, max ${maxTests} tests`);
|
|
21775
22129
|
logs2.push(`Model: ${agentModel}`);
|
|
@@ -235734,7 +236088,7 @@ var require_mcp_healer = __commonJS({
|
|
|
235734
236088
|
messages.push(system, firstUser, { role: "user", content: lines.join("\n") }, ...tail);
|
|
235735
236089
|
}
|
|
235736
236090
|
var DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS = 6;
|
|
235737
|
-
function
|
|
236091
|
+
function readPositiveIntEnv2(env, name, fallback) {
|
|
235738
236092
|
const raw = env[name]?.trim();
|
|
235739
236093
|
if (!raw || !/^\d+$/.test(raw))
|
|
235740
236094
|
return fallback;
|
|
@@ -235742,7 +236096,7 @@ var require_mcp_healer = __commonJS({
|
|
|
235742
236096
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
235743
236097
|
}
|
|
235744
236098
|
function getScriptHealMaxAttempts(env = process.env) {
|
|
235745
|
-
return
|
|
236099
|
+
return readPositiveIntEnv2(env, "MCP_HEAL_SCRIPT_MAX_ATTEMPTS", DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS);
|
|
235746
236100
|
}
|
|
235747
236101
|
exports.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.
|
|
235748
236102
|
|
|
@@ -236078,7 +236432,7 @@ ${lines.join("\n")}
|
|
|
236078
236432
|
const landingViolation = (tags ?? []).includes("@landing-violation");
|
|
236079
236433
|
const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "strong") : (0, ai_provider_js_1.getModel)("strong");
|
|
236080
236434
|
const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
|
|
236081
|
-
const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.
|
|
236435
|
+
const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
|
|
236082
236436
|
const runNativeTest = options.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
|
|
236083
236437
|
const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
|
|
236084
236438
|
const groupId = `script-heal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -236900,10 +237254,10 @@ ${(verifyResult.logs ?? "").slice(0, 2e3)}`;
|
|
|
236900
237254
|
var DEFAULT_MAX_PHASE2_ITERATIONS = 4;
|
|
236901
237255
|
var DEFAULT_MAX_MCP_EXPLORE_ITERATIONS = 25;
|
|
236902
237256
|
function getMcpHealPhase2MaxIterations(env = process.env) {
|
|
236903
|
-
return
|
|
237257
|
+
return readPositiveIntEnv2(env, "MCP_HEAL_PHASE2_MAX_ITERATIONS", DEFAULT_MAX_PHASE2_ITERATIONS);
|
|
236904
237258
|
}
|
|
236905
237259
|
function getMcpHealExploreMaxIterations(env = process.env) {
|
|
236906
|
-
return
|
|
237260
|
+
return readPositiveIntEnv2(env, "MCP_HEAL_EXPLORE_MAX_ITERATIONS", DEFAULT_MAX_MCP_EXPLORE_ITERATIONS);
|
|
236907
237261
|
}
|
|
236908
237262
|
var DEFAULT_MCP_HEAL_MAX_WALL_CLOCK_MS = 10 * 60 * 1e3;
|
|
236909
237263
|
var DEFAULT_MCP_HEAL_MAX_TOTAL_AI_CALLS = 60;
|
|
@@ -236955,7 +237309,7 @@ Only explore the failing page and the area around the broken step.`;
|
|
|
236955
237309
|
let lastVideo;
|
|
236956
237310
|
const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
|
|
236957
237311
|
const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
|
|
236958
|
-
const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.
|
|
237312
|
+
const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
|
|
236959
237313
|
const runPhase2NativeTest = options?.phase2Harness?.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
|
|
236960
237314
|
const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
|
|
236961
237315
|
const groupId = `phase2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -237645,7 +237999,10 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
|
|
|
237645
237999
|
pressed: se.pressed,
|
|
237646
238000
|
headingLevel: se.headingLevel,
|
|
237647
238001
|
currentState: se.currentState,
|
|
237648
|
-
containerPath: se.containerPath || void 0
|
|
238002
|
+
containerPath: se.containerPath || void 0,
|
|
238003
|
+
href: se.href || void 0,
|
|
238004
|
+
value: se.value || void 0,
|
|
238005
|
+
options: se.options && se.options.length > 0 ? se.options : void 0
|
|
237649
238006
|
}));
|
|
237650
238007
|
const structuredPage = {
|
|
237651
238008
|
route: pageUrl || "/",
|
|
@@ -237653,6 +238010,7 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
|
|
|
237653
238010
|
pageType: "unknown",
|
|
237654
238011
|
elements: collectedElements,
|
|
237655
238012
|
formGroups: parsed.formGroups,
|
|
238013
|
+
staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
|
|
237656
238014
|
snapshotMetrics: {
|
|
237657
238015
|
rawLineCount: parsed.rawLineCount,
|
|
237658
238016
|
parsedElementLineCount: parsed.parsedElementLineCount,
|
|
@@ -238089,7 +238447,7 @@ ${testBlocks}
|
|
|
238089
238447
|
- By iteration ${Math.floor(maxIterations * 0.3)}: you should have completed 1-2 tests
|
|
238090
238448
|
- By iteration ${Math.floor(maxIterations * 0.7)}: you should be past the halfway point
|
|
238091
238449
|
- At iteration ${Math.floor(maxIterations * 0.9)}: wrap up and call finish_capture`;
|
|
238092
|
-
const aiClient = modelOverride ? (0, ai_provider_js_1.
|
|
238450
|
+
const aiClient = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
|
|
238093
238451
|
const messages = [
|
|
238094
238452
|
{ role: "system", content: systemPrompt },
|
|
238095
238453
|
{ role: "user", content: "Start executing the tests. Navigate to the base URL and begin with Test 1." }
|
|
@@ -238272,7 +238630,10 @@ ${testBlocks}
|
|
|
238272
238630
|
pressed: se.pressed,
|
|
238273
238631
|
headingLevel: se.headingLevel,
|
|
238274
238632
|
currentState: se.currentState,
|
|
238275
|
-
containerPath: se.containerPath || void 0
|
|
238633
|
+
containerPath: se.containerPath || void 0,
|
|
238634
|
+
href: se.href || void 0,
|
|
238635
|
+
value: se.value || void 0,
|
|
238636
|
+
options: se.options && se.options.length > 0 ? se.options : void 0
|
|
238276
238637
|
}));
|
|
238277
238638
|
const structuredPage = {
|
|
238278
238639
|
route: pageUrl || "/",
|
|
@@ -238280,6 +238641,7 @@ ${testBlocks}
|
|
|
238280
238641
|
pageType: "unknown",
|
|
238281
238642
|
elements: collectedElements,
|
|
238282
238643
|
formGroups: parsed.formGroups,
|
|
238644
|
+
staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
|
|
238283
238645
|
snapshotMetrics: {
|
|
238284
238646
|
rawLineCount: parsed.rawLineCount,
|
|
238285
238647
|
parsedElementLineCount: parsed.parsedElementLineCount,
|
|
@@ -300069,8 +300431,9 @@ var require_capture_page_normalizer = __commonJS({
|
|
|
300069
300431
|
let snapshotElements = [];
|
|
300070
300432
|
let formGroups = [];
|
|
300071
300433
|
let snapshotMetrics;
|
|
300434
|
+
let staticMessages;
|
|
300072
300435
|
if (normalizedSnapshotResult.snapshotText) {
|
|
300073
|
-
const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText);
|
|
300436
|
+
const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText, { richParse: context.richParse });
|
|
300074
300437
|
snapshotMetrics = {
|
|
300075
300438
|
rawLineCount: parsed.rawLineCount,
|
|
300076
300439
|
parsedElementLineCount: parsed.parsedElementLineCount,
|
|
@@ -300099,9 +300462,13 @@ var require_capture_page_normalizer = __commonJS({
|
|
|
300099
300462
|
pressed: se.pressed,
|
|
300100
300463
|
headingLevel: se.headingLevel,
|
|
300101
300464
|
currentState: se.currentState,
|
|
300102
|
-
containerPath: se.containerPath || void 0
|
|
300465
|
+
containerPath: se.containerPath || void 0,
|
|
300466
|
+
href: se.href || void 0,
|
|
300467
|
+
value: se.value || void 0,
|
|
300468
|
+
options: se.options && se.options.length > 0 ? se.options : void 0
|
|
300103
300469
|
}));
|
|
300104
300470
|
formGroups = parsed.formGroups;
|
|
300471
|
+
staticMessages = parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0;
|
|
300105
300472
|
}
|
|
300106
300473
|
const aiElements = Array.isArray(toolArgs.elements) ? toolArgs.elements.map(normalizeAiElement) : [];
|
|
300107
300474
|
const noYamlKinds = /* @__PURE__ */ new Set(["missing", "timeout", "error", "empty"]);
|
|
@@ -300197,6 +300564,7 @@ var require_capture_page_normalizer = __commonJS({
|
|
|
300197
300564
|
} : void 0,
|
|
300198
300565
|
readinessSignals,
|
|
300199
300566
|
viewportHints,
|
|
300567
|
+
staticMessages,
|
|
300200
300568
|
provenCommands: provenCmds,
|
|
300201
300569
|
observations: rawObservations,
|
|
300202
300570
|
routeCorrected,
|
|
@@ -300505,6 +300873,10 @@ var require_perception_enricher = __commonJS({
|
|
|
300505
300873
|
const e = {};
|
|
300506
300874
|
if (p.rowContext)
|
|
300507
300875
|
e.rowContext = p.rowContext;
|
|
300876
|
+
if (p.rowContext && p.rowRole)
|
|
300877
|
+
e.rowRole = p.rowRole;
|
|
300878
|
+
if (p.inputType)
|
|
300879
|
+
e.inputType = p.inputType;
|
|
300508
300880
|
if (p.position && p.position !== "in-view")
|
|
300509
300881
|
e.position = p.position;
|
|
300510
300882
|
if (p.occluded)
|
|
@@ -300754,6 +301126,23 @@ var require_perception_enricher = __commonJS({
|
|
|
300754
301126
|
const cls = (el.getAttribute("class") || "").toLowerCase();
|
|
300755
301127
|
return /(^|[\s_-])(card|row|item|listitem|cell|tile)([\s_-]|$)/.test(cls);
|
|
300756
301128
|
};
|
|
301129
|
+
const rowRoleOf = (el) => {
|
|
301130
|
+
const explicit = (el.getAttribute("role") || "").toLowerCase().trim();
|
|
301131
|
+
if (explicit) {
|
|
301132
|
+
return explicit === "row" || explicit === "listitem" || explicit === "article" || explicit === "option" ? explicit : null;
|
|
301133
|
+
}
|
|
301134
|
+
const tag = el.tagName;
|
|
301135
|
+
if (tag === "TR") {
|
|
301136
|
+
const tbl = el.closest("table");
|
|
301137
|
+
const tblRole = tbl ? (tbl.getAttribute("role") || "").toLowerCase().trim() : "";
|
|
301138
|
+
return tblRole === "presentation" || tblRole === "none" ? null : "row";
|
|
301139
|
+
}
|
|
301140
|
+
if (tag === "LI")
|
|
301141
|
+
return el.closest('ul:not([role]),ol:not([role]),menu:not([role]),[role="list"]') ? "listitem" : null;
|
|
301142
|
+
if (tag === "ARTICLE")
|
|
301143
|
+
return "article";
|
|
301144
|
+
return null;
|
|
301145
|
+
};
|
|
300757
301146
|
const rowContextOf = (el, ownNameLower) => {
|
|
300758
301147
|
let cur = el.parentElement;
|
|
300759
301148
|
let depth = 0;
|
|
@@ -300772,7 +301161,7 @@ var require_perception_enricher = __commonJS({
|
|
|
300772
301161
|
txt = txt.slice(0, 80);
|
|
300773
301162
|
const low = txt.toLowerCase();
|
|
300774
301163
|
if (txt && low !== ownNameLower && low.length > 1)
|
|
300775
|
-
return txt;
|
|
301164
|
+
return { text: txt, role: rowRoleOf(cur) };
|
|
300776
301165
|
return null;
|
|
300777
301166
|
}
|
|
300778
301167
|
cur = cur.parentElement;
|
|
@@ -300862,11 +301251,14 @@ var require_perception_enricher = __commonJS({
|
|
|
300862
301251
|
const name = accName(el);
|
|
300863
301252
|
const lname = lower(name);
|
|
300864
301253
|
const rect = vis.rect;
|
|
301254
|
+
const rc = rowContextOf(el, lname);
|
|
300865
301255
|
out.push({
|
|
300866
301256
|
domOrder: myOrder,
|
|
300867
301257
|
roleClass: roleClassOf(tag, roleAttr, inputType),
|
|
300868
301258
|
name: lname,
|
|
300869
|
-
rowContext:
|
|
301259
|
+
rowContext: rc ? rc.text : null,
|
|
301260
|
+
rowRole: rc ? rc.role : null,
|
|
301261
|
+
inputType: inputType || null,
|
|
300870
301262
|
position: positionOf(rect),
|
|
300871
301263
|
occluded: occlusionOf(el, rect),
|
|
300872
301264
|
cursorPointer,
|
|
@@ -300903,11 +301295,14 @@ var require_perception_enricher = __commonJS({
|
|
|
300903
301295
|
if (!name)
|
|
300904
301296
|
continue;
|
|
300905
301297
|
const lname = lower(name);
|
|
301298
|
+
const rc = rowContextOf(el, lname);
|
|
300906
301299
|
out.push({
|
|
300907
301300
|
domOrder: order++,
|
|
300908
301301
|
roleClass: "button",
|
|
300909
301302
|
name: lname,
|
|
300910
|
-
rowContext:
|
|
301303
|
+
rowContext: rc ? rc.text : null,
|
|
301304
|
+
rowRole: rc ? rc.role : null,
|
|
301305
|
+
inputType: null,
|
|
300911
301306
|
position: "in-view",
|
|
300912
301307
|
occluded: false,
|
|
300913
301308
|
cursorPointer: true,
|
|
@@ -301409,7 +301804,7 @@ var require_discover_explorer = __commonJS({
|
|
|
301409
301804
|
var DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP = 100;
|
|
301410
301805
|
var DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY = 100;
|
|
301411
301806
|
var DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL = 40;
|
|
301412
|
-
function
|
|
301807
|
+
function readPositiveIntEnv2(env, name, fallback) {
|
|
301413
301808
|
const raw = env[name]?.trim();
|
|
301414
301809
|
if (!raw || !/^\d+$/.test(raw))
|
|
301415
301810
|
return fallback;
|
|
@@ -301540,15 +301935,15 @@ var require_discover_explorer = __commonJS({
|
|
|
301540
301935
|
}
|
|
301541
301936
|
function getMaxIterations(mode, incremental, env = process.env) {
|
|
301542
301937
|
if (mode === "survey" && incremental) {
|
|
301543
|
-
return
|
|
301938
|
+
return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_INCREMENTAL", DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL);
|
|
301544
301939
|
}
|
|
301545
301940
|
if (mode === "survey") {
|
|
301546
|
-
return
|
|
301941
|
+
return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_SURVEY", DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY);
|
|
301547
301942
|
}
|
|
301548
301943
|
if (mode === "deep") {
|
|
301549
|
-
return
|
|
301944
|
+
return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEEP", DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP);
|
|
301550
301945
|
}
|
|
301551
|
-
return
|
|
301946
|
+
return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEFAULT", DEFAULT_MAX_EXPLORE_ITERATIONS_DEFAULT);
|
|
301552
301947
|
}
|
|
301553
301948
|
function updatePendingRouteScreenshot(pending, { imageData, detectedRoute, fallbackRoute }) {
|
|
301554
301949
|
if (imageData) {
|
|
@@ -302274,7 +302669,7 @@ ${inboxPromptBlock}`;
|
|
|
302274
302669
|
const browserCreds = variableContext.allTestVariables ?? variableContext.publicTestVariables;
|
|
302275
302670
|
const { secrets: browserSecretsForHint } = (0, credential_tools_js_1.partitionTestVariables)(browserCreds);
|
|
302276
302671
|
const hasLoginCredentials = Object.keys(browserSecretsForHint).length > 0;
|
|
302277
|
-
const aiClient = options?.modelOverride ? (0, ai_provider_js_1.
|
|
302672
|
+
const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
|
|
302278
302673
|
const isDeepMode = options?.mode === "deep";
|
|
302279
302674
|
const maxIterations = config?.maxIterationsOverride && config.maxIterationsOverride > 0 ? config.maxIterationsOverride : getMaxIterations(options?.mode ?? "full", options?.incremental);
|
|
302280
302675
|
const configKeepTail = isDeepMode ? Math.ceil(ctxConfig.keepTail * 1.5) : ctxConfig.keepTail;
|
|
@@ -304813,7 +305208,7 @@ var require_auth_capture_run = __commonJS({
|
|
|
304813
305208
|
var scrub_credentials_js_1 = require_scrub_credentials();
|
|
304814
305209
|
var credential_tools_js_1 = require_credential_tools();
|
|
304815
305210
|
var DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS = 12;
|
|
304816
|
-
function
|
|
305211
|
+
function readPositiveIntEnv2(env, name, fallback) {
|
|
304817
305212
|
const raw = env[name]?.trim();
|
|
304818
305213
|
if (!raw || !/^\d+$/.test(raw))
|
|
304819
305214
|
return fallback;
|
|
@@ -304821,7 +305216,7 @@ var require_auth_capture_run = __commonJS({
|
|
|
304821
305216
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
304822
305217
|
}
|
|
304823
305218
|
function getLoginCaptureMaxIterations(env = process.env) {
|
|
304824
|
-
return
|
|
305219
|
+
return readPositiveIntEnv2(env, "AUTH_CAPTURE_LOGIN_MAX_ITERATIONS", DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS);
|
|
304825
305220
|
}
|
|
304826
305221
|
var CAPTURE_RATE_LIMIT_MAX_RETRIES = 2;
|
|
304827
305222
|
var CAPTURE_RATE_LIMIT_MAX_DELAY_MS = 8e3;
|
|
@@ -305104,7 +305499,7 @@ ${keyLines || "- (none provided)"}
|
|
|
305104
305499
|
}
|
|
305105
305500
|
var DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS = 18;
|
|
305106
305501
|
function getRegisterCaptureMaxIterations(env = process.env) {
|
|
305107
|
-
return
|
|
305502
|
+
return readPositiveIntEnv2(env, "AUTH_CAPTURE_REGISTER_MAX_ITERATIONS", DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS);
|
|
305108
305503
|
}
|
|
305109
305504
|
function buildRegisterCaptureSystemPrompt(baseUrl, availableKeys, maxIterations = getRegisterCaptureMaxIterations()) {
|
|
305110
305505
|
const keyLines = availableKeys.map((k) => `- \`${k}\``).join("\n");
|
|
@@ -311334,6 +311729,7 @@ var require_mobile_exploration_state = __commonJS({
|
|
|
311334
311729
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
311335
311730
|
exports.MobileExplorationState = void 0;
|
|
311336
311731
|
exports.deriveScreenId = deriveScreenId;
|
|
311732
|
+
var mobile_observation_js_1 = require_mobile_observation();
|
|
311337
311733
|
var DEFAULT_STALE_THRESHOLD = 4;
|
|
311338
311734
|
function deriveScreenId(signal) {
|
|
311339
311735
|
const namespace = signal.activity ?? signal.bundleId;
|
|
@@ -311559,21 +311955,33 @@ var require_mobile_exploration_state = __commonJS({
|
|
|
311559
311955
|
* Build the planner evidence shape: one `CollectedPageData` per screen, with
|
|
311560
311956
|
* the screen-id carried in BOTH `route` (the on-the-wire positional field the
|
|
311561
311957
|
* server keys screenshots by) AND the explicit `screenId` field (per the
|
|
311562
|
-
* Phase-0 `CollectedPageData.screenId` contract). `elements`
|
|
311563
|
-
* the
|
|
311564
|
-
*
|
|
311565
|
-
*
|
|
311566
|
-
*
|
|
311958
|
+
* Phase-0 `CollectedPageData.screenId` contract). `elements` are mapped from
|
|
311959
|
+
* the screen's parsed actionable elements into the platform-neutral
|
|
311960
|
+
* `CollectedPageElement[]` shape the server persists as SiteElements — without
|
|
311961
|
+
* this, mobile screens land with zero elements (coverage always 0%, no element
|
|
311962
|
+
* intelligence). `triggersNavigation` is derived from transition-source
|
|
311963
|
+
* membership: an element is flagged when its ref drove a transition FROM this
|
|
311964
|
+
* screen. Insertion order matches first-seen order.
|
|
311567
311965
|
*/
|
|
311568
311966
|
buildInMemoryScreenMap() {
|
|
311569
311967
|
const pages = [];
|
|
311570
311968
|
const ordered = [...this.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
|
|
311969
|
+
const navRefsByScreen = /* @__PURE__ */ new Map();
|
|
311970
|
+
for (const transition of this.transitions) {
|
|
311971
|
+
if (!transition.viaRef)
|
|
311972
|
+
continue;
|
|
311973
|
+
const set = navRefsByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
|
|
311974
|
+
set.add(transition.viaRef);
|
|
311975
|
+
navRefsByScreen.set(transition.fromScreenId, set);
|
|
311976
|
+
}
|
|
311571
311977
|
for (const state2 of ordered) {
|
|
311572
311978
|
const page = {
|
|
311573
311979
|
route: state2.screenId,
|
|
311574
311980
|
screenId: state2.screenId,
|
|
311575
311981
|
pageType: state2.screenType ?? "UNKNOWN",
|
|
311576
|
-
elements:
|
|
311982
|
+
elements: (0, mobile_observation_js_1.buildCollectedElementsForScreen)(state2.elements, {
|
|
311983
|
+
navigationRefs: navRefsByScreen.get(state2.screenId)
|
|
311984
|
+
})
|
|
311577
311985
|
};
|
|
311578
311986
|
if (state2.screenName) {
|
|
311579
311987
|
page.title = state2.screenName;
|
|
@@ -311603,6 +312011,7 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311603
312011
|
"../runner-core/dist/services/mobile/mobile-discovery-agent.js"(exports) {
|
|
311604
312012
|
"use strict";
|
|
311605
312013
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
312014
|
+
exports.getMobileExploreBudget = getMobileExploreBudget;
|
|
311606
312015
|
exports.runMobileDiscoveryOnRunner = runMobileDiscoveryOnRunner2;
|
|
311607
312016
|
var mobile_explorer_js_1 = require_mobile_explorer();
|
|
311608
312017
|
var mobile_discovery_prompts_js_1 = require_mobile_discovery_prompts();
|
|
@@ -311616,8 +312025,37 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311616
312025
|
const message = checkpointErrorMessage(err);
|
|
311617
312026
|
return /Discovery plan checkpoint rejected|Discovery run is .*not RUNNING|not RUNNING|cancelled/i.test(message);
|
|
311618
312027
|
}
|
|
311619
|
-
var
|
|
311620
|
-
var
|
|
312028
|
+
var MOBILE_EXPLORE_BUDGET_SURVEY_FIRST = 90;
|
|
312029
|
+
var MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL = 40;
|
|
312030
|
+
var MOBILE_EXPLORE_BUDGET_DEEP = 60;
|
|
312031
|
+
var MOBILE_EXPLORE_BUDGET_FULL = 90;
|
|
312032
|
+
function readPositiveIntEnv2(name, fallback, env = process.env) {
|
|
312033
|
+
const raw = env[name]?.trim();
|
|
312034
|
+
if (!raw || !/^\d+$/.test(raw))
|
|
312035
|
+
return fallback;
|
|
312036
|
+
const parsed = Number(raw);
|
|
312037
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
312038
|
+
}
|
|
312039
|
+
function getMobileExploreBudget(mode, incremental, env = process.env) {
|
|
312040
|
+
if (mode === "deep")
|
|
312041
|
+
return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_DEEP", MOBILE_EXPLORE_BUDGET_DEEP, env);
|
|
312042
|
+
if (mode === "full")
|
|
312043
|
+
return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_FULL", MOBILE_EXPLORE_BUDGET_FULL, env);
|
|
312044
|
+
if (incremental) {
|
|
312045
|
+
return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY_INCREMENTAL", MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL, env);
|
|
312046
|
+
}
|
|
312047
|
+
return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY", MOBILE_EXPLORE_BUDGET_SURVEY_FIRST, env);
|
|
312048
|
+
}
|
|
312049
|
+
function compactExistingScreens(siteMap) {
|
|
312050
|
+
if (!siteMap || !Array.isArray(siteMap.pages) || siteMap.pages.length === 0)
|
|
312051
|
+
return void 0;
|
|
312052
|
+
const screens = siteMap.pages.filter((p) => typeof p.route === "string" && p.route.trim().length > 0).map((p) => ({
|
|
312053
|
+
screenId: p.route,
|
|
312054
|
+
...p.title ? { name: p.title } : {},
|
|
312055
|
+
...p.pageType ? { screenType: p.pageType } : {}
|
|
312056
|
+
}));
|
|
312057
|
+
return screens.length > 0 ? screens : void 0;
|
|
312058
|
+
}
|
|
311621
312059
|
function platformForMedium(medium) {
|
|
311622
312060
|
return medium === "IOS_NATIVE" ? "IOS" : "ANDROID";
|
|
311623
312061
|
}
|
|
@@ -311636,6 +312074,9 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311636
312074
|
// Native screenshots are multi-megabyte PNGs. The live mirror streams them
|
|
311637
312075
|
// through heartbeat telemetry; discovery checkpoints keep the screen graph
|
|
311638
312076
|
// and transitions so large Android surveys cannot exceed server body limits.
|
|
312077
|
+
// D-D6 — pageScreenshotKeys is a small screen-id → storage-key map (NOT base64),
|
|
312078
|
+
// so it DOES ride the checkpoint: the server links each key to SitePage.screenshotKey.
|
|
312079
|
+
...explore.pageScreenshotKeys && Object.keys(explore.pageScreenshotKeys).length > 0 ? { pageScreenshotKeys: explore.pageScreenshotKeys } : {},
|
|
311639
312080
|
exploreStats: explore.exploreStats,
|
|
311640
312081
|
healthObservations: explore.healthObservations
|
|
311641
312082
|
};
|
|
@@ -311650,6 +312091,7 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311650
312091
|
const onLog = callbacks?.onLog;
|
|
311651
312092
|
const onAICall = callbacks?.onAICall;
|
|
311652
312093
|
const onScreenshot = callbacks?.onScreenshot;
|
|
312094
|
+
const onScreenshotUpload = callbacks?.onScreenshotUpload;
|
|
311653
312095
|
const runExplore = callbacks?.runExplore ?? mobile_explorer_js_1.runMobileExplorePhase;
|
|
311654
312096
|
const runPlan = callbacks?.runPlan ?? mobile_discovery_prompts_js_1.runMobileAIPlanner;
|
|
311655
312097
|
const runGenerate = callbacks?.runGenerate ?? mobile_generator_js_1.runMobileGeneratePhase;
|
|
@@ -311676,15 +312118,77 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311676
312118
|
elementsFound: 0,
|
|
311677
312119
|
transitionsFound: 0
|
|
311678
312120
|
};
|
|
312121
|
+
const resumeSavedScenarios = ctx.resume?.savedPlan?.scenarios ?? [];
|
|
312122
|
+
const isPlanReuseResume = mode !== "survey" && resumeSavedScenarios.length > 0;
|
|
311679
312123
|
try {
|
|
312124
|
+
if (isPlanReuseResume) {
|
|
312125
|
+
const alreadyGenerated = ctx.resume?.alreadyGeneratedScenarioNames ?? [];
|
|
312126
|
+
log2(`
|
|
312127
|
+
\u2500\u2500 Resume (plan-reuse): ${resumeSavedScenarios.length} saved scenario(s); ${alreadyGenerated.length} already generated. Skipping explore + plan; generating only the remainder. \u2500\u2500`);
|
|
312128
|
+
const scenarios2 = resumeSavedScenarios;
|
|
312129
|
+
const plans2 = toPlans(scenarios2);
|
|
312130
|
+
if (callbacks?.onPlanReady) {
|
|
312131
|
+
try {
|
|
312132
|
+
await callbacks.onPlanReady({ scenarios: scenarios2, featureGroups: void 0, fallbackUsed: false });
|
|
312133
|
+
log2("Plan checkpoint: reused scenario plan re-POSTed to server");
|
|
312134
|
+
} catch (err) {
|
|
312135
|
+
const message = checkpointErrorMessage(err);
|
|
312136
|
+
if (isTerminalDiscoveryCheckpointError(err)) {
|
|
312137
|
+
log2(`Plan checkpoint rejected for inactive discovery run: ${message}`);
|
|
312138
|
+
throw err;
|
|
312139
|
+
}
|
|
312140
|
+
log2(`Plan checkpoint POST failed (non-fatal): ${message}`);
|
|
312141
|
+
}
|
|
312142
|
+
}
|
|
312143
|
+
let paused = false;
|
|
312144
|
+
let pauseReason;
|
|
312145
|
+
let remainingScenarios;
|
|
312146
|
+
const tests2 = await runGenerate({
|
|
312147
|
+
scenarios: scenarios2,
|
|
312148
|
+
driver,
|
|
312149
|
+
ctx,
|
|
312150
|
+
onLog: (line) => log2(line),
|
|
312151
|
+
onAICall,
|
|
312152
|
+
onScreenshot,
|
|
312153
|
+
onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
|
|
312154
|
+
skipScenarioNames: alreadyGenerated,
|
|
312155
|
+
onInfraStop: (info) => {
|
|
312156
|
+
paused = true;
|
|
312157
|
+
pauseReason = info.reason;
|
|
312158
|
+
remainingScenarios = info.remainingScenarioNames;
|
|
312159
|
+
}
|
|
312160
|
+
});
|
|
312161
|
+
const duration2 = (Date.now() - startTime) / 1e3;
|
|
312162
|
+
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" : ""}`;
|
|
312163
|
+
log2(`
|
|
312164
|
+
=== Mobile Discovery Resume Complete (${duration2.toFixed(1)}s) ===`);
|
|
312165
|
+
log2(summary2);
|
|
312166
|
+
return {
|
|
312167
|
+
collectedPages: [],
|
|
312168
|
+
collectedTransitions: [],
|
|
312169
|
+
tests: tests2,
|
|
312170
|
+
plans: plans2,
|
|
312171
|
+
scenarios: scenarios2.length > 0 ? scenarios2 : void 0,
|
|
312172
|
+
logs: logs2.join("\n"),
|
|
312173
|
+
screenshots,
|
|
312174
|
+
summary: summary2,
|
|
312175
|
+
exploreStats: { pagesDiscovered: 0, pagesVisited: 0, elementsFound: 0, transitionsFound: 0 },
|
|
312176
|
+
duration: duration2,
|
|
312177
|
+
mode,
|
|
312178
|
+
...paused ? { paused: true, pauseReason, remainingScenarios } : {}
|
|
312179
|
+
// apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
|
|
312180
|
+
};
|
|
312181
|
+
}
|
|
311680
312182
|
log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
|
|
311681
312183
|
const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
|
|
311682
|
-
const maxIterations = mode
|
|
312184
|
+
const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
|
|
312185
|
+
const existingScreens = compactExistingScreens(ctx.existingSiteMap);
|
|
311683
312186
|
const explore = await runExplore({
|
|
311684
312187
|
ctx,
|
|
311685
312188
|
driver,
|
|
311686
312189
|
state: state2,
|
|
311687
312190
|
config: { maxIterations },
|
|
312191
|
+
existingScreens,
|
|
311688
312192
|
onLog: (line) => log2(line),
|
|
311689
312193
|
onAICall,
|
|
311690
312194
|
onScreenshot: (base64) => {
|
|
@@ -311694,7 +312198,8 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311694
312198
|
onScreenshot?.(base64);
|
|
311695
312199
|
} catch {
|
|
311696
312200
|
}
|
|
311697
|
-
}
|
|
312201
|
+
},
|
|
312202
|
+
onScreenshotUpload
|
|
311698
312203
|
});
|
|
311699
312204
|
const collectedTransitions = toCollectedTransitions(explore);
|
|
311700
312205
|
partialCollectedPages = explore.collectedPages;
|
|
@@ -311795,6 +312300,9 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
|
|
|
311795
312300
|
};
|
|
311796
312301
|
}
|
|
311797
312302
|
log2("\n\u2500\u2500 Phase 3: GENERATE \u2500\u2500");
|
|
312303
|
+
let generatePaused = false;
|
|
312304
|
+
let generatePauseReason;
|
|
312305
|
+
let generateRemainingScenarios;
|
|
311798
312306
|
const tests = await runGenerate({
|
|
311799
312307
|
scenarios,
|
|
311800
312308
|
driver,
|
|
@@ -311802,7 +312310,15 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
|
|
|
311802
312310
|
onLog: (line) => log2(line),
|
|
311803
312311
|
onAICall,
|
|
311804
312312
|
onScreenshot,
|
|
311805
|
-
onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" })
|
|
312313
|
+
onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
|
|
312314
|
+
// Legacy resume (no saved plan): still skip scenarios already generated by the
|
|
312315
|
+
// paused run so the AI isn't re-invoked for them. Undefined on a fresh run.
|
|
312316
|
+
skipScenarioNames: ctx.resume?.alreadyGeneratedScenarioNames,
|
|
312317
|
+
onInfraStop: (info) => {
|
|
312318
|
+
generatePaused = true;
|
|
312319
|
+
generatePauseReason = info.reason;
|
|
312320
|
+
generateRemainingScenarios = info.remainingScenarioNames;
|
|
312321
|
+
}
|
|
311806
312322
|
});
|
|
311807
312323
|
const generateElapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
311808
312324
|
log2(`
|
|
@@ -311818,7 +312334,7 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
|
|
|
311818
312334
|
};
|
|
311819
312335
|
}
|
|
311820
312336
|
const duration = (Date.now() - startTime) / 1e3;
|
|
311821
|
-
const summary = `Mobile discovery complete: explored ${explore.exploreStats.pagesDiscovered} screens, planned ${scenarios.length} scenarios, generated ${tests.length} tests in ${duration.toFixed(1)}s`;
|
|
312337
|
+
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)" : ""}`;
|
|
311822
312338
|
log2(`
|
|
311823
312339
|
=== Mobile Discovery Pipeline Complete (${duration.toFixed(1)}s) ===`);
|
|
311824
312340
|
log2(summary);
|
|
@@ -311843,7 +312359,11 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
|
|
|
311843
312359
|
summary,
|
|
311844
312360
|
exploreStats: explore.exploreStats,
|
|
311845
312361
|
duration,
|
|
311846
|
-
mode
|
|
312362
|
+
mode,
|
|
312363
|
+
// D-D4 — a circuit-breaker trip PAUSES the run (resumable) instead of
|
|
312364
|
+
// finalizing COMPLETED/FAILED. The server's isDiscoveryPaused reads these
|
|
312365
|
+
// (forwarded by runner.ts buildFinalBody, byte-identical to the web path).
|
|
312366
|
+
...generatePaused ? { paused: true, pauseReason: generatePauseReason, remainingScenarios: generateRemainingScenarios } : {}
|
|
311847
312367
|
// apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
|
|
311848
312368
|
};
|
|
311849
312369
|
} catch (fatalErr) {
|
|
@@ -311889,6 +312409,7 @@ var require_discover_agent = __commonJS({
|
|
|
311889
312409
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
311890
312410
|
exports.runMobileDiscoveryOnRunner = exports.buildInMemorySiteMap = void 0;
|
|
311891
312411
|
exports.runCanvasPreflight = runCanvasPreflight;
|
|
312412
|
+
exports.getFatalExploreError = getFatalExploreError;
|
|
311892
312413
|
exports.runSiteDiscoveryOnRunner = runSiteDiscoveryOnRunner2;
|
|
311893
312414
|
exports.runFeatureDiscoveryOnRunner = runFeatureDiscoveryOnRunner2;
|
|
311894
312415
|
var browser_config_js_1 = require_browser_config();
|
|
@@ -312071,7 +312592,7 @@ Respond with ONLY valid JSON:
|
|
|
312071
312592
|
"keyFindings": "What was discovered - forms, validations, behaviors. Mark inferred items with '(inferred, not tested)'.",
|
|
312072
312593
|
"testingNotes": "Gotchas, validation behaviors, error messages. List visible-but-untested routes as candidates for future exploration."
|
|
312073
312594
|
}`;
|
|
312074
|
-
const client = modelOverride ? (0, ai_provider_js_1.
|
|
312595
|
+
const client = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
|
|
312075
312596
|
const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
|
|
312076
312597
|
const callStart = Date.now();
|
|
312077
312598
|
const response = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => client.chat.completions.create({
|
|
@@ -312202,6 +312723,10 @@ Respond with ONLY valid JSON:
|
|
|
312202
312723
|
});
|
|
312203
312724
|
return timed.catch(() => null);
|
|
312204
312725
|
}
|
|
312726
|
+
function getFatalExploreError(exploreResult) {
|
|
312727
|
+
const prefix = "Fatal error:";
|
|
312728
|
+
return exploreResult.summary.startsWith(prefix) ? exploreResult.summary.slice(prefix.length).trim() || exploreResult.summary : void 0;
|
|
312729
|
+
}
|
|
312205
312730
|
async function runSiteDiscoveryOnRunner2(ctx, onLog, onAICall, sharedBrowser, onScreenshot, callbackOptions) {
|
|
312206
312731
|
const logs2 = [];
|
|
312207
312732
|
const screenshots = [];
|
|
@@ -312386,6 +312911,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
|
|
|
312386
312911
|
if (loginRunP)
|
|
312387
312912
|
await loginRunP;
|
|
312388
312913
|
const duration2 = (Date.now() - startTime) / 1e3;
|
|
312914
|
+
const fatalExploreError = getFatalExploreError(exploreResult);
|
|
312389
312915
|
return {
|
|
312390
312916
|
collectedPages: exploreResult.collectedPages,
|
|
312391
312917
|
collectedTransitions: exploreResult.collectedTransitions,
|
|
@@ -312396,6 +312922,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
|
|
|
312396
312922
|
pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
|
|
312397
312923
|
pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
|
|
312398
312924
|
summary: exploreResult.summary || "Exploration completed but no page data captured.",
|
|
312925
|
+
error: fatalExploreError,
|
|
312399
312926
|
exploreStats: {
|
|
312400
312927
|
pagesDiscovered: exploreResult.pagesDiscovered,
|
|
312401
312928
|
pagesVisited: exploreResult.pagesVisited,
|
|
@@ -312937,6 +313464,7 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
|
|
|
312937
313464
|
discoveryApiCalls = earlyClose.apiCalls;
|
|
312938
313465
|
postCloseHealthSummary = earlyClose.healthSummary;
|
|
312939
313466
|
const duration2 = (Date.now() - startTime) / 1e3;
|
|
313467
|
+
const fatalExploreError = getFatalExploreError(exploreResult);
|
|
312940
313468
|
return {
|
|
312941
313469
|
collectedPages: exploreResult.collectedPages,
|
|
312942
313470
|
collectedTransitions: exploreResult.collectedTransitions,
|
|
@@ -312947,7 +313475,8 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
|
|
|
312947
313475
|
healthObservations: earlyClose.healthSummary,
|
|
312948
313476
|
pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
|
|
312949
313477
|
pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
|
|
312950
|
-
summary: "Feature exploration completed but no page data captured.",
|
|
313478
|
+
summary: fatalExploreError ? exploreResult.summary : "Feature exploration completed but no page data captured.",
|
|
313479
|
+
error: fatalExploreError,
|
|
312951
313480
|
exploreStats: {
|
|
312952
313481
|
pagesDiscovered: exploreResult.pagesDiscovered,
|
|
312953
313482
|
pagesVisited: exploreResult.pagesVisited,
|
|
@@ -326336,7 +326865,7 @@ var require_package4 = __commonJS({
|
|
|
326336
326865
|
"package.json"(exports, module) {
|
|
326337
326866
|
module.exports = {
|
|
326338
326867
|
name: "@validate.qa/runner",
|
|
326339
|
-
version: "1.0.
|
|
326868
|
+
version: "1.0.14",
|
|
326340
326869
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
326341
326870
|
bin: {
|
|
326342
326871
|
"validate-runner": "dist/cli.js"
|
|
@@ -326344,7 +326873,7 @@ var require_package4 = __commonJS({
|
|
|
326344
326873
|
scripts: {
|
|
326345
326874
|
build: "tsup",
|
|
326346
326875
|
dev: "tsx src/cli.ts",
|
|
326347
|
-
"test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/services/mobile/mobile-network-capture.smoke.test.ts",
|
|
326876
|
+
"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",
|
|
326348
326877
|
prepublishOnly: "npm run build"
|
|
326349
326878
|
},
|
|
326350
326879
|
files: [
|
|
@@ -326571,8 +327100,10 @@ async function startAppiumServer(options) {
|
|
|
326571
327100
|
return serverState;
|
|
326572
327101
|
}
|
|
326573
327102
|
const port = options?.port ?? APPIUM_PORT;
|
|
326574
|
-
const
|
|
326575
|
-
|
|
327103
|
+
const args = ["--port", String(port)];
|
|
327104
|
+
if (options?.drivers && options.drivers.length > 0) {
|
|
327105
|
+
args.push("--use-drivers", options.drivers.join(","));
|
|
327106
|
+
}
|
|
326576
327107
|
intentionalStop = false;
|
|
326577
327108
|
lastStartOptions = { ...lastStartOptions, ...options };
|
|
326578
327109
|
if (appiumGaveUp) {
|
|
@@ -326582,11 +327113,12 @@ async function startAppiumServer(options) {
|
|
|
326582
327113
|
killPort(port);
|
|
326583
327114
|
return new Promise((resolve, reject) => {
|
|
326584
327115
|
try {
|
|
326585
|
-
|
|
327116
|
+
const child = spawn("appium", args, {
|
|
326586
327117
|
stdio: ["pipe", "pipe", "pipe"],
|
|
326587
327118
|
env: { ...process.env },
|
|
326588
327119
|
detached: false
|
|
326589
327120
|
});
|
|
327121
|
+
appiumProcess = child;
|
|
326590
327122
|
serverState.pid = appiumProcess.pid ?? null;
|
|
326591
327123
|
serverState.port = port;
|
|
326592
327124
|
appiumProcess.stdout?.on("data", (data) => {
|
|
@@ -326603,7 +327135,11 @@ async function startAppiumServer(options) {
|
|
|
326603
327135
|
options?.onLog?.(line);
|
|
326604
327136
|
}
|
|
326605
327137
|
});
|
|
326606
|
-
|
|
327138
|
+
child.on("exit", (code, signal) => {
|
|
327139
|
+
if (appiumProcess !== child) {
|
|
327140
|
+
appendLog(`[appium] Ignoring exit (code=${code}, signal=${signal}) from a superseded process`);
|
|
327141
|
+
return;
|
|
327142
|
+
}
|
|
326607
327143
|
appendLog(`[appium] Process exited (code=${code}, signal=${signal})`);
|
|
326608
327144
|
const currentPort = serverState.port;
|
|
326609
327145
|
const currentRestartCount = serverState.restartCount;
|
|
@@ -326642,7 +327178,11 @@ async function startAppiumServer(options) {
|
|
|
326642
327178
|
startupAbort.abort();
|
|
326643
327179
|
reject(err);
|
|
326644
327180
|
};
|
|
326645
|
-
|
|
327181
|
+
child.on("error", (err) => {
|
|
327182
|
+
if (appiumProcess !== child) {
|
|
327183
|
+
appendLog(`[appium] Ignoring error from a superseded process: ${err.message}`);
|
|
327184
|
+
return;
|
|
327185
|
+
}
|
|
326646
327186
|
appendLog(`[appium] Process error: ${err.message}`);
|
|
326647
327187
|
resetServerState(port);
|
|
326648
327188
|
settleReject(new Error(`Failed to start Appium: ${err.message}. Is appium installed? Run: npm i -g appium`));
|
|
@@ -326761,6 +327301,7 @@ function runCommand(command, args, timeout = 1e4) {
|
|
|
326761
327301
|
}
|
|
326762
327302
|
var realIOSProfilerNegativeUntil = 0;
|
|
326763
327303
|
var REAL_IOS_PROFILER_TTL_MS = 6e4;
|
|
327304
|
+
var REAL_IOS_NO_UDID_HINT = "no paired UDID \u2014 install libimobiledevice (brew install libimobiledevice) and trust this computer on the device";
|
|
326764
327305
|
function discoverIOSSimulators() {
|
|
326765
327306
|
if (platform() !== "darwin") return [];
|
|
326766
327307
|
const json = runCommand("xcrun", ["simctl", "list", "devices", "--json"]);
|
|
@@ -326770,7 +327311,8 @@ function discoverIOSSimulators() {
|
|
|
326770
327311
|
const devices = [];
|
|
326771
327312
|
for (const [runtime, runtimeDevices] of Object.entries(parsed.devices)) {
|
|
326772
327313
|
const versionMatch = runtime.match(/iOS[- ](\d+(?:[-.]\d+)*)/i);
|
|
326773
|
-
|
|
327314
|
+
if (!versionMatch) continue;
|
|
327315
|
+
const platformVersion = versionMatch[1].replace(/-/g, ".");
|
|
326774
327316
|
for (const device of runtimeDevices) {
|
|
326775
327317
|
const state2 = device.state === "Booted" ? "BOOTED" : device.state === "Shutdown" ? "SHUTDOWN" : "LOADING";
|
|
326776
327318
|
if (state2 === "BOOTED") {
|
|
@@ -326843,11 +327385,11 @@ function discoverRealIOSDevices() {
|
|
|
326843
327385
|
const version = runCommand("ideviceinfo", ["-u", serial, "-k", "ProductVersion"]) || "unknown";
|
|
326844
327386
|
devices.push({
|
|
326845
327387
|
id: serial,
|
|
326846
|
-
name
|
|
327388
|
+
name: `${name} (${REAL_IOS_NO_UDID_HINT})`,
|
|
326847
327389
|
platform: "IOS",
|
|
326848
327390
|
platformVersion: version,
|
|
326849
327391
|
state: "BOOTED",
|
|
326850
|
-
isAvailable:
|
|
327392
|
+
isAvailable: false,
|
|
326851
327393
|
isPhysical: true
|
|
326852
327394
|
});
|
|
326853
327395
|
}
|
|
@@ -326863,11 +327405,11 @@ function discoverRealIOSDevices() {
|
|
|
326863
327405
|
if (hasIPhone || hasIPad) {
|
|
326864
327406
|
devices.push({
|
|
326865
327407
|
id: "usb-ios-device",
|
|
326866
|
-
name: hasIPhone ? "iPhone (USB)" : "iPad (USB)"
|
|
327408
|
+
name: `${hasIPhone ? "iPhone (USB)" : "iPad (USB)"} (${REAL_IOS_NO_UDID_HINT})`,
|
|
326867
327409
|
platform: "IOS",
|
|
326868
327410
|
platformVersion: "unknown",
|
|
326869
327411
|
state: "BOOTED",
|
|
326870
|
-
isAvailable:
|
|
327412
|
+
isAvailable: false,
|
|
326871
327413
|
isPhysical: true
|
|
326872
327414
|
});
|
|
326873
327415
|
}
|
|
@@ -326945,7 +327487,9 @@ function discoverAndroidDevices() {
|
|
|
326945
327487
|
const adbDevices = parseAdbDevices();
|
|
326946
327488
|
return adbDevices.filter((d) => d.type === "device" || d.type === "unauthorized").map((d) => {
|
|
326947
327489
|
const isEmulator = d.serial.startsWith("emulator-");
|
|
326948
|
-
const
|
|
327490
|
+
const authorized = d.type === "device";
|
|
327491
|
+
const bootCompleted = authorized && getAndroidProp(d.serial, "sys.boot_completed") === "1";
|
|
327492
|
+
const isAvailable = authorized && bootCompleted;
|
|
326949
327493
|
const name = d.model || d.device || (isEmulator ? "Android Emulator" : "Android Device");
|
|
326950
327494
|
const platformVersion = isAvailable ? getAndroidProp(d.serial, "ro.build.version.release") || "unknown" : "unknown";
|
|
326951
327495
|
return {
|
|
@@ -326953,7 +327497,10 @@ function discoverAndroidDevices() {
|
|
|
326953
327497
|
name,
|
|
326954
327498
|
platform: "ANDROID",
|
|
326955
327499
|
platformVersion,
|
|
326956
|
-
|
|
327500
|
+
// LOADING = authorized but still booting; an unauthorized device stays
|
|
327501
|
+
// BOOTED (isAvailable false) so the dashboard shows it with an
|
|
327502
|
+
// "accept USB debugging" hint rather than hiding it.
|
|
327503
|
+
state: authorized && !bootCompleted ? "LOADING" : "BOOTED",
|
|
326957
327504
|
isAvailable,
|
|
326958
327505
|
// Mark if it's a real device vs emulator
|
|
326959
327506
|
...isEmulator ? {} : { isPhysical: true }
|
|
@@ -326999,15 +327546,25 @@ function discoverAllDevices(options) {
|
|
|
326999
327546
|
}
|
|
327000
327547
|
return [...iosDevices, ...androidDevices];
|
|
327001
327548
|
}
|
|
327549
|
+
var DEVICE_SNAPSHOT_TTL_MS = 8e3;
|
|
327550
|
+
var cachedDeviceSnapshot = null;
|
|
327551
|
+
function getCachedDeviceSnapshot() {
|
|
327552
|
+
const now = Date.now();
|
|
327553
|
+
if (cachedDeviceSnapshot && now - cachedDeviceSnapshot.at < DEVICE_SNAPSHOT_TTL_MS) {
|
|
327554
|
+
return cachedDeviceSnapshot.devices;
|
|
327555
|
+
}
|
|
327556
|
+
const devices = discoverAllDevices();
|
|
327557
|
+
cachedDeviceSnapshot = { at: now, devices };
|
|
327558
|
+
return devices;
|
|
327559
|
+
}
|
|
327002
327560
|
function getRunnerCapabilities() {
|
|
327003
|
-
const
|
|
327004
|
-
const androidDevices = discoverAndroidDevices();
|
|
327561
|
+
const devices = getCachedDeviceSnapshot();
|
|
327005
327562
|
const isReadyDevice = (device) => device.isAvailable && device.state === "BOOTED";
|
|
327006
327563
|
return {
|
|
327007
327564
|
web: true,
|
|
327008
327565
|
// Always capable of web testing via Playwright
|
|
327009
|
-
ios:
|
|
327010
|
-
android:
|
|
327566
|
+
ios: devices.some((d) => d.platform === "IOS" && isReadyDevice(d)),
|
|
327567
|
+
android: devices.some((d) => d.platform === "ANDROID" && isReadyDevice(d))
|
|
327011
327568
|
};
|
|
327012
327569
|
}
|
|
327013
327570
|
|
|
@@ -327030,6 +327587,8 @@ var DEVICE_CMD_TIMEOUT_MS = 15e3;
|
|
|
327030
327587
|
var CA_KEY_BITS = 2048;
|
|
327031
327588
|
var ANDROID_EMULATOR_HOST_LOOPBACK = "10.0.2.2";
|
|
327032
327589
|
var PENDING_CLEANUP_DIR = join2(tmpdir(), "validateqa-mobile-capture-pending");
|
|
327590
|
+
var IOS_SIM_HOST_PROXY_ENV = "VALIDATEQA_MOBILE_IOS_SIM_HOST_PROXY";
|
|
327591
|
+
var STALE_MARKER_RECONCILE_MS = 6 * 60 * 60 * 1e3;
|
|
327033
327592
|
function envFlag(name) {
|
|
327034
327593
|
const value = (process.env[name] ?? "").trim().toLowerCase();
|
|
327035
327594
|
return value === "1" || value === "true" || value === "yes" || value === "on";
|
|
@@ -327146,6 +327705,9 @@ var MitmProxy = class {
|
|
|
327146
327705
|
await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
|
|
327147
327706
|
await server3.on("request", (req) => this.onRequest(req));
|
|
327148
327707
|
await server3.on("response", (res) => this.onResponse(res));
|
|
327708
|
+
await server3.on("abort", (req) => {
|
|
327709
|
+
this.inFlight.delete(req.id);
|
|
327710
|
+
});
|
|
327149
327711
|
await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
|
|
327150
327712
|
await server3.on("tls-passthrough-opened", (event) => this.onTlsPassthroughOpened(event));
|
|
327151
327713
|
await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
|
|
@@ -327442,6 +328004,125 @@ async function iosSimRemoveCa(deviceId, onLog) {
|
|
|
327442
328004
|
function errMsg(err) {
|
|
327443
328005
|
return err instanceof Error ? err.message : String(err);
|
|
327444
328006
|
}
|
|
328007
|
+
function parsePrimaryNetworkService(stdout) {
|
|
328008
|
+
for (const line of stdout.split("\n")) {
|
|
328009
|
+
const m = line.match(/^\(\d+\)\s+(.+)$/);
|
|
328010
|
+
if (m) return m[1].trim();
|
|
328011
|
+
}
|
|
328012
|
+
return null;
|
|
328013
|
+
}
|
|
328014
|
+
function parseWebProxyState(stdout) {
|
|
328015
|
+
const field = (label) => {
|
|
328016
|
+
const m = stdout.match(new RegExp(`^${label}:[ \\t]*(.*)$`, "im"));
|
|
328017
|
+
return m ? m[1].trim() : "";
|
|
328018
|
+
};
|
|
328019
|
+
return {
|
|
328020
|
+
enabled: field("Enabled").toLowerCase() === "yes",
|
|
328021
|
+
server: field("Server"),
|
|
328022
|
+
port: field("Port")
|
|
328023
|
+
};
|
|
328024
|
+
}
|
|
328025
|
+
function buildHostProxySetCommands(service, host, port) {
|
|
328026
|
+
const portStr = String(port);
|
|
328027
|
+
return [
|
|
328028
|
+
["-setwebproxy", service, host, portStr],
|
|
328029
|
+
["-setsecurewebproxy", service, host, portStr]
|
|
328030
|
+
];
|
|
328031
|
+
}
|
|
328032
|
+
function buildProxyRestoreArgs(setCmd, stateCmd, service, prior) {
|
|
328033
|
+
if (prior.server) {
|
|
328034
|
+
return [
|
|
328035
|
+
[setCmd, service, prior.server, prior.port || "0"],
|
|
328036
|
+
[stateCmd, service, prior.enabled ? "on" : "off"]
|
|
328037
|
+
];
|
|
328038
|
+
}
|
|
328039
|
+
return [[stateCmd, service, "off"]];
|
|
328040
|
+
}
|
|
328041
|
+
function buildHostProxyRestoreCommands(service, prior) {
|
|
328042
|
+
return [
|
|
328043
|
+
...buildProxyRestoreArgs("-setwebproxy", "-setwebproxystate", service, prior.web),
|
|
328044
|
+
...buildProxyRestoreArgs("-setsecurewebproxy", "-setsecurewebproxystate", service, prior.secure)
|
|
328045
|
+
];
|
|
328046
|
+
}
|
|
328047
|
+
function hostProxyPriorFromMarker(marker) {
|
|
328048
|
+
return {
|
|
328049
|
+
web: {
|
|
328050
|
+
enabled: Boolean(marker.hostWebProxyWasEnabled),
|
|
328051
|
+
server: marker.hostWebProxyPriorServer ?? "",
|
|
328052
|
+
port: marker.hostWebProxyPriorPort ?? ""
|
|
328053
|
+
},
|
|
328054
|
+
secure: {
|
|
328055
|
+
enabled: Boolean(marker.hostSecureProxyWasEnabled),
|
|
328056
|
+
server: marker.hostSecureProxyPriorServer ?? "",
|
|
328057
|
+
port: marker.hostSecureProxyPriorPort ?? ""
|
|
328058
|
+
}
|
|
328059
|
+
};
|
|
328060
|
+
}
|
|
328061
|
+
function hostProxyControlSupported(platform3, isPhysical, platformName = process.platform, optedIn = envFlag(IOS_SIM_HOST_PROXY_ENV)) {
|
|
328062
|
+
if (platform3 !== "IOS") return { supported: false };
|
|
328063
|
+
if (isPhysical) {
|
|
328064
|
+
return { supported: false, reason: "physical iOS device requires a manual Wi-Fi HTTP proxy; host proxy not applied" };
|
|
328065
|
+
}
|
|
328066
|
+
if (platformName !== "darwin") {
|
|
328067
|
+
return { supported: false, reason: `iOS simulator host proxy is only controllable on macOS (host is ${platformName})` };
|
|
328068
|
+
}
|
|
328069
|
+
if (!optedIn) {
|
|
328070
|
+
return {
|
|
328071
|
+
supported: false,
|
|
328072
|
+
reason: `iOS simulator HTTPS body capture is disabled: it requires repointing the macOS SYSTEM-WIDE HTTP/HTTPS proxy, which would break host HTTPS (Safari/Chrome/Electron) and capture the host's own traffic for this run. Not applied without consent \u2014 set ${IOS_SIM_HOST_PROXY_ENV}=1 to enable it.`
|
|
328073
|
+
};
|
|
328074
|
+
}
|
|
328075
|
+
return { supported: true };
|
|
328076
|
+
}
|
|
328077
|
+
async function iosSimConfigureHostProxy(host, port, onLog) {
|
|
328078
|
+
let service;
|
|
328079
|
+
let prior;
|
|
328080
|
+
try {
|
|
328081
|
+
const { stdout: order } = await execFileAsync("networksetup", ["-listnetworkserviceorder"], {
|
|
328082
|
+
timeout: DEVICE_CMD_TIMEOUT_MS
|
|
328083
|
+
});
|
|
328084
|
+
const svc = parsePrimaryNetworkService(order);
|
|
328085
|
+
if (!svc) {
|
|
328086
|
+
return { configured: false, reason: "iOS simulator host proxy could not be configured: no active macOS network service found" };
|
|
328087
|
+
}
|
|
328088
|
+
service = svc;
|
|
328089
|
+
const [{ stdout: webOut }, { stdout: secureOut }] = await Promise.all([
|
|
328090
|
+
execFileAsync("networksetup", ["-getwebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS }),
|
|
328091
|
+
execFileAsync("networksetup", ["-getsecurewebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS })
|
|
328092
|
+
]);
|
|
328093
|
+
prior = { web: parseWebProxyState(webOut), secure: parseWebProxyState(secureOut) };
|
|
328094
|
+
for (const args of buildHostProxySetCommands(service, host, port)) {
|
|
328095
|
+
await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
|
|
328096
|
+
}
|
|
328097
|
+
onLog?.(`[mobile-net] macOS host proxy set on "${service}" \u2192 ${host}:${port} (iOS simulator shares the host network).`);
|
|
328098
|
+
return { configured: true, service, prior };
|
|
328099
|
+
} catch (err) {
|
|
328100
|
+
if (service && prior) {
|
|
328101
|
+
onLog?.(`[mobile-net] host proxy set failed mid-way \u2014 rolling back "${service}" to prior state: ${errMsg(err)}`);
|
|
328102
|
+
for (const args of buildHostProxyRestoreCommands(service, prior)) {
|
|
328103
|
+
try {
|
|
328104
|
+
await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
|
|
328105
|
+
} catch {
|
|
328106
|
+
}
|
|
328107
|
+
}
|
|
328108
|
+
}
|
|
328109
|
+
return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg(err)}` };
|
|
328110
|
+
}
|
|
328111
|
+
}
|
|
328112
|
+
async function iosSimRestoreHostProxy(service, prior, onLog) {
|
|
328113
|
+
const restore = prior ?? {
|
|
328114
|
+
web: { enabled: false, server: "", port: "" },
|
|
328115
|
+
secure: { enabled: false, server: "", port: "" }
|
|
328116
|
+
};
|
|
328117
|
+
for (const args of buildHostProxyRestoreCommands(service, restore)) {
|
|
328118
|
+
try {
|
|
328119
|
+
await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
|
|
328120
|
+
} catch (err) {
|
|
328121
|
+
onLog?.(`[mobile-net] networksetup restore (${args[0]}) failed: ${errMsg(err)}`);
|
|
328122
|
+
}
|
|
328123
|
+
}
|
|
328124
|
+
onLog?.(`[mobile-net] macOS host proxy restored on "${service}".`);
|
|
328125
|
+
}
|
|
327445
328126
|
var activeCleanups = /* @__PURE__ */ new Map();
|
|
327446
328127
|
var exitHandlersInstalled = false;
|
|
327447
328128
|
function markerPath(sessionKey) {
|
|
@@ -327476,6 +328157,14 @@ function restoreDeviceSync(marker, onLog) {
|
|
|
327476
328157
|
run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
|
|
327477
328158
|
}
|
|
327478
328159
|
}
|
|
328160
|
+
if (marker.platform === "IOS" && marker.hostProxySet && marker.hostProxyService && process.platform === "darwin") {
|
|
328161
|
+
for (const args of buildHostProxyRestoreCommands(marker.hostProxyService, hostProxyPriorFromMarker(marker))) {
|
|
328162
|
+
try {
|
|
328163
|
+
execFileSync3("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS, stdio: "ignore" });
|
|
328164
|
+
} catch {
|
|
328165
|
+
}
|
|
328166
|
+
}
|
|
328167
|
+
}
|
|
327479
328168
|
onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
|
|
327480
328169
|
}
|
|
327481
328170
|
async function reconcilePendingCleanups(onLog) {
|
|
@@ -327487,6 +328176,8 @@ async function reconcilePendingCleanups(onLog) {
|
|
|
327487
328176
|
}
|
|
327488
328177
|
for (const file of files) {
|
|
327489
328178
|
if (!file.endsWith(".json")) continue;
|
|
328179
|
+
const sessionKey = file.slice(0, -".json".length);
|
|
328180
|
+
if (activeCleanups.has(sessionKey)) continue;
|
|
327490
328181
|
const full = join2(PENDING_CLEANUP_DIR, file);
|
|
327491
328182
|
try {
|
|
327492
328183
|
const raw = await readFile(full, "utf-8");
|
|
@@ -327516,8 +328207,10 @@ function ensureExitHandlers() {
|
|
|
327516
328207
|
for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
327517
328208
|
process.on(signal, () => {
|
|
327518
328209
|
drain();
|
|
327519
|
-
process.
|
|
327520
|
-
|
|
328210
|
+
if (process.listenerCount(signal) <= 1) {
|
|
328211
|
+
process.removeAllListeners(signal);
|
|
328212
|
+
process.kill(process.pid, signal);
|
|
328213
|
+
}
|
|
327521
328214
|
});
|
|
327522
328215
|
}
|
|
327523
328216
|
}
|
|
@@ -327567,6 +328260,9 @@ async function startMobileNetworkCapture(options) {
|
|
|
327567
328260
|
let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
|
|
327568
328261
|
let proxySet = false;
|
|
327569
328262
|
let caInstalledOnDevice = false;
|
|
328263
|
+
let hostProxySet = false;
|
|
328264
|
+
let hostProxyService;
|
|
328265
|
+
let hostProxyPrior;
|
|
327570
328266
|
try {
|
|
327571
328267
|
if (platform3 === "ANDROID") {
|
|
327572
328268
|
proxySet = await androidSetProxy(deviceId, hostPort, onLog);
|
|
@@ -327581,25 +328277,50 @@ async function startMobileNetworkCapture(options) {
|
|
|
327581
328277
|
}
|
|
327582
328278
|
} else {
|
|
327583
328279
|
if (!isPhysical && caCertPath) {
|
|
327584
|
-
const { trusted
|
|
328280
|
+
const { trusted } = await iosSimTrustCa(deviceId, caCertPath);
|
|
327585
328281
|
caInstalledOnDevice = trusted;
|
|
327586
|
-
|
|
328282
|
+
}
|
|
328283
|
+
const support = hostProxyControlSupported(platform3, isPhysical);
|
|
328284
|
+
if (support.supported) {
|
|
328285
|
+
const hp = await iosSimConfigureHostProxy(proxyHost, proxyPort, onLog);
|
|
328286
|
+
if (hp.configured) {
|
|
328287
|
+
hostProxySet = true;
|
|
328288
|
+
hostProxyService = hp.service;
|
|
328289
|
+
hostProxyPrior = hp.prior;
|
|
328290
|
+
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).` : ".");
|
|
328291
|
+
} else {
|
|
328292
|
+
wiringReason = hp.reason ?? "iOS simulator host proxy could not be configured";
|
|
328293
|
+
onLog?.(`[mobile-net] ${wiringReason}`);
|
|
328294
|
+
}
|
|
327587
328295
|
} else if (isPhysical) {
|
|
327588
328296
|
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}).` : ".");
|
|
328297
|
+
} else {
|
|
328298
|
+
wiringReason = support.reason ?? "iOS simulator host proxy is unsupported on this host";
|
|
328299
|
+
onLog?.(`[mobile-net] ${wiringReason}`);
|
|
327589
328300
|
}
|
|
327590
328301
|
}
|
|
327591
328302
|
} catch (err) {
|
|
327592
328303
|
wiringReason = `device wiring degraded: ${errMsg(err)}`;
|
|
327593
328304
|
onLog?.(`[mobile-net] ${wiringReason}`);
|
|
327594
328305
|
}
|
|
327595
|
-
if (proxySet || caInstalledOnDevice) {
|
|
328306
|
+
if (proxySet || caInstalledOnDevice || hostProxySet) {
|
|
327596
328307
|
const marker = {
|
|
327597
328308
|
platform: platform3,
|
|
327598
328309
|
deviceId,
|
|
327599
328310
|
isPhysical,
|
|
327600
328311
|
proxySet,
|
|
327601
328312
|
originalProxy,
|
|
327602
|
-
caInstalledOnDevice
|
|
328313
|
+
caInstalledOnDevice,
|
|
328314
|
+
...hostProxySet ? {
|
|
328315
|
+
hostProxySet: true,
|
|
328316
|
+
hostProxyService,
|
|
328317
|
+
hostWebProxyWasEnabled: hostProxyPrior?.web.enabled,
|
|
328318
|
+
hostWebProxyPriorServer: hostProxyPrior?.web.server,
|
|
328319
|
+
hostWebProxyPriorPort: hostProxyPrior?.web.port,
|
|
328320
|
+
hostSecureProxyWasEnabled: hostProxyPrior?.secure.enabled,
|
|
328321
|
+
hostSecureProxyPriorServer: hostProxyPrior?.secure.server,
|
|
328322
|
+
hostSecureProxyPriorPort: hostProxyPrior?.secure.port
|
|
328323
|
+
} : {}
|
|
327603
328324
|
};
|
|
327604
328325
|
activeCleanups.set(sessionKey, marker);
|
|
327605
328326
|
await writePendingCleanup(sessionKey, marker);
|
|
@@ -327619,6 +328340,9 @@ async function startMobileNetworkCapture(options) {
|
|
|
327619
328340
|
if (proxySet && platform3 === "ANDROID") {
|
|
327620
328341
|
await androidClearProxy(deviceId, originalProxy, onLog);
|
|
327621
328342
|
}
|
|
328343
|
+
if (hostProxySet && hostProxyService && platform3 === "IOS" && !isPhysical) {
|
|
328344
|
+
await iosSimRestoreHostProxy(hostProxyService, hostProxyPrior, onLog);
|
|
328345
|
+
}
|
|
327622
328346
|
if (caInstalledOnDevice && capturedCaCertPath) {
|
|
327623
328347
|
if (platform3 === "ANDROID") {
|
|
327624
328348
|
await androidRemoveCa(deviceId, onLog);
|
|
@@ -327654,6 +328378,9 @@ async function startMobileNetworkCapture(options) {
|
|
|
327654
328378
|
|
|
327655
328379
|
// src/services/appium-executor.ts
|
|
327656
328380
|
var DEFAULT_STEP_TIMEOUT_MS = 1e4;
|
|
328381
|
+
var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
|
|
328382
|
+
var WD_ACTION_TIMEOUT_MS = 3e4;
|
|
328383
|
+
var MAX_SESSION_CREATE_ATTEMPTS = 3;
|
|
327657
328384
|
var execFileAsync2 = promisify2(execFile2);
|
|
327658
328385
|
var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
|
|
327659
328386
|
var WebDriverTransportError = class extends Error {
|
|
@@ -327667,13 +328394,21 @@ function isTransportError(err) {
|
|
|
327667
328394
|
return err instanceof WebDriverTransportError || typeof err === "object" && err !== null && err.isTransport === true;
|
|
327668
328395
|
}
|
|
327669
328396
|
async function wdRequest(path, options) {
|
|
328397
|
+
const isSessionCreate = (options?.method ?? "GET").toUpperCase() === "POST" && path === "/session";
|
|
328398
|
+
const timeoutMs = isSessionCreate ? WD_SESSION_CREATE_TIMEOUT_MS : WD_ACTION_TIMEOUT_MS;
|
|
327670
328399
|
let res;
|
|
327671
328400
|
try {
|
|
327672
328401
|
res = await fetch(`http://localhost:${getAppiumState().port}${path}`, {
|
|
327673
328402
|
...options,
|
|
328403
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
327674
328404
|
headers: { "Content-Type": "application/json", ...options?.headers }
|
|
327675
328405
|
});
|
|
327676
328406
|
} catch (err) {
|
|
328407
|
+
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
|
|
328408
|
+
throw new WebDriverTransportError(
|
|
328409
|
+
`WebDriver request to ${path} timed out after ${timeoutMs}ms (device/Appium unresponsive)`
|
|
328410
|
+
);
|
|
328411
|
+
}
|
|
327677
328412
|
throw new WebDriverTransportError(`WebDriver connection error: ${err instanceof Error ? err.message : String(err)}`);
|
|
327678
328413
|
}
|
|
327679
328414
|
if (!res.ok) {
|
|
@@ -327758,6 +328493,31 @@ async function createSession(target) {
|
|
|
327758
328493
|
if (!sessionId) throw new Error("Appium session creation returned no session ID");
|
|
327759
328494
|
return sessionId;
|
|
327760
328495
|
}
|
|
328496
|
+
var TERMINAL_SESSION_START = /not installed|no such (app|file)|could not confirm|bundle ?id .*(not|missing)|xcodeorgid|signing|provisioning|not authorized|unauthorized/i;
|
|
328497
|
+
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;
|
|
328498
|
+
function isRetryableSessionStartError(err) {
|
|
328499
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
328500
|
+
if (TERMINAL_SESSION_START.test(message)) return false;
|
|
328501
|
+
if (isTransportError(err)) return true;
|
|
328502
|
+
return RETRYABLE_SESSION_START.test(message);
|
|
328503
|
+
}
|
|
328504
|
+
async function createSessionWithRetry(target, onRetry) {
|
|
328505
|
+
let lastErr;
|
|
328506
|
+
for (let attempt = 1; attempt <= MAX_SESSION_CREATE_ATTEMPTS; attempt++) {
|
|
328507
|
+
try {
|
|
328508
|
+
return await createSession(target);
|
|
328509
|
+
} catch (err) {
|
|
328510
|
+
lastErr = err;
|
|
328511
|
+
if (attempt >= MAX_SESSION_CREATE_ATTEMPTS || !isRetryableSessionStartError(err)) throw err;
|
|
328512
|
+
const delayMs = Math.pow(2, attempt) * 1e3;
|
|
328513
|
+
onRetry?.(
|
|
328514
|
+
`Appium session start failed (attempt ${attempt}/${MAX_SESSION_CREATE_ATTEMPTS}, retrying in ${delayMs}ms): ${err instanceof Error ? err.message : String(err)}`
|
|
328515
|
+
);
|
|
328516
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
328517
|
+
}
|
|
328518
|
+
}
|
|
328519
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
328520
|
+
}
|
|
327761
328521
|
async function openDeepLink(sessionId, platform3, deeplink, appId) {
|
|
327762
328522
|
const args = platform3 === "IOS" ? [{ url: deeplink, bundleId: appId }] : [{ url: deeplink, package: appId }];
|
|
327763
328523
|
await wdRequest(`/session/${sessionId}/execute/sync`, {
|
|
@@ -327859,6 +328619,61 @@ async function takeScreenshot(sessionId) {
|
|
|
327859
328619
|
return null;
|
|
327860
328620
|
}
|
|
327861
328621
|
}
|
|
328622
|
+
function mobileRecordingEnabled() {
|
|
328623
|
+
const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
|
|
328624
|
+
return value !== "0" && value !== "false";
|
|
328625
|
+
}
|
|
328626
|
+
function readPositiveIntEnv(name, fallback, min, max) {
|
|
328627
|
+
const raw = process.env[name];
|
|
328628
|
+
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
|
328629
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
328630
|
+
return Math.max(min, Math.min(max, parsed));
|
|
328631
|
+
}
|
|
328632
|
+
async function startScreenRecording(sessionId, platform3, log2) {
|
|
328633
|
+
if (!mobileRecordingEnabled()) {
|
|
328634
|
+
log2("[mobile-video] recording disabled by VALIDATEQA_MOBILE_RUN_VIDEO=0");
|
|
328635
|
+
return false;
|
|
328636
|
+
}
|
|
328637
|
+
const timeLimit = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_SECONDS", 180, 5, 180);
|
|
328638
|
+
const options = {
|
|
328639
|
+
timeLimit: String(timeLimit),
|
|
328640
|
+
forceRestart: true
|
|
328641
|
+
};
|
|
328642
|
+
if (platform3 === "ANDROID") {
|
|
328643
|
+
options.bitRate = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_BITRATE", 75e4, 1e5, 4e6);
|
|
328644
|
+
} else {
|
|
328645
|
+
options.videoQuality = process.env.VALIDATEQA_MOBILE_RECORDING_IOS_QUALITY || "low";
|
|
328646
|
+
}
|
|
328647
|
+
try {
|
|
328648
|
+
await wdRequest(`/session/${sessionId}/appium/start_recording_screen`, {
|
|
328649
|
+
method: "POST",
|
|
328650
|
+
body: JSON.stringify({ options })
|
|
328651
|
+
});
|
|
328652
|
+
log2(`[mobile-video] screen recording started (${timeLimit}s max)`);
|
|
328653
|
+
return true;
|
|
328654
|
+
} catch (err) {
|
|
328655
|
+
log2(`[mobile-video] screen recording unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
328656
|
+
return false;
|
|
328657
|
+
}
|
|
328658
|
+
}
|
|
328659
|
+
async function stopScreenRecording(sessionId, log2) {
|
|
328660
|
+
try {
|
|
328661
|
+
const result = await wdRequest(`/session/${sessionId}/appium/stop_recording_screen`, {
|
|
328662
|
+
method: "POST",
|
|
328663
|
+
body: JSON.stringify({ options: {} })
|
|
328664
|
+
});
|
|
328665
|
+
const value = typeof result?.value === "string" ? result.value.trim() : "";
|
|
328666
|
+
if (!value) {
|
|
328667
|
+
log2("[mobile-video] screen recording stopped but Appium returned no video");
|
|
328668
|
+
return void 0;
|
|
328669
|
+
}
|
|
328670
|
+
log2(`[mobile-video] screen recording captured (${Math.round(value.length / 1024)}KB base64)`);
|
|
328671
|
+
return value;
|
|
328672
|
+
} catch (err) {
|
|
328673
|
+
log2(`[mobile-video] screen recording stop failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
328674
|
+
return void 0;
|
|
328675
|
+
}
|
|
328676
|
+
}
|
|
327862
328677
|
function boundsFromStep(step) {
|
|
327863
328678
|
const bounds = step.metadata?.bounds;
|
|
327864
328679
|
if (bounds && typeof bounds === "object" && !Array.isArray(bounds) && typeof bounds.x === "number" && typeof bounds.y === "number" && typeof bounds.width === "number" && typeof bounds.height === "number") {
|
|
@@ -327978,7 +328793,8 @@ async function sendKeysToFocusedInput(sessionId, value) {
|
|
|
327978
328793
|
});
|
|
327979
328794
|
}
|
|
327980
328795
|
function encodeAndroidInputText(value) {
|
|
327981
|
-
|
|
328796
|
+
const inputEncoded = value.replace(/%/g, "%25").replace(/\s/g, "%s");
|
|
328797
|
+
return `'${inputEncoded.replace(/'/g, `'\\''`)}'`;
|
|
327982
328798
|
}
|
|
327983
328799
|
async function sendAndroidTextToFocusedInput(deviceId, value) {
|
|
327984
328800
|
if (!deviceId) {
|
|
@@ -328236,6 +329052,9 @@ async function executeMobileTest(options) {
|
|
|
328236
329052
|
let sessionId = null;
|
|
328237
329053
|
let capture = null;
|
|
328238
329054
|
let captureStopped = false;
|
|
329055
|
+
let recordingStarted = false;
|
|
329056
|
+
let recordingStopped = false;
|
|
329057
|
+
let video;
|
|
328239
329058
|
const stopCapture = async () => {
|
|
328240
329059
|
if (!capture || captureStopped) return apiCalls;
|
|
328241
329060
|
captureStopped = true;
|
|
@@ -328250,6 +329069,15 @@ async function executeMobileTest(options) {
|
|
|
328250
329069
|
}
|
|
328251
329070
|
return apiCalls;
|
|
328252
329071
|
};
|
|
329072
|
+
const stopRecording = async () => {
|
|
329073
|
+
if (!sessionId || !recordingStarted || recordingStopped) return video;
|
|
329074
|
+
recordingStopped = true;
|
|
329075
|
+
const captured = await stopScreenRecording(sessionId, log2);
|
|
329076
|
+
if (captured) {
|
|
329077
|
+
video = captured;
|
|
329078
|
+
}
|
|
329079
|
+
return video;
|
|
329080
|
+
};
|
|
328253
329081
|
try {
|
|
328254
329082
|
log2("Checking Appium server health...");
|
|
328255
329083
|
const health = await checkAppiumHealth();
|
|
@@ -328303,13 +329131,13 @@ async function executeMobileTest(options) {
|
|
|
328303
329131
|
}
|
|
328304
329132
|
log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
|
|
328305
329133
|
try {
|
|
328306
|
-
sessionId = await
|
|
329134
|
+
sessionId = await createSessionWithRetry({
|
|
328307
329135
|
appId: options.target.appId,
|
|
328308
329136
|
platform: options.target.platform,
|
|
328309
329137
|
deviceId: selectedDevice.id,
|
|
328310
329138
|
platformVersion: options.target.platformVersion ?? selectedDevice.platformVersion,
|
|
328311
329139
|
isPhysical: selectedDevice.isPhysical
|
|
328312
|
-
});
|
|
329140
|
+
}, (msg) => log2(msg));
|
|
328313
329141
|
} catch (err) {
|
|
328314
329142
|
const base2 = err instanceof Error ? err.message : String(err);
|
|
328315
329143
|
if (selectedDevice.isPhysical) {
|
|
@@ -328324,6 +329152,7 @@ async function executeMobileTest(options) {
|
|
|
328324
329152
|
throw err;
|
|
328325
329153
|
}
|
|
328326
329154
|
log2(`Session created: ${sessionId}`);
|
|
329155
|
+
recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
|
|
328327
329156
|
if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
|
|
328328
329157
|
try {
|
|
328329
329158
|
log2(`Opening deep link: ${options.target.deeplink}`);
|
|
@@ -328384,7 +329213,9 @@ async function executeMobileTest(options) {
|
|
|
328384
329213
|
break;
|
|
328385
329214
|
}
|
|
328386
329215
|
}
|
|
328387
|
-
|
|
329216
|
+
if (step.action !== "home" && step.action !== "back") {
|
|
329217
|
+
await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
|
|
329218
|
+
}
|
|
328388
329219
|
} catch (err) {
|
|
328389
329220
|
status = "failed";
|
|
328390
329221
|
stepError = err instanceof Error ? err.message : String(err);
|
|
@@ -328413,10 +329244,12 @@ async function executeMobileTest(options) {
|
|
|
328413
329244
|
const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
|
|
328414
329245
|
const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
|
|
328415
329246
|
await stopCapture();
|
|
329247
|
+
await stopRecording();
|
|
328416
329248
|
return {
|
|
328417
329249
|
passed: allPassed,
|
|
328418
329250
|
stepResults,
|
|
328419
329251
|
screenshots,
|
|
329252
|
+
...video ? { video, videoMimeType: "video/mp4" } : {},
|
|
328420
329253
|
apiCalls,
|
|
328421
329254
|
logs: logLines.join("\n"),
|
|
328422
329255
|
error: firstError,
|
|
@@ -328427,10 +329260,12 @@ async function executeMobileTest(options) {
|
|
|
328427
329260
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
328428
329261
|
log2(`Fatal error: ${errorMsg}`);
|
|
328429
329262
|
await stopCapture();
|
|
329263
|
+
await stopRecording();
|
|
328430
329264
|
return {
|
|
328431
329265
|
passed: false,
|
|
328432
329266
|
stepResults,
|
|
328433
329267
|
screenshots,
|
|
329268
|
+
...video ? { video, videoMimeType: "video/mp4" } : {},
|
|
328434
329269
|
apiCalls,
|
|
328435
329270
|
logs: logLines.join("\n"),
|
|
328436
329271
|
error: errorMsg,
|
|
@@ -328442,6 +329277,7 @@ async function executeMobileTest(options) {
|
|
|
328442
329277
|
};
|
|
328443
329278
|
} finally {
|
|
328444
329279
|
await stopCapture();
|
|
329280
|
+
await stopRecording();
|
|
328445
329281
|
if (sessionId) {
|
|
328446
329282
|
log2("Tearing down Appium session...");
|
|
328447
329283
|
await deleteSession(sessionId);
|
|
@@ -328482,7 +329318,7 @@ async function createMobileDiscoverySession(target, options) {
|
|
|
328482
329318
|
isPhysical: selectedDevice.isPhysical === true,
|
|
328483
329319
|
platformVersion: selectedDevice.platformVersion
|
|
328484
329320
|
});
|
|
328485
|
-
const sessionId = await
|
|
329321
|
+
const sessionId = await createSessionWithRetry({
|
|
328486
329322
|
appId: target.appId,
|
|
328487
329323
|
platform: target.platform,
|
|
328488
329324
|
deviceId: selectedDevice.id,
|
|
@@ -329293,7 +330129,7 @@ function stopSessionReaper() {
|
|
|
329293
330129
|
}
|
|
329294
330130
|
}
|
|
329295
330131
|
async function reapIdleSession() {
|
|
329296
|
-
if (!state.appiumSessionId || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
|
|
330132
|
+
if (!state.appiumSessionId || mirrorSessionOwner !== "recording" || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
|
|
329297
330133
|
return;
|
|
329298
330134
|
}
|
|
329299
330135
|
const sessionId = state.appiumSessionId;
|
|
@@ -330109,6 +330945,79 @@ function printDoctorReport(report) {
|
|
|
330109
330945
|
console.log("");
|
|
330110
330946
|
}
|
|
330111
330947
|
|
|
330948
|
+
// src/result-payload-budget.ts
|
|
330949
|
+
var SERVER_RESULT_BODY_LIMIT_BYTES = 50 * 1024 * 1024;
|
|
330950
|
+
var RESULT_BODY_BUDGET_BYTES = 48 * 1024 * 1024;
|
|
330951
|
+
var SERVER_VIDEO_STORE_CAP_BYTES = 24 * 1024 * 1024;
|
|
330952
|
+
function serializedBytes(value) {
|
|
330953
|
+
return Buffer.byteLength(JSON.stringify(value) ?? "", "utf8");
|
|
330954
|
+
}
|
|
330955
|
+
function budgetResultPayload(result, maxBytes = RESULT_BODY_BUDGET_BYTES, videoStoreCapBytes = SERVER_VIDEO_STORE_CAP_BYTES) {
|
|
330956
|
+
const oversizedVideo = typeof result.video === "string" && result.video.length > videoStoreCapBytes;
|
|
330957
|
+
let bytes = serializedBytes(result);
|
|
330958
|
+
if (bytes <= maxBytes && !oversizedVideo) {
|
|
330959
|
+
return { payload: result, shed: [], bytes };
|
|
330960
|
+
}
|
|
330961
|
+
const shed = [];
|
|
330962
|
+
let payload = result;
|
|
330963
|
+
const drop = (fields, label) => {
|
|
330964
|
+
const next = { ...payload };
|
|
330965
|
+
for (const field of fields) next[field] = void 0;
|
|
330966
|
+
payload = next;
|
|
330967
|
+
shed.push(label);
|
|
330968
|
+
bytes = serializedBytes(payload);
|
|
330969
|
+
};
|
|
330970
|
+
if ((bytes > maxBytes || oversizedVideo) && payload.video != null) {
|
|
330971
|
+
drop(["video", "videoMimeType"], "video");
|
|
330972
|
+
}
|
|
330973
|
+
if (bytes > maxBytes && Array.isArray(payload.screenshots) && payload.screenshots.length > 0) {
|
|
330974
|
+
const shots = [...payload.screenshots];
|
|
330975
|
+
const bySizeDesc = shots.map((shot, i) => ({ i, len: typeof shot === "string" ? shot.length : 0 })).sort((a, b) => b.len - a.len);
|
|
330976
|
+
const overshoot = bytes - maxBytes;
|
|
330977
|
+
let shaved = 0;
|
|
330978
|
+
let blanked = 0;
|
|
330979
|
+
for (const { i, len } of bySizeDesc) {
|
|
330980
|
+
if (shaved >= overshoot || len === 0) break;
|
|
330981
|
+
if (shots[i] === "") continue;
|
|
330982
|
+
shots[i] = "";
|
|
330983
|
+
shaved += len;
|
|
330984
|
+
blanked++;
|
|
330985
|
+
}
|
|
330986
|
+
if (blanked > 0) {
|
|
330987
|
+
payload = { ...payload, screenshots: shots };
|
|
330988
|
+
bytes = serializedBytes(payload);
|
|
330989
|
+
shed.push(blanked === shots.length ? "screenshots" : `${blanked}/${shots.length} screenshots`);
|
|
330990
|
+
}
|
|
330991
|
+
}
|
|
330992
|
+
if (bytes > maxBytes && payload.harApiCalls != null) drop(["harApiCalls"], "harApiCalls");
|
|
330993
|
+
if (bytes > maxBytes && payload.apiCalls != null) drop(["apiCalls"], "apiCalls");
|
|
330994
|
+
if (shed.length > 0) {
|
|
330995
|
+
const note = `
|
|
330996
|
+
|
|
330997
|
+
\u26A0 [runner] Result media trimmed before upload \u2014 payload exceeded the ${Math.round(maxBytes / (1024 * 1024))}MB server limit; dropped: ${shed.join(", ")}.`;
|
|
330998
|
+
payload = {
|
|
330999
|
+
...payload,
|
|
331000
|
+
logs: (typeof payload.logs === "string" ? payload.logs : "") + note
|
|
331001
|
+
};
|
|
331002
|
+
bytes = serializedBytes(payload);
|
|
331003
|
+
}
|
|
331004
|
+
if (bytes > maxBytes && typeof payload.logs === "string" && payload.logs.length > 4096) {
|
|
331005
|
+
const over = bytes - maxBytes;
|
|
331006
|
+
const targetLen = Math.max(2048, payload.logs.length - over - 1024);
|
|
331007
|
+
if (targetLen < payload.logs.length) {
|
|
331008
|
+
const half = Math.floor(targetLen / 2);
|
|
331009
|
+
const head = payload.logs.slice(0, half);
|
|
331010
|
+
const tail = payload.logs.slice(payload.logs.length - half);
|
|
331011
|
+
payload = { ...payload, logs: `${head}
|
|
331012
|
+
\u2026 [logs truncated to fit upload] \u2026
|
|
331013
|
+
${tail}` };
|
|
331014
|
+
if (!shed.includes("logs (truncated)")) shed.push("logs (truncated)");
|
|
331015
|
+
bytes = serializedBytes(payload);
|
|
331016
|
+
}
|
|
331017
|
+
}
|
|
331018
|
+
return { payload, shed, bytes };
|
|
331019
|
+
}
|
|
331020
|
+
|
|
330112
331021
|
// src/runner.ts
|
|
330113
331022
|
init_dist();
|
|
330114
331023
|
var import_runner_core12 = __toESM(require_dist2());
|
|
@@ -330277,15 +331186,33 @@ async function claimRun(serverUrl, headers, runId) {
|
|
|
330277
331186
|
async function postResult(serverUrl, headers, runId, result) {
|
|
330278
331187
|
const MAX_ATTEMPTS = 3;
|
|
330279
331188
|
let lastError;
|
|
331189
|
+
const budgeted = budgetResultPayload(result);
|
|
331190
|
+
if (budgeted.shed.length > 0) {
|
|
331191
|
+
console.warn(
|
|
331192
|
+
`[runner] Result payload for run ${runId} exceeded the upload budget (${Math.round(budgeted.bytes / (1024 * 1024))}MB after trim); dropped: ${budgeted.shed.join(", ")}.`
|
|
331193
|
+
);
|
|
331194
|
+
}
|
|
331195
|
+
let payload = budgeted.payload;
|
|
330280
331196
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
330281
331197
|
try {
|
|
330282
331198
|
const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/result`, {
|
|
330283
331199
|
method: "POST",
|
|
330284
331200
|
headers,
|
|
330285
|
-
body: JSON.stringify(
|
|
331201
|
+
body: JSON.stringify(payload),
|
|
330286
331202
|
signal: AbortSignal.timeout(6e4)
|
|
330287
331203
|
});
|
|
330288
331204
|
if (res.ok) return;
|
|
331205
|
+
if (res.status === 413) {
|
|
331206
|
+
const minimal = budgetResultPayload(payload, 0);
|
|
331207
|
+
if (minimal.shed.length > 0) {
|
|
331208
|
+
console.warn(
|
|
331209
|
+
`[runner] Server rejected result for run ${runId} as too large (413); retrying with a verdict-only payload (dropped: ${minimal.shed.join(", ")}).`
|
|
331210
|
+
);
|
|
331211
|
+
payload = minimal.payload;
|
|
331212
|
+
lastError = new Error("HTTP 413 (payload trimmed, retrying)");
|
|
331213
|
+
continue;
|
|
331214
|
+
}
|
|
331215
|
+
}
|
|
330289
331216
|
lastError = new Error(`HTTP ${res.status}`);
|
|
330290
331217
|
} catch (err) {
|
|
330291
331218
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
@@ -331810,14 +332737,20 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
331810
332737
|
});
|
|
331811
332738
|
requestLiveBrowserHeartbeat();
|
|
331812
332739
|
},
|
|
332740
|
+
// D-D6 — upload ONE baseline PNG per newly-observed screen through the
|
|
332741
|
+
// same streaming /page-screenshot relay the web path uses (mobile has no
|
|
332742
|
+
// route, so the screen-id is the key). The returned storage key rides the
|
|
332743
|
+
// explore checkpoint so the server links it to SitePage.screenshotKey.
|
|
332744
|
+
onScreenshotUpload: (screenId, base64) => onScreenshot(screenId, base64),
|
|
331813
332745
|
onExploreComplete,
|
|
331814
332746
|
onTestGenerated,
|
|
331815
332747
|
onPlanReady
|
|
331816
332748
|
});
|
|
331817
332749
|
} finally {
|
|
331818
|
-
|
|
332750
|
+
const activeCapture = capture;
|
|
332751
|
+
if (activeCapture) {
|
|
331819
332752
|
try {
|
|
331820
|
-
const apiCalls = await
|
|
332753
|
+
const apiCalls = await activeCapture.stop();
|
|
331821
332754
|
if (discoveryResult) {
|
|
331822
332755
|
discoveryResult.apiCalls = apiCalls.length ? attachMobileDiscoveryNetworkContext(apiCalls, discoveryResult, session?.appId ?? mobileTarget.appId) : void 0;
|
|
331823
332756
|
}
|
|
@@ -332152,6 +333085,8 @@ ${result2.logs}`);
|
|
|
332152
333085
|
status,
|
|
332153
333086
|
stepResults: result2.stepResults,
|
|
332154
333087
|
screenshots: result2.screenshots,
|
|
333088
|
+
video: result2.video,
|
|
333089
|
+
videoMimeType: result2.videoMimeType,
|
|
332155
333090
|
apiCalls: result2.apiCalls,
|
|
332156
333091
|
logs: result2.logs,
|
|
332157
333092
|
error: reportedError,
|
|
@@ -332463,7 +333398,7 @@ async function startRunner(opts) {
|
|
|
332463
333398
|
};
|
|
332464
333399
|
const sendHeartbeat = async () => {
|
|
332465
333400
|
try {
|
|
332466
|
-
const devices = enableMobile ?
|
|
333401
|
+
const devices = enableMobile ? getCachedDeviceSnapshot() : [];
|
|
332467
333402
|
const capabilities = enableMobile ? {
|
|
332468
333403
|
web: true,
|
|
332469
333404
|
ios: devices.some((d) => d.platform === "IOS" && d.isAvailable && d.state === "BOOTED"),
|