@validate.qa/runner 1.0.23 → 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 +278 -10
- package/dist/cli.mjs +278 -10
- 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"
|
|
@@ -330552,6 +330552,14 @@ async function takeScreenshot(sessionId) {
|
|
|
330552
330552
|
return null;
|
|
330553
330553
|
}
|
|
330554
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
|
+
}
|
|
330555
330563
|
function mobileRecordingEnabled() {
|
|
330556
330564
|
const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
|
|
330557
330565
|
return value !== "0" && value !== "false";
|
|
@@ -330681,9 +330689,12 @@ function identityTextXpath(value) {
|
|
|
330681
330689
|
const lit = xpathLiteral(value);
|
|
330682
330690
|
return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
|
|
330683
330691
|
}
|
|
330692
|
+
function isIdentityXpath(value) {
|
|
330693
|
+
return value.includes("@") && !/(?:contains|starts-with)\s*\(/i.test(value);
|
|
330694
|
+
}
|
|
330684
330695
|
function isIdentityStrategy(strategy) {
|
|
330685
330696
|
if (strategy.using === "accessibility id" || strategy.using === "id") return true;
|
|
330686
|
-
if (strategy.using === "xpath") return strategy.value
|
|
330697
|
+
if (strategy.using === "xpath") return isIdentityXpath(strategy.value);
|
|
330687
330698
|
return false;
|
|
330688
330699
|
}
|
|
330689
330700
|
function buildIdentityLocatorStrategies(target, platform3) {
|
|
@@ -330697,7 +330708,7 @@ function buildIdentityLocatorStrategies(target, platform3) {
|
|
|
330697
330708
|
const trimmed = value?.trim();
|
|
330698
330709
|
if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
|
|
330699
330710
|
}
|
|
330700
|
-
if (bundle.xpath && bundle.xpath
|
|
330711
|
+
if (bundle.xpath && isIdentityXpath(bundle.xpath)) strategies.push({ using: "xpath", value: bundle.xpath });
|
|
330701
330712
|
}
|
|
330702
330713
|
if (!(platform3 && hasPlatformLocator(target, platform3))) {
|
|
330703
330714
|
for (const value of [target.primary, ...target.fallbacks ?? []]) {
|
|
@@ -331044,6 +331055,7 @@ async function executeMobileTest(options) {
|
|
|
331044
331055
|
let recordingStarted = false;
|
|
331045
331056
|
let recordingStopped = false;
|
|
331046
331057
|
let video;
|
|
331058
|
+
let failingSource;
|
|
331047
331059
|
const stopCapture = async () => {
|
|
331048
331060
|
if (!capture || captureStopped) return apiCalls;
|
|
331049
331061
|
captureStopped = true;
|
|
@@ -331067,6 +331079,10 @@ async function executeMobileTest(options) {
|
|
|
331067
331079
|
}
|
|
331068
331080
|
return video;
|
|
331069
331081
|
};
|
|
331082
|
+
const recordFirstFailingSource = async () => {
|
|
331083
|
+
if (failingSource != null || !sessionId) return;
|
|
331084
|
+
failingSource = await captureFailingSource(sessionId);
|
|
331085
|
+
};
|
|
331070
331086
|
try {
|
|
331071
331087
|
log2("Checking Appium server health...");
|
|
331072
331088
|
const health = await checkAppiumHealth();
|
|
@@ -331200,6 +331216,7 @@ async function executeMobileTest(options) {
|
|
|
331200
331216
|
entryDrift = true;
|
|
331201
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).`;
|
|
331202
331218
|
log2(` \u2717 Entry-screen drift: ${driftMsg}`);
|
|
331219
|
+
await recordFirstFailingSource();
|
|
331203
331220
|
stepResults.push({ stepOrder: entryStep.order, status: "failed", duration: 0, error: driftMsg });
|
|
331204
331221
|
const driftShot = await takeScreenshot(sessionId).catch(() => null);
|
|
331205
331222
|
screenshots.push(driftShot ?? "");
|
|
@@ -331276,6 +331293,9 @@ async function executeMobileTest(options) {
|
|
|
331276
331293
|
}
|
|
331277
331294
|
const screenshot = await takeScreenshot(sessionId).catch(() => null);
|
|
331278
331295
|
screenshots.push(screenshot ?? "");
|
|
331296
|
+
if (status === "failed") {
|
|
331297
|
+
await recordFirstFailingSource();
|
|
331298
|
+
}
|
|
331279
331299
|
stepResults.push({
|
|
331280
331300
|
stepOrder: step.order,
|
|
331281
331301
|
status,
|
|
@@ -331313,7 +331333,8 @@ async function executeMobileTest(options) {
|
|
|
331313
331333
|
duration: Date.now() - startTime,
|
|
331314
331334
|
deviceId: selectedDevice?.id,
|
|
331315
331335
|
deviceName: selectedDevice?.name,
|
|
331316
|
-
environmental: environmental ?? void 0
|
|
331336
|
+
environmental: environmental ?? void 0,
|
|
331337
|
+
failingSource
|
|
331317
331338
|
};
|
|
331318
331339
|
} catch (err) {
|
|
331319
331340
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -331334,7 +331355,8 @@ async function executeMobileTest(options) {
|
|
|
331334
331355
|
// A throw before/around the step loop is a setup/environment problem
|
|
331335
331356
|
// (no device booted, app not installed, session could not be created,
|
|
331336
331357
|
// Appium unreachable) — never a genuine test assertion failure.
|
|
331337
|
-
environmental: errorMsg
|
|
331358
|
+
environmental: errorMsg,
|
|
331359
|
+
failingSource
|
|
331338
331360
|
};
|
|
331339
331361
|
} finally {
|
|
331340
331362
|
await stopCapture();
|
|
@@ -333303,6 +333325,9 @@ function budgetResultPayload(result, maxBytes = RESULT_BODY_BUDGET_BYTES, videoS
|
|
|
333303
333325
|
if ((bytes > maxBytes || oversizedVideo) && payload.video != null) {
|
|
333304
333326
|
drop(["video", "videoMimeType"], "video");
|
|
333305
333327
|
}
|
|
333328
|
+
if (bytes > maxBytes && payload.failingSource != null) {
|
|
333329
|
+
drop(["failingSource"], "failingSource");
|
|
333330
|
+
}
|
|
333306
333331
|
if (bytes > maxBytes && Array.isArray(payload.screenshots) && payload.screenshots.length > 0) {
|
|
333307
333332
|
const shots = [...payload.screenshots];
|
|
333308
333333
|
const bySizeDesc = shots.map((shot, i) => ({ i, len: typeof shot === "string" ? shot.length : 0 })).sort((a, b) => b.len - a.len);
|
|
@@ -333354,6 +333379,7 @@ ${tail}` };
|
|
|
333354
333379
|
// src/runner.ts
|
|
333355
333380
|
init_dist();
|
|
333356
333381
|
var import_runner_core12 = __toESM(require_dist2());
|
|
333382
|
+
var import_runner_core13 = __toESM(require_dist2());
|
|
333357
333383
|
function fingerprintTest(test) {
|
|
333358
333384
|
const code = test.framework === "APPIUM" ? [test.appiumCode ?? "", JSON.stringify(test.steps ?? [])].join("\n--steps--\n") : test.playwrightCode ?? "";
|
|
333359
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);
|
|
@@ -333591,6 +333617,33 @@ async function postHealResult(serverUrl, headers, runId, healedCode, testCaseId,
|
|
|
333591
333617
|
}
|
|
333592
333618
|
throw new Error(`Failed to post heal result after ${MAX_ATTEMPTS} attempts: ${lastError?.message}`);
|
|
333593
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
|
+
}
|
|
333594
333647
|
async function postBatchHealResult(serverUrl, headers, runId, body) {
|
|
333595
333648
|
const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/batch-heal-result`, {
|
|
333596
333649
|
method: "POST",
|
|
@@ -335499,6 +335552,218 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
335499
335552
|
log.fail("ERROR - No mobile steps");
|
|
335500
335553
|
return;
|
|
335501
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
|
+
}
|
|
335502
335767
|
const maxRetries = typeof run2.retryCount === "number" && Number.isFinite(run2.retryCount) ? Math.max(0, Math.min(2, Math.floor(run2.retryCount))) : 0;
|
|
335503
335768
|
setExecutorBusy(true);
|
|
335504
335769
|
let result2 = null;
|
|
@@ -335568,7 +335833,10 @@ ${result2.logs}`);
|
|
|
335568
335833
|
apiCalls: result2.apiCalls,
|
|
335569
335834
|
logs: result2.logs,
|
|
335570
335835
|
error: reportedError,
|
|
335571
|
-
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
|
|
335572
335840
|
});
|
|
335573
335841
|
log[result2.passed ? "ok" : "fail"](
|
|
335574
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"
|
|
@@ -330560,6 +330560,14 @@ async function takeScreenshot(sessionId) {
|
|
|
330560
330560
|
return null;
|
|
330561
330561
|
}
|
|
330562
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
|
+
}
|
|
330563
330571
|
function mobileRecordingEnabled() {
|
|
330564
330572
|
const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
|
|
330565
330573
|
return value !== "0" && value !== "false";
|
|
@@ -330689,9 +330697,12 @@ function identityTextXpath(value) {
|
|
|
330689
330697
|
const lit = xpathLiteral(value);
|
|
330690
330698
|
return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
|
|
330691
330699
|
}
|
|
330700
|
+
function isIdentityXpath(value) {
|
|
330701
|
+
return value.includes("@") && !/(?:contains|starts-with)\s*\(/i.test(value);
|
|
330702
|
+
}
|
|
330692
330703
|
function isIdentityStrategy(strategy) {
|
|
330693
330704
|
if (strategy.using === "accessibility id" || strategy.using === "id") return true;
|
|
330694
|
-
if (strategy.using === "xpath") return strategy.value
|
|
330705
|
+
if (strategy.using === "xpath") return isIdentityXpath(strategy.value);
|
|
330695
330706
|
return false;
|
|
330696
330707
|
}
|
|
330697
330708
|
function buildIdentityLocatorStrategies(target, platform3) {
|
|
@@ -330705,7 +330716,7 @@ function buildIdentityLocatorStrategies(target, platform3) {
|
|
|
330705
330716
|
const trimmed = value?.trim();
|
|
330706
330717
|
if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
|
|
330707
330718
|
}
|
|
330708
|
-
if (bundle.xpath && bundle.xpath
|
|
330719
|
+
if (bundle.xpath && isIdentityXpath(bundle.xpath)) strategies.push({ using: "xpath", value: bundle.xpath });
|
|
330709
330720
|
}
|
|
330710
330721
|
if (!(platform3 && hasPlatformLocator(target, platform3))) {
|
|
330711
330722
|
for (const value of [target.primary, ...target.fallbacks ?? []]) {
|
|
@@ -331052,6 +331063,7 @@ async function executeMobileTest(options) {
|
|
|
331052
331063
|
let recordingStarted = false;
|
|
331053
331064
|
let recordingStopped = false;
|
|
331054
331065
|
let video;
|
|
331066
|
+
let failingSource;
|
|
331055
331067
|
const stopCapture = async () => {
|
|
331056
331068
|
if (!capture || captureStopped) return apiCalls;
|
|
331057
331069
|
captureStopped = true;
|
|
@@ -331075,6 +331087,10 @@ async function executeMobileTest(options) {
|
|
|
331075
331087
|
}
|
|
331076
331088
|
return video;
|
|
331077
331089
|
};
|
|
331090
|
+
const recordFirstFailingSource = async () => {
|
|
331091
|
+
if (failingSource != null || !sessionId) return;
|
|
331092
|
+
failingSource = await captureFailingSource(sessionId);
|
|
331093
|
+
};
|
|
331078
331094
|
try {
|
|
331079
331095
|
log2("Checking Appium server health...");
|
|
331080
331096
|
const health = await checkAppiumHealth();
|
|
@@ -331208,6 +331224,7 @@ async function executeMobileTest(options) {
|
|
|
331208
331224
|
entryDrift = true;
|
|
331209
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).`;
|
|
331210
331226
|
log2(` \u2717 Entry-screen drift: ${driftMsg}`);
|
|
331227
|
+
await recordFirstFailingSource();
|
|
331211
331228
|
stepResults.push({ stepOrder: entryStep.order, status: "failed", duration: 0, error: driftMsg });
|
|
331212
331229
|
const driftShot = await takeScreenshot(sessionId).catch(() => null);
|
|
331213
331230
|
screenshots.push(driftShot ?? "");
|
|
@@ -331284,6 +331301,9 @@ async function executeMobileTest(options) {
|
|
|
331284
331301
|
}
|
|
331285
331302
|
const screenshot = await takeScreenshot(sessionId).catch(() => null);
|
|
331286
331303
|
screenshots.push(screenshot ?? "");
|
|
331304
|
+
if (status === "failed") {
|
|
331305
|
+
await recordFirstFailingSource();
|
|
331306
|
+
}
|
|
331287
331307
|
stepResults.push({
|
|
331288
331308
|
stepOrder: step.order,
|
|
331289
331309
|
status,
|
|
@@ -331321,7 +331341,8 @@ async function executeMobileTest(options) {
|
|
|
331321
331341
|
duration: Date.now() - startTime,
|
|
331322
331342
|
deviceId: selectedDevice?.id,
|
|
331323
331343
|
deviceName: selectedDevice?.name,
|
|
331324
|
-
environmental: environmental ?? void 0
|
|
331344
|
+
environmental: environmental ?? void 0,
|
|
331345
|
+
failingSource
|
|
331325
331346
|
};
|
|
331326
331347
|
} catch (err) {
|
|
331327
331348
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -331342,7 +331363,8 @@ async function executeMobileTest(options) {
|
|
|
331342
331363
|
// A throw before/around the step loop is a setup/environment problem
|
|
331343
331364
|
// (no device booted, app not installed, session could not be created,
|
|
331344
331365
|
// Appium unreachable) — never a genuine test assertion failure.
|
|
331345
|
-
environmental: errorMsg
|
|
331366
|
+
environmental: errorMsg,
|
|
331367
|
+
failingSource
|
|
331346
331368
|
};
|
|
331347
331369
|
} finally {
|
|
331348
331370
|
await stopCapture();
|
|
@@ -333311,6 +333333,9 @@ function budgetResultPayload(result, maxBytes = RESULT_BODY_BUDGET_BYTES, videoS
|
|
|
333311
333333
|
if ((bytes > maxBytes || oversizedVideo) && payload.video != null) {
|
|
333312
333334
|
drop(["video", "videoMimeType"], "video");
|
|
333313
333335
|
}
|
|
333336
|
+
if (bytes > maxBytes && payload.failingSource != null) {
|
|
333337
|
+
drop(["failingSource"], "failingSource");
|
|
333338
|
+
}
|
|
333314
333339
|
if (bytes > maxBytes && Array.isArray(payload.screenshots) && payload.screenshots.length > 0) {
|
|
333315
333340
|
const shots = [...payload.screenshots];
|
|
333316
333341
|
const bySizeDesc = shots.map((shot, i) => ({ i, len: typeof shot === "string" ? shot.length : 0 })).sort((a, b) => b.len - a.len);
|
|
@@ -333362,6 +333387,7 @@ ${tail}` };
|
|
|
333362
333387
|
// src/runner.ts
|
|
333363
333388
|
init_dist();
|
|
333364
333389
|
var import_runner_core12 = __toESM(require_dist2());
|
|
333390
|
+
var import_runner_core13 = __toESM(require_dist2());
|
|
333365
333391
|
function fingerprintTest(test) {
|
|
333366
333392
|
const code = test.framework === "APPIUM" ? [test.appiumCode ?? "", JSON.stringify(test.steps ?? [])].join("\n--steps--\n") : test.playwrightCode ?? "";
|
|
333367
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);
|
|
@@ -333599,6 +333625,33 @@ async function postHealResult(serverUrl, headers, runId, healedCode, testCaseId,
|
|
|
333599
333625
|
}
|
|
333600
333626
|
throw new Error(`Failed to post heal result after ${MAX_ATTEMPTS} attempts: ${lastError?.message}`);
|
|
333601
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
|
+
}
|
|
333602
333655
|
async function postBatchHealResult(serverUrl, headers, runId, body) {
|
|
333603
333656
|
const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/batch-heal-result`, {
|
|
333604
333657
|
method: "POST",
|
|
@@ -335507,6 +335560,218 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
335507
335560
|
log.fail("ERROR - No mobile steps");
|
|
335508
335561
|
return;
|
|
335509
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
|
+
}
|
|
335510
335775
|
const maxRetries = typeof run2.retryCount === "number" && Number.isFinite(run2.retryCount) ? Math.max(0, Math.min(2, Math.floor(run2.retryCount))) : 0;
|
|
335511
335776
|
setExecutorBusy(true);
|
|
335512
335777
|
let result2 = null;
|
|
@@ -335576,7 +335841,10 @@ ${result2.logs}`);
|
|
|
335576
335841
|
apiCalls: result2.apiCalls,
|
|
335577
335842
|
logs: result2.logs,
|
|
335578
335843
|
error: reportedError,
|
|
335579
|
-
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
|
|
335580
335848
|
});
|
|
335581
335849
|
log[result2.passed ? "ok" : "fail"](
|
|
335582
335850
|
`\u{1F4F1} ${status} (${duration2}ms)${reportedError ? ` - ${reportedError}` : ""}`
|
package/package.json
CHANGED