@validate.qa/runner 1.0.12 → 1.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +928 -127
- package/dist/cli.mjs +928 -127
- package/package.json +2 -2
package/dist/cli.mjs
CHANGED
|
@@ -238,6 +238,9 @@ var require_ai_provider = __commonJS({
|
|
|
238
238
|
exports.getNoThinkingRequestOptions = getNoThinkingRequestOptions;
|
|
239
239
|
exports.getAIClient = getAIClient;
|
|
240
240
|
exports.getClientForModel = getClientForModel;
|
|
241
|
+
exports.shouldProxyAIThroughServerForLocalRunner = shouldProxyAIThroughServerForLocalRunner;
|
|
242
|
+
exports.getRunnerAIClient = getRunnerAIClient;
|
|
243
|
+
exports.getRunnerClientForModel = getRunnerClientForModel;
|
|
241
244
|
exports.getModel = getModel;
|
|
242
245
|
exports.resolveModelForTask = resolveModelForTask;
|
|
243
246
|
exports.getAvailableModels = getAvailableModels;
|
|
@@ -515,6 +518,21 @@ var require_ai_provider = __commonJS({
|
|
|
515
518
|
function getClientForModel(modelId) {
|
|
516
519
|
return buildClientForProvider(getProviderForModel(modelId));
|
|
517
520
|
}
|
|
521
|
+
function shouldProxyAIThroughServerForLocalRunner() {
|
|
522
|
+
const serverUrl = (process.env.SERVER_URL ?? "").trim();
|
|
523
|
+
const token = (process.env.AUTH_TOKEN || process.env.RUNNER_TOKEN || "").trim();
|
|
524
|
+
return serverUrl.length > 0 && token.startsWith("urt_");
|
|
525
|
+
}
|
|
526
|
+
function getRunnerAIClient() {
|
|
527
|
+
if (shouldProxyAIThroughServerForLocalRunner())
|
|
528
|
+
return getServerProxiedAIClient();
|
|
529
|
+
return getAIClient();
|
|
530
|
+
}
|
|
531
|
+
function getRunnerClientForModel(modelId) {
|
|
532
|
+
if (shouldProxyAIThroughServerForLocalRunner())
|
|
533
|
+
return getServerProxiedClientForModel(modelId);
|
|
534
|
+
return getClientForModel(modelId);
|
|
535
|
+
}
|
|
518
536
|
function getModel(type) {
|
|
519
537
|
return MODEL_TASK_MAP[getActiveModelId()][type];
|
|
520
538
|
}
|
|
@@ -585,7 +603,7 @@ var require_ai_provider = __commonJS({
|
|
|
585
603
|
const create = async (params, options) => {
|
|
586
604
|
const modelId = typeof params.model === "string" ? params.model : "";
|
|
587
605
|
if (!modelId) {
|
|
588
|
-
throw new Error("server-proxied AI client requires params.model
|
|
606
|
+
throw new Error("server-proxied AI client requires params.model");
|
|
589
607
|
}
|
|
590
608
|
const body = {
|
|
591
609
|
modelId,
|
|
@@ -755,6 +773,21 @@ var require_mobile_source_parser = __commonJS({
|
|
|
755
773
|
const attrText = inner.slice(tag.length);
|
|
756
774
|
return { tag, attrs: parseAttributes(attrText), selfClosing };
|
|
757
775
|
}
|
|
776
|
+
function findTagEnd(xml, from) {
|
|
777
|
+
let quote = null;
|
|
778
|
+
for (let i = from; i < xml.length; i++) {
|
|
779
|
+
const ch = xml[i];
|
|
780
|
+
if (quote !== null) {
|
|
781
|
+
if (ch === quote)
|
|
782
|
+
quote = null;
|
|
783
|
+
} else if (ch === '"' || ch === "'") {
|
|
784
|
+
quote = ch;
|
|
785
|
+
} else if (ch === ">") {
|
|
786
|
+
return i;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return -1;
|
|
790
|
+
}
|
|
758
791
|
function tokenize(xml) {
|
|
759
792
|
if (!xml || typeof xml !== "string")
|
|
760
793
|
return null;
|
|
@@ -777,7 +810,7 @@ var require_mobile_source_parser = __commonJS({
|
|
|
777
810
|
i = end === -1 ? len : end + 3;
|
|
778
811
|
continue;
|
|
779
812
|
}
|
|
780
|
-
const gt = xml
|
|
813
|
+
const gt = findTagEnd(xml, lt + 1);
|
|
781
814
|
if (gt === -1)
|
|
782
815
|
break;
|
|
783
816
|
const tagBody = xml.slice(lt + 1, gt);
|
|
@@ -1563,6 +1596,10 @@ function buildAppiumCode(testName, target, steps) {
|
|
|
1563
1596
|
` }`,
|
|
1564
1597
|
`}`,
|
|
1565
1598
|
``,
|
|
1599
|
+
`async function __clearFocused(driver: WebdriverIO.Browser) {`,
|
|
1600
|
+
` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).clearValue();`,
|
|
1601
|
+
`}`,
|
|
1602
|
+
``,
|
|
1566
1603
|
`async function __textCandidates(el: WebdriverIO.Element) {`,
|
|
1567
1604
|
` const __values: string[] = [];`,
|
|
1568
1605
|
` const __push = (__value: unknown) => {`,
|
|
@@ -1656,6 +1693,12 @@ function buildAppiumCode(testName, target, steps) {
|
|
|
1656
1693
|
lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
|
|
1657
1694
|
} else if (step.action === "clear" && hasSelector) {
|
|
1658
1695
|
lines.push(` await (await findFirst(driver, ${candidatesLiteral})).clearValue();`);
|
|
1696
|
+
} else if (step.action === "clear" && bounds) {
|
|
1697
|
+
const x = Math.round(bounds.x + bounds.width / 2);
|
|
1698
|
+
const y = Math.round(bounds.y + bounds.height / 2);
|
|
1699
|
+
lines.push(` await __tapAt(driver, ${x}, ${y});`);
|
|
1700
|
+
lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
|
|
1701
|
+
lines.push(` await __clearFocused(driver);`);
|
|
1659
1702
|
} else if (step.action === "assertVisible" && hasSelector) {
|
|
1660
1703
|
lines.push(` if (!(await (await findFirst(driver, ${candidatesLiteral})).isDisplayed())) {`);
|
|
1661
1704
|
lines.push(` throw new Error('Assertion failed: element not displayed: ' + ${toJsStringLiteral(candidatesLiteral)});`);
|
|
@@ -1663,6 +1706,7 @@ function buildAppiumCode(testName, target, steps) {
|
|
|
1663
1706
|
} else if (step.action === "assertText" && hasSelector) {
|
|
1664
1707
|
lines.push(` {`);
|
|
1665
1708
|
lines.push(` const __expected = ${toJsStringLiteral(step.assertion ?? "")};`);
|
|
1709
|
+
lines.push(` if (__expected.length === 0) throw new Error('assertText step is missing expected text');`);
|
|
1666
1710
|
lines.push(` const __actual = await __textCandidates(await findFirst(driver, ${candidatesLiteral}));`);
|
|
1667
1711
|
lines.push(` if (!__actual.some((__value) => __value.includes(__expected))) {`);
|
|
1668
1712
|
lines.push(` throw new Error('Assertion failed: expected text to contain ' + JSON.stringify(__expected) + ' but got ' + JSON.stringify(__actual));`);
|
|
@@ -4371,6 +4415,7 @@ var require_mobile_observation = __commonJS({
|
|
|
4371
4415
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4372
4416
|
exports.renderMobileObservation = renderMobileObservation;
|
|
4373
4417
|
exports.summarizeScreenForEvidence = summarizeScreenForEvidence;
|
|
4418
|
+
exports.buildCollectedElementsForScreen = buildCollectedElementsForScreen;
|
|
4374
4419
|
var DEFAULT_MAX_ELEMENTS = 60;
|
|
4375
4420
|
var MAX_LABEL_LENGTH = 80;
|
|
4376
4421
|
var EVIDENCE_LABEL_LIMIT = 40;
|
|
@@ -4412,6 +4457,46 @@ var require_mobile_observation = __commonJS({
|
|
|
4412
4457
|
}
|
|
4413
4458
|
return { elementCount, actionableCount, labels };
|
|
4414
4459
|
}
|
|
4460
|
+
function buildCollectedElementsForScreen(elements, opts = {}) {
|
|
4461
|
+
const navigationRefs = opts.navigationRefs ?? EMPTY_REF_SET;
|
|
4462
|
+
const out = [];
|
|
4463
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4464
|
+
for (const element of elements) {
|
|
4465
|
+
if (!element.actionable)
|
|
4466
|
+
continue;
|
|
4467
|
+
const collected = toCollectedPageElement(element, navigationRefs.has(element.ref));
|
|
4468
|
+
const identity = `${collected.elementType}|${collected.testId ?? ""}|${collected.label ?? ""}`;
|
|
4469
|
+
if (seen.has(identity))
|
|
4470
|
+
continue;
|
|
4471
|
+
seen.add(identity);
|
|
4472
|
+
out.push(collected);
|
|
4473
|
+
}
|
|
4474
|
+
return out;
|
|
4475
|
+
}
|
|
4476
|
+
var EMPTY_REF_SET = /* @__PURE__ */ new Set();
|
|
4477
|
+
function toCollectedPageElement(element, triggersNavigation) {
|
|
4478
|
+
const label = element.label?.trim() || element.text?.trim() || void 0;
|
|
4479
|
+
const stableId = element.accessibilityId?.trim() || element.resourceId?.trim() || void 0;
|
|
4480
|
+
const locators = [];
|
|
4481
|
+
if (stableId)
|
|
4482
|
+
locators.push({ strategy: "getByTestId", value: stableId, confidence: 0.95 });
|
|
4483
|
+
const xpath = element.xpath?.trim();
|
|
4484
|
+
if (xpath)
|
|
4485
|
+
locators.push({ strategy: "css", value: xpath, confidence: 0.4 });
|
|
4486
|
+
const collected = {
|
|
4487
|
+
elementType: element.role || "View",
|
|
4488
|
+
triggersNavigation
|
|
4489
|
+
};
|
|
4490
|
+
if (label)
|
|
4491
|
+
collected.label = label;
|
|
4492
|
+
if (stableId)
|
|
4493
|
+
collected.testId = stableId;
|
|
4494
|
+
if (locators.length > 0)
|
|
4495
|
+
collected.locators = locators;
|
|
4496
|
+
if (element.enabled === false)
|
|
4497
|
+
collected.isDisabled = true;
|
|
4498
|
+
return collected;
|
|
4499
|
+
}
|
|
4415
4500
|
function buildHeaderLine(screen) {
|
|
4416
4501
|
const parts = ["screen:"];
|
|
4417
4502
|
const name = screenDisplayName(screen.screenSignal);
|
|
@@ -4860,7 +4945,7 @@ var require_mobile_explorer = __commonJS({
|
|
|
4860
4945
|
}
|
|
4861
4946
|
return sections.join("\n");
|
|
4862
4947
|
}
|
|
4863
|
-
function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId) {
|
|
4948
|
+
function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId, existingScreens) {
|
|
4864
4949
|
const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
|
|
4865
4950
|
const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
|
|
4866
4951
|
const surveyBody = `You are a mobile QA exploration agent driving a real ${platformLabel} app on a connected device. Your goal: MAP every reachable screen so downstream agents know what to test. You are NOT testing \u2014 you are building a complete screen map.
|
|
@@ -4906,13 +4991,18 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
|
|
|
4906
4991
|
## Application Brief
|
|
4907
4992
|
${appBrief}
|
|
4908
4993
|
Use this to prioritize which screens and flows to cover.` : "";
|
|
4909
|
-
|
|
4994
|
+
const existingScreensBlock = mode === "survey" && existingScreens && existingScreens.length > 0 ? `
|
|
4995
|
+
|
|
4996
|
+
## ALREADY DISCOVERED SCREENS (skip these; find new)
|
|
4997
|
+
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.
|
|
4998
|
+
${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.name}"` : ""}${s.screenType ? ` [${s.screenType}]` : ""}`).join("\n")}` : "";
|
|
4999
|
+
return `${mode === "survey" ? surveyBody : deepBody}${briefBlock}${existingScreensBlock}`;
|
|
4910
5000
|
}
|
|
4911
5001
|
function buildKickoffMessage(mode) {
|
|
4912
5002
|
return mode === "survey" ? "Start by calling mobile_snapshot on the launch screen, then capture_screen. Map the entire app: open every tab, menu, and one row per list. Record transitions as you go." : "Start by calling mobile_snapshot on the launch screen. Then use the features: fill fields, submit forms, open menus, and observe outcomes. Record journeys after meaningful flows.";
|
|
4913
5003
|
}
|
|
4914
5004
|
async function runMobileExplorePhase(args) {
|
|
4915
|
-
const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot } = args;
|
|
5005
|
+
const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot, onScreenshotUpload, existingScreens } = args;
|
|
4916
5006
|
const logs2 = [];
|
|
4917
5007
|
const log2 = (line) => {
|
|
4918
5008
|
logs2.push(line);
|
|
@@ -4936,6 +5026,9 @@ Use this to prioritize which screens and flows to cover.` : "";
|
|
|
4936
5026
|
log2(`Budget: ${maxIterations} iterations | app: ${ctx.target?.appId ?? "(unknown)"}`);
|
|
4937
5027
|
const screenshots = [];
|
|
4938
5028
|
const pageScreenshots = {};
|
|
5029
|
+
const pageScreenshotKeys = {};
|
|
5030
|
+
const uploadedScreenIds = /* @__PURE__ */ new Set();
|
|
5031
|
+
const pendingScreenshotUploads = [];
|
|
4939
5032
|
const collectedTransitions = [];
|
|
4940
5033
|
const journeys = [];
|
|
4941
5034
|
const counters = {
|
|
@@ -4954,6 +5047,14 @@ Use this to prioritize which screens and flows to cover.` : "";
|
|
|
4954
5047
|
screenshots.push(base64);
|
|
4955
5048
|
if (screenId)
|
|
4956
5049
|
pageScreenshots[screenId] = base64;
|
|
5050
|
+
if (screenId && onScreenshotUpload && !uploadedScreenIds.has(screenId)) {
|
|
5051
|
+
uploadedScreenIds.add(screenId);
|
|
5052
|
+
pendingScreenshotUploads.push(onScreenshotUpload(screenId, base64).then((key) => {
|
|
5053
|
+
if (key)
|
|
5054
|
+
pageScreenshotKeys[screenId] = key;
|
|
5055
|
+
}).catch(() => {
|
|
5056
|
+
}));
|
|
5057
|
+
}
|
|
4957
5058
|
try {
|
|
4958
5059
|
onScreenshot?.(base64);
|
|
4959
5060
|
} catch {
|
|
@@ -5058,7 +5159,7 @@ Use this to prioritize which screens and flows to cover.` : "";
|
|
|
5058
5159
|
return null;
|
|
5059
5160
|
return { element, bounds: element.bounds };
|
|
5060
5161
|
};
|
|
5061
|
-
const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId);
|
|
5162
|
+
const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId, existingScreens);
|
|
5062
5163
|
const kickoff = buildKickoffMessage(mode);
|
|
5063
5164
|
const recentExchanges = [];
|
|
5064
5165
|
const transientDirectives = [];
|
|
@@ -5266,6 +5367,9 @@ Exploration complete: ${summary}`);
|
|
|
5266
5367
|
summary = `Explore phase aborted (${truncatedReason}) after mapping ${state2.getScreenCount()} screen(s).`;
|
|
5267
5368
|
}
|
|
5268
5369
|
}
|
|
5370
|
+
if (pendingScreenshotUploads.length > 0) {
|
|
5371
|
+
await Promise.allSettled(pendingScreenshotUploads);
|
|
5372
|
+
}
|
|
5269
5373
|
const collectedPages = state2.buildInMemoryScreenMap();
|
|
5270
5374
|
counters.pagesVisited = collectedPages.length;
|
|
5271
5375
|
if (counters.pagesDiscovered < collectedPages.length) {
|
|
@@ -5300,6 +5404,9 @@ Exploration complete: ${summary}`);
|
|
|
5300
5404
|
if (Object.keys(pageScreenshots).length > 0) {
|
|
5301
5405
|
result.pageScreenshots = pageScreenshots;
|
|
5302
5406
|
}
|
|
5407
|
+
if (Object.keys(pageScreenshotKeys).length > 0) {
|
|
5408
|
+
result.pageScreenshotKeys = pageScreenshotKeys;
|
|
5409
|
+
}
|
|
5303
5410
|
if (truncated) {
|
|
5304
5411
|
result.truncated = true;
|
|
5305
5412
|
result.truncatedReason = truncatedReason;
|
|
@@ -5801,6 +5908,44 @@ var require_snapshot_parser = __commonJS({
|
|
|
5801
5908
|
};
|
|
5802
5909
|
var SUBMIT_LABEL_RE = /^(submit|sign\s*in|sign\s*up|log\s*in|register|create|save|send|confirm|continue|next|apply|post|publish|add|update|go)$/i;
|
|
5803
5910
|
var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
|
|
5911
|
+
var RICH_CONTEXT_ROLES = /* @__PURE__ */ new Set(["table", "grid", "row", "radiogroup"]);
|
|
5912
|
+
var RICH_CONTEXT_LABEL_MAX = 60;
|
|
5913
|
+
var VALUE_BEARING_ROLES = /* @__PURE__ */ new Set(["textbox", "searchbox", "combobox", "spinbutton", "listbox", "slider"]);
|
|
5914
|
+
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;
|
|
5915
|
+
var REDACTED_VALUE = "[redacted]";
|
|
5916
|
+
var VALUE_TEXT_MAX = 120;
|
|
5917
|
+
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;
|
|
5918
|
+
var STATIC_MESSAGES_MAX = 12;
|
|
5919
|
+
var OPTIONS_PER_ELEMENT_MAX = 24;
|
|
5920
|
+
var URL_CHILD_LINE_RE = /^(\s*)- \/url:\s*(.+)$/;
|
|
5921
|
+
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;
|
|
5922
|
+
function stripYamlQuotes(raw) {
|
|
5923
|
+
const trimmed = raw.trim();
|
|
5924
|
+
if (trimmed.length >= 2 && (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
5925
|
+
return trimmed.slice(1, -1);
|
|
5926
|
+
}
|
|
5927
|
+
return trimmed;
|
|
5928
|
+
}
|
|
5929
|
+
function extractTrailingText(attrs) {
|
|
5930
|
+
const lastBracket = attrs.lastIndexOf("]");
|
|
5931
|
+
const tail = lastBracket >= 0 ? attrs.slice(lastBracket + 1) : attrs;
|
|
5932
|
+
const match = /^\s*:\s+(.+)$/.exec(tail);
|
|
5933
|
+
if (!match)
|
|
5934
|
+
return null;
|
|
5935
|
+
const text = stripYamlQuotes(match[1]);
|
|
5936
|
+
if (!text || /^(?:\||\|-|>|>-)$/.test(text))
|
|
5937
|
+
return null;
|
|
5938
|
+
return text;
|
|
5939
|
+
}
|
|
5940
|
+
function isSensitiveValueField(name, placeholder) {
|
|
5941
|
+
return SENSITIVE_VALUE_LABEL_RE.test(`${name ?? ""} ${placeholder ?? ""}`);
|
|
5942
|
+
}
|
|
5943
|
+
function scrubMessageText(text) {
|
|
5944
|
+
return text.replace(MESSAGE_TOKEN_RE, REDACTED_VALUE);
|
|
5945
|
+
}
|
|
5946
|
+
function capText(text, max) {
|
|
5947
|
+
return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
|
|
5948
|
+
}
|
|
5804
5949
|
function isProbablyDynamicTestId(testId) {
|
|
5805
5950
|
if (testId.length < 6)
|
|
5806
5951
|
return false;
|
|
@@ -5876,9 +6021,12 @@ var require_snapshot_parser = __commonJS({
|
|
|
5876
6021
|
}
|
|
5877
6022
|
return e.name;
|
|
5878
6023
|
}
|
|
5879
|
-
function parseSnapshot(snapshotText) {
|
|
6024
|
+
function parseSnapshot(snapshotText, options = {}) {
|
|
6025
|
+
const richParse = options.richParse ?? process.env.DISCOVERY_SNAPSHOT_RICH_PARSE === "true";
|
|
5880
6026
|
const lines = snapshotText.split("\n");
|
|
5881
6027
|
const elements = [];
|
|
6028
|
+
const liveMessages = [];
|
|
6029
|
+
const textMessages = [];
|
|
5882
6030
|
const seenRefs = /* @__PURE__ */ new Set();
|
|
5883
6031
|
let parsedElementLineCount = 0;
|
|
5884
6032
|
let genericLabelCount = 0;
|
|
@@ -5887,6 +6035,21 @@ var require_snapshot_parser = __commonJS({
|
|
|
5887
6035
|
const contextStack = [];
|
|
5888
6036
|
const completedForms = [];
|
|
5889
6037
|
for (const line of lines) {
|
|
6038
|
+
if (richParse) {
|
|
6039
|
+
const urlMatch = URL_CHILD_LINE_RE.exec(line);
|
|
6040
|
+
if (urlMatch) {
|
|
6041
|
+
const urlIndent = urlMatch[1].length;
|
|
6042
|
+
for (let i = elements.length - 1; i >= 0; i--) {
|
|
6043
|
+
const el = elements[i];
|
|
6044
|
+
if (el.indentDepth < urlIndent) {
|
|
6045
|
+
if (el.role === "link" && !el.href)
|
|
6046
|
+
el.href = capText(stripYamlQuotes(urlMatch[2]), 300);
|
|
6047
|
+
break;
|
|
6048
|
+
}
|
|
6049
|
+
}
|
|
6050
|
+
continue;
|
|
6051
|
+
}
|
|
6052
|
+
}
|
|
5890
6053
|
const match = ELEMENT_LINE_RE.exec(line);
|
|
5891
6054
|
if (!match)
|
|
5892
6055
|
continue;
|
|
@@ -5908,7 +6071,7 @@ var require_snapshot_parser = __commonJS({
|
|
|
5908
6071
|
}
|
|
5909
6072
|
const headingLevelMatch = LEVEL_RE.exec(attrs);
|
|
5910
6073
|
const headingLevel = headingLevelMatch ? Number.parseInt(headingLevelMatch[1], 10) : null;
|
|
5911
|
-
const contextLabel = buildContextLabel(role, name ?? null, headingLevel);
|
|
6074
|
+
const contextLabel = buildContextLabel(role, name ?? null, headingLevel, richParse);
|
|
5912
6075
|
if (contextLabel) {
|
|
5913
6076
|
contextStack.push({ indent: indentDepth, label: contextLabel });
|
|
5914
6077
|
}
|
|
@@ -5921,6 +6084,40 @@ var require_snapshot_parser = __commonJS({
|
|
|
5921
6084
|
});
|
|
5922
6085
|
continue;
|
|
5923
6086
|
}
|
|
6087
|
+
if (richParse) {
|
|
6088
|
+
if (role === "option" && name) {
|
|
6089
|
+
for (let i = elements.length - 1; i >= 0; i--) {
|
|
6090
|
+
const el = elements[i];
|
|
6091
|
+
if (el.indentDepth < indentDepth) {
|
|
6092
|
+
if (el.role === "combobox" || el.role === "listbox") {
|
|
6093
|
+
el.options ??= [];
|
|
6094
|
+
if (el.options.length < OPTIONS_PER_ELEMENT_MAX) {
|
|
6095
|
+
el.options.push({ label: capText(name, 80), selected: parseBooleanState(SELECTED_RE.exec(attrs)) === true });
|
|
6096
|
+
}
|
|
6097
|
+
}
|
|
6098
|
+
break;
|
|
6099
|
+
}
|
|
6100
|
+
}
|
|
6101
|
+
}
|
|
6102
|
+
if (role === "alert" || role === "status" || role === "paragraph" || role === "text") {
|
|
6103
|
+
const text = extractTrailingText(attrs) ?? (name || null);
|
|
6104
|
+
const isLiveRegion = role === "alert" || role === "status";
|
|
6105
|
+
if (text && (isLiveRegion || SIGNAL_TEXT_RE.test(text))) {
|
|
6106
|
+
const capped = capText(scrubMessageText(text), VALUE_TEXT_MAX);
|
|
6107
|
+
const ref2 = REF_RE.exec(attrs)?.[1] ?? null;
|
|
6108
|
+
if (isLiveRegion) {
|
|
6109
|
+
const textDupIndex = textMessages.findIndex((m) => m.text === capped);
|
|
6110
|
+
if (textDupIndex >= 0)
|
|
6111
|
+
textMessages.splice(textDupIndex, 1);
|
|
6112
|
+
if (liveMessages.length < STATIC_MESSAGES_MAX && !liveMessages.some((m) => m.text === capped)) {
|
|
6113
|
+
liveMessages.push({ role, text: capped, ref: ref2 });
|
|
6114
|
+
}
|
|
6115
|
+
} else if (textMessages.length < STATIC_MESSAGES_MAX && !textMessages.some((m) => m.text === capped) && !liveMessages.some((m) => m.text === capped)) {
|
|
6116
|
+
textMessages.push({ role: "text", text: capped, ref: ref2 });
|
|
6117
|
+
}
|
|
6118
|
+
}
|
|
6119
|
+
}
|
|
6120
|
+
}
|
|
5924
6121
|
if (!INTERACTIVE_ROLES.has(role))
|
|
5925
6122
|
continue;
|
|
5926
6123
|
const refMatch = REF_RE.exec(attrs);
|
|
@@ -5953,6 +6150,13 @@ var require_snapshot_parser = __commonJS({
|
|
|
5953
6150
|
}
|
|
5954
6151
|
const isLink = role === "link";
|
|
5955
6152
|
const isSubmitButton = role === "button" && name != null && SUBMIT_LABEL_RE.test(name);
|
|
6153
|
+
let value = null;
|
|
6154
|
+
if (richParse && VALUE_BEARING_ROLES.has(role)) {
|
|
6155
|
+
const trailing = extractTrailingText(attrs);
|
|
6156
|
+
if (trailing) {
|
|
6157
|
+
value = isSensitiveValueField(name ?? null, placeholder) ? REDACTED_VALUE : capText(trailing, VALUE_TEXT_MAX);
|
|
6158
|
+
}
|
|
6159
|
+
}
|
|
5956
6160
|
const element = {
|
|
5957
6161
|
ref,
|
|
5958
6162
|
role,
|
|
@@ -5974,7 +6178,10 @@ var require_snapshot_parser = __commonJS({
|
|
|
5974
6178
|
pressed,
|
|
5975
6179
|
headingLevel,
|
|
5976
6180
|
currentState,
|
|
5977
|
-
containerPath
|
|
6181
|
+
containerPath,
|
|
6182
|
+
href: null,
|
|
6183
|
+
value,
|
|
6184
|
+
options: null
|
|
5978
6185
|
};
|
|
5979
6186
|
if (isGenericLabel(element.name))
|
|
5980
6187
|
genericLabelCount++;
|
|
@@ -6002,6 +6209,9 @@ var require_snapshot_parser = __commonJS({
|
|
|
6002
6209
|
return {
|
|
6003
6210
|
elements,
|
|
6004
6211
|
formGroups: completedForms,
|
|
6212
|
+
// live regions first: they carry their own reservation so an end-of-body toast can
|
|
6213
|
+
// never be starved out by signal-matching plain text earlier in the document.
|
|
6214
|
+
staticMessages: [...liveMessages, ...textMessages].slice(0, STATIC_MESSAGES_MAX),
|
|
6005
6215
|
rawLineCount: lines.length,
|
|
6006
6216
|
parsedElementLineCount,
|
|
6007
6217
|
genericLabelCount,
|
|
@@ -6112,12 +6322,17 @@ var require_snapshot_parser = __commonJS({
|
|
|
6112
6322
|
return "mixed";
|
|
6113
6323
|
return false;
|
|
6114
6324
|
}
|
|
6115
|
-
function buildContextLabel(role, name, headingLevel) {
|
|
6325
|
+
function buildContextLabel(role, name, headingLevel, richContext = false) {
|
|
6116
6326
|
if (role === "heading") {
|
|
6117
6327
|
if (!name)
|
|
6118
6328
|
return null;
|
|
6119
6329
|
return headingLevel ? `heading${headingLevel}:${name}` : `heading:${name}`;
|
|
6120
6330
|
}
|
|
6331
|
+
if (richContext && RICH_CONTEXT_ROLES.has(role)) {
|
|
6332
|
+
if (!name)
|
|
6333
|
+
return null;
|
|
6334
|
+
return `${role}:${capText(name.replace(/>/g, "\u203A"), RICH_CONTEXT_LABEL_MAX)}`;
|
|
6335
|
+
}
|
|
6121
6336
|
if (!CONTEXT_ROLES.has(role))
|
|
6122
6337
|
return null;
|
|
6123
6338
|
if (name)
|
|
@@ -7827,7 +8042,7 @@ Each entry is exactly one of:
|
|
|
7827
8042
|
const systemPrompt = options.systemPromptOverride ?? (context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports.SCENARIO_SYSTEM_PROMPT);
|
|
7828
8043
|
const userPrompt = options.userPromptOverride ?? formatEvidenceForPlanner(compressed, evidence, context);
|
|
7829
8044
|
logs2.push(`AI planner: ~${Math.ceil(userPrompt.length / 3.5)} input tokens`);
|
|
7830
|
-
const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.
|
|
8045
|
+
const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
|
|
7831
8046
|
const messages = [
|
|
7832
8047
|
{ role: "system", content: systemPrompt },
|
|
7833
8048
|
{ role: "user", content: userPrompt }
|
|
@@ -7835,18 +8050,22 @@ Each entry is exactly one of:
|
|
|
7835
8050
|
const callStart = Date.now();
|
|
7836
8051
|
let response;
|
|
7837
8052
|
try {
|
|
7838
|
-
response = await (0, ai_retry_js_1.
|
|
7839
|
-
|
|
7840
|
-
|
|
7841
|
-
|
|
7842
|
-
|
|
7843
|
-
|
|
7844
|
-
|
|
7845
|
-
|
|
8053
|
+
response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
|
|
8054
|
+
arm();
|
|
8055
|
+
return aiClient.chat.completions.create({
|
|
8056
|
+
model: agentModel,
|
|
8057
|
+
messages,
|
|
8058
|
+
max_tokens: plannerMaxTokens,
|
|
8059
|
+
temperature: 0.3,
|
|
8060
|
+
...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
|
|
8061
|
+
...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
|
|
8062
|
+
}, { signal });
|
|
8063
|
+
}), {
|
|
7846
8064
|
label: "ai-planner",
|
|
7847
8065
|
baseDelayMs: 5e3,
|
|
7848
8066
|
maxRetries: 2,
|
|
7849
|
-
|
|
8067
|
+
deferTimeoutUntilArmed: true,
|
|
8068
|
+
onRetry: (attempt, reason, delay) => logs2.push(`AI planner call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
|
|
7850
8069
|
});
|
|
7851
8070
|
} catch (err) {
|
|
7852
8071
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -7912,18 +8131,25 @@ Each entry is exactly one of:
|
|
|
7912
8131
|
const retryStart = Date.now();
|
|
7913
8132
|
let retryResponse = null;
|
|
7914
8133
|
try {
|
|
7915
|
-
retryResponse = await (0, ai_retry_js_1.
|
|
7916
|
-
|
|
7917
|
-
|
|
7918
|
-
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
8134
|
+
retryResponse = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
|
|
8135
|
+
arm();
|
|
8136
|
+
return aiClient.chat.completions.create({
|
|
8137
|
+
model: agentModel,
|
|
8138
|
+
messages: [
|
|
8139
|
+
...messages,
|
|
8140
|
+
{ role: "assistant", content: raw },
|
|
8141
|
+
{ role: "user", content: retryContext }
|
|
8142
|
+
],
|
|
8143
|
+
max_tokens: plannerMaxTokens,
|
|
8144
|
+
temperature: 0.3,
|
|
8145
|
+
...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
|
|
8146
|
+
...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
|
|
8147
|
+
}, { signal });
|
|
8148
|
+
}), {
|
|
8149
|
+
label: "ai-planner-retry",
|
|
8150
|
+
deferTimeoutUntilArmed: true,
|
|
8151
|
+
onRetry: (attempt, reason, delay) => logs2.push(`AI planner retry call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
|
|
8152
|
+
});
|
|
7927
8153
|
} catch (err) {
|
|
7928
8154
|
const message = err instanceof Error ? err.message : String(err);
|
|
7929
8155
|
logs2.push(`AI planner retry failed (${message}) \u2014 using first-pass result`);
|
|
@@ -8451,12 +8677,12 @@ ${constraints.join("\n")}`);
|
|
|
8451
8677
|
const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
|
|
8452
8678
|
const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
|
|
8453
8679
|
for (const state2 of orderedScreens) {
|
|
8454
|
-
const groupName = state2.screenType === "auth" ? "Authentication" : screenDisplayName(state2);
|
|
8680
|
+
const groupName = state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(state2);
|
|
8455
8681
|
const group = groups.get(groupName) ?? {
|
|
8456
8682
|
name: groupName,
|
|
8457
8683
|
description: `Native mobile screens related to ${groupName}.`,
|
|
8458
8684
|
routes: [],
|
|
8459
|
-
criticality: state2.screenType === "auth" ? "critical" : "medium",
|
|
8685
|
+
criticality: state2.screenType?.toLowerCase() === "auth" ? "critical" : "medium",
|
|
8460
8686
|
pages: [],
|
|
8461
8687
|
flows: []
|
|
8462
8688
|
};
|
|
@@ -8479,7 +8705,7 @@ ${constraints.join("\n")}`);
|
|
|
8479
8705
|
const to = graph.screens.get(transition.toScreenId);
|
|
8480
8706
|
if (!from || !to)
|
|
8481
8707
|
continue;
|
|
8482
|
-
const groupName = from.screenType === "auth" ? "Authentication" : screenDisplayName(from);
|
|
8708
|
+
const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(from);
|
|
8483
8709
|
const group = groups.get(groupName);
|
|
8484
8710
|
if (!group)
|
|
8485
8711
|
continue;
|
|
@@ -8524,9 +8750,9 @@ ${constraints.join("\n")}`);
|
|
|
8524
8750
|
scenarios.push({
|
|
8525
8751
|
name,
|
|
8526
8752
|
type: "HAPPY_PATH",
|
|
8527
|
-
priority: state2.screenType === "auth" ? 1 : 3,
|
|
8753
|
+
priority: state2.screenType?.toLowerCase() === "auth" ? 1 : 3,
|
|
8528
8754
|
variant: "happy",
|
|
8529
|
-
featureGroup: state2.screenType === "auth" ? "Authentication" : screenName,
|
|
8755
|
+
featureGroup: state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenName,
|
|
8530
8756
|
goal: `User can reach and inspect the ${screenName} screen`,
|
|
8531
8757
|
steps,
|
|
8532
8758
|
expectedOutcomes: [{
|
|
@@ -8672,6 +8898,7 @@ var require_mobile_generator = __commonJS({
|
|
|
8672
8898
|
var mobile_observation_js_1 = require_mobile_observation();
|
|
8673
8899
|
var mobile_boundary_js_1 = require_mobile_boundary();
|
|
8674
8900
|
var MAX_SCENARIO_ITERATIONS = 30;
|
|
8901
|
+
var MAX_CONSECUTIVE_INFRA_FAILURES = 3;
|
|
8675
8902
|
var MAX_RECENT_EXCHANGES = 24;
|
|
8676
8903
|
var OBSERVATION_MAX_ELEMENTS = 50;
|
|
8677
8904
|
function finalizeAction(input) {
|
|
@@ -9396,14 +9623,31 @@ ${statePacket}` },
|
|
|
9396
9623
|
if (!snapshot || snapshot.screen.elements.length === 0)
|
|
9397
9624
|
return null;
|
|
9398
9625
|
const elements = snapshot.screen.elements;
|
|
9399
|
-
const
|
|
9400
|
-
const
|
|
9626
|
+
const stableAnchors = elements.filter((el) => hasStableId(el) && !isAlwaysPresentContainer(el));
|
|
9627
|
+
const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
|
|
9628
|
+
if (contentfulNonButton)
|
|
9629
|
+
return contentfulNonButton;
|
|
9630
|
+
const nonButton = stableAnchors.find((el) => !/button/i.test(el.role));
|
|
9401
9631
|
if (nonButton)
|
|
9402
9632
|
return nonButton;
|
|
9403
|
-
if (
|
|
9404
|
-
return
|
|
9405
|
-
const labelled = elements.find((el) => nonEmpty2(el.label) || nonEmpty2(el.text));
|
|
9406
|
-
return labelled ?? elements[0];
|
|
9633
|
+
if (stableAnchors.length > 0)
|
|
9634
|
+
return stableAnchors[0];
|
|
9635
|
+
const labelled = elements.find((el) => !isAlwaysPresentContainer(el) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
|
|
9636
|
+
return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
|
|
9637
|
+
}
|
|
9638
|
+
var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
|
|
9639
|
+
var ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS = /* @__PURE__ */ new Set([
|
|
9640
|
+
"android:id/content",
|
|
9641
|
+
"android:id/decor",
|
|
9642
|
+
"android:id/action_bar_root",
|
|
9643
|
+
"android:id/statusBarBackground",
|
|
9644
|
+
"android:id/navigationBarBackground"
|
|
9645
|
+
]);
|
|
9646
|
+
function isAlwaysPresentContainer(element) {
|
|
9647
|
+
if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
|
|
9648
|
+
return true;
|
|
9649
|
+
const resourceId = element.resourceId?.trim();
|
|
9650
|
+
return !!resourceId && ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS.has(resourceId);
|
|
9407
9651
|
}
|
|
9408
9652
|
function appendHybridFloor(actions, recording, platform3) {
|
|
9409
9653
|
const anchor = pickLandingAnchor(recording.lastSnapshot);
|
|
@@ -9462,7 +9706,7 @@ ${statePacket}` },
|
|
|
9462
9706
|
return test;
|
|
9463
9707
|
}
|
|
9464
9708
|
async function runMobileGeneratePhase(args) {
|
|
9465
|
-
const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot } = args;
|
|
9709
|
+
const { scenarios: allScenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot, skipScenarioNames, onInfraStop } = args;
|
|
9466
9710
|
const log2 = (line) => {
|
|
9467
9711
|
try {
|
|
9468
9712
|
onLog?.(line);
|
|
@@ -9477,8 +9721,16 @@ ${statePacket}` },
|
|
|
9477
9721
|
const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
|
|
9478
9722
|
const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
|
|
9479
9723
|
const targetAppId = ctx.target?.appId?.trim() || void 0;
|
|
9724
|
+
const skipSet = new Set(skipScenarioNames ?? []);
|
|
9725
|
+
const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
|
|
9726
|
+
if (skipSet.size > 0) {
|
|
9727
|
+
log2(`Resume: skipping ${allScenarios.length - scenarios.length} already-generated scenario(s); ${scenarios.length} remaining to generate`);
|
|
9728
|
+
}
|
|
9480
9729
|
log2(`Mobile generator starting \u2014 platform: ${platform3}, provider: ${provider}, model: ${agentModel}, scenarios: ${scenarios.length}`);
|
|
9481
9730
|
const tests = [];
|
|
9731
|
+
const generatedScenarioNames = /* @__PURE__ */ new Set();
|
|
9732
|
+
let consecutiveInfraFailures = 0;
|
|
9733
|
+
let infraStopped = false;
|
|
9482
9734
|
for (let i = 0; i < scenarios.length; i++) {
|
|
9483
9735
|
const scenario = scenarios[i];
|
|
9484
9736
|
log2(`
|
|
@@ -9499,6 +9751,7 @@ ${statePacket}` },
|
|
|
9499
9751
|
targetAppId,
|
|
9500
9752
|
log: log2
|
|
9501
9753
|
});
|
|
9754
|
+
consecutiveInfraFailures = 0;
|
|
9502
9755
|
const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
|
|
9503
9756
|
if (!test) {
|
|
9504
9757
|
log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
|
|
@@ -9515,13 +9768,31 @@ ${statePacket}` },
|
|
|
9515
9768
|
log2(` onTestGenerated callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
|
|
9516
9769
|
}
|
|
9517
9770
|
tests.push(test);
|
|
9771
|
+
generatedScenarioNames.add(scenario.name);
|
|
9518
9772
|
} catch (err) {
|
|
9773
|
+
if ((0, ai_retry_js_1.isAIProviderError)(err)) {
|
|
9774
|
+
consecutiveInfraFailures += 1;
|
|
9775
|
+
log2(` scenario "${scenario.name}" failed \u2014 AI provider error (${consecutiveInfraFailures}/${MAX_CONSECUTIVE_INFRA_FAILURES}): ${err instanceof Error ? err.message : String(err)}`);
|
|
9776
|
+
if (consecutiveInfraFailures >= MAX_CONSECUTIVE_INFRA_FAILURES) {
|
|
9777
|
+
const remainingScenarioNames = scenarios.filter((s) => !generatedScenarioNames.has(s.name)).map((s) => s.name);
|
|
9778
|
+
infraStopped = true;
|
|
9779
|
+
log2(`
|
|
9780
|
+
\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.`);
|
|
9781
|
+
try {
|
|
9782
|
+
onInfraStop?.({ remainingScenarioNames, reason: "ai_rate_limit" });
|
|
9783
|
+
} catch (cbErr) {
|
|
9784
|
+
log2(` onInfraStop callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
|
|
9785
|
+
}
|
|
9786
|
+
break;
|
|
9787
|
+
}
|
|
9788
|
+
continue;
|
|
9789
|
+
}
|
|
9519
9790
|
log2(` scenario "${scenario.name}" failed: ${err instanceof Error ? err.message : String(err)} \u2014 skipping.`);
|
|
9520
9791
|
continue;
|
|
9521
9792
|
}
|
|
9522
9793
|
}
|
|
9523
9794
|
log2(`
|
|
9524
|
-
Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) generated (${tests.filter((t) => t.verified).length} verified).`);
|
|
9795
|
+
Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) generated (${tests.filter((t) => t.verified).length} verified)${infraStopped ? " \u2014 PAUSED on AI provider overload" : ""}.`);
|
|
9525
9796
|
return tests;
|
|
9526
9797
|
}
|
|
9527
9798
|
}
|
|
@@ -12800,6 +13071,10 @@ var require_snapshot_observation = __commonJS({
|
|
|
12800
13071
|
var snapshot_parser_js_1 = require_snapshot_parser();
|
|
12801
13072
|
var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
|
|
12802
13073
|
var NOISE_LABEL_RE = /^(skip\s+to|skip\s+nav|back\s+to\s+top|cookie|accept\s+(all\s+)?cookies)$/i;
|
|
13074
|
+
var DIALOG_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog\b/i;
|
|
13075
|
+
var FORM_SEGMENT_RE = /(?:^|>\s*)form\b/i;
|
|
13076
|
+
var TABPANEL_SEGMENT_RE = /(?:^|>\s*)tabpanel\b/i;
|
|
13077
|
+
var DIALOG_NAME_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog:([^>]+)/i;
|
|
12803
13078
|
var INTERACTIVE_ROLE_RE = /^(button|link|textbox|searchbox|checkbox|radio|switch|combobox|listbox|option|menuitem|tab|slider|spinbutton|textarea)$/i;
|
|
12804
13079
|
var INTERACTIVE_ELEMENT_TYPE_RE = /^(button|link|input|textarea|select|checkbox|radio|switch|tab|combobox|listbox|option|menuitem|slider|spinbutton)$/i;
|
|
12805
13080
|
function renderRetrievedSnapshotMemoryBlock(priorSnapshotEvidence, priorDiscoveryCheckpoints, options = {}) {
|
|
@@ -13044,10 +13319,12 @@ var require_snapshot_observation = __commonJS({
|
|
|
13044
13319
|
summary
|
|
13045
13320
|
}));
|
|
13046
13321
|
}
|
|
13322
|
+
var TEXT_DISAMBIGUATING_INPUT_TYPES = /* @__PURE__ */ new Set(["password", "text", "email", "tel", "number", "search", "url", "date", "datetime-local", "time", "month", "week"]);
|
|
13047
13323
|
function renderCompactSnapshotBlock(page, context = {}, options = {}) {
|
|
13048
13324
|
const metrics = page.snapshotMetrics ?? page.snapshotArtifact?.metrics;
|
|
13049
13325
|
const sourceKind = page.snapshotMeta?.sourceKind ?? page.snapshotArtifact?.sourceKind ?? "missing";
|
|
13050
13326
|
const artifact = page.snapshotArtifact;
|
|
13327
|
+
const richRender = options.richRender ?? process.env.DISCOVERY_SNAPSHOT_RICH_RENDER === "true";
|
|
13051
13328
|
const activeScope = inferActiveSnapshotScope(page);
|
|
13052
13329
|
const ranked = rankSnapshotElements(getScopedSnapshotElements(page, activeScope), withScopeContext(context, activeScope));
|
|
13053
13330
|
const allInteractive = ranked.filter(({ element }) => isInteractiveCandidate(element)).filter(({ element }) => {
|
|
@@ -13093,7 +13370,7 @@ var require_snapshot_observation = __commonJS({
|
|
|
13093
13370
|
if (activeScope.rootRef) {
|
|
13094
13371
|
lines.push(` root_ref: ${yamlScalar(activeScope.rootRef)}`);
|
|
13095
13372
|
}
|
|
13096
|
-
const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText);
|
|
13373
|
+
const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText, richRender);
|
|
13097
13374
|
if (scopePreview.length > 0) {
|
|
13098
13375
|
lines.push(" scope_snapshot_preview: |");
|
|
13099
13376
|
for (const previewLine of scopePreview) {
|
|
@@ -13135,6 +13412,19 @@ var require_snapshot_observation = __commonJS({
|
|
|
13135
13412
|
lines.push(` - ${yamlScalar(modal)}`);
|
|
13136
13413
|
}
|
|
13137
13414
|
}
|
|
13415
|
+
const staticMessages = page.staticMessages ?? [];
|
|
13416
|
+
if (richRender && staticMessages.length > 0) {
|
|
13417
|
+
const messageLimit = 6;
|
|
13418
|
+
const ordered = [...staticMessages].sort((a, b) => messageRolePriority(a.role) - messageRolePriority(b.role));
|
|
13419
|
+
lines.push("messages:");
|
|
13420
|
+
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.");
|
|
13421
|
+
for (const message of ordered.slice(0, messageLimit)) {
|
|
13422
|
+
lines.push(` - [${message.role}] ${yamlScalar(message.text)}`);
|
|
13423
|
+
}
|
|
13424
|
+
if (ordered.length > messageLimit) {
|
|
13425
|
+
lines.push(` # +${ordered.length - messageLimit} more message(s) not listed`);
|
|
13426
|
+
}
|
|
13427
|
+
}
|
|
13138
13428
|
lines.push("session_state:");
|
|
13139
13429
|
if (page.sessionState) {
|
|
13140
13430
|
lines.push(` authenticated: ${yamlScalar(page.sessionState.authenticated)}`);
|
|
@@ -13197,7 +13487,10 @@ var require_snapshot_observation = __commonJS({
|
|
|
13197
13487
|
}
|
|
13198
13488
|
const hasRowContext = group.rowContexts.some((rc) => !!rc);
|
|
13199
13489
|
if (hasRowContext) {
|
|
13200
|
-
|
|
13490
|
+
const firstRowRole = group.rowRoles[0] ?? null;
|
|
13491
|
+
const rowRole = firstRowRole && group.rowRoles.every((r) => r === firstRowRole) ? firstRowRole : null;
|
|
13492
|
+
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`;
|
|
13493
|
+
lines.push(` distinguish_by_row: # same name across rows \u2014 scope by the ROW/card container (not the control); nested same-role containers need an extra cell-level scope: ${scopeHint}`);
|
|
13201
13494
|
for (let i = 0; i < group.rowContexts.length; i++) {
|
|
13202
13495
|
const rowContext = group.rowContexts[i];
|
|
13203
13496
|
if (!rowContext)
|
|
@@ -13227,8 +13520,13 @@ var require_snapshot_observation = __commonJS({
|
|
|
13227
13520
|
lines.push(` placeholder: ${yamlScalar(rankedElement.element.placeholder)}`);
|
|
13228
13521
|
}
|
|
13229
13522
|
const stateTokens = buildStateSummary(rankedElement.element);
|
|
13523
|
+
const addedDisabled = richRender && !!rankedElement.element.isDisabled && !stateTokens.includes("disabled");
|
|
13524
|
+
if (addedDisabled) {
|
|
13525
|
+
stateTokens.unshift("disabled");
|
|
13526
|
+
}
|
|
13230
13527
|
if (stateTokens.length > 0) {
|
|
13231
|
-
|
|
13528
|
+
const note = addedDisabled ? " # disabled = captured instant; may enable after valid input \u2014 still exercise fill\u2192submit" : "";
|
|
13529
|
+
lines.push(` state: [${stateTokens.map((token) => yamlScalar(token)).join(", ")}]${note}`);
|
|
13232
13530
|
}
|
|
13233
13531
|
if (rankedElement.element.containerPath) {
|
|
13234
13532
|
lines.push(` container_path: ${yamlScalar(rankedElement.element.containerPath)}`);
|
|
@@ -13238,6 +13536,22 @@ var require_snapshot_observation = __commonJS({
|
|
|
13238
13536
|
lines.push(` locator: ${yamlScalar((0, snapshot_parser_js_1.formatLocator)(topLocator))}`);
|
|
13239
13537
|
}
|
|
13240
13538
|
const enr = rankedElement.element.enrichment;
|
|
13539
|
+
if (richRender && enr?.inputType && TEXT_DISAMBIGUATING_INPUT_TYPES.has(enr.inputType)) {
|
|
13540
|
+
lines.push(` type: ${yamlScalar(enr.inputType)}`);
|
|
13541
|
+
}
|
|
13542
|
+
if (richRender && rankedElement.element.href) {
|
|
13543
|
+
lines.push(` url: ${yamlScalar(rankedElement.element.href)}`);
|
|
13544
|
+
}
|
|
13545
|
+
if (richRender && rankedElement.element.value && enr?.inputType !== "password") {
|
|
13546
|
+
lines.push(` value: ${yamlScalar(rankedElement.element.value)}`);
|
|
13547
|
+
}
|
|
13548
|
+
const elementOptions = rankedElement.element.options ?? [];
|
|
13549
|
+
if (richRender && elementOptions.length > 0) {
|
|
13550
|
+
const optionLimit = 12;
|
|
13551
|
+
const rendered = elementOptions.slice(0, optionLimit).map((option) => yamlScalar(option.selected ? `${option.label} (selected)` : option.label));
|
|
13552
|
+
const overflow = elementOptions.length > optionLimit ? ` # +${elementOptions.length - optionLimit} more option(s)` : "";
|
|
13553
|
+
lines.push(` options: [${rendered.join(", ")}]${overflow}`);
|
|
13554
|
+
}
|
|
13241
13555
|
if (enr?.rowContext) {
|
|
13242
13556
|
lines.push(` row_context: ${yamlScalar(enr.rowContext)}`);
|
|
13243
13557
|
}
|
|
@@ -13250,6 +13564,12 @@ var require_snapshot_observation = __commonJS({
|
|
|
13250
13564
|
lines.push(` visibility: [${vis.map((token) => yamlScalar(token)).join(", ")}]`);
|
|
13251
13565
|
}
|
|
13252
13566
|
}
|
|
13567
|
+
if (richRender) {
|
|
13568
|
+
const hidden = allInteractive.length - interactive.length;
|
|
13569
|
+
if (hidden > 0) {
|
|
13570
|
+
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.`);
|
|
13571
|
+
}
|
|
13572
|
+
}
|
|
13253
13573
|
}
|
|
13254
13574
|
const recoveredClickables = options.recoveredClickables ?? [];
|
|
13255
13575
|
if (recoveredClickables.length > 0) {
|
|
@@ -13406,11 +13726,11 @@ var require_snapshot_observation = __commonJS({
|
|
|
13406
13726
|
score += 10;
|
|
13407
13727
|
if (element.isExpanded)
|
|
13408
13728
|
score += 8;
|
|
13409
|
-
if (element.containerPath
|
|
13729
|
+
if (element.containerPath && DIALOG_SEGMENT_RE.test(element.containerPath))
|
|
13410
13730
|
score += 12;
|
|
13411
|
-
if (element.containerPath
|
|
13731
|
+
if (element.containerPath && FORM_SEGMENT_RE.test(element.containerPath))
|
|
13412
13732
|
score += 10;
|
|
13413
|
-
if (element.containerPath
|
|
13733
|
+
if (element.containerPath && TABPANEL_SEGMENT_RE.test(element.containerPath))
|
|
13414
13734
|
score += 8;
|
|
13415
13735
|
if (element.inputType === "email" || element.inputType === "password" || element.inputType === "search")
|
|
13416
13736
|
score += 6;
|
|
@@ -13481,10 +13801,10 @@ var require_snapshot_observation = __commonJS({
|
|
|
13481
13801
|
}
|
|
13482
13802
|
return merged;
|
|
13483
13803
|
}
|
|
13484
|
-
function buildScopeSnapshotPreview(scopeSnapshotText) {
|
|
13804
|
+
function buildScopeSnapshotPreview(scopeSnapshotText, stripTrailingValues = false) {
|
|
13485
13805
|
if (!scopeSnapshotText)
|
|
13486
13806
|
return [];
|
|
13487
|
-
return scopeSnapshotText.split("\n").map((line) => line.trim()).filter(Boolean).slice(0, 6);
|
|
13807
|
+
return scopeSnapshotText.split("\n").map((line) => line.trim()).map((line) => stripTrailingValues ? line.replace(/\]\s*:\s.+$/, "]") : line).filter(Boolean).slice(0, 6);
|
|
13488
13808
|
}
|
|
13489
13809
|
function dedupeLines(lines) {
|
|
13490
13810
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -13611,11 +13931,12 @@ var require_snapshot_observation = __commonJS({
|
|
|
13611
13931
|
const key = `${role}\0${name}`;
|
|
13612
13932
|
let group = groups.get(key);
|
|
13613
13933
|
if (!group) {
|
|
13614
|
-
group = { role, name, containers: [], rowContexts: [], refs: [] };
|
|
13934
|
+
group = { role, name, containers: [], rowContexts: [], rowRoles: [], refs: [] };
|
|
13615
13935
|
groups.set(key, group);
|
|
13616
13936
|
}
|
|
13617
13937
|
group.containers.push(element.containerPath ?? null);
|
|
13618
13938
|
group.rowContexts.push(element.enrichment?.rowContext ?? null);
|
|
13939
|
+
group.rowRoles.push(element.enrichment?.rowRole ?? null);
|
|
13619
13940
|
group.refs.push(element.ref ?? null);
|
|
13620
13941
|
}
|
|
13621
13942
|
return [...groups.values()].filter((group) => group.containers.length > 1);
|
|
@@ -13625,7 +13946,7 @@ var require_snapshot_observation = __commonJS({
|
|
|
13625
13946
|
for (const element of elements) {
|
|
13626
13947
|
const role = element.role?.toLowerCase() ?? "";
|
|
13627
13948
|
const elementType = element.elementType?.toLowerCase() ?? "";
|
|
13628
|
-
const isDialog = role === "dialog" || elementType === "dialog" || (element.containerPath
|
|
13949
|
+
const isDialog = role === "dialog" || elementType === "dialog" || DIALOG_NAME_SEGMENT_RE.test(element.containerPath ?? "");
|
|
13629
13950
|
if (!isDialog)
|
|
13630
13951
|
continue;
|
|
13631
13952
|
const hint = qualifyElementLabel(element) ?? dialogNameFromContainerPath(element.containerPath) ?? element.label ?? element.testId ?? "dialog";
|
|
@@ -13638,7 +13959,7 @@ var require_snapshot_observation = __commonJS({
|
|
|
13638
13959
|
function dialogNameFromContainerPath(containerPath) {
|
|
13639
13960
|
if (!containerPath)
|
|
13640
13961
|
return null;
|
|
13641
|
-
const match = containerPath.match(
|
|
13962
|
+
const match = containerPath.match(DIALOG_NAME_SEGMENT_RE);
|
|
13642
13963
|
return match ? match[1].trim() : null;
|
|
13643
13964
|
}
|
|
13644
13965
|
function renderObservationCue(observation) {
|
|
@@ -13647,6 +13968,13 @@ var require_snapshot_observation = __commonJS({
|
|
|
13647
13968
|
const base2 = observation.outcomeType === "SUCCESS" && outcome ? outcome : `${action} -> ${outcome || observation.outcomeType}`;
|
|
13648
13969
|
return base2.length > 120 ? `${base2.slice(0, 117)}...` : base2;
|
|
13649
13970
|
}
|
|
13971
|
+
function messageRolePriority(role) {
|
|
13972
|
+
if (role === "alert")
|
|
13973
|
+
return 0;
|
|
13974
|
+
if (role === "status")
|
|
13975
|
+
return 1;
|
|
13976
|
+
return 2;
|
|
13977
|
+
}
|
|
13650
13978
|
function yamlScalar(value) {
|
|
13651
13979
|
if (value == null || value === "")
|
|
13652
13980
|
return "null";
|
|
@@ -20195,7 +20523,10 @@ ${testOpen}`;
|
|
|
20195
20523
|
pressed: se.pressed,
|
|
20196
20524
|
headingLevel: se.headingLevel,
|
|
20197
20525
|
currentState: se.currentState,
|
|
20198
|
-
containerPath: se.containerPath || void 0
|
|
20526
|
+
containerPath: se.containerPath || void 0,
|
|
20527
|
+
href: se.href || void 0,
|
|
20528
|
+
value: se.value || void 0,
|
|
20529
|
+
options: se.options && se.options.length > 0 ? se.options : void 0
|
|
20199
20530
|
}));
|
|
20200
20531
|
const structuredPage = {
|
|
20201
20532
|
route: pageUrl || "/",
|
|
@@ -20203,6 +20534,7 @@ ${testOpen}`;
|
|
|
20203
20534
|
pageType: "unknown",
|
|
20204
20535
|
elements: collectedElements,
|
|
20205
20536
|
formGroups: parsed.formGroups,
|
|
20537
|
+
staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
|
|
20206
20538
|
snapshotMetrics: {
|
|
20207
20539
|
rawLineCount: parsed.rawLineCount,
|
|
20208
20540
|
parsedElementLineCount: parsed.parsedElementLineCount,
|
|
@@ -21769,7 +22101,7 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
|
|
|
21769
22101
|
let stopped;
|
|
21770
22102
|
const agentModel = options?.modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(options.modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
|
|
21771
22103
|
const auditGroupId = `generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
|
|
21772
|
-
const aiClient = options?.modelOverride ? (0, ai_provider_js_1.
|
|
22104
|
+
const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
|
|
21773
22105
|
const credentials = variableContext.allTestVariables ?? variableContext.publicTestVariables;
|
|
21774
22106
|
logs2.push(`Phase 3 \u2014 Generate: ${(plan.scenarios ?? []).length} scenarios, max ${maxTests} tests`);
|
|
21775
22107
|
logs2.push(`Model: ${agentModel}`);
|
|
@@ -235734,7 +236066,7 @@ var require_mcp_healer = __commonJS({
|
|
|
235734
236066
|
messages.push(system, firstUser, { role: "user", content: lines.join("\n") }, ...tail);
|
|
235735
236067
|
}
|
|
235736
236068
|
var DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS = 6;
|
|
235737
|
-
function
|
|
236069
|
+
function readPositiveIntEnv2(env, name, fallback) {
|
|
235738
236070
|
const raw = env[name]?.trim();
|
|
235739
236071
|
if (!raw || !/^\d+$/.test(raw))
|
|
235740
236072
|
return fallback;
|
|
@@ -235742,7 +236074,7 @@ var require_mcp_healer = __commonJS({
|
|
|
235742
236074
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
235743
236075
|
}
|
|
235744
236076
|
function getScriptHealMaxAttempts(env = process.env) {
|
|
235745
|
-
return
|
|
236077
|
+
return readPositiveIntEnv2(env, "MCP_HEAL_SCRIPT_MAX_ATTEMPTS", DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS);
|
|
235746
236078
|
}
|
|
235747
236079
|
exports.SCRIPT_HEAL_SYSTEM_PROMPT = `You are a QA engineer fixing a failing Playwright UI test. You will receive the test code, the error message, step-level results, and a screenshot of the failure state.
|
|
235748
236080
|
|
|
@@ -236078,7 +236410,7 @@ ${lines.join("\n")}
|
|
|
236078
236410
|
const landingViolation = (tags ?? []).includes("@landing-violation");
|
|
236079
236411
|
const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "strong") : (0, ai_provider_js_1.getModel)("strong");
|
|
236080
236412
|
const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
|
|
236081
|
-
const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.
|
|
236413
|
+
const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
|
|
236082
236414
|
const runNativeTest = options.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
|
|
236083
236415
|
const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
|
|
236084
236416
|
const groupId = `script-heal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -236900,10 +237232,10 @@ ${(verifyResult.logs ?? "").slice(0, 2e3)}`;
|
|
|
236900
237232
|
var DEFAULT_MAX_PHASE2_ITERATIONS = 4;
|
|
236901
237233
|
var DEFAULT_MAX_MCP_EXPLORE_ITERATIONS = 25;
|
|
236902
237234
|
function getMcpHealPhase2MaxIterations(env = process.env) {
|
|
236903
|
-
return
|
|
237235
|
+
return readPositiveIntEnv2(env, "MCP_HEAL_PHASE2_MAX_ITERATIONS", DEFAULT_MAX_PHASE2_ITERATIONS);
|
|
236904
237236
|
}
|
|
236905
237237
|
function getMcpHealExploreMaxIterations(env = process.env) {
|
|
236906
|
-
return
|
|
237238
|
+
return readPositiveIntEnv2(env, "MCP_HEAL_EXPLORE_MAX_ITERATIONS", DEFAULT_MAX_MCP_EXPLORE_ITERATIONS);
|
|
236907
237239
|
}
|
|
236908
237240
|
var DEFAULT_MCP_HEAL_MAX_WALL_CLOCK_MS = 10 * 60 * 1e3;
|
|
236909
237241
|
var DEFAULT_MCP_HEAL_MAX_TOTAL_AI_CALLS = 60;
|
|
@@ -236955,7 +237287,7 @@ Only explore the failing page and the area around the broken step.`;
|
|
|
236955
237287
|
let lastVideo;
|
|
236956
237288
|
const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
|
|
236957
237289
|
const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
|
|
236958
|
-
const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.
|
|
237290
|
+
const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
|
|
236959
237291
|
const runPhase2NativeTest = options?.phase2Harness?.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
|
|
236960
237292
|
const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
|
|
236961
237293
|
const groupId = `phase2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -237645,7 +237977,10 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
|
|
|
237645
237977
|
pressed: se.pressed,
|
|
237646
237978
|
headingLevel: se.headingLevel,
|
|
237647
237979
|
currentState: se.currentState,
|
|
237648
|
-
containerPath: se.containerPath || void 0
|
|
237980
|
+
containerPath: se.containerPath || void 0,
|
|
237981
|
+
href: se.href || void 0,
|
|
237982
|
+
value: se.value || void 0,
|
|
237983
|
+
options: se.options && se.options.length > 0 ? se.options : void 0
|
|
237649
237984
|
}));
|
|
237650
237985
|
const structuredPage = {
|
|
237651
237986
|
route: pageUrl || "/",
|
|
@@ -237653,6 +237988,7 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
|
|
|
237653
237988
|
pageType: "unknown",
|
|
237654
237989
|
elements: collectedElements,
|
|
237655
237990
|
formGroups: parsed.formGroups,
|
|
237991
|
+
staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
|
|
237656
237992
|
snapshotMetrics: {
|
|
237657
237993
|
rawLineCount: parsed.rawLineCount,
|
|
237658
237994
|
parsedElementLineCount: parsed.parsedElementLineCount,
|
|
@@ -238089,7 +238425,7 @@ ${testBlocks}
|
|
|
238089
238425
|
- By iteration ${Math.floor(maxIterations * 0.3)}: you should have completed 1-2 tests
|
|
238090
238426
|
- By iteration ${Math.floor(maxIterations * 0.7)}: you should be past the halfway point
|
|
238091
238427
|
- At iteration ${Math.floor(maxIterations * 0.9)}: wrap up and call finish_capture`;
|
|
238092
|
-
const aiClient = modelOverride ? (0, ai_provider_js_1.
|
|
238428
|
+
const aiClient = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
|
|
238093
238429
|
const messages = [
|
|
238094
238430
|
{ role: "system", content: systemPrompt },
|
|
238095
238431
|
{ role: "user", content: "Start executing the tests. Navigate to the base URL and begin with Test 1." }
|
|
@@ -238272,7 +238608,10 @@ ${testBlocks}
|
|
|
238272
238608
|
pressed: se.pressed,
|
|
238273
238609
|
headingLevel: se.headingLevel,
|
|
238274
238610
|
currentState: se.currentState,
|
|
238275
|
-
containerPath: se.containerPath || void 0
|
|
238611
|
+
containerPath: se.containerPath || void 0,
|
|
238612
|
+
href: se.href || void 0,
|
|
238613
|
+
value: se.value || void 0,
|
|
238614
|
+
options: se.options && se.options.length > 0 ? se.options : void 0
|
|
238276
238615
|
}));
|
|
238277
238616
|
const structuredPage = {
|
|
238278
238617
|
route: pageUrl || "/",
|
|
@@ -238280,6 +238619,7 @@ ${testBlocks}
|
|
|
238280
238619
|
pageType: "unknown",
|
|
238281
238620
|
elements: collectedElements,
|
|
238282
238621
|
formGroups: parsed.formGroups,
|
|
238622
|
+
staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
|
|
238283
238623
|
snapshotMetrics: {
|
|
238284
238624
|
rawLineCount: parsed.rawLineCount,
|
|
238285
238625
|
parsedElementLineCount: parsed.parsedElementLineCount,
|
|
@@ -300069,8 +300409,9 @@ var require_capture_page_normalizer = __commonJS({
|
|
|
300069
300409
|
let snapshotElements = [];
|
|
300070
300410
|
let formGroups = [];
|
|
300071
300411
|
let snapshotMetrics;
|
|
300412
|
+
let staticMessages;
|
|
300072
300413
|
if (normalizedSnapshotResult.snapshotText) {
|
|
300073
|
-
const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText);
|
|
300414
|
+
const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText, { richParse: context.richParse });
|
|
300074
300415
|
snapshotMetrics = {
|
|
300075
300416
|
rawLineCount: parsed.rawLineCount,
|
|
300076
300417
|
parsedElementLineCount: parsed.parsedElementLineCount,
|
|
@@ -300099,9 +300440,13 @@ var require_capture_page_normalizer = __commonJS({
|
|
|
300099
300440
|
pressed: se.pressed,
|
|
300100
300441
|
headingLevel: se.headingLevel,
|
|
300101
300442
|
currentState: se.currentState,
|
|
300102
|
-
containerPath: se.containerPath || void 0
|
|
300443
|
+
containerPath: se.containerPath || void 0,
|
|
300444
|
+
href: se.href || void 0,
|
|
300445
|
+
value: se.value || void 0,
|
|
300446
|
+
options: se.options && se.options.length > 0 ? se.options : void 0
|
|
300103
300447
|
}));
|
|
300104
300448
|
formGroups = parsed.formGroups;
|
|
300449
|
+
staticMessages = parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0;
|
|
300105
300450
|
}
|
|
300106
300451
|
const aiElements = Array.isArray(toolArgs.elements) ? toolArgs.elements.map(normalizeAiElement) : [];
|
|
300107
300452
|
const noYamlKinds = /* @__PURE__ */ new Set(["missing", "timeout", "error", "empty"]);
|
|
@@ -300197,6 +300542,7 @@ var require_capture_page_normalizer = __commonJS({
|
|
|
300197
300542
|
} : void 0,
|
|
300198
300543
|
readinessSignals,
|
|
300199
300544
|
viewportHints,
|
|
300545
|
+
staticMessages,
|
|
300200
300546
|
provenCommands: provenCmds,
|
|
300201
300547
|
observations: rawObservations,
|
|
300202
300548
|
routeCorrected,
|
|
@@ -300505,6 +300851,10 @@ var require_perception_enricher = __commonJS({
|
|
|
300505
300851
|
const e = {};
|
|
300506
300852
|
if (p.rowContext)
|
|
300507
300853
|
e.rowContext = p.rowContext;
|
|
300854
|
+
if (p.rowContext && p.rowRole)
|
|
300855
|
+
e.rowRole = p.rowRole;
|
|
300856
|
+
if (p.inputType)
|
|
300857
|
+
e.inputType = p.inputType;
|
|
300508
300858
|
if (p.position && p.position !== "in-view")
|
|
300509
300859
|
e.position = p.position;
|
|
300510
300860
|
if (p.occluded)
|
|
@@ -300754,6 +301104,23 @@ var require_perception_enricher = __commonJS({
|
|
|
300754
301104
|
const cls = (el.getAttribute("class") || "").toLowerCase();
|
|
300755
301105
|
return /(^|[\s_-])(card|row|item|listitem|cell|tile)([\s_-]|$)/.test(cls);
|
|
300756
301106
|
};
|
|
301107
|
+
const rowRoleOf = (el) => {
|
|
301108
|
+
const explicit = (el.getAttribute("role") || "").toLowerCase().trim();
|
|
301109
|
+
if (explicit) {
|
|
301110
|
+
return explicit === "row" || explicit === "listitem" || explicit === "article" || explicit === "option" ? explicit : null;
|
|
301111
|
+
}
|
|
301112
|
+
const tag = el.tagName;
|
|
301113
|
+
if (tag === "TR") {
|
|
301114
|
+
const tbl = el.closest("table");
|
|
301115
|
+
const tblRole = tbl ? (tbl.getAttribute("role") || "").toLowerCase().trim() : "";
|
|
301116
|
+
return tblRole === "presentation" || tblRole === "none" ? null : "row";
|
|
301117
|
+
}
|
|
301118
|
+
if (tag === "LI")
|
|
301119
|
+
return el.closest('ul:not([role]),ol:not([role]),menu:not([role]),[role="list"]') ? "listitem" : null;
|
|
301120
|
+
if (tag === "ARTICLE")
|
|
301121
|
+
return "article";
|
|
301122
|
+
return null;
|
|
301123
|
+
};
|
|
300757
301124
|
const rowContextOf = (el, ownNameLower) => {
|
|
300758
301125
|
let cur = el.parentElement;
|
|
300759
301126
|
let depth = 0;
|
|
@@ -300772,7 +301139,7 @@ var require_perception_enricher = __commonJS({
|
|
|
300772
301139
|
txt = txt.slice(0, 80);
|
|
300773
301140
|
const low = txt.toLowerCase();
|
|
300774
301141
|
if (txt && low !== ownNameLower && low.length > 1)
|
|
300775
|
-
return txt;
|
|
301142
|
+
return { text: txt, role: rowRoleOf(cur) };
|
|
300776
301143
|
return null;
|
|
300777
301144
|
}
|
|
300778
301145
|
cur = cur.parentElement;
|
|
@@ -300862,11 +301229,14 @@ var require_perception_enricher = __commonJS({
|
|
|
300862
301229
|
const name = accName(el);
|
|
300863
301230
|
const lname = lower(name);
|
|
300864
301231
|
const rect = vis.rect;
|
|
301232
|
+
const rc = rowContextOf(el, lname);
|
|
300865
301233
|
out.push({
|
|
300866
301234
|
domOrder: myOrder,
|
|
300867
301235
|
roleClass: roleClassOf(tag, roleAttr, inputType),
|
|
300868
301236
|
name: lname,
|
|
300869
|
-
rowContext:
|
|
301237
|
+
rowContext: rc ? rc.text : null,
|
|
301238
|
+
rowRole: rc ? rc.role : null,
|
|
301239
|
+
inputType: inputType || null,
|
|
300870
301240
|
position: positionOf(rect),
|
|
300871
301241
|
occluded: occlusionOf(el, rect),
|
|
300872
301242
|
cursorPointer,
|
|
@@ -300903,11 +301273,14 @@ var require_perception_enricher = __commonJS({
|
|
|
300903
301273
|
if (!name)
|
|
300904
301274
|
continue;
|
|
300905
301275
|
const lname = lower(name);
|
|
301276
|
+
const rc = rowContextOf(el, lname);
|
|
300906
301277
|
out.push({
|
|
300907
301278
|
domOrder: order++,
|
|
300908
301279
|
roleClass: "button",
|
|
300909
301280
|
name: lname,
|
|
300910
|
-
rowContext:
|
|
301281
|
+
rowContext: rc ? rc.text : null,
|
|
301282
|
+
rowRole: rc ? rc.role : null,
|
|
301283
|
+
inputType: null,
|
|
300911
301284
|
position: "in-view",
|
|
300912
301285
|
occluded: false,
|
|
300913
301286
|
cursorPointer: true,
|
|
@@ -301409,7 +301782,7 @@ var require_discover_explorer = __commonJS({
|
|
|
301409
301782
|
var DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP = 100;
|
|
301410
301783
|
var DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY = 100;
|
|
301411
301784
|
var DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL = 40;
|
|
301412
|
-
function
|
|
301785
|
+
function readPositiveIntEnv2(env, name, fallback) {
|
|
301413
301786
|
const raw = env[name]?.trim();
|
|
301414
301787
|
if (!raw || !/^\d+$/.test(raw))
|
|
301415
301788
|
return fallback;
|
|
@@ -301540,15 +301913,15 @@ var require_discover_explorer = __commonJS({
|
|
|
301540
301913
|
}
|
|
301541
301914
|
function getMaxIterations(mode, incremental, env = process.env) {
|
|
301542
301915
|
if (mode === "survey" && incremental) {
|
|
301543
|
-
return
|
|
301916
|
+
return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_INCREMENTAL", DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL);
|
|
301544
301917
|
}
|
|
301545
301918
|
if (mode === "survey") {
|
|
301546
|
-
return
|
|
301919
|
+
return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_SURVEY", DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY);
|
|
301547
301920
|
}
|
|
301548
301921
|
if (mode === "deep") {
|
|
301549
|
-
return
|
|
301922
|
+
return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEEP", DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP);
|
|
301550
301923
|
}
|
|
301551
|
-
return
|
|
301924
|
+
return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEFAULT", DEFAULT_MAX_EXPLORE_ITERATIONS_DEFAULT);
|
|
301552
301925
|
}
|
|
301553
301926
|
function updatePendingRouteScreenshot(pending, { imageData, detectedRoute, fallbackRoute }) {
|
|
301554
301927
|
if (imageData) {
|
|
@@ -302274,7 +302647,7 @@ ${inboxPromptBlock}`;
|
|
|
302274
302647
|
const browserCreds = variableContext.allTestVariables ?? variableContext.publicTestVariables;
|
|
302275
302648
|
const { secrets: browserSecretsForHint } = (0, credential_tools_js_1.partitionTestVariables)(browserCreds);
|
|
302276
302649
|
const hasLoginCredentials = Object.keys(browserSecretsForHint).length > 0;
|
|
302277
|
-
const aiClient = options?.modelOverride ? (0, ai_provider_js_1.
|
|
302650
|
+
const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
|
|
302278
302651
|
const isDeepMode = options?.mode === "deep";
|
|
302279
302652
|
const maxIterations = config?.maxIterationsOverride && config.maxIterationsOverride > 0 ? config.maxIterationsOverride : getMaxIterations(options?.mode ?? "full", options?.incremental);
|
|
302280
302653
|
const configKeepTail = isDeepMode ? Math.ceil(ctxConfig.keepTail * 1.5) : ctxConfig.keepTail;
|
|
@@ -304813,7 +305186,7 @@ var require_auth_capture_run = __commonJS({
|
|
|
304813
305186
|
var scrub_credentials_js_1 = require_scrub_credentials();
|
|
304814
305187
|
var credential_tools_js_1 = require_credential_tools();
|
|
304815
305188
|
var DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS = 12;
|
|
304816
|
-
function
|
|
305189
|
+
function readPositiveIntEnv2(env, name, fallback) {
|
|
304817
305190
|
const raw = env[name]?.trim();
|
|
304818
305191
|
if (!raw || !/^\d+$/.test(raw))
|
|
304819
305192
|
return fallback;
|
|
@@ -304821,7 +305194,7 @@ var require_auth_capture_run = __commonJS({
|
|
|
304821
305194
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
304822
305195
|
}
|
|
304823
305196
|
function getLoginCaptureMaxIterations(env = process.env) {
|
|
304824
|
-
return
|
|
305197
|
+
return readPositiveIntEnv2(env, "AUTH_CAPTURE_LOGIN_MAX_ITERATIONS", DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS);
|
|
304825
305198
|
}
|
|
304826
305199
|
var CAPTURE_RATE_LIMIT_MAX_RETRIES = 2;
|
|
304827
305200
|
var CAPTURE_RATE_LIMIT_MAX_DELAY_MS = 8e3;
|
|
@@ -305104,7 +305477,7 @@ ${keyLines || "- (none provided)"}
|
|
|
305104
305477
|
}
|
|
305105
305478
|
var DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS = 18;
|
|
305106
305479
|
function getRegisterCaptureMaxIterations(env = process.env) {
|
|
305107
|
-
return
|
|
305480
|
+
return readPositiveIntEnv2(env, "AUTH_CAPTURE_REGISTER_MAX_ITERATIONS", DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS);
|
|
305108
305481
|
}
|
|
305109
305482
|
function buildRegisterCaptureSystemPrompt(baseUrl, availableKeys, maxIterations = getRegisterCaptureMaxIterations()) {
|
|
305110
305483
|
const keyLines = availableKeys.map((k) => `- \`${k}\``).join("\n");
|
|
@@ -311334,6 +311707,7 @@ var require_mobile_exploration_state = __commonJS({
|
|
|
311334
311707
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
311335
311708
|
exports.MobileExplorationState = void 0;
|
|
311336
311709
|
exports.deriveScreenId = deriveScreenId;
|
|
311710
|
+
var mobile_observation_js_1 = require_mobile_observation();
|
|
311337
311711
|
var DEFAULT_STALE_THRESHOLD = 4;
|
|
311338
311712
|
function deriveScreenId(signal) {
|
|
311339
311713
|
const namespace = signal.activity ?? signal.bundleId;
|
|
@@ -311559,21 +311933,33 @@ var require_mobile_exploration_state = __commonJS({
|
|
|
311559
311933
|
* Build the planner evidence shape: one `CollectedPageData` per screen, with
|
|
311560
311934
|
* the screen-id carried in BOTH `route` (the on-the-wire positional field the
|
|
311561
311935
|
* server keys screenshots by) AND the explicit `screenId` field (per the
|
|
311562
|
-
* Phase-0 `CollectedPageData.screenId` contract). `elements`
|
|
311563
|
-
* the
|
|
311564
|
-
*
|
|
311565
|
-
*
|
|
311566
|
-
*
|
|
311936
|
+
* Phase-0 `CollectedPageData.screenId` contract). `elements` are mapped from
|
|
311937
|
+
* the screen's parsed actionable elements into the platform-neutral
|
|
311938
|
+
* `CollectedPageElement[]` shape the server persists as SiteElements — without
|
|
311939
|
+
* this, mobile screens land with zero elements (coverage always 0%, no element
|
|
311940
|
+
* intelligence). `triggersNavigation` is derived from transition-source
|
|
311941
|
+
* membership: an element is flagged when its ref drove a transition FROM this
|
|
311942
|
+
* screen. Insertion order matches first-seen order.
|
|
311567
311943
|
*/
|
|
311568
311944
|
buildInMemoryScreenMap() {
|
|
311569
311945
|
const pages = [];
|
|
311570
311946
|
const ordered = [...this.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
|
|
311947
|
+
const navRefsByScreen = /* @__PURE__ */ new Map();
|
|
311948
|
+
for (const transition of this.transitions) {
|
|
311949
|
+
if (!transition.viaRef)
|
|
311950
|
+
continue;
|
|
311951
|
+
const set = navRefsByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
|
|
311952
|
+
set.add(transition.viaRef);
|
|
311953
|
+
navRefsByScreen.set(transition.fromScreenId, set);
|
|
311954
|
+
}
|
|
311571
311955
|
for (const state2 of ordered) {
|
|
311572
311956
|
const page = {
|
|
311573
311957
|
route: state2.screenId,
|
|
311574
311958
|
screenId: state2.screenId,
|
|
311575
311959
|
pageType: state2.screenType ?? "UNKNOWN",
|
|
311576
|
-
elements:
|
|
311960
|
+
elements: (0, mobile_observation_js_1.buildCollectedElementsForScreen)(state2.elements, {
|
|
311961
|
+
navigationRefs: navRefsByScreen.get(state2.screenId)
|
|
311962
|
+
})
|
|
311577
311963
|
};
|
|
311578
311964
|
if (state2.screenName) {
|
|
311579
311965
|
page.title = state2.screenName;
|
|
@@ -311603,6 +311989,7 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311603
311989
|
"../runner-core/dist/services/mobile/mobile-discovery-agent.js"(exports) {
|
|
311604
311990
|
"use strict";
|
|
311605
311991
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
311992
|
+
exports.getMobileExploreBudget = getMobileExploreBudget;
|
|
311606
311993
|
exports.runMobileDiscoveryOnRunner = runMobileDiscoveryOnRunner2;
|
|
311607
311994
|
var mobile_explorer_js_1 = require_mobile_explorer();
|
|
311608
311995
|
var mobile_discovery_prompts_js_1 = require_mobile_discovery_prompts();
|
|
@@ -311616,8 +312003,37 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311616
312003
|
const message = checkpointErrorMessage(err);
|
|
311617
312004
|
return /Discovery plan checkpoint rejected|Discovery run is .*not RUNNING|not RUNNING|cancelled/i.test(message);
|
|
311618
312005
|
}
|
|
311619
|
-
var
|
|
311620
|
-
var
|
|
312006
|
+
var MOBILE_EXPLORE_BUDGET_SURVEY_FIRST = 90;
|
|
312007
|
+
var MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL = 40;
|
|
312008
|
+
var MOBILE_EXPLORE_BUDGET_DEEP = 60;
|
|
312009
|
+
var MOBILE_EXPLORE_BUDGET_FULL = 90;
|
|
312010
|
+
function readPositiveIntEnv2(name, fallback, env = process.env) {
|
|
312011
|
+
const raw = env[name]?.trim();
|
|
312012
|
+
if (!raw || !/^\d+$/.test(raw))
|
|
312013
|
+
return fallback;
|
|
312014
|
+
const parsed = Number(raw);
|
|
312015
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
312016
|
+
}
|
|
312017
|
+
function getMobileExploreBudget(mode, incremental, env = process.env) {
|
|
312018
|
+
if (mode === "deep")
|
|
312019
|
+
return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_DEEP", MOBILE_EXPLORE_BUDGET_DEEP, env);
|
|
312020
|
+
if (mode === "full")
|
|
312021
|
+
return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_FULL", MOBILE_EXPLORE_BUDGET_FULL, env);
|
|
312022
|
+
if (incremental) {
|
|
312023
|
+
return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY_INCREMENTAL", MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL, env);
|
|
312024
|
+
}
|
|
312025
|
+
return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY", MOBILE_EXPLORE_BUDGET_SURVEY_FIRST, env);
|
|
312026
|
+
}
|
|
312027
|
+
function compactExistingScreens(siteMap) {
|
|
312028
|
+
if (!siteMap || !Array.isArray(siteMap.pages) || siteMap.pages.length === 0)
|
|
312029
|
+
return void 0;
|
|
312030
|
+
const screens = siteMap.pages.filter((p) => typeof p.route === "string" && p.route.trim().length > 0).map((p) => ({
|
|
312031
|
+
screenId: p.route,
|
|
312032
|
+
...p.title ? { name: p.title } : {},
|
|
312033
|
+
...p.pageType ? { screenType: p.pageType } : {}
|
|
312034
|
+
}));
|
|
312035
|
+
return screens.length > 0 ? screens : void 0;
|
|
312036
|
+
}
|
|
311621
312037
|
function platformForMedium(medium) {
|
|
311622
312038
|
return medium === "IOS_NATIVE" ? "IOS" : "ANDROID";
|
|
311623
312039
|
}
|
|
@@ -311636,6 +312052,9 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311636
312052
|
// Native screenshots are multi-megabyte PNGs. The live mirror streams them
|
|
311637
312053
|
// through heartbeat telemetry; discovery checkpoints keep the screen graph
|
|
311638
312054
|
// and transitions so large Android surveys cannot exceed server body limits.
|
|
312055
|
+
// D-D6 — pageScreenshotKeys is a small screen-id → storage-key map (NOT base64),
|
|
312056
|
+
// so it DOES ride the checkpoint: the server links each key to SitePage.screenshotKey.
|
|
312057
|
+
...explore.pageScreenshotKeys && Object.keys(explore.pageScreenshotKeys).length > 0 ? { pageScreenshotKeys: explore.pageScreenshotKeys } : {},
|
|
311639
312058
|
exploreStats: explore.exploreStats,
|
|
311640
312059
|
healthObservations: explore.healthObservations
|
|
311641
312060
|
};
|
|
@@ -311650,6 +312069,7 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311650
312069
|
const onLog = callbacks?.onLog;
|
|
311651
312070
|
const onAICall = callbacks?.onAICall;
|
|
311652
312071
|
const onScreenshot = callbacks?.onScreenshot;
|
|
312072
|
+
const onScreenshotUpload = callbacks?.onScreenshotUpload;
|
|
311653
312073
|
const runExplore = callbacks?.runExplore ?? mobile_explorer_js_1.runMobileExplorePhase;
|
|
311654
312074
|
const runPlan = callbacks?.runPlan ?? mobile_discovery_prompts_js_1.runMobileAIPlanner;
|
|
311655
312075
|
const runGenerate = callbacks?.runGenerate ?? mobile_generator_js_1.runMobileGeneratePhase;
|
|
@@ -311676,15 +312096,77 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311676
312096
|
elementsFound: 0,
|
|
311677
312097
|
transitionsFound: 0
|
|
311678
312098
|
};
|
|
312099
|
+
const resumeSavedScenarios = ctx.resume?.savedPlan?.scenarios ?? [];
|
|
312100
|
+
const isPlanReuseResume = mode !== "survey" && resumeSavedScenarios.length > 0;
|
|
311679
312101
|
try {
|
|
312102
|
+
if (isPlanReuseResume) {
|
|
312103
|
+
const alreadyGenerated = ctx.resume?.alreadyGeneratedScenarioNames ?? [];
|
|
312104
|
+
log2(`
|
|
312105
|
+
\u2500\u2500 Resume (plan-reuse): ${resumeSavedScenarios.length} saved scenario(s); ${alreadyGenerated.length} already generated. Skipping explore + plan; generating only the remainder. \u2500\u2500`);
|
|
312106
|
+
const scenarios2 = resumeSavedScenarios;
|
|
312107
|
+
const plans2 = toPlans(scenarios2);
|
|
312108
|
+
if (callbacks?.onPlanReady) {
|
|
312109
|
+
try {
|
|
312110
|
+
await callbacks.onPlanReady({ scenarios: scenarios2, featureGroups: void 0, fallbackUsed: false });
|
|
312111
|
+
log2("Plan checkpoint: reused scenario plan re-POSTed to server");
|
|
312112
|
+
} catch (err) {
|
|
312113
|
+
const message = checkpointErrorMessage(err);
|
|
312114
|
+
if (isTerminalDiscoveryCheckpointError(err)) {
|
|
312115
|
+
log2(`Plan checkpoint rejected for inactive discovery run: ${message}`);
|
|
312116
|
+
throw err;
|
|
312117
|
+
}
|
|
312118
|
+
log2(`Plan checkpoint POST failed (non-fatal): ${message}`);
|
|
312119
|
+
}
|
|
312120
|
+
}
|
|
312121
|
+
let paused = false;
|
|
312122
|
+
let pauseReason;
|
|
312123
|
+
let remainingScenarios;
|
|
312124
|
+
const tests2 = await runGenerate({
|
|
312125
|
+
scenarios: scenarios2,
|
|
312126
|
+
driver,
|
|
312127
|
+
ctx,
|
|
312128
|
+
onLog: (line) => log2(line),
|
|
312129
|
+
onAICall,
|
|
312130
|
+
onScreenshot,
|
|
312131
|
+
onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
|
|
312132
|
+
skipScenarioNames: alreadyGenerated,
|
|
312133
|
+
onInfraStop: (info) => {
|
|
312134
|
+
paused = true;
|
|
312135
|
+
pauseReason = info.reason;
|
|
312136
|
+
remainingScenarios = info.remainingScenarioNames;
|
|
312137
|
+
}
|
|
312138
|
+
});
|
|
312139
|
+
const duration2 = (Date.now() - startTime) / 1e3;
|
|
312140
|
+
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" : ""}`;
|
|
312141
|
+
log2(`
|
|
312142
|
+
=== Mobile Discovery Resume Complete (${duration2.toFixed(1)}s) ===`);
|
|
312143
|
+
log2(summary2);
|
|
312144
|
+
return {
|
|
312145
|
+
collectedPages: [],
|
|
312146
|
+
collectedTransitions: [],
|
|
312147
|
+
tests: tests2,
|
|
312148
|
+
plans: plans2,
|
|
312149
|
+
scenarios: scenarios2.length > 0 ? scenarios2 : void 0,
|
|
312150
|
+
logs: logs2.join("\n"),
|
|
312151
|
+
screenshots,
|
|
312152
|
+
summary: summary2,
|
|
312153
|
+
exploreStats: { pagesDiscovered: 0, pagesVisited: 0, elementsFound: 0, transitionsFound: 0 },
|
|
312154
|
+
duration: duration2,
|
|
312155
|
+
mode,
|
|
312156
|
+
...paused ? { paused: true, pauseReason, remainingScenarios } : {}
|
|
312157
|
+
// apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
|
|
312158
|
+
};
|
|
312159
|
+
}
|
|
311680
312160
|
log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
|
|
311681
312161
|
const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
|
|
311682
|
-
const maxIterations = mode
|
|
312162
|
+
const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
|
|
312163
|
+
const existingScreens = compactExistingScreens(ctx.existingSiteMap);
|
|
311683
312164
|
const explore = await runExplore({
|
|
311684
312165
|
ctx,
|
|
311685
312166
|
driver,
|
|
311686
312167
|
state: state2,
|
|
311687
312168
|
config: { maxIterations },
|
|
312169
|
+
existingScreens,
|
|
311688
312170
|
onLog: (line) => log2(line),
|
|
311689
312171
|
onAICall,
|
|
311690
312172
|
onScreenshot: (base64) => {
|
|
@@ -311694,7 +312176,8 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
311694
312176
|
onScreenshot?.(base64);
|
|
311695
312177
|
} catch {
|
|
311696
312178
|
}
|
|
311697
|
-
}
|
|
312179
|
+
},
|
|
312180
|
+
onScreenshotUpload
|
|
311698
312181
|
});
|
|
311699
312182
|
const collectedTransitions = toCollectedTransitions(explore);
|
|
311700
312183
|
partialCollectedPages = explore.collectedPages;
|
|
@@ -311795,6 +312278,9 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
|
|
|
311795
312278
|
};
|
|
311796
312279
|
}
|
|
311797
312280
|
log2("\n\u2500\u2500 Phase 3: GENERATE \u2500\u2500");
|
|
312281
|
+
let generatePaused = false;
|
|
312282
|
+
let generatePauseReason;
|
|
312283
|
+
let generateRemainingScenarios;
|
|
311798
312284
|
const tests = await runGenerate({
|
|
311799
312285
|
scenarios,
|
|
311800
312286
|
driver,
|
|
@@ -311802,7 +312288,15 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
|
|
|
311802
312288
|
onLog: (line) => log2(line),
|
|
311803
312289
|
onAICall,
|
|
311804
312290
|
onScreenshot,
|
|
311805
|
-
onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" })
|
|
312291
|
+
onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
|
|
312292
|
+
// Legacy resume (no saved plan): still skip scenarios already generated by the
|
|
312293
|
+
// paused run so the AI isn't re-invoked for them. Undefined on a fresh run.
|
|
312294
|
+
skipScenarioNames: ctx.resume?.alreadyGeneratedScenarioNames,
|
|
312295
|
+
onInfraStop: (info) => {
|
|
312296
|
+
generatePaused = true;
|
|
312297
|
+
generatePauseReason = info.reason;
|
|
312298
|
+
generateRemainingScenarios = info.remainingScenarioNames;
|
|
312299
|
+
}
|
|
311806
312300
|
});
|
|
311807
312301
|
const generateElapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
311808
312302
|
log2(`
|
|
@@ -311818,7 +312312,7 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
|
|
|
311818
312312
|
};
|
|
311819
312313
|
}
|
|
311820
312314
|
const duration = (Date.now() - startTime) / 1e3;
|
|
311821
|
-
const summary = `Mobile discovery complete: explored ${explore.exploreStats.pagesDiscovered} screens, planned ${scenarios.length} scenarios, generated ${tests.length} tests in ${duration.toFixed(1)}s`;
|
|
312315
|
+
const summary = `Mobile discovery complete: explored ${explore.exploreStats.pagesDiscovered} screens, planned ${scenarios.length} scenarios, generated ${tests.length} tests in ${duration.toFixed(1)}s${generatePaused ? " \u2014 PAUSED on AI provider overload (resumable)" : ""}`;
|
|
311822
312316
|
log2(`
|
|
311823
312317
|
=== Mobile Discovery Pipeline Complete (${duration.toFixed(1)}s) ===`);
|
|
311824
312318
|
log2(summary);
|
|
@@ -311843,7 +312337,11 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
|
|
|
311843
312337
|
summary,
|
|
311844
312338
|
exploreStats: explore.exploreStats,
|
|
311845
312339
|
duration,
|
|
311846
|
-
mode
|
|
312340
|
+
mode,
|
|
312341
|
+
// D-D4 — a circuit-breaker trip PAUSES the run (resumable) instead of
|
|
312342
|
+
// finalizing COMPLETED/FAILED. The server's isDiscoveryPaused reads these
|
|
312343
|
+
// (forwarded by runner.ts buildFinalBody, byte-identical to the web path).
|
|
312344
|
+
...generatePaused ? { paused: true, pauseReason: generatePauseReason, remainingScenarios: generateRemainingScenarios } : {}
|
|
311847
312345
|
// apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
|
|
311848
312346
|
};
|
|
311849
312347
|
} catch (fatalErr) {
|
|
@@ -311889,6 +312387,7 @@ var require_discover_agent = __commonJS({
|
|
|
311889
312387
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
311890
312388
|
exports.runMobileDiscoveryOnRunner = exports.buildInMemorySiteMap = void 0;
|
|
311891
312389
|
exports.runCanvasPreflight = runCanvasPreflight;
|
|
312390
|
+
exports.getFatalExploreError = getFatalExploreError;
|
|
311892
312391
|
exports.runSiteDiscoveryOnRunner = runSiteDiscoveryOnRunner2;
|
|
311893
312392
|
exports.runFeatureDiscoveryOnRunner = runFeatureDiscoveryOnRunner2;
|
|
311894
312393
|
var browser_config_js_1 = require_browser_config();
|
|
@@ -312071,7 +312570,7 @@ Respond with ONLY valid JSON:
|
|
|
312071
312570
|
"keyFindings": "What was discovered - forms, validations, behaviors. Mark inferred items with '(inferred, not tested)'.",
|
|
312072
312571
|
"testingNotes": "Gotchas, validation behaviors, error messages. List visible-but-untested routes as candidates for future exploration."
|
|
312073
312572
|
}`;
|
|
312074
|
-
const client = modelOverride ? (0, ai_provider_js_1.
|
|
312573
|
+
const client = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
|
|
312075
312574
|
const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
|
|
312076
312575
|
const callStart = Date.now();
|
|
312077
312576
|
const response = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => client.chat.completions.create({
|
|
@@ -312202,6 +312701,10 @@ Respond with ONLY valid JSON:
|
|
|
312202
312701
|
});
|
|
312203
312702
|
return timed.catch(() => null);
|
|
312204
312703
|
}
|
|
312704
|
+
function getFatalExploreError(exploreResult) {
|
|
312705
|
+
const prefix = "Fatal error:";
|
|
312706
|
+
return exploreResult.summary.startsWith(prefix) ? exploreResult.summary.slice(prefix.length).trim() || exploreResult.summary : void 0;
|
|
312707
|
+
}
|
|
312205
312708
|
async function runSiteDiscoveryOnRunner2(ctx, onLog, onAICall, sharedBrowser, onScreenshot, callbackOptions) {
|
|
312206
312709
|
const logs2 = [];
|
|
312207
312710
|
const screenshots = [];
|
|
@@ -312386,6 +312889,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
|
|
|
312386
312889
|
if (loginRunP)
|
|
312387
312890
|
await loginRunP;
|
|
312388
312891
|
const duration2 = (Date.now() - startTime) / 1e3;
|
|
312892
|
+
const fatalExploreError = getFatalExploreError(exploreResult);
|
|
312389
312893
|
return {
|
|
312390
312894
|
collectedPages: exploreResult.collectedPages,
|
|
312391
312895
|
collectedTransitions: exploreResult.collectedTransitions,
|
|
@@ -312396,6 +312900,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
|
|
|
312396
312900
|
pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
|
|
312397
312901
|
pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
|
|
312398
312902
|
summary: exploreResult.summary || "Exploration completed but no page data captured.",
|
|
312903
|
+
error: fatalExploreError,
|
|
312399
312904
|
exploreStats: {
|
|
312400
312905
|
pagesDiscovered: exploreResult.pagesDiscovered,
|
|
312401
312906
|
pagesVisited: exploreResult.pagesVisited,
|
|
@@ -312937,6 +313442,7 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
|
|
|
312937
313442
|
discoveryApiCalls = earlyClose.apiCalls;
|
|
312938
313443
|
postCloseHealthSummary = earlyClose.healthSummary;
|
|
312939
313444
|
const duration2 = (Date.now() - startTime) / 1e3;
|
|
313445
|
+
const fatalExploreError = getFatalExploreError(exploreResult);
|
|
312940
313446
|
return {
|
|
312941
313447
|
collectedPages: exploreResult.collectedPages,
|
|
312942
313448
|
collectedTransitions: exploreResult.collectedTransitions,
|
|
@@ -312947,7 +313453,8 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
|
|
|
312947
313453
|
healthObservations: earlyClose.healthSummary,
|
|
312948
313454
|
pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
|
|
312949
313455
|
pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
|
|
312950
|
-
summary: "Feature exploration completed but no page data captured.",
|
|
313456
|
+
summary: fatalExploreError ? exploreResult.summary : "Feature exploration completed but no page data captured.",
|
|
313457
|
+
error: fatalExploreError,
|
|
312951
313458
|
exploreStats: {
|
|
312952
313459
|
pagesDiscovered: exploreResult.pagesDiscovered,
|
|
312953
313460
|
pagesVisited: exploreResult.pagesVisited,
|
|
@@ -326336,7 +326843,7 @@ var require_package4 = __commonJS({
|
|
|
326336
326843
|
"package.json"(exports, module) {
|
|
326337
326844
|
module.exports = {
|
|
326338
326845
|
name: "@validate.qa/runner",
|
|
326339
|
-
version: "1.0.
|
|
326846
|
+
version: "1.0.13",
|
|
326340
326847
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
326341
326848
|
bin: {
|
|
326342
326849
|
"validate-runner": "dist/cli.js"
|
|
@@ -326344,7 +326851,7 @@ var require_package4 = __commonJS({
|
|
|
326344
326851
|
scripts: {
|
|
326345
326852
|
build: "tsup",
|
|
326346
326853
|
dev: "tsx src/cli.ts",
|
|
326347
|
-
"test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/services/mobile/mobile-network-capture.smoke.test.ts",
|
|
326854
|
+
"test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/services/mobile/mobile-network-capture.smoke.test.ts src/services/mobile/mobile-host-proxy.test.ts",
|
|
326348
326855
|
prepublishOnly: "npm run build"
|
|
326349
326856
|
},
|
|
326350
326857
|
files: [
|
|
@@ -326571,8 +327078,10 @@ async function startAppiumServer(options) {
|
|
|
326571
327078
|
return serverState;
|
|
326572
327079
|
}
|
|
326573
327080
|
const port = options?.port ?? APPIUM_PORT;
|
|
326574
|
-
const
|
|
326575
|
-
|
|
327081
|
+
const args = ["--port", String(port)];
|
|
327082
|
+
if (options?.drivers && options.drivers.length > 0) {
|
|
327083
|
+
args.push("--use-drivers", options.drivers.join(","));
|
|
327084
|
+
}
|
|
326576
327085
|
intentionalStop = false;
|
|
326577
327086
|
lastStartOptions = { ...lastStartOptions, ...options };
|
|
326578
327087
|
if (appiumGaveUp) {
|
|
@@ -326582,11 +327091,12 @@ async function startAppiumServer(options) {
|
|
|
326582
327091
|
killPort(port);
|
|
326583
327092
|
return new Promise((resolve, reject) => {
|
|
326584
327093
|
try {
|
|
326585
|
-
|
|
327094
|
+
const child = spawn("appium", args, {
|
|
326586
327095
|
stdio: ["pipe", "pipe", "pipe"],
|
|
326587
327096
|
env: { ...process.env },
|
|
326588
327097
|
detached: false
|
|
326589
327098
|
});
|
|
327099
|
+
appiumProcess = child;
|
|
326590
327100
|
serverState.pid = appiumProcess.pid ?? null;
|
|
326591
327101
|
serverState.port = port;
|
|
326592
327102
|
appiumProcess.stdout?.on("data", (data) => {
|
|
@@ -326603,7 +327113,11 @@ async function startAppiumServer(options) {
|
|
|
326603
327113
|
options?.onLog?.(line);
|
|
326604
327114
|
}
|
|
326605
327115
|
});
|
|
326606
|
-
|
|
327116
|
+
child.on("exit", (code, signal) => {
|
|
327117
|
+
if (appiumProcess !== child) {
|
|
327118
|
+
appendLog(`[appium] Ignoring exit (code=${code}, signal=${signal}) from a superseded process`);
|
|
327119
|
+
return;
|
|
327120
|
+
}
|
|
326607
327121
|
appendLog(`[appium] Process exited (code=${code}, signal=${signal})`);
|
|
326608
327122
|
const currentPort = serverState.port;
|
|
326609
327123
|
const currentRestartCount = serverState.restartCount;
|
|
@@ -326642,7 +327156,11 @@ async function startAppiumServer(options) {
|
|
|
326642
327156
|
startupAbort.abort();
|
|
326643
327157
|
reject(err);
|
|
326644
327158
|
};
|
|
326645
|
-
|
|
327159
|
+
child.on("error", (err) => {
|
|
327160
|
+
if (appiumProcess !== child) {
|
|
327161
|
+
appendLog(`[appium] Ignoring error from a superseded process: ${err.message}`);
|
|
327162
|
+
return;
|
|
327163
|
+
}
|
|
326646
327164
|
appendLog(`[appium] Process error: ${err.message}`);
|
|
326647
327165
|
resetServerState(port);
|
|
326648
327166
|
settleReject(new Error(`Failed to start Appium: ${err.message}. Is appium installed? Run: npm i -g appium`));
|
|
@@ -326761,6 +327279,7 @@ function runCommand(command, args, timeout = 1e4) {
|
|
|
326761
327279
|
}
|
|
326762
327280
|
var realIOSProfilerNegativeUntil = 0;
|
|
326763
327281
|
var REAL_IOS_PROFILER_TTL_MS = 6e4;
|
|
327282
|
+
var REAL_IOS_NO_UDID_HINT = "no paired UDID \u2014 install libimobiledevice (brew install libimobiledevice) and trust this computer on the device";
|
|
326764
327283
|
function discoverIOSSimulators() {
|
|
326765
327284
|
if (platform() !== "darwin") return [];
|
|
326766
327285
|
const json = runCommand("xcrun", ["simctl", "list", "devices", "--json"]);
|
|
@@ -326770,7 +327289,8 @@ function discoverIOSSimulators() {
|
|
|
326770
327289
|
const devices = [];
|
|
326771
327290
|
for (const [runtime, runtimeDevices] of Object.entries(parsed.devices)) {
|
|
326772
327291
|
const versionMatch = runtime.match(/iOS[- ](\d+(?:[-.]\d+)*)/i);
|
|
326773
|
-
|
|
327292
|
+
if (!versionMatch) continue;
|
|
327293
|
+
const platformVersion = versionMatch[1].replace(/-/g, ".");
|
|
326774
327294
|
for (const device of runtimeDevices) {
|
|
326775
327295
|
const state2 = device.state === "Booted" ? "BOOTED" : device.state === "Shutdown" ? "SHUTDOWN" : "LOADING";
|
|
326776
327296
|
if (state2 === "BOOTED") {
|
|
@@ -326843,11 +327363,11 @@ function discoverRealIOSDevices() {
|
|
|
326843
327363
|
const version = runCommand("ideviceinfo", ["-u", serial, "-k", "ProductVersion"]) || "unknown";
|
|
326844
327364
|
devices.push({
|
|
326845
327365
|
id: serial,
|
|
326846
|
-
name
|
|
327366
|
+
name: `${name} (${REAL_IOS_NO_UDID_HINT})`,
|
|
326847
327367
|
platform: "IOS",
|
|
326848
327368
|
platformVersion: version,
|
|
326849
327369
|
state: "BOOTED",
|
|
326850
|
-
isAvailable:
|
|
327370
|
+
isAvailable: false,
|
|
326851
327371
|
isPhysical: true
|
|
326852
327372
|
});
|
|
326853
327373
|
}
|
|
@@ -326863,11 +327383,11 @@ function discoverRealIOSDevices() {
|
|
|
326863
327383
|
if (hasIPhone || hasIPad) {
|
|
326864
327384
|
devices.push({
|
|
326865
327385
|
id: "usb-ios-device",
|
|
326866
|
-
name: hasIPhone ? "iPhone (USB)" : "iPad (USB)"
|
|
327386
|
+
name: `${hasIPhone ? "iPhone (USB)" : "iPad (USB)"} (${REAL_IOS_NO_UDID_HINT})`,
|
|
326867
327387
|
platform: "IOS",
|
|
326868
327388
|
platformVersion: "unknown",
|
|
326869
327389
|
state: "BOOTED",
|
|
326870
|
-
isAvailable:
|
|
327390
|
+
isAvailable: false,
|
|
326871
327391
|
isPhysical: true
|
|
326872
327392
|
});
|
|
326873
327393
|
}
|
|
@@ -326945,7 +327465,9 @@ function discoverAndroidDevices() {
|
|
|
326945
327465
|
const adbDevices = parseAdbDevices();
|
|
326946
327466
|
return adbDevices.filter((d) => d.type === "device" || d.type === "unauthorized").map((d) => {
|
|
326947
327467
|
const isEmulator = d.serial.startsWith("emulator-");
|
|
326948
|
-
const
|
|
327468
|
+
const authorized = d.type === "device";
|
|
327469
|
+
const bootCompleted = authorized && getAndroidProp(d.serial, "sys.boot_completed") === "1";
|
|
327470
|
+
const isAvailable = authorized && bootCompleted;
|
|
326949
327471
|
const name = d.model || d.device || (isEmulator ? "Android Emulator" : "Android Device");
|
|
326950
327472
|
const platformVersion = isAvailable ? getAndroidProp(d.serial, "ro.build.version.release") || "unknown" : "unknown";
|
|
326951
327473
|
return {
|
|
@@ -326953,7 +327475,10 @@ function discoverAndroidDevices() {
|
|
|
326953
327475
|
name,
|
|
326954
327476
|
platform: "ANDROID",
|
|
326955
327477
|
platformVersion,
|
|
326956
|
-
|
|
327478
|
+
// LOADING = authorized but still booting; an unauthorized device stays
|
|
327479
|
+
// BOOTED (isAvailable false) so the dashboard shows it with an
|
|
327480
|
+
// "accept USB debugging" hint rather than hiding it.
|
|
327481
|
+
state: authorized && !bootCompleted ? "LOADING" : "BOOTED",
|
|
326957
327482
|
isAvailable,
|
|
326958
327483
|
// Mark if it's a real device vs emulator
|
|
326959
327484
|
...isEmulator ? {} : { isPhysical: true }
|
|
@@ -326999,15 +327524,25 @@ function discoverAllDevices(options) {
|
|
|
326999
327524
|
}
|
|
327000
327525
|
return [...iosDevices, ...androidDevices];
|
|
327001
327526
|
}
|
|
327527
|
+
var DEVICE_SNAPSHOT_TTL_MS = 8e3;
|
|
327528
|
+
var cachedDeviceSnapshot = null;
|
|
327529
|
+
function getCachedDeviceSnapshot() {
|
|
327530
|
+
const now = Date.now();
|
|
327531
|
+
if (cachedDeviceSnapshot && now - cachedDeviceSnapshot.at < DEVICE_SNAPSHOT_TTL_MS) {
|
|
327532
|
+
return cachedDeviceSnapshot.devices;
|
|
327533
|
+
}
|
|
327534
|
+
const devices = discoverAllDevices();
|
|
327535
|
+
cachedDeviceSnapshot = { at: now, devices };
|
|
327536
|
+
return devices;
|
|
327537
|
+
}
|
|
327002
327538
|
function getRunnerCapabilities() {
|
|
327003
|
-
const
|
|
327004
|
-
const androidDevices = discoverAndroidDevices();
|
|
327539
|
+
const devices = getCachedDeviceSnapshot();
|
|
327005
327540
|
const isReadyDevice = (device) => device.isAvailable && device.state === "BOOTED";
|
|
327006
327541
|
return {
|
|
327007
327542
|
web: true,
|
|
327008
327543
|
// Always capable of web testing via Playwright
|
|
327009
|
-
ios:
|
|
327010
|
-
android:
|
|
327544
|
+
ios: devices.some((d) => d.platform === "IOS" && isReadyDevice(d)),
|
|
327545
|
+
android: devices.some((d) => d.platform === "ANDROID" && isReadyDevice(d))
|
|
327011
327546
|
};
|
|
327012
327547
|
}
|
|
327013
327548
|
|
|
@@ -327146,6 +327681,9 @@ var MitmProxy = class {
|
|
|
327146
327681
|
await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
|
|
327147
327682
|
await server3.on("request", (req) => this.onRequest(req));
|
|
327148
327683
|
await server3.on("response", (res) => this.onResponse(res));
|
|
327684
|
+
await server3.on("abort", (req) => {
|
|
327685
|
+
this.inFlight.delete(req.id);
|
|
327686
|
+
});
|
|
327149
327687
|
await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
|
|
327150
327688
|
await server3.on("tls-passthrough-opened", (event) => this.onTlsPassthroughOpened(event));
|
|
327151
327689
|
await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
|
|
@@ -327442,6 +327980,107 @@ async function iosSimRemoveCa(deviceId, onLog) {
|
|
|
327442
327980
|
function errMsg(err) {
|
|
327443
327981
|
return err instanceof Error ? err.message : String(err);
|
|
327444
327982
|
}
|
|
327983
|
+
function parsePrimaryNetworkService(stdout) {
|
|
327984
|
+
for (const line of stdout.split("\n")) {
|
|
327985
|
+
const m = line.match(/^\(\d+\)\s+(.+)$/);
|
|
327986
|
+
if (m) return m[1].trim();
|
|
327987
|
+
}
|
|
327988
|
+
return null;
|
|
327989
|
+
}
|
|
327990
|
+
function parseWebProxyState(stdout) {
|
|
327991
|
+
const field = (label) => {
|
|
327992
|
+
const m = stdout.match(new RegExp(`^${label}:[ \\t]*(.*)$`, "im"));
|
|
327993
|
+
return m ? m[1].trim() : "";
|
|
327994
|
+
};
|
|
327995
|
+
return {
|
|
327996
|
+
enabled: field("Enabled").toLowerCase() === "yes",
|
|
327997
|
+
server: field("Server"),
|
|
327998
|
+
port: field("Port")
|
|
327999
|
+
};
|
|
328000
|
+
}
|
|
328001
|
+
function buildHostProxySetCommands(service, host, port) {
|
|
328002
|
+
const portStr = String(port);
|
|
328003
|
+
return [
|
|
328004
|
+
["-setwebproxy", service, host, portStr],
|
|
328005
|
+
["-setsecurewebproxy", service, host, portStr]
|
|
328006
|
+
];
|
|
328007
|
+
}
|
|
328008
|
+
function buildProxyRestoreArgs(setCmd, stateCmd, service, prior) {
|
|
328009
|
+
if (prior.server) {
|
|
328010
|
+
return [
|
|
328011
|
+
[setCmd, service, prior.server, prior.port || "0"],
|
|
328012
|
+
[stateCmd, service, prior.enabled ? "on" : "off"]
|
|
328013
|
+
];
|
|
328014
|
+
}
|
|
328015
|
+
return [[stateCmd, service, "off"]];
|
|
328016
|
+
}
|
|
328017
|
+
function buildHostProxyRestoreCommands(service, prior) {
|
|
328018
|
+
return [
|
|
328019
|
+
...buildProxyRestoreArgs("-setwebproxy", "-setwebproxystate", service, prior.web),
|
|
328020
|
+
...buildProxyRestoreArgs("-setsecurewebproxy", "-setsecurewebproxystate", service, prior.secure)
|
|
328021
|
+
];
|
|
328022
|
+
}
|
|
328023
|
+
function hostProxyPriorFromMarker(marker) {
|
|
328024
|
+
return {
|
|
328025
|
+
web: {
|
|
328026
|
+
enabled: Boolean(marker.hostWebProxyWasEnabled),
|
|
328027
|
+
server: marker.hostWebProxyPriorServer ?? "",
|
|
328028
|
+
port: marker.hostWebProxyPriorPort ?? ""
|
|
328029
|
+
},
|
|
328030
|
+
secure: {
|
|
328031
|
+
enabled: Boolean(marker.hostSecureProxyWasEnabled),
|
|
328032
|
+
server: marker.hostSecureProxyPriorServer ?? "",
|
|
328033
|
+
port: marker.hostSecureProxyPriorPort ?? ""
|
|
328034
|
+
}
|
|
328035
|
+
};
|
|
328036
|
+
}
|
|
328037
|
+
function hostProxyControlSupported(platform3, isPhysical, platformName = process.platform) {
|
|
328038
|
+
if (platform3 !== "IOS") return { supported: false };
|
|
328039
|
+
if (isPhysical) {
|
|
328040
|
+
return { supported: false, reason: "physical iOS device requires a manual Wi-Fi HTTP proxy; host proxy not applied" };
|
|
328041
|
+
}
|
|
328042
|
+
if (platformName !== "darwin") {
|
|
328043
|
+
return { supported: false, reason: `iOS simulator host proxy is only controllable on macOS (host is ${platformName})` };
|
|
328044
|
+
}
|
|
328045
|
+
return { supported: true };
|
|
328046
|
+
}
|
|
328047
|
+
async function iosSimConfigureHostProxy(host, port, onLog) {
|
|
328048
|
+
try {
|
|
328049
|
+
const { stdout: order } = await execFileAsync("networksetup", ["-listnetworkserviceorder"], {
|
|
328050
|
+
timeout: DEVICE_CMD_TIMEOUT_MS
|
|
328051
|
+
});
|
|
328052
|
+
const service = parsePrimaryNetworkService(order);
|
|
328053
|
+
if (!service) {
|
|
328054
|
+
return { configured: false, reason: "iOS simulator host proxy could not be configured: no active macOS network service found" };
|
|
328055
|
+
}
|
|
328056
|
+
const [{ stdout: webOut }, { stdout: secureOut }] = await Promise.all([
|
|
328057
|
+
execFileAsync("networksetup", ["-getwebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS }),
|
|
328058
|
+
execFileAsync("networksetup", ["-getsecurewebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS })
|
|
328059
|
+
]);
|
|
328060
|
+
const prior = { web: parseWebProxyState(webOut), secure: parseWebProxyState(secureOut) };
|
|
328061
|
+
for (const args of buildHostProxySetCommands(service, host, port)) {
|
|
328062
|
+
await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
|
|
328063
|
+
}
|
|
328064
|
+
onLog?.(`[mobile-net] macOS host proxy set on "${service}" \u2192 ${host}:${port} (iOS simulator shares the host network).`);
|
|
328065
|
+
return { configured: true, service, prior };
|
|
328066
|
+
} catch (err) {
|
|
328067
|
+
return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg(err)}` };
|
|
328068
|
+
}
|
|
328069
|
+
}
|
|
328070
|
+
async function iosSimRestoreHostProxy(service, prior, onLog) {
|
|
328071
|
+
const restore = prior ?? {
|
|
328072
|
+
web: { enabled: false, server: "", port: "" },
|
|
328073
|
+
secure: { enabled: false, server: "", port: "" }
|
|
328074
|
+
};
|
|
328075
|
+
for (const args of buildHostProxyRestoreCommands(service, restore)) {
|
|
328076
|
+
try {
|
|
328077
|
+
await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
|
|
328078
|
+
} catch (err) {
|
|
328079
|
+
onLog?.(`[mobile-net] networksetup restore (${args[0]}) failed: ${errMsg(err)}`);
|
|
328080
|
+
}
|
|
328081
|
+
}
|
|
328082
|
+
onLog?.(`[mobile-net] macOS host proxy restored on "${service}".`);
|
|
328083
|
+
}
|
|
327445
328084
|
var activeCleanups = /* @__PURE__ */ new Map();
|
|
327446
328085
|
var exitHandlersInstalled = false;
|
|
327447
328086
|
function markerPath(sessionKey) {
|
|
@@ -327476,6 +328115,14 @@ function restoreDeviceSync(marker, onLog) {
|
|
|
327476
328115
|
run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
|
|
327477
328116
|
}
|
|
327478
328117
|
}
|
|
328118
|
+
if (marker.platform === "IOS" && marker.hostProxySet && marker.hostProxyService && process.platform === "darwin") {
|
|
328119
|
+
for (const args of buildHostProxyRestoreCommands(marker.hostProxyService, hostProxyPriorFromMarker(marker))) {
|
|
328120
|
+
try {
|
|
328121
|
+
execFileSync3("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS, stdio: "ignore" });
|
|
328122
|
+
} catch {
|
|
328123
|
+
}
|
|
328124
|
+
}
|
|
328125
|
+
}
|
|
327479
328126
|
onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
|
|
327480
328127
|
}
|
|
327481
328128
|
async function reconcilePendingCleanups(onLog) {
|
|
@@ -327487,6 +328134,8 @@ async function reconcilePendingCleanups(onLog) {
|
|
|
327487
328134
|
}
|
|
327488
328135
|
for (const file of files) {
|
|
327489
328136
|
if (!file.endsWith(".json")) continue;
|
|
328137
|
+
const sessionKey = file.slice(0, -".json".length);
|
|
328138
|
+
if (activeCleanups.has(sessionKey)) continue;
|
|
327490
328139
|
const full = join2(PENDING_CLEANUP_DIR, file);
|
|
327491
328140
|
try {
|
|
327492
328141
|
const raw = await readFile(full, "utf-8");
|
|
@@ -327516,8 +328165,10 @@ function ensureExitHandlers() {
|
|
|
327516
328165
|
for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
327517
328166
|
process.on(signal, () => {
|
|
327518
328167
|
drain();
|
|
327519
|
-
process.
|
|
327520
|
-
|
|
328168
|
+
if (process.listenerCount(signal) <= 1) {
|
|
328169
|
+
process.removeAllListeners(signal);
|
|
328170
|
+
process.kill(process.pid, signal);
|
|
328171
|
+
}
|
|
327521
328172
|
});
|
|
327522
328173
|
}
|
|
327523
328174
|
}
|
|
@@ -327567,6 +328218,9 @@ async function startMobileNetworkCapture(options) {
|
|
|
327567
328218
|
let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
|
|
327568
328219
|
let proxySet = false;
|
|
327569
328220
|
let caInstalledOnDevice = false;
|
|
328221
|
+
let hostProxySet = false;
|
|
328222
|
+
let hostProxyService;
|
|
328223
|
+
let hostProxyPrior;
|
|
327570
328224
|
try {
|
|
327571
328225
|
if (platform3 === "ANDROID") {
|
|
327572
328226
|
proxySet = await androidSetProxy(deviceId, hostPort, onLog);
|
|
@@ -327581,25 +328235,50 @@ async function startMobileNetworkCapture(options) {
|
|
|
327581
328235
|
}
|
|
327582
328236
|
} else {
|
|
327583
328237
|
if (!isPhysical && caCertPath) {
|
|
327584
|
-
const { trusted
|
|
328238
|
+
const { trusted } = await iosSimTrustCa(deviceId, caCertPath);
|
|
327585
328239
|
caInstalledOnDevice = trusted;
|
|
327586
|
-
|
|
328240
|
+
}
|
|
328241
|
+
const support = hostProxyControlSupported(platform3, isPhysical);
|
|
328242
|
+
if (support.supported) {
|
|
328243
|
+
const hp = await iosSimConfigureHostProxy(proxyHost, proxyPort, onLog);
|
|
328244
|
+
if (hp.configured) {
|
|
328245
|
+
hostProxySet = true;
|
|
328246
|
+
hostProxyService = hp.service;
|
|
328247
|
+
hostProxyPrior = hp.prior;
|
|
328248
|
+
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).` : ".");
|
|
328249
|
+
} else {
|
|
328250
|
+
wiringReason = hp.reason ?? "iOS simulator host proxy could not be configured";
|
|
328251
|
+
onLog?.(`[mobile-net] ${wiringReason}`);
|
|
328252
|
+
}
|
|
327587
328253
|
} else if (isPhysical) {
|
|
327588
328254
|
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}).` : ".");
|
|
328255
|
+
} else {
|
|
328256
|
+
wiringReason = support.reason ?? "iOS simulator host proxy is unsupported on this host";
|
|
328257
|
+
onLog?.(`[mobile-net] ${wiringReason}`);
|
|
327589
328258
|
}
|
|
327590
328259
|
}
|
|
327591
328260
|
} catch (err) {
|
|
327592
328261
|
wiringReason = `device wiring degraded: ${errMsg(err)}`;
|
|
327593
328262
|
onLog?.(`[mobile-net] ${wiringReason}`);
|
|
327594
328263
|
}
|
|
327595
|
-
if (proxySet || caInstalledOnDevice) {
|
|
328264
|
+
if (proxySet || caInstalledOnDevice || hostProxySet) {
|
|
327596
328265
|
const marker = {
|
|
327597
328266
|
platform: platform3,
|
|
327598
328267
|
deviceId,
|
|
327599
328268
|
isPhysical,
|
|
327600
328269
|
proxySet,
|
|
327601
328270
|
originalProxy,
|
|
327602
|
-
caInstalledOnDevice
|
|
328271
|
+
caInstalledOnDevice,
|
|
328272
|
+
...hostProxySet ? {
|
|
328273
|
+
hostProxySet: true,
|
|
328274
|
+
hostProxyService,
|
|
328275
|
+
hostWebProxyWasEnabled: hostProxyPrior?.web.enabled,
|
|
328276
|
+
hostWebProxyPriorServer: hostProxyPrior?.web.server,
|
|
328277
|
+
hostWebProxyPriorPort: hostProxyPrior?.web.port,
|
|
328278
|
+
hostSecureProxyWasEnabled: hostProxyPrior?.secure.enabled,
|
|
328279
|
+
hostSecureProxyPriorServer: hostProxyPrior?.secure.server,
|
|
328280
|
+
hostSecureProxyPriorPort: hostProxyPrior?.secure.port
|
|
328281
|
+
} : {}
|
|
327603
328282
|
};
|
|
327604
328283
|
activeCleanups.set(sessionKey, marker);
|
|
327605
328284
|
await writePendingCleanup(sessionKey, marker);
|
|
@@ -327619,6 +328298,9 @@ async function startMobileNetworkCapture(options) {
|
|
|
327619
328298
|
if (proxySet && platform3 === "ANDROID") {
|
|
327620
328299
|
await androidClearProxy(deviceId, originalProxy, onLog);
|
|
327621
328300
|
}
|
|
328301
|
+
if (hostProxySet && hostProxyService && platform3 === "IOS" && !isPhysical) {
|
|
328302
|
+
await iosSimRestoreHostProxy(hostProxyService, hostProxyPrior, onLog);
|
|
328303
|
+
}
|
|
327622
328304
|
if (caInstalledOnDevice && capturedCaCertPath) {
|
|
327623
328305
|
if (platform3 === "ANDROID") {
|
|
327624
328306
|
await androidRemoveCa(deviceId, onLog);
|
|
@@ -327654,6 +328336,9 @@ async function startMobileNetworkCapture(options) {
|
|
|
327654
328336
|
|
|
327655
328337
|
// src/services/appium-executor.ts
|
|
327656
328338
|
var DEFAULT_STEP_TIMEOUT_MS = 1e4;
|
|
328339
|
+
var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
|
|
328340
|
+
var WD_ACTION_TIMEOUT_MS = 3e4;
|
|
328341
|
+
var MAX_SESSION_CREATE_ATTEMPTS = 3;
|
|
327657
328342
|
var execFileAsync2 = promisify2(execFile2);
|
|
327658
328343
|
var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
|
|
327659
328344
|
var WebDriverTransportError = class extends Error {
|
|
@@ -327667,13 +328352,21 @@ function isTransportError(err) {
|
|
|
327667
328352
|
return err instanceof WebDriverTransportError || typeof err === "object" && err !== null && err.isTransport === true;
|
|
327668
328353
|
}
|
|
327669
328354
|
async function wdRequest(path, options) {
|
|
328355
|
+
const isSessionCreate = (options?.method ?? "GET").toUpperCase() === "POST" && path === "/session";
|
|
328356
|
+
const timeoutMs = isSessionCreate ? WD_SESSION_CREATE_TIMEOUT_MS : WD_ACTION_TIMEOUT_MS;
|
|
327670
328357
|
let res;
|
|
327671
328358
|
try {
|
|
327672
328359
|
res = await fetch(`http://localhost:${getAppiumState().port}${path}`, {
|
|
327673
328360
|
...options,
|
|
328361
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
327674
328362
|
headers: { "Content-Type": "application/json", ...options?.headers }
|
|
327675
328363
|
});
|
|
327676
328364
|
} catch (err) {
|
|
328365
|
+
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
|
|
328366
|
+
throw new WebDriverTransportError(
|
|
328367
|
+
`WebDriver request to ${path} timed out after ${timeoutMs}ms (device/Appium unresponsive)`
|
|
328368
|
+
);
|
|
328369
|
+
}
|
|
327677
328370
|
throw new WebDriverTransportError(`WebDriver connection error: ${err instanceof Error ? err.message : String(err)}`);
|
|
327678
328371
|
}
|
|
327679
328372
|
if (!res.ok) {
|
|
@@ -327758,6 +328451,31 @@ async function createSession(target) {
|
|
|
327758
328451
|
if (!sessionId) throw new Error("Appium session creation returned no session ID");
|
|
327759
328452
|
return sessionId;
|
|
327760
328453
|
}
|
|
328454
|
+
var TERMINAL_SESSION_START = /not installed|no such (app|file)|could not confirm|bundle ?id .*(not|missing)|xcodeorgid|signing|provisioning|not authorized|unauthorized/i;
|
|
328455
|
+
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;
|
|
328456
|
+
function isRetryableSessionStartError(err) {
|
|
328457
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
328458
|
+
if (TERMINAL_SESSION_START.test(message)) return false;
|
|
328459
|
+
if (isTransportError(err)) return true;
|
|
328460
|
+
return RETRYABLE_SESSION_START.test(message);
|
|
328461
|
+
}
|
|
328462
|
+
async function createSessionWithRetry(target, onRetry) {
|
|
328463
|
+
let lastErr;
|
|
328464
|
+
for (let attempt = 1; attempt <= MAX_SESSION_CREATE_ATTEMPTS; attempt++) {
|
|
328465
|
+
try {
|
|
328466
|
+
return await createSession(target);
|
|
328467
|
+
} catch (err) {
|
|
328468
|
+
lastErr = err;
|
|
328469
|
+
if (attempt >= MAX_SESSION_CREATE_ATTEMPTS || !isRetryableSessionStartError(err)) throw err;
|
|
328470
|
+
const delayMs = Math.pow(2, attempt) * 1e3;
|
|
328471
|
+
onRetry?.(
|
|
328472
|
+
`Appium session start failed (attempt ${attempt}/${MAX_SESSION_CREATE_ATTEMPTS}, retrying in ${delayMs}ms): ${err instanceof Error ? err.message : String(err)}`
|
|
328473
|
+
);
|
|
328474
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
328475
|
+
}
|
|
328476
|
+
}
|
|
328477
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
328478
|
+
}
|
|
327761
328479
|
async function openDeepLink(sessionId, platform3, deeplink, appId) {
|
|
327762
328480
|
const args = platform3 === "IOS" ? [{ url: deeplink, bundleId: appId }] : [{ url: deeplink, package: appId }];
|
|
327763
328481
|
await wdRequest(`/session/${sessionId}/execute/sync`, {
|
|
@@ -327859,6 +328577,61 @@ async function takeScreenshot(sessionId) {
|
|
|
327859
328577
|
return null;
|
|
327860
328578
|
}
|
|
327861
328579
|
}
|
|
328580
|
+
function mobileRecordingEnabled() {
|
|
328581
|
+
const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
|
|
328582
|
+
return value !== "0" && value !== "false";
|
|
328583
|
+
}
|
|
328584
|
+
function readPositiveIntEnv(name, fallback, min, max) {
|
|
328585
|
+
const raw = process.env[name];
|
|
328586
|
+
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
|
328587
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
328588
|
+
return Math.max(min, Math.min(max, parsed));
|
|
328589
|
+
}
|
|
328590
|
+
async function startScreenRecording(sessionId, platform3, log2) {
|
|
328591
|
+
if (!mobileRecordingEnabled()) {
|
|
328592
|
+
log2("[mobile-video] recording disabled by VALIDATEQA_MOBILE_RUN_VIDEO=0");
|
|
328593
|
+
return false;
|
|
328594
|
+
}
|
|
328595
|
+
const timeLimit = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_SECONDS", 180, 5, 180);
|
|
328596
|
+
const options = {
|
|
328597
|
+
timeLimit: String(timeLimit),
|
|
328598
|
+
forceRestart: true
|
|
328599
|
+
};
|
|
328600
|
+
if (platform3 === "ANDROID") {
|
|
328601
|
+
options.bitRate = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_BITRATE", 75e4, 1e5, 4e6);
|
|
328602
|
+
} else {
|
|
328603
|
+
options.videoQuality = process.env.VALIDATEQA_MOBILE_RECORDING_IOS_QUALITY || "low";
|
|
328604
|
+
}
|
|
328605
|
+
try {
|
|
328606
|
+
await wdRequest(`/session/${sessionId}/appium/start_recording_screen`, {
|
|
328607
|
+
method: "POST",
|
|
328608
|
+
body: JSON.stringify({ options })
|
|
328609
|
+
});
|
|
328610
|
+
log2(`[mobile-video] screen recording started (${timeLimit}s max)`);
|
|
328611
|
+
return true;
|
|
328612
|
+
} catch (err) {
|
|
328613
|
+
log2(`[mobile-video] screen recording unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
328614
|
+
return false;
|
|
328615
|
+
}
|
|
328616
|
+
}
|
|
328617
|
+
async function stopScreenRecording(sessionId, log2) {
|
|
328618
|
+
try {
|
|
328619
|
+
const result = await wdRequest(`/session/${sessionId}/appium/stop_recording_screen`, {
|
|
328620
|
+
method: "POST",
|
|
328621
|
+
body: JSON.stringify({ options: {} })
|
|
328622
|
+
});
|
|
328623
|
+
const value = typeof result?.value === "string" ? result.value.trim() : "";
|
|
328624
|
+
if (!value) {
|
|
328625
|
+
log2("[mobile-video] screen recording stopped but Appium returned no video");
|
|
328626
|
+
return void 0;
|
|
328627
|
+
}
|
|
328628
|
+
log2(`[mobile-video] screen recording captured (${Math.round(value.length / 1024)}KB base64)`);
|
|
328629
|
+
return value;
|
|
328630
|
+
} catch (err) {
|
|
328631
|
+
log2(`[mobile-video] screen recording stop failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
328632
|
+
return void 0;
|
|
328633
|
+
}
|
|
328634
|
+
}
|
|
327862
328635
|
function boundsFromStep(step) {
|
|
327863
328636
|
const bounds = step.metadata?.bounds;
|
|
327864
328637
|
if (bounds && typeof bounds === "object" && !Array.isArray(bounds) && typeof bounds.x === "number" && typeof bounds.y === "number" && typeof bounds.width === "number" && typeof bounds.height === "number") {
|
|
@@ -327978,7 +328751,8 @@ async function sendKeysToFocusedInput(sessionId, value) {
|
|
|
327978
328751
|
});
|
|
327979
328752
|
}
|
|
327980
328753
|
function encodeAndroidInputText(value) {
|
|
327981
|
-
|
|
328754
|
+
const inputEncoded = value.replace(/%/g, "%25").replace(/\s/g, "%s");
|
|
328755
|
+
return `'${inputEncoded.replace(/'/g, `'\\''`)}'`;
|
|
327982
328756
|
}
|
|
327983
328757
|
async function sendAndroidTextToFocusedInput(deviceId, value) {
|
|
327984
328758
|
if (!deviceId) {
|
|
@@ -328236,6 +329010,9 @@ async function executeMobileTest(options) {
|
|
|
328236
329010
|
let sessionId = null;
|
|
328237
329011
|
let capture = null;
|
|
328238
329012
|
let captureStopped = false;
|
|
329013
|
+
let recordingStarted = false;
|
|
329014
|
+
let recordingStopped = false;
|
|
329015
|
+
let video;
|
|
328239
329016
|
const stopCapture = async () => {
|
|
328240
329017
|
if (!capture || captureStopped) return apiCalls;
|
|
328241
329018
|
captureStopped = true;
|
|
@@ -328250,6 +329027,15 @@ async function executeMobileTest(options) {
|
|
|
328250
329027
|
}
|
|
328251
329028
|
return apiCalls;
|
|
328252
329029
|
};
|
|
329030
|
+
const stopRecording = async () => {
|
|
329031
|
+
if (!sessionId || !recordingStarted || recordingStopped) return video;
|
|
329032
|
+
recordingStopped = true;
|
|
329033
|
+
const captured = await stopScreenRecording(sessionId, log2);
|
|
329034
|
+
if (captured) {
|
|
329035
|
+
video = captured;
|
|
329036
|
+
}
|
|
329037
|
+
return video;
|
|
329038
|
+
};
|
|
328253
329039
|
try {
|
|
328254
329040
|
log2("Checking Appium server health...");
|
|
328255
329041
|
const health = await checkAppiumHealth();
|
|
@@ -328303,13 +329089,13 @@ async function executeMobileTest(options) {
|
|
|
328303
329089
|
}
|
|
328304
329090
|
log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
|
|
328305
329091
|
try {
|
|
328306
|
-
sessionId = await
|
|
329092
|
+
sessionId = await createSessionWithRetry({
|
|
328307
329093
|
appId: options.target.appId,
|
|
328308
329094
|
platform: options.target.platform,
|
|
328309
329095
|
deviceId: selectedDevice.id,
|
|
328310
329096
|
platformVersion: options.target.platformVersion ?? selectedDevice.platformVersion,
|
|
328311
329097
|
isPhysical: selectedDevice.isPhysical
|
|
328312
|
-
});
|
|
329098
|
+
}, (msg) => log2(msg));
|
|
328313
329099
|
} catch (err) {
|
|
328314
329100
|
const base2 = err instanceof Error ? err.message : String(err);
|
|
328315
329101
|
if (selectedDevice.isPhysical) {
|
|
@@ -328324,6 +329110,7 @@ async function executeMobileTest(options) {
|
|
|
328324
329110
|
throw err;
|
|
328325
329111
|
}
|
|
328326
329112
|
log2(`Session created: ${sessionId}`);
|
|
329113
|
+
recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
|
|
328327
329114
|
if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
|
|
328328
329115
|
try {
|
|
328329
329116
|
log2(`Opening deep link: ${options.target.deeplink}`);
|
|
@@ -328384,7 +329171,9 @@ async function executeMobileTest(options) {
|
|
|
328384
329171
|
break;
|
|
328385
329172
|
}
|
|
328386
329173
|
}
|
|
328387
|
-
|
|
329174
|
+
if (step.action !== "home" && step.action !== "back") {
|
|
329175
|
+
await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
|
|
329176
|
+
}
|
|
328388
329177
|
} catch (err) {
|
|
328389
329178
|
status = "failed";
|
|
328390
329179
|
stepError = err instanceof Error ? err.message : String(err);
|
|
@@ -328413,10 +329202,12 @@ async function executeMobileTest(options) {
|
|
|
328413
329202
|
const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
|
|
328414
329203
|
const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
|
|
328415
329204
|
await stopCapture();
|
|
329205
|
+
await stopRecording();
|
|
328416
329206
|
return {
|
|
328417
329207
|
passed: allPassed,
|
|
328418
329208
|
stepResults,
|
|
328419
329209
|
screenshots,
|
|
329210
|
+
...video ? { video, videoMimeType: "video/mp4" } : {},
|
|
328420
329211
|
apiCalls,
|
|
328421
329212
|
logs: logLines.join("\n"),
|
|
328422
329213
|
error: firstError,
|
|
@@ -328427,10 +329218,12 @@ async function executeMobileTest(options) {
|
|
|
328427
329218
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
328428
329219
|
log2(`Fatal error: ${errorMsg}`);
|
|
328429
329220
|
await stopCapture();
|
|
329221
|
+
await stopRecording();
|
|
328430
329222
|
return {
|
|
328431
329223
|
passed: false,
|
|
328432
329224
|
stepResults,
|
|
328433
329225
|
screenshots,
|
|
329226
|
+
...video ? { video, videoMimeType: "video/mp4" } : {},
|
|
328434
329227
|
apiCalls,
|
|
328435
329228
|
logs: logLines.join("\n"),
|
|
328436
329229
|
error: errorMsg,
|
|
@@ -328442,6 +329235,7 @@ async function executeMobileTest(options) {
|
|
|
328442
329235
|
};
|
|
328443
329236
|
} finally {
|
|
328444
329237
|
await stopCapture();
|
|
329238
|
+
await stopRecording();
|
|
328445
329239
|
if (sessionId) {
|
|
328446
329240
|
log2("Tearing down Appium session...");
|
|
328447
329241
|
await deleteSession(sessionId);
|
|
@@ -328482,7 +329276,7 @@ async function createMobileDiscoverySession(target, options) {
|
|
|
328482
329276
|
isPhysical: selectedDevice.isPhysical === true,
|
|
328483
329277
|
platformVersion: selectedDevice.platformVersion
|
|
328484
329278
|
});
|
|
328485
|
-
const sessionId = await
|
|
329279
|
+
const sessionId = await createSessionWithRetry({
|
|
328486
329280
|
appId: target.appId,
|
|
328487
329281
|
platform: target.platform,
|
|
328488
329282
|
deviceId: selectedDevice.id,
|
|
@@ -329293,7 +330087,7 @@ function stopSessionReaper() {
|
|
|
329293
330087
|
}
|
|
329294
330088
|
}
|
|
329295
330089
|
async function reapIdleSession() {
|
|
329296
|
-
if (!state.appiumSessionId || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
|
|
330090
|
+
if (!state.appiumSessionId || mirrorSessionOwner !== "recording" || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
|
|
329297
330091
|
return;
|
|
329298
330092
|
}
|
|
329299
330093
|
const sessionId = state.appiumSessionId;
|
|
@@ -331810,6 +332604,11 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
331810
332604
|
});
|
|
331811
332605
|
requestLiveBrowserHeartbeat();
|
|
331812
332606
|
},
|
|
332607
|
+
// D-D6 — upload ONE baseline PNG per newly-observed screen through the
|
|
332608
|
+
// same streaming /page-screenshot relay the web path uses (mobile has no
|
|
332609
|
+
// route, so the screen-id is the key). The returned storage key rides the
|
|
332610
|
+
// explore checkpoint so the server links it to SitePage.screenshotKey.
|
|
332611
|
+
onScreenshotUpload: (screenId, base64) => onScreenshot(screenId, base64),
|
|
331813
332612
|
onExploreComplete,
|
|
331814
332613
|
onTestGenerated,
|
|
331815
332614
|
onPlanReady
|
|
@@ -332152,6 +332951,8 @@ ${result2.logs}`);
|
|
|
332152
332951
|
status,
|
|
332153
332952
|
stepResults: result2.stepResults,
|
|
332154
332953
|
screenshots: result2.screenshots,
|
|
332954
|
+
video: result2.video,
|
|
332955
|
+
videoMimeType: result2.videoMimeType,
|
|
332155
332956
|
apiCalls: result2.apiCalls,
|
|
332156
332957
|
logs: result2.logs,
|
|
332157
332958
|
error: reportedError,
|
|
@@ -332463,7 +333264,7 @@ async function startRunner(opts) {
|
|
|
332463
333264
|
};
|
|
332464
333265
|
const sendHeartbeat = async () => {
|
|
332465
333266
|
try {
|
|
332466
|
-
const devices = enableMobile ?
|
|
333267
|
+
const devices = enableMobile ? getCachedDeviceSnapshot() : [];
|
|
332467
333268
|
const capabilities = enableMobile ? {
|
|
332468
333269
|
web: true,
|
|
332469
333270
|
ios: devices.some((d) => d.platform === "IOS" && d.isAvailable && d.state === "BOOTED"),
|