@validate.qa/runner 1.0.22 → 1.0.24
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 +292 -16
- package/dist/cli.mjs +292 -16
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -245,8 +245,8 @@ var require_ai_provider = __commonJS({
|
|
|
245
245
|
exports2.resetAIClient = resetAIClient;
|
|
246
246
|
exports2.getServerProxiedAIClient = getServerProxiedAIClient;
|
|
247
247
|
exports2.getServerProxiedClientForModel = getServerProxiedClientForModel;
|
|
248
|
-
exports2.getMobileAIClient =
|
|
249
|
-
exports2.getMobileClientForModel =
|
|
248
|
+
exports2.getMobileAIClient = getMobileAIClient2;
|
|
249
|
+
exports2.getMobileClientForModel = getMobileClientForModel2;
|
|
250
250
|
var openai_1 = __importDefault(require("openai"));
|
|
251
251
|
var async_hooks_1 = require("async_hooks");
|
|
252
252
|
var ai_retry_js_1 = require_ai_retry();
|
|
@@ -643,14 +643,14 @@ var require_ai_provider = __commonJS({
|
|
|
643
643
|
function getServerProxiedClientForModel(_modelId) {
|
|
644
644
|
return getServerProxiedAIClient();
|
|
645
645
|
}
|
|
646
|
-
function
|
|
646
|
+
function getMobileAIClient2() {
|
|
647
647
|
if ((process.env.SERVER_URL ?? "").trim().length > 0)
|
|
648
648
|
return getServerProxiedAIClient();
|
|
649
649
|
if (isMobileAiUnitTestMode())
|
|
650
650
|
return getAIClient();
|
|
651
651
|
throw new Error("Mobile AI requires the platform server URL (SERVER_URL). Customer runners do not provision provider API keys \u2014 the platform proxies every mobile AI completion through /api/runner/ai/execute, which lives on the shared server with the keys. If you reached this from `validate-runner start`, the credentials file is missing `serverUrl` or the install is broken; re-run `npx @validate.qa/runner login`. If you are calling the runner-core library directly, pass `options.aiClient` in tests or set VALIDATEQA_TEST_MODE=1 to opt into the local-provider fallback.");
|
|
652
652
|
}
|
|
653
|
-
function
|
|
653
|
+
function getMobileClientForModel2(modelId) {
|
|
654
654
|
if ((process.env.SERVER_URL ?? "").trim().length > 0)
|
|
655
655
|
return getServerProxiedClientForModel(modelId);
|
|
656
656
|
if (isMobileAiUnitTestMode())
|
|
@@ -328483,7 +328483,7 @@ var require_package4 = __commonJS({
|
|
|
328483
328483
|
"package.json"(exports2, module2) {
|
|
328484
328484
|
module2.exports = {
|
|
328485
328485
|
name: "@validate.qa/runner",
|
|
328486
|
-
version: "1.0.
|
|
328486
|
+
version: "1.0.24",
|
|
328487
328487
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
328488
328488
|
bin: {
|
|
328489
328489
|
"validate-runner": "dist/cli.js"
|
|
@@ -330256,6 +330256,7 @@ var MOBILE_NEW_COMMAND_TIMEOUT_SECONDS = (() => {
|
|
|
330256
330256
|
const raw = Number(process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT);
|
|
330257
330257
|
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 600;
|
|
330258
330258
|
})();
|
|
330259
|
+
var COLD_BOOT_ENTRY_TIMEOUT_MS = 3e4;
|
|
330259
330260
|
var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
|
|
330260
330261
|
var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
|
|
330261
330262
|
var WD_ACTION_TIMEOUT_MS = 3e4;
|
|
@@ -330490,18 +330491,24 @@ async function freshLaunchTargetApp(sessionId, platform3, appId, log2) {
|
|
|
330490
330491
|
}
|
|
330491
330492
|
async function waitForColdBootContent(sessionId, log2, timeoutMs = 2e4) {
|
|
330492
330493
|
const deadline = Date.now() + timeoutMs;
|
|
330493
|
-
const MIN_ELEMENTS =
|
|
330494
|
+
const MIN_ELEMENTS = 12;
|
|
330494
330495
|
let lastCount = 0;
|
|
330496
|
+
let stableFrames = 0;
|
|
330495
330497
|
while (Date.now() < deadline) {
|
|
330496
330498
|
try {
|
|
330497
330499
|
const res = await wdRequest(`/session/${sessionId}/source`);
|
|
330498
330500
|
const xml = typeof res?.value === "string" ? res.value : "";
|
|
330499
330501
|
const count = (xml.match(/<(android\.|XCUIElementType)/g) ?? []).length;
|
|
330500
|
-
|
|
330501
|
-
|
|
330502
|
-
|
|
330503
|
-
|
|
330502
|
+
if (count >= MIN_ELEMENTS && count === lastCount) {
|
|
330503
|
+
stableFrames++;
|
|
330504
|
+
if (stableFrames >= 2) {
|
|
330505
|
+
log2(` Cold-boot ready: UI tree settled at ${count} node(s).`);
|
|
330506
|
+
return;
|
|
330507
|
+
}
|
|
330508
|
+
} else {
|
|
330509
|
+
stableFrames = 0;
|
|
330504
330510
|
}
|
|
330511
|
+
lastCount = count;
|
|
330505
330512
|
} catch {
|
|
330506
330513
|
}
|
|
330507
330514
|
await new Promise((r) => setTimeout(r, 750));
|
|
@@ -330545,6 +330552,14 @@ async function takeScreenshot(sessionId) {
|
|
|
330545
330552
|
return null;
|
|
330546
330553
|
}
|
|
330547
330554
|
}
|
|
330555
|
+
async function captureFailingSource(sessionId) {
|
|
330556
|
+
try {
|
|
330557
|
+
const result = await wdRequest(`/session/${sessionId}/source`);
|
|
330558
|
+
return typeof result?.value === "string" ? result.value : void 0;
|
|
330559
|
+
} catch {
|
|
330560
|
+
return void 0;
|
|
330561
|
+
}
|
|
330562
|
+
}
|
|
330548
330563
|
function mobileRecordingEnabled() {
|
|
330549
330564
|
const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
|
|
330550
330565
|
return value !== "0" && value !== "false";
|
|
@@ -330674,9 +330689,12 @@ function identityTextXpath(value) {
|
|
|
330674
330689
|
const lit = xpathLiteral(value);
|
|
330675
330690
|
return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
|
|
330676
330691
|
}
|
|
330692
|
+
function isIdentityXpath(value) {
|
|
330693
|
+
return value.includes("@") && !/(?:contains|starts-with)\s*\(/i.test(value);
|
|
330694
|
+
}
|
|
330677
330695
|
function isIdentityStrategy(strategy) {
|
|
330678
330696
|
if (strategy.using === "accessibility id" || strategy.using === "id") return true;
|
|
330679
|
-
if (strategy.using === "xpath") return strategy.value
|
|
330697
|
+
if (strategy.using === "xpath") return isIdentityXpath(strategy.value);
|
|
330680
330698
|
return false;
|
|
330681
330699
|
}
|
|
330682
330700
|
function buildIdentityLocatorStrategies(target, platform3) {
|
|
@@ -330690,7 +330708,7 @@ function buildIdentityLocatorStrategies(target, platform3) {
|
|
|
330690
330708
|
const trimmed = value?.trim();
|
|
330691
330709
|
if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
|
|
330692
330710
|
}
|
|
330693
|
-
if (bundle.xpath && bundle.xpath
|
|
330711
|
+
if (bundle.xpath && isIdentityXpath(bundle.xpath)) strategies.push({ using: "xpath", value: bundle.xpath });
|
|
330694
330712
|
}
|
|
330695
330713
|
if (!(platform3 && hasPlatformLocator(target, platform3))) {
|
|
330696
330714
|
for (const value of [target.primary, ...target.fallbacks ?? []]) {
|
|
@@ -331037,6 +331055,7 @@ async function executeMobileTest(options) {
|
|
|
331037
331055
|
let recordingStarted = false;
|
|
331038
331056
|
let recordingStopped = false;
|
|
331039
331057
|
let video;
|
|
331058
|
+
let failingSource;
|
|
331040
331059
|
const stopCapture = async () => {
|
|
331041
331060
|
if (!capture || captureStopped) return apiCalls;
|
|
331042
331061
|
captureStopped = true;
|
|
@@ -331060,6 +331079,10 @@ async function executeMobileTest(options) {
|
|
|
331060
331079
|
}
|
|
331061
331080
|
return video;
|
|
331062
331081
|
};
|
|
331082
|
+
const recordFirstFailingSource = async () => {
|
|
331083
|
+
if (failingSource != null || !sessionId) return;
|
|
331084
|
+
failingSource = await captureFailingSource(sessionId);
|
|
331085
|
+
};
|
|
331063
331086
|
try {
|
|
331064
331087
|
log2("Checking Appium server health...");
|
|
331065
331088
|
const health = await checkAppiumHealth();
|
|
@@ -331186,12 +331209,14 @@ async function executeMobileTest(options) {
|
|
|
331186
331209
|
);
|
|
331187
331210
|
if (entryStep) {
|
|
331188
331211
|
try {
|
|
331189
|
-
|
|
331212
|
+
const entryTimeout = recordedFromClearedState ? Math.max(DEFAULT_STEP_TIMEOUT_MS, COLD_BOOT_ENTRY_TIMEOUT_MS) : DEFAULT_STEP_TIMEOUT_MS;
|
|
331213
|
+
await findElement(sessionId, entryStep.target, entryTimeout, { identityOnly: true, platform: runPlatform });
|
|
331190
331214
|
} catch (err) {
|
|
331191
331215
|
if (isTransportError(err)) throw err;
|
|
331192
331216
|
entryDrift = true;
|
|
331193
331217
|
const driftMsg = `App started on an unexpected screen \u2014 the recorded flow's first element ("${entryStep.target?.primary ?? entryStep.description}") is not present, so the replay was aborted before it could blind-tap stale coordinates (screen drift; the app may be on onboarding or a different screen than when the test was recorded).`;
|
|
331194
331218
|
log2(` \u2717 Entry-screen drift: ${driftMsg}`);
|
|
331219
|
+
await recordFirstFailingSource();
|
|
331195
331220
|
stepResults.push({ stepOrder: entryStep.order, status: "failed", duration: 0, error: driftMsg });
|
|
331196
331221
|
const driftShot = await takeScreenshot(sessionId).catch(() => null);
|
|
331197
331222
|
screenshots.push(driftShot ?? "");
|
|
@@ -331268,6 +331293,9 @@ async function executeMobileTest(options) {
|
|
|
331268
331293
|
}
|
|
331269
331294
|
const screenshot = await takeScreenshot(sessionId).catch(() => null);
|
|
331270
331295
|
screenshots.push(screenshot ?? "");
|
|
331296
|
+
if (status === "failed") {
|
|
331297
|
+
await recordFirstFailingSource();
|
|
331298
|
+
}
|
|
331271
331299
|
stepResults.push({
|
|
331272
331300
|
stepOrder: step.order,
|
|
331273
331301
|
status,
|
|
@@ -331305,7 +331333,8 @@ async function executeMobileTest(options) {
|
|
|
331305
331333
|
duration: Date.now() - startTime,
|
|
331306
331334
|
deviceId: selectedDevice?.id,
|
|
331307
331335
|
deviceName: selectedDevice?.name,
|
|
331308
|
-
environmental: environmental ?? void 0
|
|
331336
|
+
environmental: environmental ?? void 0,
|
|
331337
|
+
failingSource
|
|
331309
331338
|
};
|
|
331310
331339
|
} catch (err) {
|
|
331311
331340
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -331326,7 +331355,8 @@ async function executeMobileTest(options) {
|
|
|
331326
331355
|
// A throw before/around the step loop is a setup/environment problem
|
|
331327
331356
|
// (no device booted, app not installed, session could not be created,
|
|
331328
331357
|
// Appium unreachable) — never a genuine test assertion failure.
|
|
331329
|
-
environmental: errorMsg
|
|
331358
|
+
environmental: errorMsg,
|
|
331359
|
+
failingSource
|
|
331330
331360
|
};
|
|
331331
331361
|
} finally {
|
|
331332
331362
|
await stopCapture();
|
|
@@ -333295,6 +333325,9 @@ function budgetResultPayload(result, maxBytes = RESULT_BODY_BUDGET_BYTES, videoS
|
|
|
333295
333325
|
if ((bytes > maxBytes || oversizedVideo) && payload.video != null) {
|
|
333296
333326
|
drop(["video", "videoMimeType"], "video");
|
|
333297
333327
|
}
|
|
333328
|
+
if (bytes > maxBytes && payload.failingSource != null) {
|
|
333329
|
+
drop(["failingSource"], "failingSource");
|
|
333330
|
+
}
|
|
333298
333331
|
if (bytes > maxBytes && Array.isArray(payload.screenshots) && payload.screenshots.length > 0) {
|
|
333299
333332
|
const shots = [...payload.screenshots];
|
|
333300
333333
|
const bySizeDesc = shots.map((shot, i) => ({ i, len: typeof shot === "string" ? shot.length : 0 })).sort((a, b) => b.len - a.len);
|
|
@@ -333346,6 +333379,7 @@ ${tail}` };
|
|
|
333346
333379
|
// src/runner.ts
|
|
333347
333380
|
init_dist();
|
|
333348
333381
|
var import_runner_core12 = __toESM(require_dist2());
|
|
333382
|
+
var import_runner_core13 = __toESM(require_dist2());
|
|
333349
333383
|
function fingerprintTest(test) {
|
|
333350
333384
|
const code = test.framework === "APPIUM" ? [test.appiumCode ?? "", JSON.stringify(test.steps ?? [])].join("\n--steps--\n") : test.playwrightCode ?? "";
|
|
333351
333385
|
return (0, import_node_crypto2.createHash)("sha256").update(test.name).update("\n--suite--\n").update(test.suiteName ?? "").update("\n--type--\n").update(test.testType ?? "").update("\n--framework--\n").update(test.framework ?? "").update("\n--code--\n").update(code).digest("hex").slice(0, 24);
|
|
@@ -333583,6 +333617,33 @@ async function postHealResult(serverUrl, headers, runId, healedCode, testCaseId,
|
|
|
333583
333617
|
}
|
|
333584
333618
|
throw new Error(`Failed to post heal result after ${MAX_ATTEMPTS} attempts: ${lastError?.message}`);
|
|
333585
333619
|
}
|
|
333620
|
+
async function postMobileHealResult(serverUrl, headers, runId, body) {
|
|
333621
|
+
const MAX_ATTEMPTS = 3;
|
|
333622
|
+
let lastError;
|
|
333623
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
333624
|
+
try {
|
|
333625
|
+
const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/mobile-heal-result`, {
|
|
333626
|
+
method: "POST",
|
|
333627
|
+
headers,
|
|
333628
|
+
body: JSON.stringify(body),
|
|
333629
|
+
signal: AbortSignal.timeout(6e4)
|
|
333630
|
+
});
|
|
333631
|
+
if (res.ok) return;
|
|
333632
|
+
lastError = new Error(`HTTP ${res.status}`);
|
|
333633
|
+
} catch (err) {
|
|
333634
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
333635
|
+
}
|
|
333636
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
333637
|
+
const delay = Math.pow(2, attempt) * 1e3;
|
|
333638
|
+
console.warn(`[runner] postMobileHealResult attempt ${attempt} failed (${lastError.message}), retrying in ${delay}ms...`);
|
|
333639
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
333640
|
+
}
|
|
333641
|
+
}
|
|
333642
|
+
throw new Error(`Failed to post mobile heal result after ${MAX_ATTEMPTS} attempts: ${lastError?.message}`);
|
|
333643
|
+
}
|
|
333644
|
+
function narrowMobileLaunchMode(mode) {
|
|
333645
|
+
return mode === "LAUNCH_APP" || mode === "DEEP_LINK" || mode === "RESUME" ? mode : void 0;
|
|
333646
|
+
}
|
|
333586
333647
|
async function postBatchHealResult(serverUrl, headers, runId, body) {
|
|
333587
333648
|
const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/batch-heal-result`, {
|
|
333588
333649
|
method: "POST",
|
|
@@ -335491,6 +335552,218 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
335491
335552
|
log.fail("ERROR - No mobile steps");
|
|
335492
335553
|
return;
|
|
335493
335554
|
}
|
|
335555
|
+
if (runMode === "heal" && run2.mobileAiHeal === true) {
|
|
335556
|
+
if (getBridgeState().hasActiveSession) {
|
|
335557
|
+
await postResult(opts.serverUrl, headers, run2.runId, {
|
|
335558
|
+
status: "ERROR",
|
|
335559
|
+
error: "CONFIG_ISSUE: the shared mobile device is busy with an active recording session; the AI heal will retry once it ends.",
|
|
335560
|
+
duration: Date.now() - startTime
|
|
335561
|
+
});
|
|
335562
|
+
log.warn("Skipped mobile AI heal (device busy with a recording)");
|
|
335563
|
+
return;
|
|
335564
|
+
}
|
|
335565
|
+
const healTarget = {
|
|
335566
|
+
appId: target.appId,
|
|
335567
|
+
platform: target.platform,
|
|
335568
|
+
platformVersion: target.platformVersion,
|
|
335569
|
+
recordedDeviceId: target.recordedDeviceId,
|
|
335570
|
+
assignedDeviceId,
|
|
335571
|
+
launchMode: target.launchMode,
|
|
335572
|
+
deeplink: target.deeplink,
|
|
335573
|
+
authoringPlatform: mobilePlatformFromMedium(run2.authoringMedium) ?? void 0
|
|
335574
|
+
};
|
|
335575
|
+
const nativeVerify = { last: null };
|
|
335576
|
+
const runNativeTest = async ({ steps, target: t, testVariables }) => {
|
|
335577
|
+
const res = await executeMobileTest({
|
|
335578
|
+
steps,
|
|
335579
|
+
target: {
|
|
335580
|
+
appId: t.appId,
|
|
335581
|
+
platform: t.platform,
|
|
335582
|
+
platformVersion: t.platformVersion,
|
|
335583
|
+
recordedDeviceId: t.recordedDeviceId,
|
|
335584
|
+
assignedDeviceId: t.assignedDeviceId,
|
|
335585
|
+
launchMode: narrowMobileLaunchMode(t.launchMode),
|
|
335586
|
+
deeplink: t.deeplink,
|
|
335587
|
+
authoringPlatform: t.authoringPlatform
|
|
335588
|
+
},
|
|
335589
|
+
testVariables
|
|
335590
|
+
});
|
|
335591
|
+
nativeVerify.last = res;
|
|
335592
|
+
return {
|
|
335593
|
+
passed: res.passed,
|
|
335594
|
+
error: res.error,
|
|
335595
|
+
stepResults: res.stepResults.map((s) => ({ stepOrder: s.stepOrder, status: s.status, error: s.error })),
|
|
335596
|
+
failingSource: res.failingSource,
|
|
335597
|
+
logs: res.logs
|
|
335598
|
+
};
|
|
335599
|
+
};
|
|
335600
|
+
setExecutorBusy(true);
|
|
335601
|
+
try {
|
|
335602
|
+
log.info("\u{1FA7A} Mobile AI heal \u2014 reproducing the failure on device...");
|
|
335603
|
+
const reproduce = await runNativeTest({ steps: mobileSteps, target: healTarget, testVariables: run2.testVariables });
|
|
335604
|
+
if (reproduce.passed) {
|
|
335605
|
+
const recovered = nativeVerify.last;
|
|
335606
|
+
await postResult(opts.serverUrl, headers, run2.runId, {
|
|
335607
|
+
status: "PASSED",
|
|
335608
|
+
stepResults: recovered?.stepResults,
|
|
335609
|
+
screenshots: recovered?.screenshots,
|
|
335610
|
+
video: recovered?.video,
|
|
335611
|
+
videoMimeType: recovered?.videoMimeType,
|
|
335612
|
+
apiCalls: recovered?.apiCalls,
|
|
335613
|
+
logs: recovered?.logs,
|
|
335614
|
+
duration: Date.now() - startTime
|
|
335615
|
+
});
|
|
335616
|
+
try {
|
|
335617
|
+
await postMobileHealResult(opts.serverUrl, headers, run2.runId, {
|
|
335618
|
+
healedSteps: mobileSteps,
|
|
335619
|
+
healedAppiumCode: "",
|
|
335620
|
+
healReason: "recovered on reproduce",
|
|
335621
|
+
verdict: "fix",
|
|
335622
|
+
recovered: true,
|
|
335623
|
+
priorStepsHash: run2.priorStepsHash
|
|
335624
|
+
});
|
|
335625
|
+
log.ok("\u{1FA7A} Reproduce PASSED \u2014 test recovered (flaky); promotion posted");
|
|
335626
|
+
} catch (patchErr) {
|
|
335627
|
+
log.warn(`Failed to post recovered heal result: ${patchErr instanceof Error ? patchErr.message : String(patchErr)}`);
|
|
335628
|
+
}
|
|
335629
|
+
return;
|
|
335630
|
+
}
|
|
335631
|
+
const reproduceError = reproduce.error;
|
|
335632
|
+
const modelOverride = run2.healerModel ?? void 0;
|
|
335633
|
+
const healerOptions = {
|
|
335634
|
+
aiClient: modelOverride ? (0, import_runner_core13.getMobileClientForModel)(modelOverride) : (0, import_runner_core13.getMobileAIClient)(),
|
|
335635
|
+
...modelOverride ? { model: modelOverride } : {},
|
|
335636
|
+
runNativeTest,
|
|
335637
|
+
selectorRegistryContext: run2.selectorRegistryContext ?? null,
|
|
335638
|
+
runLiveHeal: async (a) => {
|
|
335639
|
+
let liveSession = null;
|
|
335640
|
+
try {
|
|
335641
|
+
liveSession = await createMobileDiscoverySession({
|
|
335642
|
+
appId: target.appId,
|
|
335643
|
+
platform: target.platform,
|
|
335644
|
+
platformVersion: target.platformVersion,
|
|
335645
|
+
recordedDeviceId: target.recordedDeviceId,
|
|
335646
|
+
assignedDeviceId,
|
|
335647
|
+
launchMode: target.launchMode,
|
|
335648
|
+
deeplink: target.deeplink
|
|
335649
|
+
});
|
|
335650
|
+
attachMirrorSession(liveSession.sessionId, target.platform, liveSession.deviceId);
|
|
335651
|
+
const bridgeDriver = new MobileBridgeDriver({
|
|
335652
|
+
sessionId: liveSession.sessionId,
|
|
335653
|
+
platform: target.platform,
|
|
335654
|
+
appId: liveSession.appId,
|
|
335655
|
+
allowAppReset: shouldResetMobileAppData(target.platform, liveSession.deviceId)
|
|
335656
|
+
});
|
|
335657
|
+
const liveDriver = {
|
|
335658
|
+
snapshot: async () => {
|
|
335659
|
+
const snap = await bridgeDriver.snapshot();
|
|
335660
|
+
return { xml: snap.xml, screenshot: snap.screenshot, windowSize: snap.windowSize };
|
|
335661
|
+
},
|
|
335662
|
+
tap: async (bounds) => {
|
|
335663
|
+
await bridgeDriver.tap(bounds);
|
|
335664
|
+
},
|
|
335665
|
+
type: async (value, bounds) => {
|
|
335666
|
+
await bridgeDriver.type(value, bounds);
|
|
335667
|
+
},
|
|
335668
|
+
clear: async (bounds) => {
|
|
335669
|
+
await bridgeDriver.clear(bounds);
|
|
335670
|
+
},
|
|
335671
|
+
swipe: async (direction, distance) => {
|
|
335672
|
+
await bridgeDriver.swipe(direction, distance);
|
|
335673
|
+
},
|
|
335674
|
+
back: async () => {
|
|
335675
|
+
await bridgeDriver.back();
|
|
335676
|
+
},
|
|
335677
|
+
home: async () => {
|
|
335678
|
+
await bridgeDriver.home();
|
|
335679
|
+
},
|
|
335680
|
+
relaunch: async () => {
|
|
335681
|
+
await bridgeDriver.relaunch();
|
|
335682
|
+
},
|
|
335683
|
+
resetAppState: async () => bridgeDriver.resetAppState()
|
|
335684
|
+
};
|
|
335685
|
+
return await (0, import_runner_core13.executeMobileLiveHeal)({
|
|
335686
|
+
steps: a.steps,
|
|
335687
|
+
target: a.target,
|
|
335688
|
+
testVariables: a.testVariables,
|
|
335689
|
+
priorError: reproduceError,
|
|
335690
|
+
options: { ...a.options ?? healerOptions, liveDriver }
|
|
335691
|
+
});
|
|
335692
|
+
} finally {
|
|
335693
|
+
if (liveSession) {
|
|
335694
|
+
detachMirrorSession(liveSession.deviceId);
|
|
335695
|
+
try {
|
|
335696
|
+
await liveSession.stop();
|
|
335697
|
+
} catch {
|
|
335698
|
+
}
|
|
335699
|
+
}
|
|
335700
|
+
}
|
|
335701
|
+
}
|
|
335702
|
+
};
|
|
335703
|
+
log.info("\u{1FA7A} Mobile AI heal \u2014 Phase 1 (script) \u2192 Phase 2 (live device, lazy)...");
|
|
335704
|
+
const heal = await (0, import_runner_core13.executeMobileTestWithHealing)(
|
|
335705
|
+
mobileSteps,
|
|
335706
|
+
healTarget,
|
|
335707
|
+
{ error: reproduceError, logs: reproduce.logs, failingSource: reproduce.failingSource, testVariables: run2.testVariables },
|
|
335708
|
+
healerOptions
|
|
335709
|
+
);
|
|
335710
|
+
const finalRes = nativeVerify.last;
|
|
335711
|
+
const outcome = (0, import_runner_core13.computeMobileHealOutcome)({
|
|
335712
|
+
heal,
|
|
335713
|
+
finalError: finalRes?.error ?? null,
|
|
335714
|
+
reproduceError,
|
|
335715
|
+
testVariables: run2.testVariables
|
|
335716
|
+
});
|
|
335717
|
+
await postResult(opts.serverUrl, headers, run2.runId, {
|
|
335718
|
+
status: outcome.status,
|
|
335719
|
+
stepResults: finalRes?.stepResults,
|
|
335720
|
+
screenshots: finalRes?.screenshots,
|
|
335721
|
+
video: finalRes?.video,
|
|
335722
|
+
videoMimeType: finalRes?.videoMimeType,
|
|
335723
|
+
apiCalls: finalRes?.apiCalls,
|
|
335724
|
+
logs: finalRes?.logs,
|
|
335725
|
+
error: outcome.error,
|
|
335726
|
+
healReason: heal.healReason,
|
|
335727
|
+
// NB: the loop's numeric `confidence` (0-1) is intentionally NOT posted
|
|
335728
|
+
// here — RunnerResultPayload.confidence is the web healer's categorical
|
|
335729
|
+
// 'high'|'medium'|'low'. The numeric value rides /mobile-heal-result via
|
|
335730
|
+
// the healReason instead. Mapping the two would be lossy and misleading.
|
|
335731
|
+
// extraTags carries @needs-review for the non-fix verdicts (applied by
|
|
335732
|
+
// the server's mobileAiHeal /result tag hook); PASSED heals apply tags
|
|
335733
|
+
// via /mobile-heal-result instead.
|
|
335734
|
+
extraTags: outcome.extraTags,
|
|
335735
|
+
removeTags: outcome.removeTags,
|
|
335736
|
+
duration: Date.now() - startTime,
|
|
335737
|
+
failingSource: finalRes?.failingSource
|
|
335738
|
+
});
|
|
335739
|
+
if (outcome.shouldPatch && heal.healedSteps && heal.healedSteps.length > 0) {
|
|
335740
|
+
const healedAppiumCode = heal.healedAppiumCode ?? buildAppiumCode(run2.testName ?? run2.testCaseId ?? "Healed mobile test", run2.target ?? null, heal.healedSteps);
|
|
335741
|
+
try {
|
|
335742
|
+
await postMobileHealResult(opts.serverUrl, headers, run2.runId, {
|
|
335743
|
+
healedSteps: heal.healedSteps,
|
|
335744
|
+
healedAppiumCode,
|
|
335745
|
+
healReason: heal.healReason ?? "",
|
|
335746
|
+
verdict: heal.verdict,
|
|
335747
|
+
extraTags: heal.extraTags,
|
|
335748
|
+
removeTags: heal.removeTags,
|
|
335749
|
+
priorStepsHash: run2.priorStepsHash
|
|
335750
|
+
});
|
|
335751
|
+
log.ok(`\u{1FA7A} Mobile heal patch posted for test ${run2.testCaseId ?? "(unknown)"} (verdict: ${heal.verdict})`);
|
|
335752
|
+
} catch (patchErr) {
|
|
335753
|
+
log.warn(`Failed to post mobile heal patch: ${patchErr instanceof Error ? patchErr.message : String(patchErr)}`);
|
|
335754
|
+
}
|
|
335755
|
+
} else if (outcome.secretLeak) {
|
|
335756
|
+
log.warn(`\u{1FA7A} Mobile heal patch DROPPED \u2014 healed steps contained a secret (${outcome.secretLeak}); tagged @needs-review`);
|
|
335757
|
+
}
|
|
335758
|
+
const duration3 = Date.now() - startTime;
|
|
335759
|
+
log[outcome.status === "PASSED" ? "ok" : "fail"](
|
|
335760
|
+
`\u{1FA7A} Mobile AI heal ${outcome.status} (verdict: ${heal.verdict}, ${duration3}ms)${outcome.error ? ` - ${outcome.error}` : ""}`
|
|
335761
|
+
);
|
|
335762
|
+
} finally {
|
|
335763
|
+
setExecutorBusy(false);
|
|
335764
|
+
}
|
|
335765
|
+
return;
|
|
335766
|
+
}
|
|
335494
335767
|
const maxRetries = typeof run2.retryCount === "number" && Number.isFinite(run2.retryCount) ? Math.max(0, Math.min(2, Math.floor(run2.retryCount))) : 0;
|
|
335495
335768
|
setExecutorBusy(true);
|
|
335496
335769
|
let result2 = null;
|
|
@@ -335560,7 +335833,10 @@ ${result2.logs}`);
|
|
|
335560
335833
|
apiCalls: result2.apiCalls,
|
|
335561
335834
|
logs: result2.logs,
|
|
335562
335835
|
error: reportedError,
|
|
335563
|
-
duration: duration2
|
|
335836
|
+
duration: duration2,
|
|
335837
|
+
// Point-of-failure page-source XML (mobile) for the AI healer. Budget-
|
|
335838
|
+
// droppable (see result-payload-budget.ts); the verdict is never dropped.
|
|
335839
|
+
failingSource: result2.failingSource
|
|
335564
335840
|
});
|
|
335565
335841
|
log[result2.passed ? "ok" : "fail"](
|
|
335566
335842
|
`\u{1F4F1} ${status} (${duration2}ms)${reportedError ? ` - ${reportedError}` : ""}`
|
package/dist/cli.mjs
CHANGED
|
@@ -250,8 +250,8 @@ var require_ai_provider = __commonJS({
|
|
|
250
250
|
exports.resetAIClient = resetAIClient;
|
|
251
251
|
exports.getServerProxiedAIClient = getServerProxiedAIClient;
|
|
252
252
|
exports.getServerProxiedClientForModel = getServerProxiedClientForModel;
|
|
253
|
-
exports.getMobileAIClient =
|
|
254
|
-
exports.getMobileClientForModel =
|
|
253
|
+
exports.getMobileAIClient = getMobileAIClient2;
|
|
254
|
+
exports.getMobileClientForModel = getMobileClientForModel2;
|
|
255
255
|
var openai_1 = __importDefault(__require("openai"));
|
|
256
256
|
var async_hooks_1 = __require("async_hooks");
|
|
257
257
|
var ai_retry_js_1 = require_ai_retry();
|
|
@@ -648,14 +648,14 @@ var require_ai_provider = __commonJS({
|
|
|
648
648
|
function getServerProxiedClientForModel(_modelId) {
|
|
649
649
|
return getServerProxiedAIClient();
|
|
650
650
|
}
|
|
651
|
-
function
|
|
651
|
+
function getMobileAIClient2() {
|
|
652
652
|
if ((process.env.SERVER_URL ?? "").trim().length > 0)
|
|
653
653
|
return getServerProxiedAIClient();
|
|
654
654
|
if (isMobileAiUnitTestMode())
|
|
655
655
|
return getAIClient();
|
|
656
656
|
throw new Error("Mobile AI requires the platform server URL (SERVER_URL). Customer runners do not provision provider API keys \u2014 the platform proxies every mobile AI completion through /api/runner/ai/execute, which lives on the shared server with the keys. If you reached this from `validate-runner start`, the credentials file is missing `serverUrl` or the install is broken; re-run `npx @validate.qa/runner login`. If you are calling the runner-core library directly, pass `options.aiClient` in tests or set VALIDATEQA_TEST_MODE=1 to opt into the local-provider fallback.");
|
|
657
657
|
}
|
|
658
|
-
function
|
|
658
|
+
function getMobileClientForModel2(modelId) {
|
|
659
659
|
if ((process.env.SERVER_URL ?? "").trim().length > 0)
|
|
660
660
|
return getServerProxiedClientForModel(modelId);
|
|
661
661
|
if (isMobileAiUnitTestMode())
|
|
@@ -328488,7 +328488,7 @@ var require_package4 = __commonJS({
|
|
|
328488
328488
|
"package.json"(exports, module) {
|
|
328489
328489
|
module.exports = {
|
|
328490
328490
|
name: "@validate.qa/runner",
|
|
328491
|
-
version: "1.0.
|
|
328491
|
+
version: "1.0.24",
|
|
328492
328492
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
328493
328493
|
bin: {
|
|
328494
328494
|
"validate-runner": "dist/cli.js"
|
|
@@ -330264,6 +330264,7 @@ var MOBILE_NEW_COMMAND_TIMEOUT_SECONDS = (() => {
|
|
|
330264
330264
|
const raw = Number(process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT);
|
|
330265
330265
|
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 600;
|
|
330266
330266
|
})();
|
|
330267
|
+
var COLD_BOOT_ENTRY_TIMEOUT_MS = 3e4;
|
|
330267
330268
|
var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
|
|
330268
330269
|
var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
|
|
330269
330270
|
var WD_ACTION_TIMEOUT_MS = 3e4;
|
|
@@ -330498,18 +330499,24 @@ async function freshLaunchTargetApp(sessionId, platform3, appId, log2) {
|
|
|
330498
330499
|
}
|
|
330499
330500
|
async function waitForColdBootContent(sessionId, log2, timeoutMs = 2e4) {
|
|
330500
330501
|
const deadline = Date.now() + timeoutMs;
|
|
330501
|
-
const MIN_ELEMENTS =
|
|
330502
|
+
const MIN_ELEMENTS = 12;
|
|
330502
330503
|
let lastCount = 0;
|
|
330504
|
+
let stableFrames = 0;
|
|
330503
330505
|
while (Date.now() < deadline) {
|
|
330504
330506
|
try {
|
|
330505
330507
|
const res = await wdRequest(`/session/${sessionId}/source`);
|
|
330506
330508
|
const xml = typeof res?.value === "string" ? res.value : "";
|
|
330507
330509
|
const count = (xml.match(/<(android\.|XCUIElementType)/g) ?? []).length;
|
|
330508
|
-
|
|
330509
|
-
|
|
330510
|
-
|
|
330511
|
-
|
|
330510
|
+
if (count >= MIN_ELEMENTS && count === lastCount) {
|
|
330511
|
+
stableFrames++;
|
|
330512
|
+
if (stableFrames >= 2) {
|
|
330513
|
+
log2(` Cold-boot ready: UI tree settled at ${count} node(s).`);
|
|
330514
|
+
return;
|
|
330515
|
+
}
|
|
330516
|
+
} else {
|
|
330517
|
+
stableFrames = 0;
|
|
330512
330518
|
}
|
|
330519
|
+
lastCount = count;
|
|
330513
330520
|
} catch {
|
|
330514
330521
|
}
|
|
330515
330522
|
await new Promise((r) => setTimeout(r, 750));
|
|
@@ -330553,6 +330560,14 @@ async function takeScreenshot(sessionId) {
|
|
|
330553
330560
|
return null;
|
|
330554
330561
|
}
|
|
330555
330562
|
}
|
|
330563
|
+
async function captureFailingSource(sessionId) {
|
|
330564
|
+
try {
|
|
330565
|
+
const result = await wdRequest(`/session/${sessionId}/source`);
|
|
330566
|
+
return typeof result?.value === "string" ? result.value : void 0;
|
|
330567
|
+
} catch {
|
|
330568
|
+
return void 0;
|
|
330569
|
+
}
|
|
330570
|
+
}
|
|
330556
330571
|
function mobileRecordingEnabled() {
|
|
330557
330572
|
const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
|
|
330558
330573
|
return value !== "0" && value !== "false";
|
|
@@ -330682,9 +330697,12 @@ function identityTextXpath(value) {
|
|
|
330682
330697
|
const lit = xpathLiteral(value);
|
|
330683
330698
|
return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
|
|
330684
330699
|
}
|
|
330700
|
+
function isIdentityXpath(value) {
|
|
330701
|
+
return value.includes("@") && !/(?:contains|starts-with)\s*\(/i.test(value);
|
|
330702
|
+
}
|
|
330685
330703
|
function isIdentityStrategy(strategy) {
|
|
330686
330704
|
if (strategy.using === "accessibility id" || strategy.using === "id") return true;
|
|
330687
|
-
if (strategy.using === "xpath") return strategy.value
|
|
330705
|
+
if (strategy.using === "xpath") return isIdentityXpath(strategy.value);
|
|
330688
330706
|
return false;
|
|
330689
330707
|
}
|
|
330690
330708
|
function buildIdentityLocatorStrategies(target, platform3) {
|
|
@@ -330698,7 +330716,7 @@ function buildIdentityLocatorStrategies(target, platform3) {
|
|
|
330698
330716
|
const trimmed = value?.trim();
|
|
330699
330717
|
if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
|
|
330700
330718
|
}
|
|
330701
|
-
if (bundle.xpath && bundle.xpath
|
|
330719
|
+
if (bundle.xpath && isIdentityXpath(bundle.xpath)) strategies.push({ using: "xpath", value: bundle.xpath });
|
|
330702
330720
|
}
|
|
330703
330721
|
if (!(platform3 && hasPlatformLocator(target, platform3))) {
|
|
330704
330722
|
for (const value of [target.primary, ...target.fallbacks ?? []]) {
|
|
@@ -331045,6 +331063,7 @@ async function executeMobileTest(options) {
|
|
|
331045
331063
|
let recordingStarted = false;
|
|
331046
331064
|
let recordingStopped = false;
|
|
331047
331065
|
let video;
|
|
331066
|
+
let failingSource;
|
|
331048
331067
|
const stopCapture = async () => {
|
|
331049
331068
|
if (!capture || captureStopped) return apiCalls;
|
|
331050
331069
|
captureStopped = true;
|
|
@@ -331068,6 +331087,10 @@ async function executeMobileTest(options) {
|
|
|
331068
331087
|
}
|
|
331069
331088
|
return video;
|
|
331070
331089
|
};
|
|
331090
|
+
const recordFirstFailingSource = async () => {
|
|
331091
|
+
if (failingSource != null || !sessionId) return;
|
|
331092
|
+
failingSource = await captureFailingSource(sessionId);
|
|
331093
|
+
};
|
|
331071
331094
|
try {
|
|
331072
331095
|
log2("Checking Appium server health...");
|
|
331073
331096
|
const health = await checkAppiumHealth();
|
|
@@ -331194,12 +331217,14 @@ async function executeMobileTest(options) {
|
|
|
331194
331217
|
);
|
|
331195
331218
|
if (entryStep) {
|
|
331196
331219
|
try {
|
|
331197
|
-
|
|
331220
|
+
const entryTimeout = recordedFromClearedState ? Math.max(DEFAULT_STEP_TIMEOUT_MS, COLD_BOOT_ENTRY_TIMEOUT_MS) : DEFAULT_STEP_TIMEOUT_MS;
|
|
331221
|
+
await findElement(sessionId, entryStep.target, entryTimeout, { identityOnly: true, platform: runPlatform });
|
|
331198
331222
|
} catch (err) {
|
|
331199
331223
|
if (isTransportError(err)) throw err;
|
|
331200
331224
|
entryDrift = true;
|
|
331201
331225
|
const driftMsg = `App started on an unexpected screen \u2014 the recorded flow's first element ("${entryStep.target?.primary ?? entryStep.description}") is not present, so the replay was aborted before it could blind-tap stale coordinates (screen drift; the app may be on onboarding or a different screen than when the test was recorded).`;
|
|
331202
331226
|
log2(` \u2717 Entry-screen drift: ${driftMsg}`);
|
|
331227
|
+
await recordFirstFailingSource();
|
|
331203
331228
|
stepResults.push({ stepOrder: entryStep.order, status: "failed", duration: 0, error: driftMsg });
|
|
331204
331229
|
const driftShot = await takeScreenshot(sessionId).catch(() => null);
|
|
331205
331230
|
screenshots.push(driftShot ?? "");
|
|
@@ -331276,6 +331301,9 @@ async function executeMobileTest(options) {
|
|
|
331276
331301
|
}
|
|
331277
331302
|
const screenshot = await takeScreenshot(sessionId).catch(() => null);
|
|
331278
331303
|
screenshots.push(screenshot ?? "");
|
|
331304
|
+
if (status === "failed") {
|
|
331305
|
+
await recordFirstFailingSource();
|
|
331306
|
+
}
|
|
331279
331307
|
stepResults.push({
|
|
331280
331308
|
stepOrder: step.order,
|
|
331281
331309
|
status,
|
|
@@ -331313,7 +331341,8 @@ async function executeMobileTest(options) {
|
|
|
331313
331341
|
duration: Date.now() - startTime,
|
|
331314
331342
|
deviceId: selectedDevice?.id,
|
|
331315
331343
|
deviceName: selectedDevice?.name,
|
|
331316
|
-
environmental: environmental ?? void 0
|
|
331344
|
+
environmental: environmental ?? void 0,
|
|
331345
|
+
failingSource
|
|
331317
331346
|
};
|
|
331318
331347
|
} catch (err) {
|
|
331319
331348
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -331334,7 +331363,8 @@ async function executeMobileTest(options) {
|
|
|
331334
331363
|
// A throw before/around the step loop is a setup/environment problem
|
|
331335
331364
|
// (no device booted, app not installed, session could not be created,
|
|
331336
331365
|
// Appium unreachable) — never a genuine test assertion failure.
|
|
331337
|
-
environmental: errorMsg
|
|
331366
|
+
environmental: errorMsg,
|
|
331367
|
+
failingSource
|
|
331338
331368
|
};
|
|
331339
331369
|
} finally {
|
|
331340
331370
|
await stopCapture();
|
|
@@ -333303,6 +333333,9 @@ function budgetResultPayload(result, maxBytes = RESULT_BODY_BUDGET_BYTES, videoS
|
|
|
333303
333333
|
if ((bytes > maxBytes || oversizedVideo) && payload.video != null) {
|
|
333304
333334
|
drop(["video", "videoMimeType"], "video");
|
|
333305
333335
|
}
|
|
333336
|
+
if (bytes > maxBytes && payload.failingSource != null) {
|
|
333337
|
+
drop(["failingSource"], "failingSource");
|
|
333338
|
+
}
|
|
333306
333339
|
if (bytes > maxBytes && Array.isArray(payload.screenshots) && payload.screenshots.length > 0) {
|
|
333307
333340
|
const shots = [...payload.screenshots];
|
|
333308
333341
|
const bySizeDesc = shots.map((shot, i) => ({ i, len: typeof shot === "string" ? shot.length : 0 })).sort((a, b) => b.len - a.len);
|
|
@@ -333354,6 +333387,7 @@ ${tail}` };
|
|
|
333354
333387
|
// src/runner.ts
|
|
333355
333388
|
init_dist();
|
|
333356
333389
|
var import_runner_core12 = __toESM(require_dist2());
|
|
333390
|
+
var import_runner_core13 = __toESM(require_dist2());
|
|
333357
333391
|
function fingerprintTest(test) {
|
|
333358
333392
|
const code = test.framework === "APPIUM" ? [test.appiumCode ?? "", JSON.stringify(test.steps ?? [])].join("\n--steps--\n") : test.playwrightCode ?? "";
|
|
333359
333393
|
return createHash("sha256").update(test.name).update("\n--suite--\n").update(test.suiteName ?? "").update("\n--type--\n").update(test.testType ?? "").update("\n--framework--\n").update(test.framework ?? "").update("\n--code--\n").update(code).digest("hex").slice(0, 24);
|
|
@@ -333591,6 +333625,33 @@ async function postHealResult(serverUrl, headers, runId, healedCode, testCaseId,
|
|
|
333591
333625
|
}
|
|
333592
333626
|
throw new Error(`Failed to post heal result after ${MAX_ATTEMPTS} attempts: ${lastError?.message}`);
|
|
333593
333627
|
}
|
|
333628
|
+
async function postMobileHealResult(serverUrl, headers, runId, body) {
|
|
333629
|
+
const MAX_ATTEMPTS = 3;
|
|
333630
|
+
let lastError;
|
|
333631
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
333632
|
+
try {
|
|
333633
|
+
const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/mobile-heal-result`, {
|
|
333634
|
+
method: "POST",
|
|
333635
|
+
headers,
|
|
333636
|
+
body: JSON.stringify(body),
|
|
333637
|
+
signal: AbortSignal.timeout(6e4)
|
|
333638
|
+
});
|
|
333639
|
+
if (res.ok) return;
|
|
333640
|
+
lastError = new Error(`HTTP ${res.status}`);
|
|
333641
|
+
} catch (err) {
|
|
333642
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
333643
|
+
}
|
|
333644
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
333645
|
+
const delay = Math.pow(2, attempt) * 1e3;
|
|
333646
|
+
console.warn(`[runner] postMobileHealResult attempt ${attempt} failed (${lastError.message}), retrying in ${delay}ms...`);
|
|
333647
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
333648
|
+
}
|
|
333649
|
+
}
|
|
333650
|
+
throw new Error(`Failed to post mobile heal result after ${MAX_ATTEMPTS} attempts: ${lastError?.message}`);
|
|
333651
|
+
}
|
|
333652
|
+
function narrowMobileLaunchMode(mode) {
|
|
333653
|
+
return mode === "LAUNCH_APP" || mode === "DEEP_LINK" || mode === "RESUME" ? mode : void 0;
|
|
333654
|
+
}
|
|
333594
333655
|
async function postBatchHealResult(serverUrl, headers, runId, body) {
|
|
333595
333656
|
const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/batch-heal-result`, {
|
|
333596
333657
|
method: "POST",
|
|
@@ -335499,6 +335560,218 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
335499
335560
|
log.fail("ERROR - No mobile steps");
|
|
335500
335561
|
return;
|
|
335501
335562
|
}
|
|
335563
|
+
if (runMode === "heal" && run2.mobileAiHeal === true) {
|
|
335564
|
+
if (getBridgeState().hasActiveSession) {
|
|
335565
|
+
await postResult(opts.serverUrl, headers, run2.runId, {
|
|
335566
|
+
status: "ERROR",
|
|
335567
|
+
error: "CONFIG_ISSUE: the shared mobile device is busy with an active recording session; the AI heal will retry once it ends.",
|
|
335568
|
+
duration: Date.now() - startTime
|
|
335569
|
+
});
|
|
335570
|
+
log.warn("Skipped mobile AI heal (device busy with a recording)");
|
|
335571
|
+
return;
|
|
335572
|
+
}
|
|
335573
|
+
const healTarget = {
|
|
335574
|
+
appId: target.appId,
|
|
335575
|
+
platform: target.platform,
|
|
335576
|
+
platformVersion: target.platformVersion,
|
|
335577
|
+
recordedDeviceId: target.recordedDeviceId,
|
|
335578
|
+
assignedDeviceId,
|
|
335579
|
+
launchMode: target.launchMode,
|
|
335580
|
+
deeplink: target.deeplink,
|
|
335581
|
+
authoringPlatform: mobilePlatformFromMedium(run2.authoringMedium) ?? void 0
|
|
335582
|
+
};
|
|
335583
|
+
const nativeVerify = { last: null };
|
|
335584
|
+
const runNativeTest = async ({ steps, target: t, testVariables }) => {
|
|
335585
|
+
const res = await executeMobileTest({
|
|
335586
|
+
steps,
|
|
335587
|
+
target: {
|
|
335588
|
+
appId: t.appId,
|
|
335589
|
+
platform: t.platform,
|
|
335590
|
+
platformVersion: t.platformVersion,
|
|
335591
|
+
recordedDeviceId: t.recordedDeviceId,
|
|
335592
|
+
assignedDeviceId: t.assignedDeviceId,
|
|
335593
|
+
launchMode: narrowMobileLaunchMode(t.launchMode),
|
|
335594
|
+
deeplink: t.deeplink,
|
|
335595
|
+
authoringPlatform: t.authoringPlatform
|
|
335596
|
+
},
|
|
335597
|
+
testVariables
|
|
335598
|
+
});
|
|
335599
|
+
nativeVerify.last = res;
|
|
335600
|
+
return {
|
|
335601
|
+
passed: res.passed,
|
|
335602
|
+
error: res.error,
|
|
335603
|
+
stepResults: res.stepResults.map((s) => ({ stepOrder: s.stepOrder, status: s.status, error: s.error })),
|
|
335604
|
+
failingSource: res.failingSource,
|
|
335605
|
+
logs: res.logs
|
|
335606
|
+
};
|
|
335607
|
+
};
|
|
335608
|
+
setExecutorBusy(true);
|
|
335609
|
+
try {
|
|
335610
|
+
log.info("\u{1FA7A} Mobile AI heal \u2014 reproducing the failure on device...");
|
|
335611
|
+
const reproduce = await runNativeTest({ steps: mobileSteps, target: healTarget, testVariables: run2.testVariables });
|
|
335612
|
+
if (reproduce.passed) {
|
|
335613
|
+
const recovered = nativeVerify.last;
|
|
335614
|
+
await postResult(opts.serverUrl, headers, run2.runId, {
|
|
335615
|
+
status: "PASSED",
|
|
335616
|
+
stepResults: recovered?.stepResults,
|
|
335617
|
+
screenshots: recovered?.screenshots,
|
|
335618
|
+
video: recovered?.video,
|
|
335619
|
+
videoMimeType: recovered?.videoMimeType,
|
|
335620
|
+
apiCalls: recovered?.apiCalls,
|
|
335621
|
+
logs: recovered?.logs,
|
|
335622
|
+
duration: Date.now() - startTime
|
|
335623
|
+
});
|
|
335624
|
+
try {
|
|
335625
|
+
await postMobileHealResult(opts.serverUrl, headers, run2.runId, {
|
|
335626
|
+
healedSteps: mobileSteps,
|
|
335627
|
+
healedAppiumCode: "",
|
|
335628
|
+
healReason: "recovered on reproduce",
|
|
335629
|
+
verdict: "fix",
|
|
335630
|
+
recovered: true,
|
|
335631
|
+
priorStepsHash: run2.priorStepsHash
|
|
335632
|
+
});
|
|
335633
|
+
log.ok("\u{1FA7A} Reproduce PASSED \u2014 test recovered (flaky); promotion posted");
|
|
335634
|
+
} catch (patchErr) {
|
|
335635
|
+
log.warn(`Failed to post recovered heal result: ${patchErr instanceof Error ? patchErr.message : String(patchErr)}`);
|
|
335636
|
+
}
|
|
335637
|
+
return;
|
|
335638
|
+
}
|
|
335639
|
+
const reproduceError = reproduce.error;
|
|
335640
|
+
const modelOverride = run2.healerModel ?? void 0;
|
|
335641
|
+
const healerOptions = {
|
|
335642
|
+
aiClient: modelOverride ? (0, import_runner_core13.getMobileClientForModel)(modelOverride) : (0, import_runner_core13.getMobileAIClient)(),
|
|
335643
|
+
...modelOverride ? { model: modelOverride } : {},
|
|
335644
|
+
runNativeTest,
|
|
335645
|
+
selectorRegistryContext: run2.selectorRegistryContext ?? null,
|
|
335646
|
+
runLiveHeal: async (a) => {
|
|
335647
|
+
let liveSession = null;
|
|
335648
|
+
try {
|
|
335649
|
+
liveSession = await createMobileDiscoverySession({
|
|
335650
|
+
appId: target.appId,
|
|
335651
|
+
platform: target.platform,
|
|
335652
|
+
platformVersion: target.platformVersion,
|
|
335653
|
+
recordedDeviceId: target.recordedDeviceId,
|
|
335654
|
+
assignedDeviceId,
|
|
335655
|
+
launchMode: target.launchMode,
|
|
335656
|
+
deeplink: target.deeplink
|
|
335657
|
+
});
|
|
335658
|
+
attachMirrorSession(liveSession.sessionId, target.platform, liveSession.deviceId);
|
|
335659
|
+
const bridgeDriver = new MobileBridgeDriver({
|
|
335660
|
+
sessionId: liveSession.sessionId,
|
|
335661
|
+
platform: target.platform,
|
|
335662
|
+
appId: liveSession.appId,
|
|
335663
|
+
allowAppReset: shouldResetMobileAppData(target.platform, liveSession.deviceId)
|
|
335664
|
+
});
|
|
335665
|
+
const liveDriver = {
|
|
335666
|
+
snapshot: async () => {
|
|
335667
|
+
const snap = await bridgeDriver.snapshot();
|
|
335668
|
+
return { xml: snap.xml, screenshot: snap.screenshot, windowSize: snap.windowSize };
|
|
335669
|
+
},
|
|
335670
|
+
tap: async (bounds) => {
|
|
335671
|
+
await bridgeDriver.tap(bounds);
|
|
335672
|
+
},
|
|
335673
|
+
type: async (value, bounds) => {
|
|
335674
|
+
await bridgeDriver.type(value, bounds);
|
|
335675
|
+
},
|
|
335676
|
+
clear: async (bounds) => {
|
|
335677
|
+
await bridgeDriver.clear(bounds);
|
|
335678
|
+
},
|
|
335679
|
+
swipe: async (direction, distance) => {
|
|
335680
|
+
await bridgeDriver.swipe(direction, distance);
|
|
335681
|
+
},
|
|
335682
|
+
back: async () => {
|
|
335683
|
+
await bridgeDriver.back();
|
|
335684
|
+
},
|
|
335685
|
+
home: async () => {
|
|
335686
|
+
await bridgeDriver.home();
|
|
335687
|
+
},
|
|
335688
|
+
relaunch: async () => {
|
|
335689
|
+
await bridgeDriver.relaunch();
|
|
335690
|
+
},
|
|
335691
|
+
resetAppState: async () => bridgeDriver.resetAppState()
|
|
335692
|
+
};
|
|
335693
|
+
return await (0, import_runner_core13.executeMobileLiveHeal)({
|
|
335694
|
+
steps: a.steps,
|
|
335695
|
+
target: a.target,
|
|
335696
|
+
testVariables: a.testVariables,
|
|
335697
|
+
priorError: reproduceError,
|
|
335698
|
+
options: { ...a.options ?? healerOptions, liveDriver }
|
|
335699
|
+
});
|
|
335700
|
+
} finally {
|
|
335701
|
+
if (liveSession) {
|
|
335702
|
+
detachMirrorSession(liveSession.deviceId);
|
|
335703
|
+
try {
|
|
335704
|
+
await liveSession.stop();
|
|
335705
|
+
} catch {
|
|
335706
|
+
}
|
|
335707
|
+
}
|
|
335708
|
+
}
|
|
335709
|
+
}
|
|
335710
|
+
};
|
|
335711
|
+
log.info("\u{1FA7A} Mobile AI heal \u2014 Phase 1 (script) \u2192 Phase 2 (live device, lazy)...");
|
|
335712
|
+
const heal = await (0, import_runner_core13.executeMobileTestWithHealing)(
|
|
335713
|
+
mobileSteps,
|
|
335714
|
+
healTarget,
|
|
335715
|
+
{ error: reproduceError, logs: reproduce.logs, failingSource: reproduce.failingSource, testVariables: run2.testVariables },
|
|
335716
|
+
healerOptions
|
|
335717
|
+
);
|
|
335718
|
+
const finalRes = nativeVerify.last;
|
|
335719
|
+
const outcome = (0, import_runner_core13.computeMobileHealOutcome)({
|
|
335720
|
+
heal,
|
|
335721
|
+
finalError: finalRes?.error ?? null,
|
|
335722
|
+
reproduceError,
|
|
335723
|
+
testVariables: run2.testVariables
|
|
335724
|
+
});
|
|
335725
|
+
await postResult(opts.serverUrl, headers, run2.runId, {
|
|
335726
|
+
status: outcome.status,
|
|
335727
|
+
stepResults: finalRes?.stepResults,
|
|
335728
|
+
screenshots: finalRes?.screenshots,
|
|
335729
|
+
video: finalRes?.video,
|
|
335730
|
+
videoMimeType: finalRes?.videoMimeType,
|
|
335731
|
+
apiCalls: finalRes?.apiCalls,
|
|
335732
|
+
logs: finalRes?.logs,
|
|
335733
|
+
error: outcome.error,
|
|
335734
|
+
healReason: heal.healReason,
|
|
335735
|
+
// NB: the loop's numeric `confidence` (0-1) is intentionally NOT posted
|
|
335736
|
+
// here — RunnerResultPayload.confidence is the web healer's categorical
|
|
335737
|
+
// 'high'|'medium'|'low'. The numeric value rides /mobile-heal-result via
|
|
335738
|
+
// the healReason instead. Mapping the two would be lossy and misleading.
|
|
335739
|
+
// extraTags carries @needs-review for the non-fix verdicts (applied by
|
|
335740
|
+
// the server's mobileAiHeal /result tag hook); PASSED heals apply tags
|
|
335741
|
+
// via /mobile-heal-result instead.
|
|
335742
|
+
extraTags: outcome.extraTags,
|
|
335743
|
+
removeTags: outcome.removeTags,
|
|
335744
|
+
duration: Date.now() - startTime,
|
|
335745
|
+
failingSource: finalRes?.failingSource
|
|
335746
|
+
});
|
|
335747
|
+
if (outcome.shouldPatch && heal.healedSteps && heal.healedSteps.length > 0) {
|
|
335748
|
+
const healedAppiumCode = heal.healedAppiumCode ?? buildAppiumCode(run2.testName ?? run2.testCaseId ?? "Healed mobile test", run2.target ?? null, heal.healedSteps);
|
|
335749
|
+
try {
|
|
335750
|
+
await postMobileHealResult(opts.serverUrl, headers, run2.runId, {
|
|
335751
|
+
healedSteps: heal.healedSteps,
|
|
335752
|
+
healedAppiumCode,
|
|
335753
|
+
healReason: heal.healReason ?? "",
|
|
335754
|
+
verdict: heal.verdict,
|
|
335755
|
+
extraTags: heal.extraTags,
|
|
335756
|
+
removeTags: heal.removeTags,
|
|
335757
|
+
priorStepsHash: run2.priorStepsHash
|
|
335758
|
+
});
|
|
335759
|
+
log.ok(`\u{1FA7A} Mobile heal patch posted for test ${run2.testCaseId ?? "(unknown)"} (verdict: ${heal.verdict})`);
|
|
335760
|
+
} catch (patchErr) {
|
|
335761
|
+
log.warn(`Failed to post mobile heal patch: ${patchErr instanceof Error ? patchErr.message : String(patchErr)}`);
|
|
335762
|
+
}
|
|
335763
|
+
} else if (outcome.secretLeak) {
|
|
335764
|
+
log.warn(`\u{1FA7A} Mobile heal patch DROPPED \u2014 healed steps contained a secret (${outcome.secretLeak}); tagged @needs-review`);
|
|
335765
|
+
}
|
|
335766
|
+
const duration3 = Date.now() - startTime;
|
|
335767
|
+
log[outcome.status === "PASSED" ? "ok" : "fail"](
|
|
335768
|
+
`\u{1FA7A} Mobile AI heal ${outcome.status} (verdict: ${heal.verdict}, ${duration3}ms)${outcome.error ? ` - ${outcome.error}` : ""}`
|
|
335769
|
+
);
|
|
335770
|
+
} finally {
|
|
335771
|
+
setExecutorBusy(false);
|
|
335772
|
+
}
|
|
335773
|
+
return;
|
|
335774
|
+
}
|
|
335502
335775
|
const maxRetries = typeof run2.retryCount === "number" && Number.isFinite(run2.retryCount) ? Math.max(0, Math.min(2, Math.floor(run2.retryCount))) : 0;
|
|
335503
335776
|
setExecutorBusy(true);
|
|
335504
335777
|
let result2 = null;
|
|
@@ -335568,7 +335841,10 @@ ${result2.logs}`);
|
|
|
335568
335841
|
apiCalls: result2.apiCalls,
|
|
335569
335842
|
logs: result2.logs,
|
|
335570
335843
|
error: reportedError,
|
|
335571
|
-
duration: duration2
|
|
335844
|
+
duration: duration2,
|
|
335845
|
+
// Point-of-failure page-source XML (mobile) for the AI healer. Budget-
|
|
335846
|
+
// droppable (see result-payload-budget.ts); the verdict is never dropped.
|
|
335847
|
+
failingSource: result2.failingSource
|
|
335572
335848
|
});
|
|
335573
335849
|
log[result2.passed ? "ok" : "fail"](
|
|
335574
335850
|
`\u{1F4F1} ${status} (${duration2}ms)${reportedError ? ` - ${reportedError}` : ""}`
|
package/package.json
CHANGED