@validate.qa/runner 1.0.9 → 1.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1035 -154
- package/dist/cli.mjs +1035 -154
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -233,6 +233,9 @@ var require_ai_provider = __commonJS({
|
|
|
233
233
|
exports2.getNoThinkingRequestOptions = getNoThinkingRequestOptions;
|
|
234
234
|
exports2.getAIClient = getAIClient;
|
|
235
235
|
exports2.getClientForModel = getClientForModel;
|
|
236
|
+
exports2.shouldProxyAIThroughServerForLocalRunner = shouldProxyAIThroughServerForLocalRunner;
|
|
237
|
+
exports2.getRunnerAIClient = getRunnerAIClient;
|
|
238
|
+
exports2.getRunnerClientForModel = getRunnerClientForModel;
|
|
236
239
|
exports2.getModel = getModel;
|
|
237
240
|
exports2.resolveModelForTask = resolveModelForTask;
|
|
238
241
|
exports2.getAvailableModels = getAvailableModels;
|
|
@@ -263,14 +266,16 @@ var require_ai_provider = __commonJS({
|
|
|
263
266
|
{ id: "minimax-m2.7", label: "MiniMax M2.7", provider: "minimax", description: "Fast M2.7 tier (Highspeed); auto-fails over to standard M2.7, ~200K ctx", contextWindow: 2e5 },
|
|
264
267
|
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", provider: "anthropic", description: "Anthropic - fast & intelligent, 200k ctx", contextWindow: 2e5 },
|
|
265
268
|
{ id: "nvidia-nemotron-nano", label: "NVIDIA Nemotron Nano", provider: "nvidia", description: "NVIDIA NIM - Nemotron 30B Nano, reasoning model, 128k ctx", contextWindow: 128e3 },
|
|
266
|
-
{ id: "nvidia-kimi-k2.6", label: "NVIDIA Kimi K2.6", provider: "nvidia", description: "NVIDIA NIM - Moonshot Kimi K2.6, multimodal coding and agentic model, 256k ctx", contextWindow: 256e3 }
|
|
269
|
+
{ id: "nvidia-kimi-k2.6", label: "NVIDIA Kimi K2.6", provider: "nvidia", description: "NVIDIA NIM - Moonshot Kimi K2.6, multimodal coding and agentic model, 256k ctx", contextWindow: 256e3 },
|
|
270
|
+
{ id: "deepseek-v4-flash", label: "DeepSeek V4 Flash", provider: "deepseek", description: "DeepSeek - V4 Flash, OpenAI-compatible, 1M ctx", contextWindow: 1e6 }
|
|
267
271
|
];
|
|
268
272
|
var PROVIDER_DEFAULT_MODEL = {
|
|
269
273
|
openai: "gpt-4o",
|
|
270
274
|
xai: "grok-build-0.1",
|
|
271
275
|
anthropic: "claude-sonnet-4-6",
|
|
272
276
|
minimax: "minimax-m3",
|
|
273
|
-
nvidia: "nvidia-nemotron-nano"
|
|
277
|
+
nvidia: "nvidia-nemotron-nano",
|
|
278
|
+
deepseek: "deepseek-v4-flash"
|
|
274
279
|
};
|
|
275
280
|
function getContextConfig(modelId) {
|
|
276
281
|
const info = exports2.AI_MODEL_CATALOG.find((m) => m.id === modelId);
|
|
@@ -321,7 +326,8 @@ var require_ai_provider = __commonJS({
|
|
|
321
326
|
"minimax-m2.7-highspeed": { strong: "MiniMax-M2.7-highspeed", fast: "MiniMax-M2.7-highspeed", agent: "MiniMax-M2.7-highspeed" },
|
|
322
327
|
"claude-sonnet-4-6": { strong: "claude-sonnet-4-6", fast: "claude-sonnet-4-6", agent: "claude-sonnet-4-6" },
|
|
323
328
|
"nvidia-nemotron-nano": { strong: "nvidia/nemotron-3-nano-30b-a3b", fast: "nvidia/nemotron-3-nano-30b-a3b", agent: "nvidia/nemotron-3-nano-30b-a3b" },
|
|
324
|
-
"nvidia-kimi-k2.6": { strong: "moonshotai/kimi-k2.6", fast: "moonshotai/kimi-k2.6", agent: "moonshotai/kimi-k2.6" }
|
|
329
|
+
"nvidia-kimi-k2.6": { strong: "moonshotai/kimi-k2.6", fast: "moonshotai/kimi-k2.6", agent: "moonshotai/kimi-k2.6" },
|
|
330
|
+
"deepseek-v4-flash": { strong: "deepseek-v4-flash", fast: "deepseek-v4-flash", agent: "deepseek-v4-flash" }
|
|
325
331
|
};
|
|
326
332
|
var _requestModelStorage = new async_hooks_1.AsyncLocalStorage();
|
|
327
333
|
function withModelContext(modelId, fn) {
|
|
@@ -383,6 +389,9 @@ var require_ai_provider = __commonJS({
|
|
|
383
389
|
if (modelId === "nvidia/nemotron-3-nano-30b-a3b") {
|
|
384
390
|
return { chat_template_kwargs: { enable_thinking: false } };
|
|
385
391
|
}
|
|
392
|
+
if (modelId === "deepseek-v4-flash") {
|
|
393
|
+
return { thinking: { type: "disabled" } };
|
|
394
|
+
}
|
|
386
395
|
return {};
|
|
387
396
|
}
|
|
388
397
|
exports2.MINIMAX_FALLBACK_CHAIN = ["MiniMax-M3", "MiniMax-M2.7-highspeed", "MiniMax-M2.7"];
|
|
@@ -484,6 +493,11 @@ var require_ai_provider = __commonJS({
|
|
|
484
493
|
if (!apiKey)
|
|
485
494
|
throw new Error("NVIDIA_API_KEY is required when using an NVIDIA model");
|
|
486
495
|
client = new openai_1.default({ apiKey, baseURL: process.env.NVIDIA_API_BASE ?? "https://integrate.api.nvidia.com/v1" });
|
|
496
|
+
} else if (provider === "deepseek") {
|
|
497
|
+
const apiKey = process.env.DEEPSEEK_API_KEY;
|
|
498
|
+
if (!apiKey)
|
|
499
|
+
throw new Error("DEEPSEEK_API_KEY is required when using a DeepSeek model");
|
|
500
|
+
client = new openai_1.default({ apiKey, baseURL: process.env.DEEPSEEK_API_BASE ?? "https://api.deepseek.com" });
|
|
487
501
|
} else {
|
|
488
502
|
const apiKey = process.env.OPENAI_API_KEY;
|
|
489
503
|
if (!apiKey)
|
|
@@ -499,6 +513,21 @@ var require_ai_provider = __commonJS({
|
|
|
499
513
|
function getClientForModel(modelId) {
|
|
500
514
|
return buildClientForProvider(getProviderForModel(modelId));
|
|
501
515
|
}
|
|
516
|
+
function shouldProxyAIThroughServerForLocalRunner() {
|
|
517
|
+
const serverUrl = (process.env.SERVER_URL ?? "").trim();
|
|
518
|
+
const token = (process.env.AUTH_TOKEN || process.env.RUNNER_TOKEN || "").trim();
|
|
519
|
+
return serverUrl.length > 0 && token.startsWith("urt_");
|
|
520
|
+
}
|
|
521
|
+
function getRunnerAIClient() {
|
|
522
|
+
if (shouldProxyAIThroughServerForLocalRunner())
|
|
523
|
+
return getServerProxiedAIClient();
|
|
524
|
+
return getAIClient();
|
|
525
|
+
}
|
|
526
|
+
function getRunnerClientForModel(modelId) {
|
|
527
|
+
if (shouldProxyAIThroughServerForLocalRunner())
|
|
528
|
+
return getServerProxiedClientForModel(modelId);
|
|
529
|
+
return getClientForModel(modelId);
|
|
530
|
+
}
|
|
502
531
|
function getModel(type) {
|
|
503
532
|
return MODEL_TASK_MAP[getActiveModelId()][type];
|
|
504
533
|
}
|
|
@@ -517,6 +546,8 @@ var require_ai_provider = __commonJS({
|
|
|
517
546
|
return !!process.env.ANTHROPIC_API_KEY?.trim();
|
|
518
547
|
if (provider === "nvidia")
|
|
519
548
|
return !!process.env.NVIDIA_API_KEY?.trim();
|
|
549
|
+
if (provider === "deepseek")
|
|
550
|
+
return !!process.env.DEEPSEEK_API_KEY?.trim();
|
|
520
551
|
return false;
|
|
521
552
|
}
|
|
522
553
|
function getAvailableModels() {
|
|
@@ -567,7 +598,7 @@ var require_ai_provider = __commonJS({
|
|
|
567
598
|
const create = async (params, options) => {
|
|
568
599
|
const modelId = typeof params.model === "string" ? params.model : "";
|
|
569
600
|
if (!modelId) {
|
|
570
|
-
throw new Error("server-proxied AI client requires params.model
|
|
601
|
+
throw new Error("server-proxied AI client requires params.model");
|
|
571
602
|
}
|
|
572
603
|
const body = {
|
|
573
604
|
modelId,
|
|
@@ -737,6 +768,21 @@ var require_mobile_source_parser = __commonJS({
|
|
|
737
768
|
const attrText = inner.slice(tag.length);
|
|
738
769
|
return { tag, attrs: parseAttributes(attrText), selfClosing };
|
|
739
770
|
}
|
|
771
|
+
function findTagEnd(xml, from) {
|
|
772
|
+
let quote = null;
|
|
773
|
+
for (let i = from; i < xml.length; i++) {
|
|
774
|
+
const ch = xml[i];
|
|
775
|
+
if (quote !== null) {
|
|
776
|
+
if (ch === quote)
|
|
777
|
+
quote = null;
|
|
778
|
+
} else if (ch === '"' || ch === "'") {
|
|
779
|
+
quote = ch;
|
|
780
|
+
} else if (ch === ">") {
|
|
781
|
+
return i;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return -1;
|
|
785
|
+
}
|
|
740
786
|
function tokenize(xml) {
|
|
741
787
|
if (!xml || typeof xml !== "string")
|
|
742
788
|
return null;
|
|
@@ -759,7 +805,7 @@ var require_mobile_source_parser = __commonJS({
|
|
|
759
805
|
i = end === -1 ? len : end + 3;
|
|
760
806
|
continue;
|
|
761
807
|
}
|
|
762
|
-
const gt = xml
|
|
808
|
+
const gt = findTagEnd(xml, lt + 1);
|
|
763
809
|
if (gt === -1)
|
|
764
810
|
break;
|
|
765
811
|
const tagBody = xml.slice(lt + 1, gt);
|
|
@@ -1544,6 +1590,25 @@ function buildAppiumCode(testName, target, steps) {
|
|
|
1544
1590
|
` await driver.keys(text);`,
|
|
1545
1591
|
` }`,
|
|
1546
1592
|
`}`,
|
|
1593
|
+
``,
|
|
1594
|
+
`async function __clearFocused(driver: WebdriverIO.Browser) {`,
|
|
1595
|
+
` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).clearValue();`,
|
|
1596
|
+
`}`,
|
|
1597
|
+
``,
|
|
1598
|
+
`async function __textCandidates(el: WebdriverIO.Element) {`,
|
|
1599
|
+
` const __values: string[] = [];`,
|
|
1600
|
+
` const __push = (__value: unknown) => {`,
|
|
1601
|
+
` if (typeof __value !== 'string') return;`,
|
|
1602
|
+
` const __trimmed = __value.trim();`,
|
|
1603
|
+
` if (__trimmed.length === 0 || __values.includes(__trimmed)) return;`,
|
|
1604
|
+
` __values.push(__trimmed);`,
|
|
1605
|
+
` };`,
|
|
1606
|
+
` __push(await el.getText().catch(() => ''));`,
|
|
1607
|
+
` for (const __attr of ['text', 'content-desc', 'contentDescription', 'name', 'label', 'value']) {`,
|
|
1608
|
+
` __push(await el.getAttribute(__attr).catch(() => ''));`,
|
|
1609
|
+
` }`,
|
|
1610
|
+
` return __values;`,
|
|
1611
|
+
`}`,
|
|
1547
1612
|
...hasSwipe ? [
|
|
1548
1613
|
``,
|
|
1549
1614
|
`// Read the device window with a few retries (matches the runtime executor).`,
|
|
@@ -1623,6 +1688,12 @@ function buildAppiumCode(testName, target, steps) {
|
|
|
1623
1688
|
lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
|
|
1624
1689
|
} else if (step.action === "clear" && hasSelector) {
|
|
1625
1690
|
lines.push(` await (await findFirst(driver, ${candidatesLiteral})).clearValue();`);
|
|
1691
|
+
} else if (step.action === "clear" && bounds) {
|
|
1692
|
+
const x = Math.round(bounds.x + bounds.width / 2);
|
|
1693
|
+
const y = Math.round(bounds.y + bounds.height / 2);
|
|
1694
|
+
lines.push(` await __tapAt(driver, ${x}, ${y});`);
|
|
1695
|
+
lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
|
|
1696
|
+
lines.push(` await __clearFocused(driver);`);
|
|
1626
1697
|
} else if (step.action === "assertVisible" && hasSelector) {
|
|
1627
1698
|
lines.push(` if (!(await (await findFirst(driver, ${candidatesLiteral})).isDisplayed())) {`);
|
|
1628
1699
|
lines.push(` throw new Error('Assertion failed: element not displayed: ' + ${toJsStringLiteral(candidatesLiteral)});`);
|
|
@@ -1630,8 +1701,9 @@ function buildAppiumCode(testName, target, steps) {
|
|
|
1630
1701
|
} else if (step.action === "assertText" && hasSelector) {
|
|
1631
1702
|
lines.push(` {`);
|
|
1632
1703
|
lines.push(` const __expected = ${toJsStringLiteral(step.assertion ?? "")};`);
|
|
1633
|
-
lines.push(`
|
|
1634
|
-
lines.push(`
|
|
1704
|
+
lines.push(` if (__expected.length === 0) throw new Error('assertText step is missing expected text');`);
|
|
1705
|
+
lines.push(` const __actual = await __textCandidates(await findFirst(driver, ${candidatesLiteral}));`);
|
|
1706
|
+
lines.push(` if (!__actual.some((__value) => __value.includes(__expected))) {`);
|
|
1635
1707
|
lines.push(` throw new Error('Assertion failed: expected text to contain ' + JSON.stringify(__expected) + ' but got ' + JSON.stringify(__actual));`);
|
|
1636
1708
|
lines.push(` }`);
|
|
1637
1709
|
lines.push(` }`);
|
|
@@ -4338,6 +4410,7 @@ var require_mobile_observation = __commonJS({
|
|
|
4338
4410
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
4339
4411
|
exports2.renderMobileObservation = renderMobileObservation;
|
|
4340
4412
|
exports2.summarizeScreenForEvidence = summarizeScreenForEvidence;
|
|
4413
|
+
exports2.buildCollectedElementsForScreen = buildCollectedElementsForScreen;
|
|
4341
4414
|
var DEFAULT_MAX_ELEMENTS = 60;
|
|
4342
4415
|
var MAX_LABEL_LENGTH = 80;
|
|
4343
4416
|
var EVIDENCE_LABEL_LIMIT = 40;
|
|
@@ -4379,6 +4452,46 @@ var require_mobile_observation = __commonJS({
|
|
|
4379
4452
|
}
|
|
4380
4453
|
return { elementCount, actionableCount, labels };
|
|
4381
4454
|
}
|
|
4455
|
+
function buildCollectedElementsForScreen(elements, opts = {}) {
|
|
4456
|
+
const navigationRefs = opts.navigationRefs ?? EMPTY_REF_SET;
|
|
4457
|
+
const out = [];
|
|
4458
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4459
|
+
for (const element of elements) {
|
|
4460
|
+
if (!element.actionable)
|
|
4461
|
+
continue;
|
|
4462
|
+
const collected = toCollectedPageElement(element, navigationRefs.has(element.ref));
|
|
4463
|
+
const identity = `${collected.elementType}|${collected.testId ?? ""}|${collected.label ?? ""}`;
|
|
4464
|
+
if (seen.has(identity))
|
|
4465
|
+
continue;
|
|
4466
|
+
seen.add(identity);
|
|
4467
|
+
out.push(collected);
|
|
4468
|
+
}
|
|
4469
|
+
return out;
|
|
4470
|
+
}
|
|
4471
|
+
var EMPTY_REF_SET = /* @__PURE__ */ new Set();
|
|
4472
|
+
function toCollectedPageElement(element, triggersNavigation) {
|
|
4473
|
+
const label = element.label?.trim() || element.text?.trim() || void 0;
|
|
4474
|
+
const stableId = element.accessibilityId?.trim() || element.resourceId?.trim() || void 0;
|
|
4475
|
+
const locators = [];
|
|
4476
|
+
if (stableId)
|
|
4477
|
+
locators.push({ strategy: "getByTestId", value: stableId, confidence: 0.95 });
|
|
4478
|
+
const xpath = element.xpath?.trim();
|
|
4479
|
+
if (xpath)
|
|
4480
|
+
locators.push({ strategy: "css", value: xpath, confidence: 0.4 });
|
|
4481
|
+
const collected = {
|
|
4482
|
+
elementType: element.role || "View",
|
|
4483
|
+
triggersNavigation
|
|
4484
|
+
};
|
|
4485
|
+
if (label)
|
|
4486
|
+
collected.label = label;
|
|
4487
|
+
if (stableId)
|
|
4488
|
+
collected.testId = stableId;
|
|
4489
|
+
if (locators.length > 0)
|
|
4490
|
+
collected.locators = locators;
|
|
4491
|
+
if (element.enabled === false)
|
|
4492
|
+
collected.isDisabled = true;
|
|
4493
|
+
return collected;
|
|
4494
|
+
}
|
|
4382
4495
|
function buildHeaderLine(screen) {
|
|
4383
4496
|
const parts = ["screen:"];
|
|
4384
4497
|
const name = screenDisplayName(screen.screenSignal);
|
|
@@ -4827,7 +4940,7 @@ var require_mobile_explorer = __commonJS({
|
|
|
4827
4940
|
}
|
|
4828
4941
|
return sections.join("\n");
|
|
4829
4942
|
}
|
|
4830
|
-
function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId) {
|
|
4943
|
+
function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId, existingScreens) {
|
|
4831
4944
|
const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
|
|
4832
4945
|
const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
|
|
4833
4946
|
const surveyBody = `You are a mobile QA exploration agent driving a real ${platformLabel} app on a connected device. Your goal: MAP every reachable screen so downstream agents know what to test. You are NOT testing \u2014 you are building a complete screen map.
|
|
@@ -4873,13 +4986,18 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
|
|
|
4873
4986
|
## Application Brief
|
|
4874
4987
|
${appBrief}
|
|
4875
4988
|
Use this to prioritize which screens and flows to cover.` : "";
|
|
4876
|
-
|
|
4989
|
+
const existingScreensBlock = mode === "survey" && existingScreens && existingScreens.length > 0 ? `
|
|
4990
|
+
|
|
4991
|
+
## ALREADY DISCOVERED SCREENS (skip these; find new)
|
|
4992
|
+
These screens were mapped in a previous survey. Do NOT re-map them \u2014 use your budget to find NEW screens and flows not listed here. If you land on one of these, move on to something new.
|
|
4993
|
+
${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.name}"` : ""}${s.screenType ? ` [${s.screenType}]` : ""}`).join("\n")}` : "";
|
|
4994
|
+
return `${mode === "survey" ? surveyBody : deepBody}${briefBlock}${existingScreensBlock}`;
|
|
4877
4995
|
}
|
|
4878
4996
|
function buildKickoffMessage(mode) {
|
|
4879
4997
|
return mode === "survey" ? "Start by calling mobile_snapshot on the launch screen, then capture_screen. Map the entire app: open every tab, menu, and one row per list. Record transitions as you go." : "Start by calling mobile_snapshot on the launch screen. Then use the features: fill fields, submit forms, open menus, and observe outcomes. Record journeys after meaningful flows.";
|
|
4880
4998
|
}
|
|
4881
4999
|
async function runMobileExplorePhase(args) {
|
|
4882
|
-
const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot } = args;
|
|
5000
|
+
const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot, onScreenshotUpload, existingScreens } = args;
|
|
4883
5001
|
const logs2 = [];
|
|
4884
5002
|
const log2 = (line) => {
|
|
4885
5003
|
logs2.push(line);
|
|
@@ -4903,6 +5021,9 @@ Use this to prioritize which screens and flows to cover.` : "";
|
|
|
4903
5021
|
log2(`Budget: ${maxIterations} iterations | app: ${ctx.target?.appId ?? "(unknown)"}`);
|
|
4904
5022
|
const screenshots = [];
|
|
4905
5023
|
const pageScreenshots = {};
|
|
5024
|
+
const pageScreenshotKeys = {};
|
|
5025
|
+
const uploadedScreenIds = /* @__PURE__ */ new Set();
|
|
5026
|
+
const pendingScreenshotUploads = [];
|
|
4906
5027
|
const collectedTransitions = [];
|
|
4907
5028
|
const journeys = [];
|
|
4908
5029
|
const counters = {
|
|
@@ -4921,6 +5042,14 @@ Use this to prioritize which screens and flows to cover.` : "";
|
|
|
4921
5042
|
screenshots.push(base64);
|
|
4922
5043
|
if (screenId)
|
|
4923
5044
|
pageScreenshots[screenId] = base64;
|
|
5045
|
+
if (screenId && onScreenshotUpload && !uploadedScreenIds.has(screenId)) {
|
|
5046
|
+
uploadedScreenIds.add(screenId);
|
|
5047
|
+
pendingScreenshotUploads.push(onScreenshotUpload(screenId, base64).then((key) => {
|
|
5048
|
+
if (key)
|
|
5049
|
+
pageScreenshotKeys[screenId] = key;
|
|
5050
|
+
}).catch(() => {
|
|
5051
|
+
}));
|
|
5052
|
+
}
|
|
4924
5053
|
try {
|
|
4925
5054
|
onScreenshot?.(base64);
|
|
4926
5055
|
} catch {
|
|
@@ -5025,7 +5154,7 @@ Use this to prioritize which screens and flows to cover.` : "";
|
|
|
5025
5154
|
return null;
|
|
5026
5155
|
return { element, bounds: element.bounds };
|
|
5027
5156
|
};
|
|
5028
|
-
const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId);
|
|
5157
|
+
const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId, existingScreens);
|
|
5029
5158
|
const kickoff = buildKickoffMessage(mode);
|
|
5030
5159
|
const recentExchanges = [];
|
|
5031
5160
|
const transientDirectives = [];
|
|
@@ -5233,6 +5362,9 @@ Exploration complete: ${summary}`);
|
|
|
5233
5362
|
summary = `Explore phase aborted (${truncatedReason}) after mapping ${state2.getScreenCount()} screen(s).`;
|
|
5234
5363
|
}
|
|
5235
5364
|
}
|
|
5365
|
+
if (pendingScreenshotUploads.length > 0) {
|
|
5366
|
+
await Promise.allSettled(pendingScreenshotUploads);
|
|
5367
|
+
}
|
|
5236
5368
|
const collectedPages = state2.buildInMemoryScreenMap();
|
|
5237
5369
|
counters.pagesVisited = collectedPages.length;
|
|
5238
5370
|
if (counters.pagesDiscovered < collectedPages.length) {
|
|
@@ -5267,6 +5399,9 @@ Exploration complete: ${summary}`);
|
|
|
5267
5399
|
if (Object.keys(pageScreenshots).length > 0) {
|
|
5268
5400
|
result.pageScreenshots = pageScreenshots;
|
|
5269
5401
|
}
|
|
5402
|
+
if (Object.keys(pageScreenshotKeys).length > 0) {
|
|
5403
|
+
result.pageScreenshotKeys = pageScreenshotKeys;
|
|
5404
|
+
}
|
|
5270
5405
|
if (truncated) {
|
|
5271
5406
|
result.truncated = true;
|
|
5272
5407
|
result.truncatedReason = truncatedReason;
|
|
@@ -5768,6 +5903,44 @@ var require_snapshot_parser = __commonJS({
|
|
|
5768
5903
|
};
|
|
5769
5904
|
var SUBMIT_LABEL_RE = /^(submit|sign\s*in|sign\s*up|log\s*in|register|create|save|send|confirm|continue|next|apply|post|publish|add|update|go)$/i;
|
|
5770
5905
|
var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
|
|
5906
|
+
var RICH_CONTEXT_ROLES = /* @__PURE__ */ new Set(["table", "grid", "row", "radiogroup"]);
|
|
5907
|
+
var RICH_CONTEXT_LABEL_MAX = 60;
|
|
5908
|
+
var VALUE_BEARING_ROLES = /* @__PURE__ */ new Set(["textbox", "searchbox", "combobox", "spinbutton", "listbox", "slider"]);
|
|
5909
|
+
var SENSITIVE_VALUE_LABEL_RE = /\bpass(?:word|code|phrase)?\b|\bpassport\b|\bpwd\b|\bsecrets?\b|\btokens?\b|\botp\b|one[- ]?time|\bpin\b|\bcvv\b|\bcvc\b|card\s*number|\bssn\b|social\s*security|security\s*(?:answer|question)|api[-_ ]?key|private\s*key|\bauth\b|authoriz|authenticat|credential/i;
|
|
5910
|
+
var REDACTED_VALUE = "[redacted]";
|
|
5911
|
+
var VALUE_TEXT_MAX = 120;
|
|
5912
|
+
var SIGNAL_TEXT_RE = /\b(?:showing|displaying|page)\b[\s\S]*\b(?:of|results?|items?|entries)\b|\b(?:no|nothing)\b[\s\S]*\b(?:found|yet|available|results?|items?)\b|\bempty\b|\b(?:error|invalid|required|failed|expired?|success(?:fully)?|saved|updated|deleted|created|welcome)\b/i;
|
|
5913
|
+
var STATIC_MESSAGES_MAX = 12;
|
|
5914
|
+
var OPTIONS_PER_ELEMENT_MAX = 24;
|
|
5915
|
+
var URL_CHILD_LINE_RE = /^(\s*)- \/url:\s*(.+)$/;
|
|
5916
|
+
var MESSAGE_TOKEN_RE = /\b(?:sk|pk|rk|ak|key|tok)[-_][A-Za-z0-9_-]{8,}\b|\b[A-Za-z0-9_-]{24,}\b/g;
|
|
5917
|
+
function stripYamlQuotes(raw) {
|
|
5918
|
+
const trimmed = raw.trim();
|
|
5919
|
+
if (trimmed.length >= 2 && (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
5920
|
+
return trimmed.slice(1, -1);
|
|
5921
|
+
}
|
|
5922
|
+
return trimmed;
|
|
5923
|
+
}
|
|
5924
|
+
function extractTrailingText(attrs) {
|
|
5925
|
+
const lastBracket = attrs.lastIndexOf("]");
|
|
5926
|
+
const tail = lastBracket >= 0 ? attrs.slice(lastBracket + 1) : attrs;
|
|
5927
|
+
const match = /^\s*:\s+(.+)$/.exec(tail);
|
|
5928
|
+
if (!match)
|
|
5929
|
+
return null;
|
|
5930
|
+
const text = stripYamlQuotes(match[1]);
|
|
5931
|
+
if (!text || /^(?:\||\|-|>|>-)$/.test(text))
|
|
5932
|
+
return null;
|
|
5933
|
+
return text;
|
|
5934
|
+
}
|
|
5935
|
+
function isSensitiveValueField(name, placeholder) {
|
|
5936
|
+
return SENSITIVE_VALUE_LABEL_RE.test(`${name ?? ""} ${placeholder ?? ""}`);
|
|
5937
|
+
}
|
|
5938
|
+
function scrubMessageText(text) {
|
|
5939
|
+
return text.replace(MESSAGE_TOKEN_RE, REDACTED_VALUE);
|
|
5940
|
+
}
|
|
5941
|
+
function capText(text, max) {
|
|
5942
|
+
return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
|
|
5943
|
+
}
|
|
5771
5944
|
function isProbablyDynamicTestId(testId) {
|
|
5772
5945
|
if (testId.length < 6)
|
|
5773
5946
|
return false;
|
|
@@ -5843,9 +6016,12 @@ var require_snapshot_parser = __commonJS({
|
|
|
5843
6016
|
}
|
|
5844
6017
|
return e.name;
|
|
5845
6018
|
}
|
|
5846
|
-
function parseSnapshot(snapshotText) {
|
|
6019
|
+
function parseSnapshot(snapshotText, options = {}) {
|
|
6020
|
+
const richParse = options.richParse ?? process.env.DISCOVERY_SNAPSHOT_RICH_PARSE === "true";
|
|
5847
6021
|
const lines = snapshotText.split("\n");
|
|
5848
6022
|
const elements = [];
|
|
6023
|
+
const liveMessages = [];
|
|
6024
|
+
const textMessages = [];
|
|
5849
6025
|
const seenRefs = /* @__PURE__ */ new Set();
|
|
5850
6026
|
let parsedElementLineCount = 0;
|
|
5851
6027
|
let genericLabelCount = 0;
|
|
@@ -5854,6 +6030,21 @@ var require_snapshot_parser = __commonJS({
|
|
|
5854
6030
|
const contextStack = [];
|
|
5855
6031
|
const completedForms = [];
|
|
5856
6032
|
for (const line of lines) {
|
|
6033
|
+
if (richParse) {
|
|
6034
|
+
const urlMatch = URL_CHILD_LINE_RE.exec(line);
|
|
6035
|
+
if (urlMatch) {
|
|
6036
|
+
const urlIndent = urlMatch[1].length;
|
|
6037
|
+
for (let i = elements.length - 1; i >= 0; i--) {
|
|
6038
|
+
const el = elements[i];
|
|
6039
|
+
if (el.indentDepth < urlIndent) {
|
|
6040
|
+
if (el.role === "link" && !el.href)
|
|
6041
|
+
el.href = capText(stripYamlQuotes(urlMatch[2]), 300);
|
|
6042
|
+
break;
|
|
6043
|
+
}
|
|
6044
|
+
}
|
|
6045
|
+
continue;
|
|
6046
|
+
}
|
|
6047
|
+
}
|
|
5857
6048
|
const match = ELEMENT_LINE_RE.exec(line);
|
|
5858
6049
|
if (!match)
|
|
5859
6050
|
continue;
|
|
@@ -5875,7 +6066,7 @@ var require_snapshot_parser = __commonJS({
|
|
|
5875
6066
|
}
|
|
5876
6067
|
const headingLevelMatch = LEVEL_RE.exec(attrs);
|
|
5877
6068
|
const headingLevel = headingLevelMatch ? Number.parseInt(headingLevelMatch[1], 10) : null;
|
|
5878
|
-
const contextLabel = buildContextLabel(role, name ?? null, headingLevel);
|
|
6069
|
+
const contextLabel = buildContextLabel(role, name ?? null, headingLevel, richParse);
|
|
5879
6070
|
if (contextLabel) {
|
|
5880
6071
|
contextStack.push({ indent: indentDepth, label: contextLabel });
|
|
5881
6072
|
}
|
|
@@ -5888,6 +6079,40 @@ var require_snapshot_parser = __commonJS({
|
|
|
5888
6079
|
});
|
|
5889
6080
|
continue;
|
|
5890
6081
|
}
|
|
6082
|
+
if (richParse) {
|
|
6083
|
+
if (role === "option" && name) {
|
|
6084
|
+
for (let i = elements.length - 1; i >= 0; i--) {
|
|
6085
|
+
const el = elements[i];
|
|
6086
|
+
if (el.indentDepth < indentDepth) {
|
|
6087
|
+
if (el.role === "combobox" || el.role === "listbox") {
|
|
6088
|
+
el.options ??= [];
|
|
6089
|
+
if (el.options.length < OPTIONS_PER_ELEMENT_MAX) {
|
|
6090
|
+
el.options.push({ label: capText(name, 80), selected: parseBooleanState(SELECTED_RE.exec(attrs)) === true });
|
|
6091
|
+
}
|
|
6092
|
+
}
|
|
6093
|
+
break;
|
|
6094
|
+
}
|
|
6095
|
+
}
|
|
6096
|
+
}
|
|
6097
|
+
if (role === "alert" || role === "status" || role === "paragraph" || role === "text") {
|
|
6098
|
+
const text = extractTrailingText(attrs) ?? (name || null);
|
|
6099
|
+
const isLiveRegion = role === "alert" || role === "status";
|
|
6100
|
+
if (text && (isLiveRegion || SIGNAL_TEXT_RE.test(text))) {
|
|
6101
|
+
const capped = capText(scrubMessageText(text), VALUE_TEXT_MAX);
|
|
6102
|
+
const ref2 = REF_RE.exec(attrs)?.[1] ?? null;
|
|
6103
|
+
if (isLiveRegion) {
|
|
6104
|
+
const textDupIndex = textMessages.findIndex((m) => m.text === capped);
|
|
6105
|
+
if (textDupIndex >= 0)
|
|
6106
|
+
textMessages.splice(textDupIndex, 1);
|
|
6107
|
+
if (liveMessages.length < STATIC_MESSAGES_MAX && !liveMessages.some((m) => m.text === capped)) {
|
|
6108
|
+
liveMessages.push({ role, text: capped, ref: ref2 });
|
|
6109
|
+
}
|
|
6110
|
+
} else if (textMessages.length < STATIC_MESSAGES_MAX && !textMessages.some((m) => m.text === capped) && !liveMessages.some((m) => m.text === capped)) {
|
|
6111
|
+
textMessages.push({ role: "text", text: capped, ref: ref2 });
|
|
6112
|
+
}
|
|
6113
|
+
}
|
|
6114
|
+
}
|
|
6115
|
+
}
|
|
5891
6116
|
if (!INTERACTIVE_ROLES.has(role))
|
|
5892
6117
|
continue;
|
|
5893
6118
|
const refMatch = REF_RE.exec(attrs);
|
|
@@ -5920,6 +6145,13 @@ var require_snapshot_parser = __commonJS({
|
|
|
5920
6145
|
}
|
|
5921
6146
|
const isLink = role === "link";
|
|
5922
6147
|
const isSubmitButton = role === "button" && name != null && SUBMIT_LABEL_RE.test(name);
|
|
6148
|
+
let value = null;
|
|
6149
|
+
if (richParse && VALUE_BEARING_ROLES.has(role)) {
|
|
6150
|
+
const trailing = extractTrailingText(attrs);
|
|
6151
|
+
if (trailing) {
|
|
6152
|
+
value = isSensitiveValueField(name ?? null, placeholder) ? REDACTED_VALUE : capText(trailing, VALUE_TEXT_MAX);
|
|
6153
|
+
}
|
|
6154
|
+
}
|
|
5923
6155
|
const element = {
|
|
5924
6156
|
ref,
|
|
5925
6157
|
role,
|
|
@@ -5941,7 +6173,10 @@ var require_snapshot_parser = __commonJS({
|
|
|
5941
6173
|
pressed,
|
|
5942
6174
|
headingLevel,
|
|
5943
6175
|
currentState,
|
|
5944
|
-
containerPath
|
|
6176
|
+
containerPath,
|
|
6177
|
+
href: null,
|
|
6178
|
+
value,
|
|
6179
|
+
options: null
|
|
5945
6180
|
};
|
|
5946
6181
|
if (isGenericLabel(element.name))
|
|
5947
6182
|
genericLabelCount++;
|
|
@@ -5969,6 +6204,9 @@ var require_snapshot_parser = __commonJS({
|
|
|
5969
6204
|
return {
|
|
5970
6205
|
elements,
|
|
5971
6206
|
formGroups: completedForms,
|
|
6207
|
+
// live regions first: they carry their own reservation so an end-of-body toast can
|
|
6208
|
+
// never be starved out by signal-matching plain text earlier in the document.
|
|
6209
|
+
staticMessages: [...liveMessages, ...textMessages].slice(0, STATIC_MESSAGES_MAX),
|
|
5972
6210
|
rawLineCount: lines.length,
|
|
5973
6211
|
parsedElementLineCount,
|
|
5974
6212
|
genericLabelCount,
|
|
@@ -6079,12 +6317,17 @@ var require_snapshot_parser = __commonJS({
|
|
|
6079
6317
|
return "mixed";
|
|
6080
6318
|
return false;
|
|
6081
6319
|
}
|
|
6082
|
-
function buildContextLabel(role, name, headingLevel) {
|
|
6320
|
+
function buildContextLabel(role, name, headingLevel, richContext = false) {
|
|
6083
6321
|
if (role === "heading") {
|
|
6084
6322
|
if (!name)
|
|
6085
6323
|
return null;
|
|
6086
6324
|
return headingLevel ? `heading${headingLevel}:${name}` : `heading:${name}`;
|
|
6087
6325
|
}
|
|
6326
|
+
if (richContext && RICH_CONTEXT_ROLES.has(role)) {
|
|
6327
|
+
if (!name)
|
|
6328
|
+
return null;
|
|
6329
|
+
return `${role}:${capText(name.replace(/>/g, "\u203A"), RICH_CONTEXT_LABEL_MAX)}`;
|
|
6330
|
+
}
|
|
6088
6331
|
if (!CONTEXT_ROLES.has(role))
|
|
6089
6332
|
return null;
|
|
6090
6333
|
if (name)
|
|
@@ -7794,7 +8037,7 @@ Each entry is exactly one of:
|
|
|
7794
8037
|
const systemPrompt = options.systemPromptOverride ?? (context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports2.SCENARIO_SYSTEM_PROMPT);
|
|
7795
8038
|
const userPrompt = options.userPromptOverride ?? formatEvidenceForPlanner(compressed, evidence, context);
|
|
7796
8039
|
logs2.push(`AI planner: ~${Math.ceil(userPrompt.length / 3.5)} input tokens`);
|
|
7797
|
-
const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.
|
|
8040
|
+
const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
|
|
7798
8041
|
const messages = [
|
|
7799
8042
|
{ role: "system", content: systemPrompt },
|
|
7800
8043
|
{ role: "user", content: userPrompt }
|
|
@@ -7802,18 +8045,22 @@ Each entry is exactly one of:
|
|
|
7802
8045
|
const callStart = Date.now();
|
|
7803
8046
|
let response;
|
|
7804
8047
|
try {
|
|
7805
|
-
response = await (0, ai_retry_js_1.
|
|
7806
|
-
|
|
7807
|
-
|
|
7808
|
-
|
|
7809
|
-
|
|
7810
|
-
|
|
7811
|
-
|
|
7812
|
-
|
|
8048
|
+
response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
|
|
8049
|
+
arm();
|
|
8050
|
+
return aiClient.chat.completions.create({
|
|
8051
|
+
model: agentModel,
|
|
8052
|
+
messages,
|
|
8053
|
+
max_tokens: plannerMaxTokens,
|
|
8054
|
+
temperature: 0.3,
|
|
8055
|
+
...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
|
|
8056
|
+
...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
|
|
8057
|
+
}, { signal });
|
|
8058
|
+
}), {
|
|
7813
8059
|
label: "ai-planner",
|
|
7814
8060
|
baseDelayMs: 5e3,
|
|
7815
8061
|
maxRetries: 2,
|
|
7816
|
-
|
|
8062
|
+
deferTimeoutUntilArmed: true,
|
|
8063
|
+
onRetry: (attempt, reason, delay) => logs2.push(`AI planner call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
|
|
7817
8064
|
});
|
|
7818
8065
|
} catch (err) {
|
|
7819
8066
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -7879,18 +8126,25 @@ Each entry is exactly one of:
|
|
|
7879
8126
|
const retryStart = Date.now();
|
|
7880
8127
|
let retryResponse = null;
|
|
7881
8128
|
try {
|
|
7882
|
-
retryResponse = await (0, ai_retry_js_1.
|
|
7883
|
-
|
|
7884
|
-
|
|
7885
|
-
|
|
7886
|
-
|
|
7887
|
-
|
|
7888
|
-
|
|
7889
|
-
|
|
7890
|
-
|
|
7891
|
-
|
|
7892
|
-
|
|
7893
|
-
|
|
8129
|
+
retryResponse = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
|
|
8130
|
+
arm();
|
|
8131
|
+
return aiClient.chat.completions.create({
|
|
8132
|
+
model: agentModel,
|
|
8133
|
+
messages: [
|
|
8134
|
+
...messages,
|
|
8135
|
+
{ role: "assistant", content: raw },
|
|
8136
|
+
{ role: "user", content: retryContext }
|
|
8137
|
+
],
|
|
8138
|
+
max_tokens: plannerMaxTokens,
|
|
8139
|
+
temperature: 0.3,
|
|
8140
|
+
...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
|
|
8141
|
+
...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
|
|
8142
|
+
}, { signal });
|
|
8143
|
+
}), {
|
|
8144
|
+
label: "ai-planner-retry",
|
|
8145
|
+
deferTimeoutUntilArmed: true,
|
|
8146
|
+
onRetry: (attempt, reason, delay) => logs2.push(`AI planner retry call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
|
|
8147
|
+
});
|
|
7894
8148
|
} catch (err) {
|
|
7895
8149
|
const message = err instanceof Error ? err.message : String(err);
|
|
7896
8150
|
logs2.push(`AI planner retry failed (${message}) \u2014 using first-pass result`);
|
|
@@ -8418,12 +8672,12 @@ ${constraints.join("\n")}`);
|
|
|
8418
8672
|
const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
|
|
8419
8673
|
const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
|
|
8420
8674
|
for (const state2 of orderedScreens) {
|
|
8421
|
-
const groupName = state2.screenType === "auth" ? "Authentication" : screenDisplayName(state2);
|
|
8675
|
+
const groupName = state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(state2);
|
|
8422
8676
|
const group = groups.get(groupName) ?? {
|
|
8423
8677
|
name: groupName,
|
|
8424
8678
|
description: `Native mobile screens related to ${groupName}.`,
|
|
8425
8679
|
routes: [],
|
|
8426
|
-
criticality: state2.screenType === "auth" ? "critical" : "medium",
|
|
8680
|
+
criticality: state2.screenType?.toLowerCase() === "auth" ? "critical" : "medium",
|
|
8427
8681
|
pages: [],
|
|
8428
8682
|
flows: []
|
|
8429
8683
|
};
|
|
@@ -8446,7 +8700,7 @@ ${constraints.join("\n")}`);
|
|
|
8446
8700
|
const to = graph.screens.get(transition.toScreenId);
|
|
8447
8701
|
if (!from || !to)
|
|
8448
8702
|
continue;
|
|
8449
|
-
const groupName = from.screenType === "auth" ? "Authentication" : screenDisplayName(from);
|
|
8703
|
+
const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(from);
|
|
8450
8704
|
const group = groups.get(groupName);
|
|
8451
8705
|
if (!group)
|
|
8452
8706
|
continue;
|
|
@@ -8491,9 +8745,9 @@ ${constraints.join("\n")}`);
|
|
|
8491
8745
|
scenarios.push({
|
|
8492
8746
|
name,
|
|
8493
8747
|
type: "HAPPY_PATH",
|
|
8494
|
-
priority: state2.screenType === "auth" ? 1 : 3,
|
|
8748
|
+
priority: state2.screenType?.toLowerCase() === "auth" ? 1 : 3,
|
|
8495
8749
|
variant: "happy",
|
|
8496
|
-
featureGroup: state2.screenType === "auth" ? "Authentication" : screenName,
|
|
8750
|
+
featureGroup: state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenName,
|
|
8497
8751
|
goal: `User can reach and inspect the ${screenName} screen`,
|
|
8498
8752
|
steps,
|
|
8499
8753
|
expectedOutcomes: [{
|
|
@@ -8639,6 +8893,7 @@ var require_mobile_generator = __commonJS({
|
|
|
8639
8893
|
var mobile_observation_js_1 = require_mobile_observation();
|
|
8640
8894
|
var mobile_boundary_js_1 = require_mobile_boundary();
|
|
8641
8895
|
var MAX_SCENARIO_ITERATIONS = 30;
|
|
8896
|
+
var MAX_CONSECUTIVE_INFRA_FAILURES = 3;
|
|
8642
8897
|
var MAX_RECENT_EXCHANGES = 24;
|
|
8643
8898
|
var OBSERVATION_MAX_ELEMENTS = 50;
|
|
8644
8899
|
function finalizeAction(input) {
|
|
@@ -9363,14 +9618,31 @@ ${statePacket}` },
|
|
|
9363
9618
|
if (!snapshot || snapshot.screen.elements.length === 0)
|
|
9364
9619
|
return null;
|
|
9365
9620
|
const elements = snapshot.screen.elements;
|
|
9366
|
-
const
|
|
9367
|
-
const
|
|
9621
|
+
const stableAnchors = elements.filter((el) => hasStableId(el) && !isAlwaysPresentContainer(el));
|
|
9622
|
+
const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
|
|
9623
|
+
if (contentfulNonButton)
|
|
9624
|
+
return contentfulNonButton;
|
|
9625
|
+
const nonButton = stableAnchors.find((el) => !/button/i.test(el.role));
|
|
9368
9626
|
if (nonButton)
|
|
9369
9627
|
return nonButton;
|
|
9370
|
-
if (
|
|
9371
|
-
return
|
|
9372
|
-
const labelled = elements.find((el) => nonEmpty2(el.label) || nonEmpty2(el.text));
|
|
9373
|
-
return labelled ?? elements[0];
|
|
9628
|
+
if (stableAnchors.length > 0)
|
|
9629
|
+
return stableAnchors[0];
|
|
9630
|
+
const labelled = elements.find((el) => !isAlwaysPresentContainer(el) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
|
|
9631
|
+
return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
|
|
9632
|
+
}
|
|
9633
|
+
var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
|
|
9634
|
+
var ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS = /* @__PURE__ */ new Set([
|
|
9635
|
+
"android:id/content",
|
|
9636
|
+
"android:id/decor",
|
|
9637
|
+
"android:id/action_bar_root",
|
|
9638
|
+
"android:id/statusBarBackground",
|
|
9639
|
+
"android:id/navigationBarBackground"
|
|
9640
|
+
]);
|
|
9641
|
+
function isAlwaysPresentContainer(element) {
|
|
9642
|
+
if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
|
|
9643
|
+
return true;
|
|
9644
|
+
const resourceId = element.resourceId?.trim();
|
|
9645
|
+
return !!resourceId && ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS.has(resourceId);
|
|
9374
9646
|
}
|
|
9375
9647
|
function appendHybridFloor(actions, recording, platform3) {
|
|
9376
9648
|
const anchor = pickLandingAnchor(recording.lastSnapshot);
|
|
@@ -9429,7 +9701,7 @@ ${statePacket}` },
|
|
|
9429
9701
|
return test;
|
|
9430
9702
|
}
|
|
9431
9703
|
async function runMobileGeneratePhase(args) {
|
|
9432
|
-
const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot } = args;
|
|
9704
|
+
const { scenarios: allScenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot, skipScenarioNames, onInfraStop } = args;
|
|
9433
9705
|
const log2 = (line) => {
|
|
9434
9706
|
try {
|
|
9435
9707
|
onLog?.(line);
|
|
@@ -9444,8 +9716,16 @@ ${statePacket}` },
|
|
|
9444
9716
|
const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
|
|
9445
9717
|
const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
|
|
9446
9718
|
const targetAppId = ctx.target?.appId?.trim() || void 0;
|
|
9719
|
+
const skipSet = new Set(skipScenarioNames ?? []);
|
|
9720
|
+
const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
|
|
9721
|
+
if (skipSet.size > 0) {
|
|
9722
|
+
log2(`Resume: skipping ${allScenarios.length - scenarios.length} already-generated scenario(s); ${scenarios.length} remaining to generate`);
|
|
9723
|
+
}
|
|
9447
9724
|
log2(`Mobile generator starting \u2014 platform: ${platform3}, provider: ${provider}, model: ${agentModel}, scenarios: ${scenarios.length}`);
|
|
9448
9725
|
const tests = [];
|
|
9726
|
+
const generatedScenarioNames = /* @__PURE__ */ new Set();
|
|
9727
|
+
let consecutiveInfraFailures = 0;
|
|
9728
|
+
let infraStopped = false;
|
|
9449
9729
|
for (let i = 0; i < scenarios.length; i++) {
|
|
9450
9730
|
const scenario = scenarios[i];
|
|
9451
9731
|
log2(`
|
|
@@ -9466,6 +9746,7 @@ ${statePacket}` },
|
|
|
9466
9746
|
targetAppId,
|
|
9467
9747
|
log: log2
|
|
9468
9748
|
});
|
|
9749
|
+
consecutiveInfraFailures = 0;
|
|
9469
9750
|
const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
|
|
9470
9751
|
if (!test) {
|
|
9471
9752
|
log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
|
|
@@ -9482,13 +9763,31 @@ ${statePacket}` },
|
|
|
9482
9763
|
log2(` onTestGenerated callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
|
|
9483
9764
|
}
|
|
9484
9765
|
tests.push(test);
|
|
9766
|
+
generatedScenarioNames.add(scenario.name);
|
|
9485
9767
|
} catch (err) {
|
|
9768
|
+
if ((0, ai_retry_js_1.isAIProviderError)(err)) {
|
|
9769
|
+
consecutiveInfraFailures += 1;
|
|
9770
|
+
log2(` scenario "${scenario.name}" failed \u2014 AI provider error (${consecutiveInfraFailures}/${MAX_CONSECUTIVE_INFRA_FAILURES}): ${err instanceof Error ? err.message : String(err)}`);
|
|
9771
|
+
if (consecutiveInfraFailures >= MAX_CONSECUTIVE_INFRA_FAILURES) {
|
|
9772
|
+
const remainingScenarioNames = scenarios.filter((s) => !generatedScenarioNames.has(s.name)).map((s) => s.name);
|
|
9773
|
+
infraStopped = true;
|
|
9774
|
+
log2(`
|
|
9775
|
+
\u26D4 Stopping mobile generation: ${consecutiveInfraFailures} consecutive AI provider failures \u2014 the provider appears overloaded/unavailable. ${tests.length}/${scenarios.length} tests generated and saved; the remaining ${remainingScenarioNames.length} scenario(s) will resume later.`);
|
|
9776
|
+
try {
|
|
9777
|
+
onInfraStop?.({ remainingScenarioNames, reason: "ai_rate_limit" });
|
|
9778
|
+
} catch (cbErr) {
|
|
9779
|
+
log2(` onInfraStop callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
|
|
9780
|
+
}
|
|
9781
|
+
break;
|
|
9782
|
+
}
|
|
9783
|
+
continue;
|
|
9784
|
+
}
|
|
9486
9785
|
log2(` scenario "${scenario.name}" failed: ${err instanceof Error ? err.message : String(err)} \u2014 skipping.`);
|
|
9487
9786
|
continue;
|
|
9488
9787
|
}
|
|
9489
9788
|
}
|
|
9490
9789
|
log2(`
|
|
9491
|
-
Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) generated (${tests.filter((t) => t.verified).length} verified).`);
|
|
9790
|
+
Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) generated (${tests.filter((t) => t.verified).length} verified)${infraStopped ? " \u2014 PAUSED on AI provider overload" : ""}.`);
|
|
9492
9791
|
return tests;
|
|
9493
9792
|
}
|
|
9494
9793
|
}
|
|
@@ -12767,6 +13066,10 @@ var require_snapshot_observation = __commonJS({
|
|
|
12767
13066
|
var snapshot_parser_js_1 = require_snapshot_parser();
|
|
12768
13067
|
var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
|
|
12769
13068
|
var NOISE_LABEL_RE = /^(skip\s+to|skip\s+nav|back\s+to\s+top|cookie|accept\s+(all\s+)?cookies)$/i;
|
|
13069
|
+
var DIALOG_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog\b/i;
|
|
13070
|
+
var FORM_SEGMENT_RE = /(?:^|>\s*)form\b/i;
|
|
13071
|
+
var TABPANEL_SEGMENT_RE = /(?:^|>\s*)tabpanel\b/i;
|
|
13072
|
+
var DIALOG_NAME_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog:([^>]+)/i;
|
|
12770
13073
|
var INTERACTIVE_ROLE_RE = /^(button|link|textbox|searchbox|checkbox|radio|switch|combobox|listbox|option|menuitem|tab|slider|spinbutton|textarea)$/i;
|
|
12771
13074
|
var INTERACTIVE_ELEMENT_TYPE_RE = /^(button|link|input|textarea|select|checkbox|radio|switch|tab|combobox|listbox|option|menuitem|slider|spinbutton)$/i;
|
|
12772
13075
|
function renderRetrievedSnapshotMemoryBlock(priorSnapshotEvidence, priorDiscoveryCheckpoints, options = {}) {
|
|
@@ -13011,10 +13314,12 @@ var require_snapshot_observation = __commonJS({
|
|
|
13011
13314
|
summary
|
|
13012
13315
|
}));
|
|
13013
13316
|
}
|
|
13317
|
+
var TEXT_DISAMBIGUATING_INPUT_TYPES = /* @__PURE__ */ new Set(["password", "text", "email", "tel", "number", "search", "url", "date", "datetime-local", "time", "month", "week"]);
|
|
13014
13318
|
function renderCompactSnapshotBlock(page, context = {}, options = {}) {
|
|
13015
13319
|
const metrics = page.snapshotMetrics ?? page.snapshotArtifact?.metrics;
|
|
13016
13320
|
const sourceKind = page.snapshotMeta?.sourceKind ?? page.snapshotArtifact?.sourceKind ?? "missing";
|
|
13017
13321
|
const artifact = page.snapshotArtifact;
|
|
13322
|
+
const richRender = options.richRender ?? process.env.DISCOVERY_SNAPSHOT_RICH_RENDER === "true";
|
|
13018
13323
|
const activeScope = inferActiveSnapshotScope(page);
|
|
13019
13324
|
const ranked = rankSnapshotElements(getScopedSnapshotElements(page, activeScope), withScopeContext(context, activeScope));
|
|
13020
13325
|
const allInteractive = ranked.filter(({ element }) => isInteractiveCandidate(element)).filter(({ element }) => {
|
|
@@ -13060,7 +13365,7 @@ var require_snapshot_observation = __commonJS({
|
|
|
13060
13365
|
if (activeScope.rootRef) {
|
|
13061
13366
|
lines.push(` root_ref: ${yamlScalar(activeScope.rootRef)}`);
|
|
13062
13367
|
}
|
|
13063
|
-
const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText);
|
|
13368
|
+
const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText, richRender);
|
|
13064
13369
|
if (scopePreview.length > 0) {
|
|
13065
13370
|
lines.push(" scope_snapshot_preview: |");
|
|
13066
13371
|
for (const previewLine of scopePreview) {
|
|
@@ -13102,6 +13407,19 @@ var require_snapshot_observation = __commonJS({
|
|
|
13102
13407
|
lines.push(` - ${yamlScalar(modal)}`);
|
|
13103
13408
|
}
|
|
13104
13409
|
}
|
|
13410
|
+
const staticMessages = page.staticMessages ?? [];
|
|
13411
|
+
if (richRender && staticMessages.length > 0) {
|
|
13412
|
+
const messageLimit = 6;
|
|
13413
|
+
const ordered = [...staticMessages].sort((a, b) => messageRolePriority(a.role) - messageRolePriority(b.role));
|
|
13414
|
+
lines.push("messages:");
|
|
13415
|
+
lines.push(" # Non-interactive page text worth asserting on (validation / toast / empty-state / counts). Text is the capture instant \u2014 toasts may have dismissed since.");
|
|
13416
|
+
for (const message of ordered.slice(0, messageLimit)) {
|
|
13417
|
+
lines.push(` - [${message.role}] ${yamlScalar(message.text)}`);
|
|
13418
|
+
}
|
|
13419
|
+
if (ordered.length > messageLimit) {
|
|
13420
|
+
lines.push(` # +${ordered.length - messageLimit} more message(s) not listed`);
|
|
13421
|
+
}
|
|
13422
|
+
}
|
|
13105
13423
|
lines.push("session_state:");
|
|
13106
13424
|
if (page.sessionState) {
|
|
13107
13425
|
lines.push(` authenticated: ${yamlScalar(page.sessionState.authenticated)}`);
|
|
@@ -13164,7 +13482,10 @@ var require_snapshot_observation = __commonJS({
|
|
|
13164
13482
|
}
|
|
13165
13483
|
const hasRowContext = group.rowContexts.some((rc) => !!rc);
|
|
13166
13484
|
if (hasRowContext) {
|
|
13167
|
-
|
|
13485
|
+
const firstRowRole = group.rowRoles[0] ?? null;
|
|
13486
|
+
const rowRole = firstRowRole && group.rowRoles.every((r) => r === firstRowRole) ? firstRowRole : null;
|
|
13487
|
+
const scopeHint = rowRole ? `getByRole(${yamlScalar(rowRole)}).filter({ has: getByText("<row>", { exact: true }) }).getByRole(${yamlScalar(group.role)}, { name: ${yamlScalar(group.name)} })` : `locate the container by its text (getByText("<row>", { exact: true })), then getByRole(${yamlScalar(group.role)}, { name: ${yamlScalar(group.name)} }) within it`;
|
|
13488
|
+
lines.push(` distinguish_by_row: # same name across rows \u2014 scope by the ROW/card container (not the control); nested same-role containers need an extra cell-level scope: ${scopeHint}`);
|
|
13168
13489
|
for (let i = 0; i < group.rowContexts.length; i++) {
|
|
13169
13490
|
const rowContext = group.rowContexts[i];
|
|
13170
13491
|
if (!rowContext)
|
|
@@ -13194,8 +13515,13 @@ var require_snapshot_observation = __commonJS({
|
|
|
13194
13515
|
lines.push(` placeholder: ${yamlScalar(rankedElement.element.placeholder)}`);
|
|
13195
13516
|
}
|
|
13196
13517
|
const stateTokens = buildStateSummary(rankedElement.element);
|
|
13518
|
+
const addedDisabled = richRender && !!rankedElement.element.isDisabled && !stateTokens.includes("disabled");
|
|
13519
|
+
if (addedDisabled) {
|
|
13520
|
+
stateTokens.unshift("disabled");
|
|
13521
|
+
}
|
|
13197
13522
|
if (stateTokens.length > 0) {
|
|
13198
|
-
|
|
13523
|
+
const note = addedDisabled ? " # disabled = captured instant; may enable after valid input \u2014 still exercise fill\u2192submit" : "";
|
|
13524
|
+
lines.push(` state: [${stateTokens.map((token) => yamlScalar(token)).join(", ")}]${note}`);
|
|
13199
13525
|
}
|
|
13200
13526
|
if (rankedElement.element.containerPath) {
|
|
13201
13527
|
lines.push(` container_path: ${yamlScalar(rankedElement.element.containerPath)}`);
|
|
@@ -13205,6 +13531,22 @@ var require_snapshot_observation = __commonJS({
|
|
|
13205
13531
|
lines.push(` locator: ${yamlScalar((0, snapshot_parser_js_1.formatLocator)(topLocator))}`);
|
|
13206
13532
|
}
|
|
13207
13533
|
const enr = rankedElement.element.enrichment;
|
|
13534
|
+
if (richRender && enr?.inputType && TEXT_DISAMBIGUATING_INPUT_TYPES.has(enr.inputType)) {
|
|
13535
|
+
lines.push(` type: ${yamlScalar(enr.inputType)}`);
|
|
13536
|
+
}
|
|
13537
|
+
if (richRender && rankedElement.element.href) {
|
|
13538
|
+
lines.push(` url: ${yamlScalar(rankedElement.element.href)}`);
|
|
13539
|
+
}
|
|
13540
|
+
if (richRender && rankedElement.element.value && enr?.inputType !== "password") {
|
|
13541
|
+
lines.push(` value: ${yamlScalar(rankedElement.element.value)}`);
|
|
13542
|
+
}
|
|
13543
|
+
const elementOptions = rankedElement.element.options ?? [];
|
|
13544
|
+
if (richRender && elementOptions.length > 0) {
|
|
13545
|
+
const optionLimit = 12;
|
|
13546
|
+
const rendered = elementOptions.slice(0, optionLimit).map((option) => yamlScalar(option.selected ? `${option.label} (selected)` : option.label));
|
|
13547
|
+
const overflow = elementOptions.length > optionLimit ? ` # +${elementOptions.length - optionLimit} more option(s)` : "";
|
|
13548
|
+
lines.push(` options: [${rendered.join(", ")}]${overflow}`);
|
|
13549
|
+
}
|
|
13208
13550
|
if (enr?.rowContext) {
|
|
13209
13551
|
lines.push(` row_context: ${yamlScalar(enr.rowContext)}`);
|
|
13210
13552
|
}
|
|
@@ -13217,6 +13559,12 @@ var require_snapshot_observation = __commonJS({
|
|
|
13217
13559
|
lines.push(` visibility: [${vis.map((token) => yamlScalar(token)).join(", ")}]`);
|
|
13218
13560
|
}
|
|
13219
13561
|
}
|
|
13562
|
+
if (richRender) {
|
|
13563
|
+
const hidden = allInteractive.length - interactive.length;
|
|
13564
|
+
if (hidden > 0) {
|
|
13565
|
+
lines.push(` # +${hidden} more interactive element(s) not individually listed (capped) \u2014 some may appear in the duplicates block above; if your target isn't here, narrow the scope or act on it by ref.`);
|
|
13566
|
+
}
|
|
13567
|
+
}
|
|
13220
13568
|
}
|
|
13221
13569
|
const recoveredClickables = options.recoveredClickables ?? [];
|
|
13222
13570
|
if (recoveredClickables.length > 0) {
|
|
@@ -13373,11 +13721,11 @@ var require_snapshot_observation = __commonJS({
|
|
|
13373
13721
|
score += 10;
|
|
13374
13722
|
if (element.isExpanded)
|
|
13375
13723
|
score += 8;
|
|
13376
|
-
if (element.containerPath
|
|
13724
|
+
if (element.containerPath && DIALOG_SEGMENT_RE.test(element.containerPath))
|
|
13377
13725
|
score += 12;
|
|
13378
|
-
if (element.containerPath
|
|
13726
|
+
if (element.containerPath && FORM_SEGMENT_RE.test(element.containerPath))
|
|
13379
13727
|
score += 10;
|
|
13380
|
-
if (element.containerPath
|
|
13728
|
+
if (element.containerPath && TABPANEL_SEGMENT_RE.test(element.containerPath))
|
|
13381
13729
|
score += 8;
|
|
13382
13730
|
if (element.inputType === "email" || element.inputType === "password" || element.inputType === "search")
|
|
13383
13731
|
score += 6;
|
|
@@ -13448,10 +13796,10 @@ var require_snapshot_observation = __commonJS({
|
|
|
13448
13796
|
}
|
|
13449
13797
|
return merged;
|
|
13450
13798
|
}
|
|
13451
|
-
function buildScopeSnapshotPreview(scopeSnapshotText) {
|
|
13799
|
+
function buildScopeSnapshotPreview(scopeSnapshotText, stripTrailingValues = false) {
|
|
13452
13800
|
if (!scopeSnapshotText)
|
|
13453
13801
|
return [];
|
|
13454
|
-
return scopeSnapshotText.split("\n").map((line) => line.trim()).filter(Boolean).slice(0, 6);
|
|
13802
|
+
return scopeSnapshotText.split("\n").map((line) => line.trim()).map((line) => stripTrailingValues ? line.replace(/\]\s*:\s.+$/, "]") : line).filter(Boolean).slice(0, 6);
|
|
13455
13803
|
}
|
|
13456
13804
|
function dedupeLines(lines) {
|
|
13457
13805
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -13578,11 +13926,12 @@ var require_snapshot_observation = __commonJS({
|
|
|
13578
13926
|
const key = `${role}\0${name}`;
|
|
13579
13927
|
let group = groups.get(key);
|
|
13580
13928
|
if (!group) {
|
|
13581
|
-
group = { role, name, containers: [], rowContexts: [], refs: [] };
|
|
13929
|
+
group = { role, name, containers: [], rowContexts: [], rowRoles: [], refs: [] };
|
|
13582
13930
|
groups.set(key, group);
|
|
13583
13931
|
}
|
|
13584
13932
|
group.containers.push(element.containerPath ?? null);
|
|
13585
13933
|
group.rowContexts.push(element.enrichment?.rowContext ?? null);
|
|
13934
|
+
group.rowRoles.push(element.enrichment?.rowRole ?? null);
|
|
13586
13935
|
group.refs.push(element.ref ?? null);
|
|
13587
13936
|
}
|
|
13588
13937
|
return [...groups.values()].filter((group) => group.containers.length > 1);
|
|
@@ -13592,7 +13941,7 @@ var require_snapshot_observation = __commonJS({
|
|
|
13592
13941
|
for (const element of elements) {
|
|
13593
13942
|
const role = element.role?.toLowerCase() ?? "";
|
|
13594
13943
|
const elementType = element.elementType?.toLowerCase() ?? "";
|
|
13595
|
-
const isDialog = role === "dialog" || elementType === "dialog" || (element.containerPath
|
|
13944
|
+
const isDialog = role === "dialog" || elementType === "dialog" || DIALOG_NAME_SEGMENT_RE.test(element.containerPath ?? "");
|
|
13596
13945
|
if (!isDialog)
|
|
13597
13946
|
continue;
|
|
13598
13947
|
const hint = qualifyElementLabel(element) ?? dialogNameFromContainerPath(element.containerPath) ?? element.label ?? element.testId ?? "dialog";
|
|
@@ -13605,7 +13954,7 @@ var require_snapshot_observation = __commonJS({
|
|
|
13605
13954
|
function dialogNameFromContainerPath(containerPath) {
|
|
13606
13955
|
if (!containerPath)
|
|
13607
13956
|
return null;
|
|
13608
|
-
const match = containerPath.match(
|
|
13957
|
+
const match = containerPath.match(DIALOG_NAME_SEGMENT_RE);
|
|
13609
13958
|
return match ? match[1].trim() : null;
|
|
13610
13959
|
}
|
|
13611
13960
|
function renderObservationCue(observation) {
|
|
@@ -13614,6 +13963,13 @@ var require_snapshot_observation = __commonJS({
|
|
|
13614
13963
|
const base2 = observation.outcomeType === "SUCCESS" && outcome ? outcome : `${action} -> ${outcome || observation.outcomeType}`;
|
|
13615
13964
|
return base2.length > 120 ? `${base2.slice(0, 117)}...` : base2;
|
|
13616
13965
|
}
|
|
13966
|
+
function messageRolePriority(role) {
|
|
13967
|
+
if (role === "alert")
|
|
13968
|
+
return 0;
|
|
13969
|
+
if (role === "status")
|
|
13970
|
+
return 1;
|
|
13971
|
+
return 2;
|
|
13972
|
+
}
|
|
13617
13973
|
function yamlScalar(value) {
|
|
13618
13974
|
if (value == null || value === "")
|
|
13619
13975
|
return "null";
|
|
@@ -20162,7 +20518,10 @@ ${testOpen}`;
|
|
|
20162
20518
|
pressed: se.pressed,
|
|
20163
20519
|
headingLevel: se.headingLevel,
|
|
20164
20520
|
currentState: se.currentState,
|
|
20165
|
-
containerPath: se.containerPath || void 0
|
|
20521
|
+
containerPath: se.containerPath || void 0,
|
|
20522
|
+
href: se.href || void 0,
|
|
20523
|
+
value: se.value || void 0,
|
|
20524
|
+
options: se.options && se.options.length > 0 ? se.options : void 0
|
|
20166
20525
|
}));
|
|
20167
20526
|
const structuredPage = {
|
|
20168
20527
|
route: pageUrl || "/",
|
|
@@ -20170,6 +20529,7 @@ ${testOpen}`;
|
|
|
20170
20529
|
pageType: "unknown",
|
|
20171
20530
|
elements: collectedElements,
|
|
20172
20531
|
formGroups: parsed.formGroups,
|
|
20532
|
+
staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
|
|
20173
20533
|
snapshotMetrics: {
|
|
20174
20534
|
rawLineCount: parsed.rawLineCount,
|
|
20175
20535
|
parsedElementLineCount: parsed.parsedElementLineCount,
|
|
@@ -21736,7 +22096,7 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
|
|
|
21736
22096
|
let stopped;
|
|
21737
22097
|
const agentModel = options?.modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(options.modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
|
|
21738
22098
|
const auditGroupId = `generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
|
|
21739
|
-
const aiClient = options?.modelOverride ? (0, ai_provider_js_1.
|
|
22099
|
+
const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
|
|
21740
22100
|
const credentials = variableContext.allTestVariables ?? variableContext.publicTestVariables;
|
|
21741
22101
|
logs2.push(`Phase 3 \u2014 Generate: ${(plan.scenarios ?? []).length} scenarios, max ${maxTests} tests`);
|
|
21742
22102
|
logs2.push(`Model: ${agentModel}`);
|
|
@@ -235701,7 +236061,7 @@ var require_mcp_healer = __commonJS({
|
|
|
235701
236061
|
messages.push(system, firstUser, { role: "user", content: lines.join("\n") }, ...tail);
|
|
235702
236062
|
}
|
|
235703
236063
|
var DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS = 6;
|
|
235704
|
-
function
|
|
236064
|
+
function readPositiveIntEnv2(env, name, fallback) {
|
|
235705
236065
|
const raw = env[name]?.trim();
|
|
235706
236066
|
if (!raw || !/^\d+$/.test(raw))
|
|
235707
236067
|
return fallback;
|
|
@@ -235709,7 +236069,7 @@ var require_mcp_healer = __commonJS({
|
|
|
235709
236069
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
235710
236070
|
}
|
|
235711
236071
|
function getScriptHealMaxAttempts(env = process.env) {
|
|
235712
|
-
return
|
|
236072
|
+
return readPositiveIntEnv2(env, "MCP_HEAL_SCRIPT_MAX_ATTEMPTS", DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS);
|
|
235713
236073
|
}
|
|
235714
236074
|
exports2.SCRIPT_HEAL_SYSTEM_PROMPT = `You are a QA engineer fixing a failing Playwright UI test. You will receive the test code, the error message, step-level results, and a screenshot of the failure state.
|
|
235715
236075
|
|
|
@@ -236045,7 +236405,7 @@ ${lines.join("\n")}
|
|
|
236045
236405
|
const landingViolation = (tags ?? []).includes("@landing-violation");
|
|
236046
236406
|
const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "strong") : (0, ai_provider_js_1.getModel)("strong");
|
|
236047
236407
|
const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
|
|
236048
|
-
const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.
|
|
236408
|
+
const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
|
|
236049
236409
|
const runNativeTest = options.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
|
|
236050
236410
|
const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
|
|
236051
236411
|
const groupId = `script-heal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -236867,10 +237227,10 @@ ${(verifyResult.logs ?? "").slice(0, 2e3)}`;
|
|
|
236867
237227
|
var DEFAULT_MAX_PHASE2_ITERATIONS = 4;
|
|
236868
237228
|
var DEFAULT_MAX_MCP_EXPLORE_ITERATIONS = 25;
|
|
236869
237229
|
function getMcpHealPhase2MaxIterations(env = process.env) {
|
|
236870
|
-
return
|
|
237230
|
+
return readPositiveIntEnv2(env, "MCP_HEAL_PHASE2_MAX_ITERATIONS", DEFAULT_MAX_PHASE2_ITERATIONS);
|
|
236871
237231
|
}
|
|
236872
237232
|
function getMcpHealExploreMaxIterations(env = process.env) {
|
|
236873
|
-
return
|
|
237233
|
+
return readPositiveIntEnv2(env, "MCP_HEAL_EXPLORE_MAX_ITERATIONS", DEFAULT_MAX_MCP_EXPLORE_ITERATIONS);
|
|
236874
237234
|
}
|
|
236875
237235
|
var DEFAULT_MCP_HEAL_MAX_WALL_CLOCK_MS = 10 * 60 * 1e3;
|
|
236876
237236
|
var DEFAULT_MCP_HEAL_MAX_TOTAL_AI_CALLS = 60;
|
|
@@ -236922,7 +237282,7 @@ Only explore the failing page and the area around the broken step.`;
|
|
|
236922
237282
|
let lastVideo;
|
|
236923
237283
|
const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
|
|
236924
237284
|
const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
|
|
236925
|
-
const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.
|
|
237285
|
+
const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
|
|
236926
237286
|
const runPhase2NativeTest = options?.phase2Harness?.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
|
|
236927
237287
|
const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
|
|
236928
237288
|
const groupId = `phase2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -237612,7 +237972,10 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
|
|
|
237612
237972
|
pressed: se.pressed,
|
|
237613
237973
|
headingLevel: se.headingLevel,
|
|
237614
237974
|
currentState: se.currentState,
|
|
237615
|
-
containerPath: se.containerPath || void 0
|
|
237975
|
+
containerPath: se.containerPath || void 0,
|
|
237976
|
+
href: se.href || void 0,
|
|
237977
|
+
value: se.value || void 0,
|
|
237978
|
+
options: se.options && se.options.length > 0 ? se.options : void 0
|
|
237616
237979
|
}));
|
|
237617
237980
|
const structuredPage = {
|
|
237618
237981
|
route: pageUrl || "/",
|
|
@@ -237620,6 +237983,7 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
|
|
|
237620
237983
|
pageType: "unknown",
|
|
237621
237984
|
elements: collectedElements,
|
|
237622
237985
|
formGroups: parsed.formGroups,
|
|
237986
|
+
staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
|
|
237623
237987
|
snapshotMetrics: {
|
|
237624
237988
|
rawLineCount: parsed.rawLineCount,
|
|
237625
237989
|
parsedElementLineCount: parsed.parsedElementLineCount,
|
|
@@ -238056,7 +238420,7 @@ ${testBlocks}
|
|
|
238056
238420
|
- By iteration ${Math.floor(maxIterations * 0.3)}: you should have completed 1-2 tests
|
|
238057
238421
|
- By iteration ${Math.floor(maxIterations * 0.7)}: you should be past the halfway point
|
|
238058
238422
|
- At iteration ${Math.floor(maxIterations * 0.9)}: wrap up and call finish_capture`;
|
|
238059
|
-
const aiClient = modelOverride ? (0, ai_provider_js_1.
|
|
238423
|
+
const aiClient = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
|
|
238060
238424
|
const messages = [
|
|
238061
238425
|
{ role: "system", content: systemPrompt },
|
|
238062
238426
|
{ role: "user", content: "Start executing the tests. Navigate to the base URL and begin with Test 1." }
|
|
@@ -238239,7 +238603,10 @@ ${testBlocks}
|
|
|
238239
238603
|
pressed: se.pressed,
|
|
238240
238604
|
headingLevel: se.headingLevel,
|
|
238241
238605
|
currentState: se.currentState,
|
|
238242
|
-
containerPath: se.containerPath || void 0
|
|
238606
|
+
containerPath: se.containerPath || void 0,
|
|
238607
|
+
href: se.href || void 0,
|
|
238608
|
+
value: se.value || void 0,
|
|
238609
|
+
options: se.options && se.options.length > 0 ? se.options : void 0
|
|
238243
238610
|
}));
|
|
238244
238611
|
const structuredPage = {
|
|
238245
238612
|
route: pageUrl || "/",
|
|
@@ -238247,6 +238614,7 @@ ${testBlocks}
|
|
|
238247
238614
|
pageType: "unknown",
|
|
238248
238615
|
elements: collectedElements,
|
|
238249
238616
|
formGroups: parsed.formGroups,
|
|
238617
|
+
staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
|
|
238250
238618
|
snapshotMetrics: {
|
|
238251
238619
|
rawLineCount: parsed.rawLineCount,
|
|
238252
238620
|
parsedElementLineCount: parsed.parsedElementLineCount,
|
|
@@ -300036,8 +300404,9 @@ var require_capture_page_normalizer = __commonJS({
|
|
|
300036
300404
|
let snapshotElements = [];
|
|
300037
300405
|
let formGroups = [];
|
|
300038
300406
|
let snapshotMetrics;
|
|
300407
|
+
let staticMessages;
|
|
300039
300408
|
if (normalizedSnapshotResult.snapshotText) {
|
|
300040
|
-
const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText);
|
|
300409
|
+
const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText, { richParse: context.richParse });
|
|
300041
300410
|
snapshotMetrics = {
|
|
300042
300411
|
rawLineCount: parsed.rawLineCount,
|
|
300043
300412
|
parsedElementLineCount: parsed.parsedElementLineCount,
|
|
@@ -300066,9 +300435,13 @@ var require_capture_page_normalizer = __commonJS({
|
|
|
300066
300435
|
pressed: se.pressed,
|
|
300067
300436
|
headingLevel: se.headingLevel,
|
|
300068
300437
|
currentState: se.currentState,
|
|
300069
|
-
containerPath: se.containerPath || void 0
|
|
300438
|
+
containerPath: se.containerPath || void 0,
|
|
300439
|
+
href: se.href || void 0,
|
|
300440
|
+
value: se.value || void 0,
|
|
300441
|
+
options: se.options && se.options.length > 0 ? se.options : void 0
|
|
300070
300442
|
}));
|
|
300071
300443
|
formGroups = parsed.formGroups;
|
|
300444
|
+
staticMessages = parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0;
|
|
300072
300445
|
}
|
|
300073
300446
|
const aiElements = Array.isArray(toolArgs.elements) ? toolArgs.elements.map(normalizeAiElement) : [];
|
|
300074
300447
|
const noYamlKinds = /* @__PURE__ */ new Set(["missing", "timeout", "error", "empty"]);
|
|
@@ -300164,6 +300537,7 @@ var require_capture_page_normalizer = __commonJS({
|
|
|
300164
300537
|
} : void 0,
|
|
300165
300538
|
readinessSignals,
|
|
300166
300539
|
viewportHints,
|
|
300540
|
+
staticMessages,
|
|
300167
300541
|
provenCommands: provenCmds,
|
|
300168
300542
|
observations: rawObservations,
|
|
300169
300543
|
routeCorrected,
|
|
@@ -300472,6 +300846,10 @@ var require_perception_enricher = __commonJS({
|
|
|
300472
300846
|
const e = {};
|
|
300473
300847
|
if (p.rowContext)
|
|
300474
300848
|
e.rowContext = p.rowContext;
|
|
300849
|
+
if (p.rowContext && p.rowRole)
|
|
300850
|
+
e.rowRole = p.rowRole;
|
|
300851
|
+
if (p.inputType)
|
|
300852
|
+
e.inputType = p.inputType;
|
|
300475
300853
|
if (p.position && p.position !== "in-view")
|
|
300476
300854
|
e.position = p.position;
|
|
300477
300855
|
if (p.occluded)
|
|
@@ -300721,6 +301099,23 @@ var require_perception_enricher = __commonJS({
|
|
|
300721
301099
|
const cls = (el.getAttribute("class") || "").toLowerCase();
|
|
300722
301100
|
return /(^|[\s_-])(card|row|item|listitem|cell|tile)([\s_-]|$)/.test(cls);
|
|
300723
301101
|
};
|
|
301102
|
+
const rowRoleOf = (el) => {
|
|
301103
|
+
const explicit = (el.getAttribute("role") || "").toLowerCase().trim();
|
|
301104
|
+
if (explicit) {
|
|
301105
|
+
return explicit === "row" || explicit === "listitem" || explicit === "article" || explicit === "option" ? explicit : null;
|
|
301106
|
+
}
|
|
301107
|
+
const tag = el.tagName;
|
|
301108
|
+
if (tag === "TR") {
|
|
301109
|
+
const tbl = el.closest("table");
|
|
301110
|
+
const tblRole = tbl ? (tbl.getAttribute("role") || "").toLowerCase().trim() : "";
|
|
301111
|
+
return tblRole === "presentation" || tblRole === "none" ? null : "row";
|
|
301112
|
+
}
|
|
301113
|
+
if (tag === "LI")
|
|
301114
|
+
return el.closest('ul:not([role]),ol:not([role]),menu:not([role]),[role="list"]') ? "listitem" : null;
|
|
301115
|
+
if (tag === "ARTICLE")
|
|
301116
|
+
return "article";
|
|
301117
|
+
return null;
|
|
301118
|
+
};
|
|
300724
301119
|
const rowContextOf = (el, ownNameLower) => {
|
|
300725
301120
|
let cur = el.parentElement;
|
|
300726
301121
|
let depth = 0;
|
|
@@ -300739,7 +301134,7 @@ var require_perception_enricher = __commonJS({
|
|
|
300739
301134
|
txt = txt.slice(0, 80);
|
|
300740
301135
|
const low = txt.toLowerCase();
|
|
300741
301136
|
if (txt && low !== ownNameLower && low.length > 1)
|
|
300742
|
-
return txt;
|
|
301137
|
+
return { text: txt, role: rowRoleOf(cur) };
|
|
300743
301138
|
return null;
|
|
300744
301139
|
}
|
|
300745
301140
|
cur = cur.parentElement;
|
|
@@ -300829,11 +301224,14 @@ var require_perception_enricher = __commonJS({
|
|
|
300829
301224
|
const name = accName(el);
|
|
300830
301225
|
const lname = lower(name);
|
|
300831
301226
|
const rect = vis.rect;
|
|
301227
|
+
const rc = rowContextOf(el, lname);
|
|
300832
301228
|
out.push({
|
|
300833
301229
|
domOrder: myOrder,
|
|
300834
301230
|
roleClass: roleClassOf(tag, roleAttr, inputType),
|
|
300835
301231
|
name: lname,
|
|
300836
|
-
rowContext:
|
|
301232
|
+
rowContext: rc ? rc.text : null,
|
|
301233
|
+
rowRole: rc ? rc.role : null,
|
|
301234
|
+
inputType: inputType || null,
|
|
300837
301235
|
position: positionOf(rect),
|
|
300838
301236
|
occluded: occlusionOf(el, rect),
|
|
300839
301237
|
cursorPointer,
|
|
@@ -300870,11 +301268,14 @@ var require_perception_enricher = __commonJS({
|
|
|
300870
301268
|
if (!name)
|
|
300871
301269
|
continue;
|
|
300872
301270
|
const lname = lower(name);
|
|
301271
|
+
const rc = rowContextOf(el, lname);
|
|
300873
301272
|
out.push({
|
|
300874
301273
|
domOrder: order++,
|
|
300875
301274
|
roleClass: "button",
|
|
300876
301275
|
name: lname,
|
|
300877
|
-
rowContext:
|
|
301276
|
+
rowContext: rc ? rc.text : null,
|
|
301277
|
+
rowRole: rc ? rc.role : null,
|
|
301278
|
+
inputType: null,
|
|
300878
301279
|
position: "in-view",
|
|
300879
301280
|
occluded: false,
|
|
300880
301281
|
cursorPointer: true,
|
|
@@ -301376,7 +301777,7 @@ var require_discover_explorer = __commonJS({
|
|
|
301376
301777
|
var DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP = 100;
|
|
301377
301778
|
var DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY = 100;
|
|
301378
301779
|
var DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL = 40;
|
|
301379
|
-
function
|
|
301780
|
+
function readPositiveIntEnv2(env, name, fallback) {
|
|
301380
301781
|
const raw = env[name]?.trim();
|
|
301381
301782
|
if (!raw || !/^\d+$/.test(raw))
|
|
301382
301783
|
return fallback;
|
|
@@ -301507,15 +301908,15 @@ var require_discover_explorer = __commonJS({
|
|
|
301507
301908
|
}
|
|
301508
301909
|
function getMaxIterations(mode, incremental, env = process.env) {
|
|
301509
301910
|
if (mode === "survey" && incremental) {
|
|
301510
|
-
return
|
|
301911
|
+
return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_INCREMENTAL", DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL);
|
|
301511
301912
|
}
|
|
301512
301913
|
if (mode === "survey") {
|
|
301513
|
-
return
|
|
301914
|
+
return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_SURVEY", DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY);
|
|
301514
301915
|
}
|
|
301515
301916
|
if (mode === "deep") {
|
|
301516
|
-
return
|
|
301917
|
+
return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEEP", DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP);
|
|
301517
301918
|
}
|
|
301518
|
-
return
|
|
301919
|
+
return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEFAULT", DEFAULT_MAX_EXPLORE_ITERATIONS_DEFAULT);
|
|
301519
301920
|
}
|
|
301520
301921
|
function updatePendingRouteScreenshot(pending, { imageData, detectedRoute, fallbackRoute }) {
|
|
301521
301922
|
if (imageData) {
|
|
@@ -302241,7 +302642,7 @@ ${inboxPromptBlock}`;
|
|
|
302241
302642
|
const browserCreds = variableContext.allTestVariables ?? variableContext.publicTestVariables;
|
|
302242
302643
|
const { secrets: browserSecretsForHint } = (0, credential_tools_js_1.partitionTestVariables)(browserCreds);
|
|
302243
302644
|
const hasLoginCredentials = Object.keys(browserSecretsForHint).length > 0;
|
|
302244
|
-
const aiClient = options?.modelOverride ? (0, ai_provider_js_1.
|
|
302645
|
+
const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
|
|
302245
302646
|
const isDeepMode = options?.mode === "deep";
|
|
302246
302647
|
const maxIterations = config?.maxIterationsOverride && config.maxIterationsOverride > 0 ? config.maxIterationsOverride : getMaxIterations(options?.mode ?? "full", options?.incremental);
|
|
302247
302648
|
const configKeepTail = isDeepMode ? Math.ceil(ctxConfig.keepTail * 1.5) : ctxConfig.keepTail;
|
|
@@ -304780,7 +305181,7 @@ var require_auth_capture_run = __commonJS({
|
|
|
304780
305181
|
var scrub_credentials_js_1 = require_scrub_credentials();
|
|
304781
305182
|
var credential_tools_js_1 = require_credential_tools();
|
|
304782
305183
|
var DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS = 12;
|
|
304783
|
-
function
|
|
305184
|
+
function readPositiveIntEnv2(env, name, fallback) {
|
|
304784
305185
|
const raw = env[name]?.trim();
|
|
304785
305186
|
if (!raw || !/^\d+$/.test(raw))
|
|
304786
305187
|
return fallback;
|
|
@@ -304788,7 +305189,7 @@ var require_auth_capture_run = __commonJS({
|
|
|
304788
305189
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
304789
305190
|
}
|
|
304790
305191
|
function getLoginCaptureMaxIterations(env = process.env) {
|
|
304791
|
-
return
|
|
305192
|
+
return readPositiveIntEnv2(env, "AUTH_CAPTURE_LOGIN_MAX_ITERATIONS", DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS);
|
|
304792
305193
|
}
|
|
304793
305194
|
var CAPTURE_RATE_LIMIT_MAX_RETRIES = 2;
|
|
304794
305195
|
var CAPTURE_RATE_LIMIT_MAX_DELAY_MS = 8e3;
|
|
@@ -305071,7 +305472,7 @@ ${keyLines || "- (none provided)"}
|
|
|
305071
305472
|
}
|
|
305072
305473
|
var DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS = 18;
|
|
305073
305474
|
function getRegisterCaptureMaxIterations(env = process.env) {
|
|
305074
|
-
return
|
|
305475
|
+
return readPositiveIntEnv2(env, "AUTH_CAPTURE_REGISTER_MAX_ITERATIONS", DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS);
|
|
305075
305476
|
}
|
|
305076
305477
|
function buildRegisterCaptureSystemPrompt(baseUrl, availableKeys, maxIterations = getRegisterCaptureMaxIterations()) {
|
|
305077
305478
|
const keyLines = availableKeys.map((k) => `- \`${k}\``).join("\n");
|
|
@@ -311301,6 +311702,7 @@ var require_mobile_exploration_state = __commonJS({
|
|
|
311301
311702
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
311302
311703
|
exports2.MobileExplorationState = void 0;
|
|
311303
311704
|
exports2.deriveScreenId = deriveScreenId;
|
|
311705
|
+
var mobile_observation_js_1 = require_mobile_observation();
|
|
311304
311706
|
var DEFAULT_STALE_THRESHOLD = 4;
|
|
311305
311707
|
function deriveScreenId(signal) {
|
|
311306
311708
|
const namespace = signal.activity ?? signal.bundleId;
|
|
@@ -311526,21 +311928,33 @@ var require_mobile_exploration_state = __commonJS({
|
|
|
311526
311928
|
* Build the planner evidence shape: one `CollectedPageData` per screen, with
|
|
311527
311929
|
* the screen-id carried in BOTH `route` (the on-the-wire positional field the
|
|
311528
311930
|
* server keys screenshots by) AND the explicit `screenId` field (per the
|
|
311529
|
-
* Phase-0 `CollectedPageData.screenId` contract). `elements`
|
|
311530
|
-
* the
|
|
311531
|
-
*
|
|
311532
|
-
*
|
|
311533
|
-
*
|
|
311931
|
+
* Phase-0 `CollectedPageData.screenId` contract). `elements` are mapped from
|
|
311932
|
+
* the screen's parsed actionable elements into the platform-neutral
|
|
311933
|
+
* `CollectedPageElement[]` shape the server persists as SiteElements — without
|
|
311934
|
+
* this, mobile screens land with zero elements (coverage always 0%, no element
|
|
311935
|
+
* intelligence). `triggersNavigation` is derived from transition-source
|
|
311936
|
+
* membership: an element is flagged when its ref drove a transition FROM this
|
|
311937
|
+
* screen. Insertion order matches first-seen order.
|
|
311534
311938
|
*/
|
|
311535
311939
|
buildInMemoryScreenMap() {
|
|
311536
311940
|
const pages = [];
|
|
311537
311941
|
const ordered = [...this.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
|
|
311942
|
+
const navRefsByScreen = /* @__PURE__ */ new Map();
|
|
311943
|
+
for (const transition of this.transitions) {
|
|
311944
|
+
if (!transition.viaRef)
|
|
311945
|
+
continue;
|
|
311946
|
+
const set = navRefsByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
|
|
311947
|
+
set.add(transition.viaRef);
|
|
311948
|
+
navRefsByScreen.set(transition.fromScreenId, set);
|
|
311949
|
+
}
|
|
311538
311950
|
for (const state2 of ordered) {
|
|
311539
311951
|
const page = {
|
|
311540
311952
|
route: state2.screenId,
|
|
311541
311953
|
screenId: state2.screenId,
|
|
311542
311954
|
pageType: state2.screenType ?? "UNKNOWN",
|
|
311543
|
-
elements:
|
|
311955
|
+
elements: (0, mobile_observation_js_1.buildCollectedElementsForScreen)(state2.elements, {
|
|
311956
|
+
navigationRefs: navRefsByScreen.get(state2.screenId)
|
|
311957
|
+
})
|
|
311544
311958
|
};
|
|
311545
311959
|
if (state2.screenName) {
|
|
311546
311960
|
page.title = state2.screenName;
|
|
@@ -311570,14 +311984,51 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311570
311984
|
"../runner-core/dist/services/mobile/mobile-discovery-agent.js"(exports2) {
|
|
311571
311985
|
"use strict";
|
|
311572
311986
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
311987
|
+
exports2.getMobileExploreBudget = getMobileExploreBudget;
|
|
311573
311988
|
exports2.runMobileDiscoveryOnRunner = runMobileDiscoveryOnRunner2;
|
|
311574
311989
|
var mobile_explorer_js_1 = require_mobile_explorer();
|
|
311575
311990
|
var mobile_discovery_prompts_js_1 = require_mobile_discovery_prompts();
|
|
311576
311991
|
var mobile_generator_js_1 = require_mobile_generator();
|
|
311577
311992
|
var mobile_exploration_state_js_1 = require_mobile_exploration_state();
|
|
311578
311993
|
var discover_planner_js_1 = require_discover_planner();
|
|
311579
|
-
|
|
311580
|
-
|
|
311994
|
+
function checkpointErrorMessage(err) {
|
|
311995
|
+
return err instanceof Error ? err.message : String(err);
|
|
311996
|
+
}
|
|
311997
|
+
function isTerminalDiscoveryCheckpointError(err) {
|
|
311998
|
+
const message = checkpointErrorMessage(err);
|
|
311999
|
+
return /Discovery plan checkpoint rejected|Discovery run is .*not RUNNING|not RUNNING|cancelled/i.test(message);
|
|
312000
|
+
}
|
|
312001
|
+
var MOBILE_EXPLORE_BUDGET_SURVEY_FIRST = 90;
|
|
312002
|
+
var MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL = 40;
|
|
312003
|
+
var MOBILE_EXPLORE_BUDGET_DEEP = 60;
|
|
312004
|
+
var MOBILE_EXPLORE_BUDGET_FULL = 90;
|
|
312005
|
+
function readPositiveIntEnv2(name, fallback, env = process.env) {
|
|
312006
|
+
const raw = env[name]?.trim();
|
|
312007
|
+
if (!raw || !/^\d+$/.test(raw))
|
|
312008
|
+
return fallback;
|
|
312009
|
+
const parsed = Number(raw);
|
|
312010
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
312011
|
+
}
|
|
312012
|
+
function getMobileExploreBudget(mode, incremental, env = process.env) {
|
|
312013
|
+
if (mode === "deep")
|
|
312014
|
+
return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_DEEP", MOBILE_EXPLORE_BUDGET_DEEP, env);
|
|
312015
|
+
if (mode === "full")
|
|
312016
|
+
return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_FULL", MOBILE_EXPLORE_BUDGET_FULL, env);
|
|
312017
|
+
if (incremental) {
|
|
312018
|
+
return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY_INCREMENTAL", MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL, env);
|
|
312019
|
+
}
|
|
312020
|
+
return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY", MOBILE_EXPLORE_BUDGET_SURVEY_FIRST, env);
|
|
312021
|
+
}
|
|
312022
|
+
function compactExistingScreens(siteMap) {
|
|
312023
|
+
if (!siteMap || !Array.isArray(siteMap.pages) || siteMap.pages.length === 0)
|
|
312024
|
+
return void 0;
|
|
312025
|
+
const screens = siteMap.pages.filter((p) => typeof p.route === "string" && p.route.trim().length > 0).map((p) => ({
|
|
312026
|
+
screenId: p.route,
|
|
312027
|
+
...p.title ? { name: p.title } : {},
|
|
312028
|
+
...p.pageType ? { screenType: p.pageType } : {}
|
|
312029
|
+
}));
|
|
312030
|
+
return screens.length > 0 ? screens : void 0;
|
|
312031
|
+
}
|
|
311581
312032
|
function platformForMedium(medium) {
|
|
311582
312033
|
return medium === "IOS_NATIVE" ? "IOS" : "ANDROID";
|
|
311583
312034
|
}
|
|
@@ -311596,6 +312047,9 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311596
312047
|
// Native screenshots are multi-megabyte PNGs. The live mirror streams them
|
|
311597
312048
|
// through heartbeat telemetry; discovery checkpoints keep the screen graph
|
|
311598
312049
|
// and transitions so large Android surveys cannot exceed server body limits.
|
|
312050
|
+
// D-D6 — pageScreenshotKeys is a small screen-id → storage-key map (NOT base64),
|
|
312051
|
+
// so it DOES ride the checkpoint: the server links each key to SitePage.screenshotKey.
|
|
312052
|
+
...explore.pageScreenshotKeys && Object.keys(explore.pageScreenshotKeys).length > 0 ? { pageScreenshotKeys: explore.pageScreenshotKeys } : {},
|
|
311599
312053
|
exploreStats: explore.exploreStats,
|
|
311600
312054
|
healthObservations: explore.healthObservations
|
|
311601
312055
|
};
|
|
@@ -311610,6 +312064,7 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311610
312064
|
const onLog = callbacks?.onLog;
|
|
311611
312065
|
const onAICall = callbacks?.onAICall;
|
|
311612
312066
|
const onScreenshot = callbacks?.onScreenshot;
|
|
312067
|
+
const onScreenshotUpload = callbacks?.onScreenshotUpload;
|
|
311613
312068
|
const runExplore = callbacks?.runExplore ?? mobile_explorer_js_1.runMobileExplorePhase;
|
|
311614
312069
|
const runPlan = callbacks?.runPlan ?? mobile_discovery_prompts_js_1.runMobileAIPlanner;
|
|
311615
312070
|
const runGenerate = callbacks?.runGenerate ?? mobile_generator_js_1.runMobileGeneratePhase;
|
|
@@ -311636,15 +312091,77 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311636
312091
|
elementsFound: 0,
|
|
311637
312092
|
transitionsFound: 0
|
|
311638
312093
|
};
|
|
312094
|
+
const resumeSavedScenarios = ctx.resume?.savedPlan?.scenarios ?? [];
|
|
312095
|
+
const isPlanReuseResume = mode !== "survey" && resumeSavedScenarios.length > 0;
|
|
311639
312096
|
try {
|
|
312097
|
+
if (isPlanReuseResume) {
|
|
312098
|
+
const alreadyGenerated = ctx.resume?.alreadyGeneratedScenarioNames ?? [];
|
|
312099
|
+
log2(`
|
|
312100
|
+
\u2500\u2500 Resume (plan-reuse): ${resumeSavedScenarios.length} saved scenario(s); ${alreadyGenerated.length} already generated. Skipping explore + plan; generating only the remainder. \u2500\u2500`);
|
|
312101
|
+
const scenarios2 = resumeSavedScenarios;
|
|
312102
|
+
const plans2 = toPlans(scenarios2);
|
|
312103
|
+
if (callbacks?.onPlanReady) {
|
|
312104
|
+
try {
|
|
312105
|
+
await callbacks.onPlanReady({ scenarios: scenarios2, featureGroups: void 0, fallbackUsed: false });
|
|
312106
|
+
log2("Plan checkpoint: reused scenario plan re-POSTed to server");
|
|
312107
|
+
} catch (err) {
|
|
312108
|
+
const message = checkpointErrorMessage(err);
|
|
312109
|
+
if (isTerminalDiscoveryCheckpointError(err)) {
|
|
312110
|
+
log2(`Plan checkpoint rejected for inactive discovery run: ${message}`);
|
|
312111
|
+
throw err;
|
|
312112
|
+
}
|
|
312113
|
+
log2(`Plan checkpoint POST failed (non-fatal): ${message}`);
|
|
312114
|
+
}
|
|
312115
|
+
}
|
|
312116
|
+
let paused = false;
|
|
312117
|
+
let pauseReason;
|
|
312118
|
+
let remainingScenarios;
|
|
312119
|
+
const tests2 = await runGenerate({
|
|
312120
|
+
scenarios: scenarios2,
|
|
312121
|
+
driver,
|
|
312122
|
+
ctx,
|
|
312123
|
+
onLog: (line) => log2(line),
|
|
312124
|
+
onAICall,
|
|
312125
|
+
onScreenshot,
|
|
312126
|
+
onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
|
|
312127
|
+
skipScenarioNames: alreadyGenerated,
|
|
312128
|
+
onInfraStop: (info) => {
|
|
312129
|
+
paused = true;
|
|
312130
|
+
pauseReason = info.reason;
|
|
312131
|
+
remainingScenarios = info.remainingScenarioNames;
|
|
312132
|
+
}
|
|
312133
|
+
});
|
|
312134
|
+
const duration2 = (Date.now() - startTime) / 1e3;
|
|
312135
|
+
const summary2 = `Mobile resume complete: generated ${tests2.length} test(s) from ${scenarios2.length} saved scenario(s) in ${duration2.toFixed(1)}s${paused ? " \u2014 PAUSED again on AI provider overload" : ""}`;
|
|
312136
|
+
log2(`
|
|
312137
|
+
=== Mobile Discovery Resume Complete (${duration2.toFixed(1)}s) ===`);
|
|
312138
|
+
log2(summary2);
|
|
312139
|
+
return {
|
|
312140
|
+
collectedPages: [],
|
|
312141
|
+
collectedTransitions: [],
|
|
312142
|
+
tests: tests2,
|
|
312143
|
+
plans: plans2,
|
|
312144
|
+
scenarios: scenarios2.length > 0 ? scenarios2 : void 0,
|
|
312145
|
+
logs: logs2.join("\n"),
|
|
312146
|
+
screenshots,
|
|
312147
|
+
summary: summary2,
|
|
312148
|
+
exploreStats: { pagesDiscovered: 0, pagesVisited: 0, elementsFound: 0, transitionsFound: 0 },
|
|
312149
|
+
duration: duration2,
|
|
312150
|
+
mode,
|
|
312151
|
+
...paused ? { paused: true, pauseReason, remainingScenarios } : {}
|
|
312152
|
+
// apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
|
|
312153
|
+
};
|
|
312154
|
+
}
|
|
311640
312155
|
log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
|
|
311641
312156
|
const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
|
|
311642
|
-
const maxIterations = mode
|
|
312157
|
+
const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
|
|
312158
|
+
const existingScreens = compactExistingScreens(ctx.existingSiteMap);
|
|
311643
312159
|
const explore = await runExplore({
|
|
311644
312160
|
ctx,
|
|
311645
312161
|
driver,
|
|
311646
312162
|
state: state2,
|
|
311647
312163
|
config: { maxIterations },
|
|
312164
|
+
existingScreens,
|
|
311648
312165
|
onLog: (line) => log2(line),
|
|
311649
312166
|
onAICall,
|
|
311650
312167
|
onScreenshot: (base64) => {
|
|
@@ -311654,7 +312171,8 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311654
312171
|
onScreenshot?.(base64);
|
|
311655
312172
|
} catch {
|
|
311656
312173
|
}
|
|
311657
|
-
}
|
|
312174
|
+
},
|
|
312175
|
+
onScreenshotUpload
|
|
311658
312176
|
});
|
|
311659
312177
|
const collectedTransitions = toCollectedTransitions(explore);
|
|
311660
312178
|
partialCollectedPages = explore.collectedPages;
|
|
@@ -311706,14 +312224,22 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
|
|
|
311706
312224
|
const plans = toPlans(scenarios);
|
|
311707
312225
|
const coverageGaps = planResult.coverageGaps && planResult.coverageGaps.length > 0 ? planResult.coverageGaps : void 0;
|
|
311708
312226
|
if (callbacks?.onPlanReady) {
|
|
311709
|
-
|
|
311710
|
-
|
|
311711
|
-
|
|
311712
|
-
|
|
311713
|
-
|
|
311714
|
-
|
|
311715
|
-
|
|
311716
|
-
|
|
312227
|
+
try {
|
|
312228
|
+
await callbacks.onPlanReady({
|
|
312229
|
+
scenarios,
|
|
312230
|
+
featureGroups,
|
|
312231
|
+
fallbackUsed: planResult.fallbackUsed ?? false,
|
|
312232
|
+
fallbackReason: planResult.fallbackReason
|
|
312233
|
+
});
|
|
312234
|
+
log2("Plan checkpoint: scenario plan POSTed to server");
|
|
312235
|
+
} catch (err) {
|
|
312236
|
+
const message = checkpointErrorMessage(err);
|
|
312237
|
+
if (isTerminalDiscoveryCheckpointError(err)) {
|
|
312238
|
+
log2(`Plan checkpoint rejected for inactive discovery run: ${message}`);
|
|
312239
|
+
throw err;
|
|
312240
|
+
}
|
|
312241
|
+
log2(`Plan checkpoint POST failed (non-fatal): ${message}`);
|
|
312242
|
+
}
|
|
311717
312243
|
}
|
|
311718
312244
|
if (isSurvey) {
|
|
311719
312245
|
const duration2 = (Date.now() - startTime) / 1e3;
|
|
@@ -311747,6 +312273,9 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
|
|
|
311747
312273
|
};
|
|
311748
312274
|
}
|
|
311749
312275
|
log2("\n\u2500\u2500 Phase 3: GENERATE \u2500\u2500");
|
|
312276
|
+
let generatePaused = false;
|
|
312277
|
+
let generatePauseReason;
|
|
312278
|
+
let generateRemainingScenarios;
|
|
311750
312279
|
const tests = await runGenerate({
|
|
311751
312280
|
scenarios,
|
|
311752
312281
|
driver,
|
|
@@ -311754,7 +312283,15 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
|
|
|
311754
312283
|
onLog: (line) => log2(line),
|
|
311755
312284
|
onAICall,
|
|
311756
312285
|
onScreenshot,
|
|
311757
|
-
onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" })
|
|
312286
|
+
onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
|
|
312287
|
+
// Legacy resume (no saved plan): still skip scenarios already generated by the
|
|
312288
|
+
// paused run so the AI isn't re-invoked for them. Undefined on a fresh run.
|
|
312289
|
+
skipScenarioNames: ctx.resume?.alreadyGeneratedScenarioNames,
|
|
312290
|
+
onInfraStop: (info) => {
|
|
312291
|
+
generatePaused = true;
|
|
312292
|
+
generatePauseReason = info.reason;
|
|
312293
|
+
generateRemainingScenarios = info.remainingScenarioNames;
|
|
312294
|
+
}
|
|
311758
312295
|
});
|
|
311759
312296
|
const generateElapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
311760
312297
|
log2(`
|
|
@@ -311770,7 +312307,7 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
|
|
|
311770
312307
|
};
|
|
311771
312308
|
}
|
|
311772
312309
|
const duration = (Date.now() - startTime) / 1e3;
|
|
311773
|
-
const summary = `Mobile discovery complete: explored ${explore.exploreStats.pagesDiscovered} screens, planned ${scenarios.length} scenarios, generated ${tests.length} tests in ${duration.toFixed(1)}s`;
|
|
312310
|
+
const summary = `Mobile discovery complete: explored ${explore.exploreStats.pagesDiscovered} screens, planned ${scenarios.length} scenarios, generated ${tests.length} tests in ${duration.toFixed(1)}s${generatePaused ? " \u2014 PAUSED on AI provider overload (resumable)" : ""}`;
|
|
311774
312311
|
log2(`
|
|
311775
312312
|
=== Mobile Discovery Pipeline Complete (${duration.toFixed(1)}s) ===`);
|
|
311776
312313
|
log2(summary);
|
|
@@ -311795,7 +312332,11 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
|
|
|
311795
312332
|
summary,
|
|
311796
312333
|
exploreStats: explore.exploreStats,
|
|
311797
312334
|
duration,
|
|
311798
|
-
mode
|
|
312335
|
+
mode,
|
|
312336
|
+
// D-D4 — a circuit-breaker trip PAUSES the run (resumable) instead of
|
|
312337
|
+
// finalizing COMPLETED/FAILED. The server's isDiscoveryPaused reads these
|
|
312338
|
+
// (forwarded by runner.ts buildFinalBody, byte-identical to the web path).
|
|
312339
|
+
...generatePaused ? { paused: true, pauseReason: generatePauseReason, remainingScenarios: generateRemainingScenarios } : {}
|
|
311799
312340
|
// apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
|
|
311800
312341
|
};
|
|
311801
312342
|
} catch (fatalErr) {
|
|
@@ -311841,6 +312382,7 @@ var require_discover_agent = __commonJS({
|
|
|
311841
312382
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
311842
312383
|
exports2.runMobileDiscoveryOnRunner = exports2.buildInMemorySiteMap = void 0;
|
|
311843
312384
|
exports2.runCanvasPreflight = runCanvasPreflight;
|
|
312385
|
+
exports2.getFatalExploreError = getFatalExploreError;
|
|
311844
312386
|
exports2.runSiteDiscoveryOnRunner = runSiteDiscoveryOnRunner2;
|
|
311845
312387
|
exports2.runFeatureDiscoveryOnRunner = runFeatureDiscoveryOnRunner2;
|
|
311846
312388
|
var browser_config_js_1 = require_browser_config();
|
|
@@ -312023,7 +312565,7 @@ Respond with ONLY valid JSON:
|
|
|
312023
312565
|
"keyFindings": "What was discovered - forms, validations, behaviors. Mark inferred items with '(inferred, not tested)'.",
|
|
312024
312566
|
"testingNotes": "Gotchas, validation behaviors, error messages. List visible-but-untested routes as candidates for future exploration."
|
|
312025
312567
|
}`;
|
|
312026
|
-
const client = modelOverride ? (0, ai_provider_js_1.
|
|
312568
|
+
const client = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
|
|
312027
312569
|
const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
|
|
312028
312570
|
const callStart = Date.now();
|
|
312029
312571
|
const response = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => client.chat.completions.create({
|
|
@@ -312154,6 +312696,10 @@ Respond with ONLY valid JSON:
|
|
|
312154
312696
|
});
|
|
312155
312697
|
return timed.catch(() => null);
|
|
312156
312698
|
}
|
|
312699
|
+
function getFatalExploreError(exploreResult) {
|
|
312700
|
+
const prefix = "Fatal error:";
|
|
312701
|
+
return exploreResult.summary.startsWith(prefix) ? exploreResult.summary.slice(prefix.length).trim() || exploreResult.summary : void 0;
|
|
312702
|
+
}
|
|
312157
312703
|
async function runSiteDiscoveryOnRunner2(ctx, onLog, onAICall, sharedBrowser, onScreenshot, callbackOptions) {
|
|
312158
312704
|
const logs2 = [];
|
|
312159
312705
|
const screenshots = [];
|
|
@@ -312338,6 +312884,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
|
|
|
312338
312884
|
if (loginRunP)
|
|
312339
312885
|
await loginRunP;
|
|
312340
312886
|
const duration2 = (Date.now() - startTime) / 1e3;
|
|
312887
|
+
const fatalExploreError = getFatalExploreError(exploreResult);
|
|
312341
312888
|
return {
|
|
312342
312889
|
collectedPages: exploreResult.collectedPages,
|
|
312343
312890
|
collectedTransitions: exploreResult.collectedTransitions,
|
|
@@ -312348,6 +312895,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
|
|
|
312348
312895
|
pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
|
|
312349
312896
|
pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
|
|
312350
312897
|
summary: exploreResult.summary || "Exploration completed but no page data captured.",
|
|
312898
|
+
error: fatalExploreError,
|
|
312351
312899
|
exploreStats: {
|
|
312352
312900
|
pagesDiscovered: exploreResult.pagesDiscovered,
|
|
312353
312901
|
pagesVisited: exploreResult.pagesVisited,
|
|
@@ -312889,6 +313437,7 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
|
|
|
312889
313437
|
discoveryApiCalls = earlyClose.apiCalls;
|
|
312890
313438
|
postCloseHealthSummary = earlyClose.healthSummary;
|
|
312891
313439
|
const duration2 = (Date.now() - startTime) / 1e3;
|
|
313440
|
+
const fatalExploreError = getFatalExploreError(exploreResult);
|
|
312892
313441
|
return {
|
|
312893
313442
|
collectedPages: exploreResult.collectedPages,
|
|
312894
313443
|
collectedTransitions: exploreResult.collectedTransitions,
|
|
@@ -312899,7 +313448,8 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
|
|
|
312899
313448
|
healthObservations: earlyClose.healthSummary,
|
|
312900
313449
|
pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
|
|
312901
313450
|
pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
|
|
312902
|
-
summary: "Feature exploration completed but no page data captured.",
|
|
313451
|
+
summary: fatalExploreError ? exploreResult.summary : "Feature exploration completed but no page data captured.",
|
|
313452
|
+
error: fatalExploreError,
|
|
312903
313453
|
exploreStats: {
|
|
312904
313454
|
pagesDiscovered: exploreResult.pagesDiscovered,
|
|
312905
313455
|
pagesVisited: exploreResult.pagesVisited,
|
|
@@ -326288,7 +326838,7 @@ var require_package4 = __commonJS({
|
|
|
326288
326838
|
"package.json"(exports2, module2) {
|
|
326289
326839
|
module2.exports = {
|
|
326290
326840
|
name: "@validate.qa/runner",
|
|
326291
|
-
version: "1.0.
|
|
326841
|
+
version: "1.0.13",
|
|
326292
326842
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
326293
326843
|
bin: {
|
|
326294
326844
|
"validate-runner": "dist/cli.js"
|
|
@@ -326296,7 +326846,7 @@ var require_package4 = __commonJS({
|
|
|
326296
326846
|
scripts: {
|
|
326297
326847
|
build: "tsup",
|
|
326298
326848
|
dev: "tsx src/cli.ts",
|
|
326299
|
-
"test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/services/mobile/mobile-network-capture.smoke.test.ts",
|
|
326849
|
+
"test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/services/mobile/mobile-network-capture.smoke.test.ts src/services/mobile/mobile-host-proxy.test.ts",
|
|
326300
326850
|
prepublishOnly: "npm run build"
|
|
326301
326851
|
},
|
|
326302
326852
|
files: [
|
|
@@ -326523,8 +327073,10 @@ async function startAppiumServer(options) {
|
|
|
326523
327073
|
return serverState;
|
|
326524
327074
|
}
|
|
326525
327075
|
const port = options?.port ?? APPIUM_PORT;
|
|
326526
|
-
const
|
|
326527
|
-
|
|
327076
|
+
const args = ["--port", String(port)];
|
|
327077
|
+
if (options?.drivers && options.drivers.length > 0) {
|
|
327078
|
+
args.push("--use-drivers", options.drivers.join(","));
|
|
327079
|
+
}
|
|
326528
327080
|
intentionalStop = false;
|
|
326529
327081
|
lastStartOptions = { ...lastStartOptions, ...options };
|
|
326530
327082
|
if (appiumGaveUp) {
|
|
@@ -326534,11 +327086,12 @@ async function startAppiumServer(options) {
|
|
|
326534
327086
|
killPort(port);
|
|
326535
327087
|
return new Promise((resolve, reject) => {
|
|
326536
327088
|
try {
|
|
326537
|
-
|
|
327089
|
+
const child = (0, import_child_process.spawn)("appium", args, {
|
|
326538
327090
|
stdio: ["pipe", "pipe", "pipe"],
|
|
326539
327091
|
env: { ...process.env },
|
|
326540
327092
|
detached: false
|
|
326541
327093
|
});
|
|
327094
|
+
appiumProcess = child;
|
|
326542
327095
|
serverState.pid = appiumProcess.pid ?? null;
|
|
326543
327096
|
serverState.port = port;
|
|
326544
327097
|
appiumProcess.stdout?.on("data", (data) => {
|
|
@@ -326555,7 +327108,11 @@ async function startAppiumServer(options) {
|
|
|
326555
327108
|
options?.onLog?.(line);
|
|
326556
327109
|
}
|
|
326557
327110
|
});
|
|
326558
|
-
|
|
327111
|
+
child.on("exit", (code, signal) => {
|
|
327112
|
+
if (appiumProcess !== child) {
|
|
327113
|
+
appendLog(`[appium] Ignoring exit (code=${code}, signal=${signal}) from a superseded process`);
|
|
327114
|
+
return;
|
|
327115
|
+
}
|
|
326559
327116
|
appendLog(`[appium] Process exited (code=${code}, signal=${signal})`);
|
|
326560
327117
|
const currentPort = serverState.port;
|
|
326561
327118
|
const currentRestartCount = serverState.restartCount;
|
|
@@ -326594,7 +327151,11 @@ async function startAppiumServer(options) {
|
|
|
326594
327151
|
startupAbort.abort();
|
|
326595
327152
|
reject(err);
|
|
326596
327153
|
};
|
|
326597
|
-
|
|
327154
|
+
child.on("error", (err) => {
|
|
327155
|
+
if (appiumProcess !== child) {
|
|
327156
|
+
appendLog(`[appium] Ignoring error from a superseded process: ${err.message}`);
|
|
327157
|
+
return;
|
|
327158
|
+
}
|
|
326598
327159
|
appendLog(`[appium] Process error: ${err.message}`);
|
|
326599
327160
|
resetServerState(port);
|
|
326600
327161
|
settleReject(new Error(`Failed to start Appium: ${err.message}. Is appium installed? Run: npm i -g appium`));
|
|
@@ -326713,6 +327274,7 @@ function runCommand(command, args, timeout = 1e4) {
|
|
|
326713
327274
|
}
|
|
326714
327275
|
var realIOSProfilerNegativeUntil = 0;
|
|
326715
327276
|
var REAL_IOS_PROFILER_TTL_MS = 6e4;
|
|
327277
|
+
var REAL_IOS_NO_UDID_HINT = "no paired UDID \u2014 install libimobiledevice (brew install libimobiledevice) and trust this computer on the device";
|
|
326716
327278
|
function discoverIOSSimulators() {
|
|
326717
327279
|
if ((0, import_os2.platform)() !== "darwin") return [];
|
|
326718
327280
|
const json = runCommand("xcrun", ["simctl", "list", "devices", "--json"]);
|
|
@@ -326722,7 +327284,8 @@ function discoverIOSSimulators() {
|
|
|
326722
327284
|
const devices = [];
|
|
326723
327285
|
for (const [runtime, runtimeDevices] of Object.entries(parsed.devices)) {
|
|
326724
327286
|
const versionMatch = runtime.match(/iOS[- ](\d+(?:[-.]\d+)*)/i);
|
|
326725
|
-
|
|
327287
|
+
if (!versionMatch) continue;
|
|
327288
|
+
const platformVersion = versionMatch[1].replace(/-/g, ".");
|
|
326726
327289
|
for (const device of runtimeDevices) {
|
|
326727
327290
|
const state2 = device.state === "Booted" ? "BOOTED" : device.state === "Shutdown" ? "SHUTDOWN" : "LOADING";
|
|
326728
327291
|
if (state2 === "BOOTED") {
|
|
@@ -326795,11 +327358,11 @@ function discoverRealIOSDevices() {
|
|
|
326795
327358
|
const version = runCommand("ideviceinfo", ["-u", serial, "-k", "ProductVersion"]) || "unknown";
|
|
326796
327359
|
devices.push({
|
|
326797
327360
|
id: serial,
|
|
326798
|
-
name
|
|
327361
|
+
name: `${name} (${REAL_IOS_NO_UDID_HINT})`,
|
|
326799
327362
|
platform: "IOS",
|
|
326800
327363
|
platformVersion: version,
|
|
326801
327364
|
state: "BOOTED",
|
|
326802
|
-
isAvailable:
|
|
327365
|
+
isAvailable: false,
|
|
326803
327366
|
isPhysical: true
|
|
326804
327367
|
});
|
|
326805
327368
|
}
|
|
@@ -326815,11 +327378,11 @@ function discoverRealIOSDevices() {
|
|
|
326815
327378
|
if (hasIPhone || hasIPad) {
|
|
326816
327379
|
devices.push({
|
|
326817
327380
|
id: "usb-ios-device",
|
|
326818
|
-
name: hasIPhone ? "iPhone (USB)" : "iPad (USB)"
|
|
327381
|
+
name: `${hasIPhone ? "iPhone (USB)" : "iPad (USB)"} (${REAL_IOS_NO_UDID_HINT})`,
|
|
326819
327382
|
platform: "IOS",
|
|
326820
327383
|
platformVersion: "unknown",
|
|
326821
327384
|
state: "BOOTED",
|
|
326822
|
-
isAvailable:
|
|
327385
|
+
isAvailable: false,
|
|
326823
327386
|
isPhysical: true
|
|
326824
327387
|
});
|
|
326825
327388
|
}
|
|
@@ -326897,7 +327460,9 @@ function discoverAndroidDevices() {
|
|
|
326897
327460
|
const adbDevices = parseAdbDevices();
|
|
326898
327461
|
return adbDevices.filter((d) => d.type === "device" || d.type === "unauthorized").map((d) => {
|
|
326899
327462
|
const isEmulator = d.serial.startsWith("emulator-");
|
|
326900
|
-
const
|
|
327463
|
+
const authorized = d.type === "device";
|
|
327464
|
+
const bootCompleted = authorized && getAndroidProp(d.serial, "sys.boot_completed") === "1";
|
|
327465
|
+
const isAvailable = authorized && bootCompleted;
|
|
326901
327466
|
const name = d.model || d.device || (isEmulator ? "Android Emulator" : "Android Device");
|
|
326902
327467
|
const platformVersion = isAvailable ? getAndroidProp(d.serial, "ro.build.version.release") || "unknown" : "unknown";
|
|
326903
327468
|
return {
|
|
@@ -326905,7 +327470,10 @@ function discoverAndroidDevices() {
|
|
|
326905
327470
|
name,
|
|
326906
327471
|
platform: "ANDROID",
|
|
326907
327472
|
platformVersion,
|
|
326908
|
-
|
|
327473
|
+
// LOADING = authorized but still booting; an unauthorized device stays
|
|
327474
|
+
// BOOTED (isAvailable false) so the dashboard shows it with an
|
|
327475
|
+
// "accept USB debugging" hint rather than hiding it.
|
|
327476
|
+
state: authorized && !bootCompleted ? "LOADING" : "BOOTED",
|
|
326909
327477
|
isAvailable,
|
|
326910
327478
|
// Mark if it's a real device vs emulator
|
|
326911
327479
|
...isEmulator ? {} : { isPhysical: true }
|
|
@@ -326951,15 +327519,25 @@ function discoverAllDevices(options) {
|
|
|
326951
327519
|
}
|
|
326952
327520
|
return [...iosDevices, ...androidDevices];
|
|
326953
327521
|
}
|
|
327522
|
+
var DEVICE_SNAPSHOT_TTL_MS = 8e3;
|
|
327523
|
+
var cachedDeviceSnapshot = null;
|
|
327524
|
+
function getCachedDeviceSnapshot() {
|
|
327525
|
+
const now = Date.now();
|
|
327526
|
+
if (cachedDeviceSnapshot && now - cachedDeviceSnapshot.at < DEVICE_SNAPSHOT_TTL_MS) {
|
|
327527
|
+
return cachedDeviceSnapshot.devices;
|
|
327528
|
+
}
|
|
327529
|
+
const devices = discoverAllDevices();
|
|
327530
|
+
cachedDeviceSnapshot = { at: now, devices };
|
|
327531
|
+
return devices;
|
|
327532
|
+
}
|
|
326954
327533
|
function getRunnerCapabilities() {
|
|
326955
|
-
const
|
|
326956
|
-
const androidDevices = discoverAndroidDevices();
|
|
327534
|
+
const devices = getCachedDeviceSnapshot();
|
|
326957
327535
|
const isReadyDevice = (device) => device.isAvailable && device.state === "BOOTED";
|
|
326958
327536
|
return {
|
|
326959
327537
|
web: true,
|
|
326960
327538
|
// Always capable of web testing via Playwright
|
|
326961
|
-
ios:
|
|
326962
|
-
android:
|
|
327539
|
+
ios: devices.some((d) => d.platform === "IOS" && isReadyDevice(d)),
|
|
327540
|
+
android: devices.some((d) => d.platform === "ANDROID" && isReadyDevice(d))
|
|
326963
327541
|
};
|
|
326964
327542
|
}
|
|
326965
327543
|
|
|
@@ -327095,6 +327673,9 @@ var MitmProxy = class {
|
|
|
327095
327673
|
await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
|
|
327096
327674
|
await server3.on("request", (req) => this.onRequest(req));
|
|
327097
327675
|
await server3.on("response", (res) => this.onResponse(res));
|
|
327676
|
+
await server3.on("abort", (req) => {
|
|
327677
|
+
this.inFlight.delete(req.id);
|
|
327678
|
+
});
|
|
327098
327679
|
await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
|
|
327099
327680
|
await server3.on("tls-passthrough-opened", (event) => this.onTlsPassthroughOpened(event));
|
|
327100
327681
|
await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
|
|
@@ -327391,6 +327972,107 @@ async function iosSimRemoveCa(deviceId, onLog) {
|
|
|
327391
327972
|
function errMsg(err) {
|
|
327392
327973
|
return err instanceof Error ? err.message : String(err);
|
|
327393
327974
|
}
|
|
327975
|
+
function parsePrimaryNetworkService(stdout) {
|
|
327976
|
+
for (const line of stdout.split("\n")) {
|
|
327977
|
+
const m = line.match(/^\(\d+\)\s+(.+)$/);
|
|
327978
|
+
if (m) return m[1].trim();
|
|
327979
|
+
}
|
|
327980
|
+
return null;
|
|
327981
|
+
}
|
|
327982
|
+
function parseWebProxyState(stdout) {
|
|
327983
|
+
const field = (label) => {
|
|
327984
|
+
const m = stdout.match(new RegExp(`^${label}:[ \\t]*(.*)$`, "im"));
|
|
327985
|
+
return m ? m[1].trim() : "";
|
|
327986
|
+
};
|
|
327987
|
+
return {
|
|
327988
|
+
enabled: field("Enabled").toLowerCase() === "yes",
|
|
327989
|
+
server: field("Server"),
|
|
327990
|
+
port: field("Port")
|
|
327991
|
+
};
|
|
327992
|
+
}
|
|
327993
|
+
function buildHostProxySetCommands(service, host, port) {
|
|
327994
|
+
const portStr = String(port);
|
|
327995
|
+
return [
|
|
327996
|
+
["-setwebproxy", service, host, portStr],
|
|
327997
|
+
["-setsecurewebproxy", service, host, portStr]
|
|
327998
|
+
];
|
|
327999
|
+
}
|
|
328000
|
+
function buildProxyRestoreArgs(setCmd, stateCmd, service, prior) {
|
|
328001
|
+
if (prior.server) {
|
|
328002
|
+
return [
|
|
328003
|
+
[setCmd, service, prior.server, prior.port || "0"],
|
|
328004
|
+
[stateCmd, service, prior.enabled ? "on" : "off"]
|
|
328005
|
+
];
|
|
328006
|
+
}
|
|
328007
|
+
return [[stateCmd, service, "off"]];
|
|
328008
|
+
}
|
|
328009
|
+
function buildHostProxyRestoreCommands(service, prior) {
|
|
328010
|
+
return [
|
|
328011
|
+
...buildProxyRestoreArgs("-setwebproxy", "-setwebproxystate", service, prior.web),
|
|
328012
|
+
...buildProxyRestoreArgs("-setsecurewebproxy", "-setsecurewebproxystate", service, prior.secure)
|
|
328013
|
+
];
|
|
328014
|
+
}
|
|
328015
|
+
function hostProxyPriorFromMarker(marker) {
|
|
328016
|
+
return {
|
|
328017
|
+
web: {
|
|
328018
|
+
enabled: Boolean(marker.hostWebProxyWasEnabled),
|
|
328019
|
+
server: marker.hostWebProxyPriorServer ?? "",
|
|
328020
|
+
port: marker.hostWebProxyPriorPort ?? ""
|
|
328021
|
+
},
|
|
328022
|
+
secure: {
|
|
328023
|
+
enabled: Boolean(marker.hostSecureProxyWasEnabled),
|
|
328024
|
+
server: marker.hostSecureProxyPriorServer ?? "",
|
|
328025
|
+
port: marker.hostSecureProxyPriorPort ?? ""
|
|
328026
|
+
}
|
|
328027
|
+
};
|
|
328028
|
+
}
|
|
328029
|
+
function hostProxyControlSupported(platform3, isPhysical, platformName = process.platform) {
|
|
328030
|
+
if (platform3 !== "IOS") return { supported: false };
|
|
328031
|
+
if (isPhysical) {
|
|
328032
|
+
return { supported: false, reason: "physical iOS device requires a manual Wi-Fi HTTP proxy; host proxy not applied" };
|
|
328033
|
+
}
|
|
328034
|
+
if (platformName !== "darwin") {
|
|
328035
|
+
return { supported: false, reason: `iOS simulator host proxy is only controllable on macOS (host is ${platformName})` };
|
|
328036
|
+
}
|
|
328037
|
+
return { supported: true };
|
|
328038
|
+
}
|
|
328039
|
+
async function iosSimConfigureHostProxy(host, port, onLog) {
|
|
328040
|
+
try {
|
|
328041
|
+
const { stdout: order } = await execFileAsync("networksetup", ["-listnetworkserviceorder"], {
|
|
328042
|
+
timeout: DEVICE_CMD_TIMEOUT_MS
|
|
328043
|
+
});
|
|
328044
|
+
const service = parsePrimaryNetworkService(order);
|
|
328045
|
+
if (!service) {
|
|
328046
|
+
return { configured: false, reason: "iOS simulator host proxy could not be configured: no active macOS network service found" };
|
|
328047
|
+
}
|
|
328048
|
+
const [{ stdout: webOut }, { stdout: secureOut }] = await Promise.all([
|
|
328049
|
+
execFileAsync("networksetup", ["-getwebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS }),
|
|
328050
|
+
execFileAsync("networksetup", ["-getsecurewebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS })
|
|
328051
|
+
]);
|
|
328052
|
+
const prior = { web: parseWebProxyState(webOut), secure: parseWebProxyState(secureOut) };
|
|
328053
|
+
for (const args of buildHostProxySetCommands(service, host, port)) {
|
|
328054
|
+
await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
|
|
328055
|
+
}
|
|
328056
|
+
onLog?.(`[mobile-net] macOS host proxy set on "${service}" \u2192 ${host}:${port} (iOS simulator shares the host network).`);
|
|
328057
|
+
return { configured: true, service, prior };
|
|
328058
|
+
} catch (err) {
|
|
328059
|
+
return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg(err)}` };
|
|
328060
|
+
}
|
|
328061
|
+
}
|
|
328062
|
+
async function iosSimRestoreHostProxy(service, prior, onLog) {
|
|
328063
|
+
const restore = prior ?? {
|
|
328064
|
+
web: { enabled: false, server: "", port: "" },
|
|
328065
|
+
secure: { enabled: false, server: "", port: "" }
|
|
328066
|
+
};
|
|
328067
|
+
for (const args of buildHostProxyRestoreCommands(service, restore)) {
|
|
328068
|
+
try {
|
|
328069
|
+
await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
|
|
328070
|
+
} catch (err) {
|
|
328071
|
+
onLog?.(`[mobile-net] networksetup restore (${args[0]}) failed: ${errMsg(err)}`);
|
|
328072
|
+
}
|
|
328073
|
+
}
|
|
328074
|
+
onLog?.(`[mobile-net] macOS host proxy restored on "${service}".`);
|
|
328075
|
+
}
|
|
327394
328076
|
var activeCleanups = /* @__PURE__ */ new Map();
|
|
327395
328077
|
var exitHandlersInstalled = false;
|
|
327396
328078
|
function markerPath(sessionKey) {
|
|
@@ -327425,6 +328107,14 @@ function restoreDeviceSync(marker, onLog) {
|
|
|
327425
328107
|
run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
|
|
327426
328108
|
}
|
|
327427
328109
|
}
|
|
328110
|
+
if (marker.platform === "IOS" && marker.hostProxySet && marker.hostProxyService && process.platform === "darwin") {
|
|
328111
|
+
for (const args of buildHostProxyRestoreCommands(marker.hostProxyService, hostProxyPriorFromMarker(marker))) {
|
|
328112
|
+
try {
|
|
328113
|
+
(0, import_node_child_process.execFileSync)("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS, stdio: "ignore" });
|
|
328114
|
+
} catch {
|
|
328115
|
+
}
|
|
328116
|
+
}
|
|
328117
|
+
}
|
|
327428
328118
|
onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
|
|
327429
328119
|
}
|
|
327430
328120
|
async function reconcilePendingCleanups(onLog) {
|
|
@@ -327436,6 +328126,8 @@ async function reconcilePendingCleanups(onLog) {
|
|
|
327436
328126
|
}
|
|
327437
328127
|
for (const file of files) {
|
|
327438
328128
|
if (!file.endsWith(".json")) continue;
|
|
328129
|
+
const sessionKey = file.slice(0, -".json".length);
|
|
328130
|
+
if (activeCleanups.has(sessionKey)) continue;
|
|
327439
328131
|
const full = (0, import_node_path.join)(PENDING_CLEANUP_DIR, file);
|
|
327440
328132
|
try {
|
|
327441
328133
|
const raw = await (0, import_promises.readFile)(full, "utf-8");
|
|
@@ -327465,8 +328157,10 @@ function ensureExitHandlers() {
|
|
|
327465
328157
|
for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
327466
328158
|
process.on(signal, () => {
|
|
327467
328159
|
drain();
|
|
327468
|
-
process.
|
|
327469
|
-
|
|
328160
|
+
if (process.listenerCount(signal) <= 1) {
|
|
328161
|
+
process.removeAllListeners(signal);
|
|
328162
|
+
process.kill(process.pid, signal);
|
|
328163
|
+
}
|
|
327470
328164
|
});
|
|
327471
328165
|
}
|
|
327472
328166
|
}
|
|
@@ -327516,6 +328210,9 @@ async function startMobileNetworkCapture(options) {
|
|
|
327516
328210
|
let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
|
|
327517
328211
|
let proxySet = false;
|
|
327518
328212
|
let caInstalledOnDevice = false;
|
|
328213
|
+
let hostProxySet = false;
|
|
328214
|
+
let hostProxyService;
|
|
328215
|
+
let hostProxyPrior;
|
|
327519
328216
|
try {
|
|
327520
328217
|
if (platform3 === "ANDROID") {
|
|
327521
328218
|
proxySet = await androidSetProxy(deviceId, hostPort, onLog);
|
|
@@ -327530,25 +328227,50 @@ async function startMobileNetworkCapture(options) {
|
|
|
327530
328227
|
}
|
|
327531
328228
|
} else {
|
|
327532
328229
|
if (!isPhysical && caCertPath) {
|
|
327533
|
-
const { trusted
|
|
328230
|
+
const { trusted } = await iosSimTrustCa(deviceId, caCertPath);
|
|
327534
328231
|
caInstalledOnDevice = trusted;
|
|
327535
|
-
|
|
328232
|
+
}
|
|
328233
|
+
const support = hostProxyControlSupported(platform3, isPhysical);
|
|
328234
|
+
if (support.supported) {
|
|
328235
|
+
const hp = await iosSimConfigureHostProxy(proxyHost, proxyPort, onLog);
|
|
328236
|
+
if (hp.configured) {
|
|
328237
|
+
hostProxySet = true;
|
|
328238
|
+
hostProxyService = hp.service;
|
|
328239
|
+
hostProxyPrior = hp.prior;
|
|
328240
|
+
wiringReason = "iOS simulator traffic routed via the macOS host proxy; HTTPS interception pending until the app completes a TLS handshake through the proxy" + (caCertPath ? ` (trust ${caCertPath} in the simulator if the app rejects our CA).` : ".");
|
|
328241
|
+
} else {
|
|
328242
|
+
wiringReason = hp.reason ?? "iOS simulator host proxy could not be configured";
|
|
328243
|
+
onLog?.(`[mobile-net] ${wiringReason}`);
|
|
328244
|
+
}
|
|
327536
328245
|
} else if (isPhysical) {
|
|
327537
328246
|
wiringReason = "Physical iOS HTTPS is tunneled without MITM by default. Set the Wi-Fi HTTP proxy and trust the CA manually, then run with VALIDATEQA_MOBILE_FORCE_TLS_MITM=1 to decrypt HTTPS bodies" + (caCertPath ? ` (${caCertPath}).` : ".");
|
|
328247
|
+
} else {
|
|
328248
|
+
wiringReason = support.reason ?? "iOS simulator host proxy is unsupported on this host";
|
|
328249
|
+
onLog?.(`[mobile-net] ${wiringReason}`);
|
|
327538
328250
|
}
|
|
327539
328251
|
}
|
|
327540
328252
|
} catch (err) {
|
|
327541
328253
|
wiringReason = `device wiring degraded: ${errMsg(err)}`;
|
|
327542
328254
|
onLog?.(`[mobile-net] ${wiringReason}`);
|
|
327543
328255
|
}
|
|
327544
|
-
if (proxySet || caInstalledOnDevice) {
|
|
328256
|
+
if (proxySet || caInstalledOnDevice || hostProxySet) {
|
|
327545
328257
|
const marker = {
|
|
327546
328258
|
platform: platform3,
|
|
327547
328259
|
deviceId,
|
|
327548
328260
|
isPhysical,
|
|
327549
328261
|
proxySet,
|
|
327550
328262
|
originalProxy,
|
|
327551
|
-
caInstalledOnDevice
|
|
328263
|
+
caInstalledOnDevice,
|
|
328264
|
+
...hostProxySet ? {
|
|
328265
|
+
hostProxySet: true,
|
|
328266
|
+
hostProxyService,
|
|
328267
|
+
hostWebProxyWasEnabled: hostProxyPrior?.web.enabled,
|
|
328268
|
+
hostWebProxyPriorServer: hostProxyPrior?.web.server,
|
|
328269
|
+
hostWebProxyPriorPort: hostProxyPrior?.web.port,
|
|
328270
|
+
hostSecureProxyWasEnabled: hostProxyPrior?.secure.enabled,
|
|
328271
|
+
hostSecureProxyPriorServer: hostProxyPrior?.secure.server,
|
|
328272
|
+
hostSecureProxyPriorPort: hostProxyPrior?.secure.port
|
|
328273
|
+
} : {}
|
|
327552
328274
|
};
|
|
327553
328275
|
activeCleanups.set(sessionKey, marker);
|
|
327554
328276
|
await writePendingCleanup(sessionKey, marker);
|
|
@@ -327568,6 +328290,9 @@ async function startMobileNetworkCapture(options) {
|
|
|
327568
328290
|
if (proxySet && platform3 === "ANDROID") {
|
|
327569
328291
|
await androidClearProxy(deviceId, originalProxy, onLog);
|
|
327570
328292
|
}
|
|
328293
|
+
if (hostProxySet && hostProxyService && platform3 === "IOS" && !isPhysical) {
|
|
328294
|
+
await iosSimRestoreHostProxy(hostProxyService, hostProxyPrior, onLog);
|
|
328295
|
+
}
|
|
327571
328296
|
if (caInstalledOnDevice && capturedCaCertPath) {
|
|
327572
328297
|
if (platform3 === "ANDROID") {
|
|
327573
328298
|
await androidRemoveCa(deviceId, onLog);
|
|
@@ -327603,7 +328328,11 @@ async function startMobileNetworkCapture(options) {
|
|
|
327603
328328
|
|
|
327604
328329
|
// src/services/appium-executor.ts
|
|
327605
328330
|
var DEFAULT_STEP_TIMEOUT_MS = 1e4;
|
|
328331
|
+
var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
|
|
328332
|
+
var WD_ACTION_TIMEOUT_MS = 3e4;
|
|
328333
|
+
var MAX_SESSION_CREATE_ATTEMPTS = 3;
|
|
327606
328334
|
var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
|
|
328335
|
+
var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
|
|
327607
328336
|
var WebDriverTransportError = class extends Error {
|
|
327608
328337
|
isTransport = true;
|
|
327609
328338
|
constructor(message) {
|
|
@@ -327615,13 +328344,21 @@ function isTransportError(err) {
|
|
|
327615
328344
|
return err instanceof WebDriverTransportError || typeof err === "object" && err !== null && err.isTransport === true;
|
|
327616
328345
|
}
|
|
327617
328346
|
async function wdRequest(path, options) {
|
|
328347
|
+
const isSessionCreate = (options?.method ?? "GET").toUpperCase() === "POST" && path === "/session";
|
|
328348
|
+
const timeoutMs = isSessionCreate ? WD_SESSION_CREATE_TIMEOUT_MS : WD_ACTION_TIMEOUT_MS;
|
|
327618
328349
|
let res;
|
|
327619
328350
|
try {
|
|
327620
328351
|
res = await fetch(`http://localhost:${getAppiumState().port}${path}`, {
|
|
327621
328352
|
...options,
|
|
328353
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
327622
328354
|
headers: { "Content-Type": "application/json", ...options?.headers }
|
|
327623
328355
|
});
|
|
327624
328356
|
} catch (err) {
|
|
328357
|
+
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
|
|
328358
|
+
throw new WebDriverTransportError(
|
|
328359
|
+
`WebDriver request to ${path} timed out after ${timeoutMs}ms (device/Appium unresponsive)`
|
|
328360
|
+
);
|
|
328361
|
+
}
|
|
327625
328362
|
throw new WebDriverTransportError(`WebDriver connection error: ${err instanceof Error ? err.message : String(err)}`);
|
|
327626
328363
|
}
|
|
327627
328364
|
if (!res.ok) {
|
|
@@ -327706,6 +328443,31 @@ async function createSession(target) {
|
|
|
327706
328443
|
if (!sessionId) throw new Error("Appium session creation returned no session ID");
|
|
327707
328444
|
return sessionId;
|
|
327708
328445
|
}
|
|
328446
|
+
var TERMINAL_SESSION_START = /not installed|no such (app|file)|could not confirm|bundle ?id .*(not|missing)|xcodeorgid|signing|provisioning|not authorized|unauthorized/i;
|
|
328447
|
+
var RETRYABLE_SESSION_START = /timed out|timeout|webdriveragent|xcodebuild|instruments|could not connect|lockdownd|failed to (start|launch|create)|unable to launch|session is either terminated|unavailable|econnrefused|connection error|xcuitest|\b50[234]\b/i;
|
|
328448
|
+
function isRetryableSessionStartError(err) {
|
|
328449
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
328450
|
+
if (TERMINAL_SESSION_START.test(message)) return false;
|
|
328451
|
+
if (isTransportError(err)) return true;
|
|
328452
|
+
return RETRYABLE_SESSION_START.test(message);
|
|
328453
|
+
}
|
|
328454
|
+
async function createSessionWithRetry(target, onRetry) {
|
|
328455
|
+
let lastErr;
|
|
328456
|
+
for (let attempt = 1; attempt <= MAX_SESSION_CREATE_ATTEMPTS; attempt++) {
|
|
328457
|
+
try {
|
|
328458
|
+
return await createSession(target);
|
|
328459
|
+
} catch (err) {
|
|
328460
|
+
lastErr = err;
|
|
328461
|
+
if (attempt >= MAX_SESSION_CREATE_ATTEMPTS || !isRetryableSessionStartError(err)) throw err;
|
|
328462
|
+
const delayMs = Math.pow(2, attempt) * 1e3;
|
|
328463
|
+
onRetry?.(
|
|
328464
|
+
`Appium session start failed (attempt ${attempt}/${MAX_SESSION_CREATE_ATTEMPTS}, retrying in ${delayMs}ms): ${err instanceof Error ? err.message : String(err)}`
|
|
328465
|
+
);
|
|
328466
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
328467
|
+
}
|
|
328468
|
+
}
|
|
328469
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
328470
|
+
}
|
|
327709
328471
|
async function openDeepLink(sessionId, platform3, deeplink, appId) {
|
|
327710
328472
|
const args = platform3 === "IOS" ? [{ url: deeplink, bundleId: appId }] : [{ url: deeplink, package: appId }];
|
|
327711
328473
|
await wdRequest(`/session/${sessionId}/execute/sync`, {
|
|
@@ -327807,6 +328569,61 @@ async function takeScreenshot(sessionId) {
|
|
|
327807
328569
|
return null;
|
|
327808
328570
|
}
|
|
327809
328571
|
}
|
|
328572
|
+
function mobileRecordingEnabled() {
|
|
328573
|
+
const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
|
|
328574
|
+
return value !== "0" && value !== "false";
|
|
328575
|
+
}
|
|
328576
|
+
function readPositiveIntEnv(name, fallback, min, max) {
|
|
328577
|
+
const raw = process.env[name];
|
|
328578
|
+
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
|
328579
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
328580
|
+
return Math.max(min, Math.min(max, parsed));
|
|
328581
|
+
}
|
|
328582
|
+
async function startScreenRecording(sessionId, platform3, log2) {
|
|
328583
|
+
if (!mobileRecordingEnabled()) {
|
|
328584
|
+
log2("[mobile-video] recording disabled by VALIDATEQA_MOBILE_RUN_VIDEO=0");
|
|
328585
|
+
return false;
|
|
328586
|
+
}
|
|
328587
|
+
const timeLimit = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_SECONDS", 180, 5, 180);
|
|
328588
|
+
const options = {
|
|
328589
|
+
timeLimit: String(timeLimit),
|
|
328590
|
+
forceRestart: true
|
|
328591
|
+
};
|
|
328592
|
+
if (platform3 === "ANDROID") {
|
|
328593
|
+
options.bitRate = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_BITRATE", 75e4, 1e5, 4e6);
|
|
328594
|
+
} else {
|
|
328595
|
+
options.videoQuality = process.env.VALIDATEQA_MOBILE_RECORDING_IOS_QUALITY || "low";
|
|
328596
|
+
}
|
|
328597
|
+
try {
|
|
328598
|
+
await wdRequest(`/session/${sessionId}/appium/start_recording_screen`, {
|
|
328599
|
+
method: "POST",
|
|
328600
|
+
body: JSON.stringify({ options })
|
|
328601
|
+
});
|
|
328602
|
+
log2(`[mobile-video] screen recording started (${timeLimit}s max)`);
|
|
328603
|
+
return true;
|
|
328604
|
+
} catch (err) {
|
|
328605
|
+
log2(`[mobile-video] screen recording unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
328606
|
+
return false;
|
|
328607
|
+
}
|
|
328608
|
+
}
|
|
328609
|
+
async function stopScreenRecording(sessionId, log2) {
|
|
328610
|
+
try {
|
|
328611
|
+
const result = await wdRequest(`/session/${sessionId}/appium/stop_recording_screen`, {
|
|
328612
|
+
method: "POST",
|
|
328613
|
+
body: JSON.stringify({ options: {} })
|
|
328614
|
+
});
|
|
328615
|
+
const value = typeof result?.value === "string" ? result.value.trim() : "";
|
|
328616
|
+
if (!value) {
|
|
328617
|
+
log2("[mobile-video] screen recording stopped but Appium returned no video");
|
|
328618
|
+
return void 0;
|
|
328619
|
+
}
|
|
328620
|
+
log2(`[mobile-video] screen recording captured (${Math.round(value.length / 1024)}KB base64)`);
|
|
328621
|
+
return value;
|
|
328622
|
+
} catch (err) {
|
|
328623
|
+
log2(`[mobile-video] screen recording stop failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
328624
|
+
return void 0;
|
|
328625
|
+
}
|
|
328626
|
+
}
|
|
327810
328627
|
function boundsFromStep(step) {
|
|
327811
328628
|
const bounds = step.metadata?.bounds;
|
|
327812
328629
|
if (bounds && typeof bounds === "object" && !Array.isArray(bounds) && typeof bounds.x === "number" && typeof bounds.y === "number" && typeof bounds.width === "number" && typeof bounds.height === "number") {
|
|
@@ -327926,7 +328743,8 @@ async function sendKeysToFocusedInput(sessionId, value) {
|
|
|
327926
328743
|
});
|
|
327927
328744
|
}
|
|
327928
328745
|
function encodeAndroidInputText(value) {
|
|
327929
|
-
|
|
328746
|
+
const inputEncoded = value.replace(/%/g, "%25").replace(/\s/g, "%s");
|
|
328747
|
+
return `'${inputEncoded.replace(/'/g, `'\\''`)}'`;
|
|
327930
328748
|
}
|
|
327931
328749
|
async function sendAndroidTextToFocusedInput(deviceId, value) {
|
|
327932
328750
|
if (!deviceId) {
|
|
@@ -328150,9 +328968,29 @@ async function executeAssertText(sessionId, step) {
|
|
|
328150
328968
|
const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
|
|
328151
328969
|
const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
|
|
328152
328970
|
const actualText = typeof result?.value === "string" ? result.value : "";
|
|
328153
|
-
|
|
328154
|
-
|
|
328971
|
+
const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
|
|
328972
|
+
if (!textCandidates.some((value) => value.includes(step.assertion))) {
|
|
328973
|
+
throw new Error(`Text assertion failed: expected "${step.assertion}" to be contained in ${JSON.stringify(textCandidates)}`);
|
|
328974
|
+
}
|
|
328975
|
+
}
|
|
328976
|
+
async function readElementTextCandidates(sessionId, elementId, actualText) {
|
|
328977
|
+
const values = [];
|
|
328978
|
+
const push = (value) => {
|
|
328979
|
+
if (typeof value !== "string") return;
|
|
328980
|
+
const trimmed = value.trim();
|
|
328981
|
+
if (trimmed.length === 0 || values.includes(trimmed)) return;
|
|
328982
|
+
values.push(trimmed);
|
|
328983
|
+
};
|
|
328984
|
+
push(actualText);
|
|
328985
|
+
for (const attr of MOBILE_TEXT_ATTRIBUTE_NAMES) {
|
|
328986
|
+
try {
|
|
328987
|
+
const result = await wdRequest(`/session/${sessionId}/element/${elementId}/attribute/${encodeURIComponent(attr)}`);
|
|
328988
|
+
push(result?.value);
|
|
328989
|
+
} catch (err) {
|
|
328990
|
+
if (isTransportError(err)) throw err;
|
|
328991
|
+
}
|
|
328155
328992
|
}
|
|
328993
|
+
return values;
|
|
328156
328994
|
}
|
|
328157
328995
|
async function executeMobileTest(options) {
|
|
328158
328996
|
const logLines = [];
|
|
@@ -328164,6 +329002,9 @@ async function executeMobileTest(options) {
|
|
|
328164
329002
|
let sessionId = null;
|
|
328165
329003
|
let capture = null;
|
|
328166
329004
|
let captureStopped = false;
|
|
329005
|
+
let recordingStarted = false;
|
|
329006
|
+
let recordingStopped = false;
|
|
329007
|
+
let video;
|
|
328167
329008
|
const stopCapture = async () => {
|
|
328168
329009
|
if (!capture || captureStopped) return apiCalls;
|
|
328169
329010
|
captureStopped = true;
|
|
@@ -328178,6 +329019,15 @@ async function executeMobileTest(options) {
|
|
|
328178
329019
|
}
|
|
328179
329020
|
return apiCalls;
|
|
328180
329021
|
};
|
|
329022
|
+
const stopRecording = async () => {
|
|
329023
|
+
if (!sessionId || !recordingStarted || recordingStopped) return video;
|
|
329024
|
+
recordingStopped = true;
|
|
329025
|
+
const captured = await stopScreenRecording(sessionId, log2);
|
|
329026
|
+
if (captured) {
|
|
329027
|
+
video = captured;
|
|
329028
|
+
}
|
|
329029
|
+
return video;
|
|
329030
|
+
};
|
|
328181
329031
|
try {
|
|
328182
329032
|
log2("Checking Appium server health...");
|
|
328183
329033
|
const health = await checkAppiumHealth();
|
|
@@ -328231,13 +329081,13 @@ async function executeMobileTest(options) {
|
|
|
328231
329081
|
}
|
|
328232
329082
|
log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
|
|
328233
329083
|
try {
|
|
328234
|
-
sessionId = await
|
|
329084
|
+
sessionId = await createSessionWithRetry({
|
|
328235
329085
|
appId: options.target.appId,
|
|
328236
329086
|
platform: options.target.platform,
|
|
328237
329087
|
deviceId: selectedDevice.id,
|
|
328238
329088
|
platformVersion: options.target.platformVersion ?? selectedDevice.platformVersion,
|
|
328239
329089
|
isPhysical: selectedDevice.isPhysical
|
|
328240
|
-
});
|
|
329090
|
+
}, (msg) => log2(msg));
|
|
328241
329091
|
} catch (err) {
|
|
328242
329092
|
const base2 = err instanceof Error ? err.message : String(err);
|
|
328243
329093
|
if (selectedDevice.isPhysical) {
|
|
@@ -328252,6 +329102,7 @@ async function executeMobileTest(options) {
|
|
|
328252
329102
|
throw err;
|
|
328253
329103
|
}
|
|
328254
329104
|
log2(`Session created: ${sessionId}`);
|
|
329105
|
+
recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
|
|
328255
329106
|
if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
|
|
328256
329107
|
try {
|
|
328257
329108
|
log2(`Opening deep link: ${options.target.deeplink}`);
|
|
@@ -328312,7 +329163,9 @@ async function executeMobileTest(options) {
|
|
|
328312
329163
|
break;
|
|
328313
329164
|
}
|
|
328314
329165
|
}
|
|
328315
|
-
|
|
329166
|
+
if (step.action !== "home" && step.action !== "back") {
|
|
329167
|
+
await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
|
|
329168
|
+
}
|
|
328316
329169
|
} catch (err) {
|
|
328317
329170
|
status = "failed";
|
|
328318
329171
|
stepError = err instanceof Error ? err.message : String(err);
|
|
@@ -328341,10 +329194,12 @@ async function executeMobileTest(options) {
|
|
|
328341
329194
|
const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
|
|
328342
329195
|
const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
|
|
328343
329196
|
await stopCapture();
|
|
329197
|
+
await stopRecording();
|
|
328344
329198
|
return {
|
|
328345
329199
|
passed: allPassed,
|
|
328346
329200
|
stepResults,
|
|
328347
329201
|
screenshots,
|
|
329202
|
+
...video ? { video, videoMimeType: "video/mp4" } : {},
|
|
328348
329203
|
apiCalls,
|
|
328349
329204
|
logs: logLines.join("\n"),
|
|
328350
329205
|
error: firstError,
|
|
@@ -328355,10 +329210,12 @@ async function executeMobileTest(options) {
|
|
|
328355
329210
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
328356
329211
|
log2(`Fatal error: ${errorMsg}`);
|
|
328357
329212
|
await stopCapture();
|
|
329213
|
+
await stopRecording();
|
|
328358
329214
|
return {
|
|
328359
329215
|
passed: false,
|
|
328360
329216
|
stepResults,
|
|
328361
329217
|
screenshots,
|
|
329218
|
+
...video ? { video, videoMimeType: "video/mp4" } : {},
|
|
328362
329219
|
apiCalls,
|
|
328363
329220
|
logs: logLines.join("\n"),
|
|
328364
329221
|
error: errorMsg,
|
|
@@ -328370,6 +329227,7 @@ async function executeMobileTest(options) {
|
|
|
328370
329227
|
};
|
|
328371
329228
|
} finally {
|
|
328372
329229
|
await stopCapture();
|
|
329230
|
+
await stopRecording();
|
|
328373
329231
|
if (sessionId) {
|
|
328374
329232
|
log2("Tearing down Appium session...");
|
|
328375
329233
|
await deleteSession(sessionId);
|
|
@@ -328410,7 +329268,7 @@ async function createMobileDiscoverySession(target, options) {
|
|
|
328410
329268
|
isPhysical: selectedDevice.isPhysical === true,
|
|
328411
329269
|
platformVersion: selectedDevice.platformVersion
|
|
328412
329270
|
});
|
|
328413
|
-
const sessionId = await
|
|
329271
|
+
const sessionId = await createSessionWithRetry({
|
|
328414
329272
|
appId: target.appId,
|
|
328415
329273
|
platform: target.platform,
|
|
328416
329274
|
deviceId: selectedDevice.id,
|
|
@@ -329221,7 +330079,7 @@ function stopSessionReaper() {
|
|
|
329221
330079
|
}
|
|
329222
330080
|
}
|
|
329223
330081
|
async function reapIdleSession() {
|
|
329224
|
-
if (!state.appiumSessionId || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
|
|
330082
|
+
if (!state.appiumSessionId || mirrorSessionOwner !== "recording" || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
|
|
329225
330083
|
return;
|
|
329226
330084
|
}
|
|
329227
330085
|
const sessionId = state.appiumSessionId;
|
|
@@ -331738,6 +332596,11 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
331738
332596
|
});
|
|
331739
332597
|
requestLiveBrowserHeartbeat();
|
|
331740
332598
|
},
|
|
332599
|
+
// D-D6 — upload ONE baseline PNG per newly-observed screen through the
|
|
332600
|
+
// same streaming /page-screenshot relay the web path uses (mobile has no
|
|
332601
|
+
// route, so the screen-id is the key). The returned storage key rides the
|
|
332602
|
+
// explore checkpoint so the server links it to SitePage.screenshotKey.
|
|
332603
|
+
onScreenshotUpload: (screenId, base64) => onScreenshot(screenId, base64),
|
|
331741
332604
|
onExploreComplete,
|
|
331742
332605
|
onTestGenerated,
|
|
331743
332606
|
onPlanReady
|
|
@@ -332040,23 +332903,39 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
332040
332903
|
log.fail("ERROR - No mobile steps");
|
|
332041
332904
|
return;
|
|
332042
332905
|
}
|
|
332906
|
+
const maxRetries = typeof run2.retryCount === "number" && Number.isFinite(run2.retryCount) ? Math.max(0, Math.min(2, Math.floor(run2.retryCount))) : 0;
|
|
332043
332907
|
setExecutorBusy(true);
|
|
332044
|
-
let result2;
|
|
332908
|
+
let result2 = null;
|
|
332909
|
+
const attemptLogs = [];
|
|
332045
332910
|
try {
|
|
332046
|
-
|
|
332047
|
-
|
|
332048
|
-
|
|
332049
|
-
|
|
332050
|
-
|
|
332051
|
-
|
|
332052
|
-
|
|
332053
|
-
|
|
332054
|
-
|
|
332055
|
-
|
|
332056
|
-
|
|
332911
|
+
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
|
332912
|
+
if (attempt > 0) {
|
|
332913
|
+
log.info(`Retrying mobile test after failed Appium step (${attempt}/${maxRetries})`);
|
|
332914
|
+
}
|
|
332915
|
+
result2 = await executeMobileTest({
|
|
332916
|
+
steps: mobileSteps,
|
|
332917
|
+
target: {
|
|
332918
|
+
appId: target.appId,
|
|
332919
|
+
platform: target.platform,
|
|
332920
|
+
platformVersion: target.platformVersion,
|
|
332921
|
+
recordedDeviceId: target.recordedDeviceId,
|
|
332922
|
+
launchMode: target.launchMode,
|
|
332923
|
+
deeplink: target.deeplink
|
|
332924
|
+
}
|
|
332925
|
+
});
|
|
332926
|
+
attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
|
|
332927
|
+
${result2.logs}`);
|
|
332928
|
+
if (result2.passed || result2.environmental || attempt >= maxRetries) break;
|
|
332929
|
+
}
|
|
332057
332930
|
} finally {
|
|
332058
332931
|
setExecutorBusy(false);
|
|
332059
332932
|
}
|
|
332933
|
+
if (!result2) {
|
|
332934
|
+
throw new Error("Appium execution did not produce a result.");
|
|
332935
|
+
}
|
|
332936
|
+
if (attemptLogs.length > 1) {
|
|
332937
|
+
result2 = { ...result2, logs: attemptLogs.join("\n\n") };
|
|
332938
|
+
}
|
|
332060
332939
|
const duration2 = Date.now() - startTime;
|
|
332061
332940
|
const status = result2.environmental ? "ERROR" : result2.passed ? "PASSED" : "FAILED";
|
|
332062
332941
|
const reportedError = result2.environmental ? `CONFIG_ISSUE: ${result2.environmental}` : result2.error ?? void 0;
|
|
@@ -332064,6 +332943,8 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
332064
332943
|
status,
|
|
332065
332944
|
stepResults: result2.stepResults,
|
|
332066
332945
|
screenshots: result2.screenshots,
|
|
332946
|
+
video: result2.video,
|
|
332947
|
+
videoMimeType: result2.videoMimeType,
|
|
332067
332948
|
apiCalls: result2.apiCalls,
|
|
332068
332949
|
logs: result2.logs,
|
|
332069
332950
|
error: reportedError,
|
|
@@ -332375,7 +333256,7 @@ async function startRunner(opts) {
|
|
|
332375
333256
|
};
|
|
332376
333257
|
const sendHeartbeat = async () => {
|
|
332377
333258
|
try {
|
|
332378
|
-
const devices = enableMobile ?
|
|
333259
|
+
const devices = enableMobile ? getCachedDeviceSnapshot() : [];
|
|
332379
333260
|
const capabilities = enableMobile ? {
|
|
332380
333261
|
web: true,
|
|
332381
333262
|
ios: devices.some((d) => d.platform === "IOS" && d.isAvailable && d.state === "BOOTED"),
|