@validate.qa/runner 1.0.18 → 1.0.20
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 +355 -245
- package/dist/cli.mjs +355 -245
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -1532,7 +1532,7 @@ function buildMobileStep(action, order) {
|
|
|
1532
1532
|
}
|
|
1533
1533
|
return step;
|
|
1534
1534
|
}
|
|
1535
|
-
function buildLaunchStep(target, medium) {
|
|
1535
|
+
function buildLaunchStep(target, medium, appResetOnLaunch = false) {
|
|
1536
1536
|
const screenName = target.deeplink ? "DeepLinkEntry" : `${target.platform === "IOS" ? "IOS" : "Android"}Launch`;
|
|
1537
1537
|
const description = target.launchMode === "DEEP_LINK" ? `Launch ${target.appId} via deep link` : `Launch ${target.appId} on ${medium === "IOS_NATIVE" ? "iOS" : "Android"}`;
|
|
1538
1538
|
return {
|
|
@@ -1549,7 +1549,10 @@ function buildLaunchStep(target, medium) {
|
|
|
1549
1549
|
platform: target.platform,
|
|
1550
1550
|
deeplink: target.deeplink,
|
|
1551
1551
|
deviceId: target.recordedDeviceId,
|
|
1552
|
-
platformVersion: target.platformVersion
|
|
1552
|
+
platformVersion: target.platformVersion,
|
|
1553
|
+
// Recorded from a cleared app-data state: replay must clear before launch
|
|
1554
|
+
// (policy-gated in the executor) to recreate the recording's entry state.
|
|
1555
|
+
...appResetOnLaunch ? { appReset: "cleared" } : {}
|
|
1553
1556
|
}
|
|
1554
1557
|
};
|
|
1555
1558
|
}
|
|
@@ -1800,7 +1803,7 @@ function buildAppiumCode(testName, target, steps) {
|
|
|
1800
1803
|
function generateMobileTestFromSession(input) {
|
|
1801
1804
|
const steps = [];
|
|
1802
1805
|
if (input.target) {
|
|
1803
|
-
steps.push(buildLaunchStep(input.target, input.medium));
|
|
1806
|
+
steps.push(buildLaunchStep(input.target, input.medium, input.appResetOnLaunch === true));
|
|
1804
1807
|
}
|
|
1805
1808
|
for (const action of input.actions) {
|
|
1806
1809
|
const step = buildMobileStep(action, steps.length);
|
|
@@ -62045,7 +62048,7 @@ var require_mobile_explorer = __commonJS({
|
|
|
62045
62048
|
}
|
|
62046
62049
|
sections.push("");
|
|
62047
62050
|
if (mode === "survey") {
|
|
62048
|
-
sections.push(
|
|
62051
|
+
sections.push(`**Mission:** Map every reachable screen. For each new screen: mobile_snapshot -> capture_screen -> record_transition. Get PAST the entry gate into the app's real main content \u2014 the login/landing screens are the ENTRANCE, not the app. If test credentials are provided (see the Test Credentials section), SIGN IN with them FIRST and map the authenticated app; that is the real product surface. Use a guest/demo/skip entry (e.g. "See Demo Profile", "Continue as guest") ONLY as a fallback when no credentials are available or login cannot be completed \u2014 demo mode is a limited sandbox, not the authenticated app. Do NOT submit forms with junk data or mutate real data. Call finish_exploration ONLY when every reachable screen \u2014 including the authenticated content \u2014 has been captured and no unvisited frontier screens remain.`);
|
|
62049
62052
|
} else {
|
|
62050
62053
|
sections.push("**Mission:** Use the focus features like a real user. Tap, type, submit, and observe outcomes. After a meaningful flow, call record_journey. Call finish_exploration when the flows are covered.");
|
|
62051
62054
|
}
|
|
@@ -62103,7 +62106,14 @@ var require_mobile_explorer = __commonJS({
|
|
|
62103
62106
|
This app has stored login credentials under these keys:
|
|
62104
62107
|
${keyList}
|
|
62105
62108
|
|
|
62106
|
-
|
|
62109
|
+
LOG IN FIRST \u2014 before broad exploration. The authenticated app is the real product surface; a survey that only maps the logged-out/demo screens has missed most of the app. This OVERRIDES the "do not submit forms" rule for the login form ONLY. The login sequence is:
|
|
62110
|
+
1. Reach the email/password sign-in form (tap "Sign in with Email" / "Login" from the landing screen if needed).
|
|
62111
|
+
2. Fill the email field: \`mobile_fill_credential({ ref, credentialKey: "TEST_USER_EMAIL" })\`. Fill the password field: \`mobile_fill_credential({ ref, credentialKey: "TEST_USER_PASSWORD" })\`. The runner substitutes the real value on the device; you never see, log, or echo it.
|
|
62112
|
+
3. TAP THE SUBMIT BUTTON (e.g. "Sign In", "Log In", "Continue"). Filling the fields does NOT log you in \u2014 you MUST tap submit.
|
|
62113
|
+
4. VERIFY you are actually signed in: re-snapshot and confirm an authenticated signal appeared (a home/feed/dashboard with real content, an account/profile tab, a "Sign out" option, or your user identity). If you are still on the login form, the login failed \u2014 check for an error, re-enter, and submit again (up to 2 attempts).
|
|
62114
|
+
5. Only AFTER you have confirmed you are signed in, map the authenticated app.
|
|
62115
|
+
|
|
62116
|
+
PREFER real login over any "demo"/"guest"/"skip" entry: those give a limited sandbox, NOT the authenticated app. Use them only if login is impossible.
|
|
62107
62117
|
|
|
62108
62118
|
SECURITY RULES:
|
|
62109
62119
|
- NEVER call \`mobile_type\` with an email, password, or token value \u2014 the runner will REJECT the call and you lose an iteration. Use \`mobile_fill_credential\` for every credential field.
|
|
@@ -62410,6 +62420,8 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
|
|
|
62410
62420
|
let summary = "";
|
|
62411
62421
|
let truncated = false;
|
|
62412
62422
|
let truncatedReason;
|
|
62423
|
+
let finishRedirects = 0;
|
|
62424
|
+
const MAX_FINISH_REDIRECTS = 2;
|
|
62413
62425
|
try {
|
|
62414
62426
|
try {
|
|
62415
62427
|
const seed = await driver.snapshot();
|
|
@@ -62539,6 +62551,20 @@ ${statePacket}` }
|
|
|
62539
62551
|
continue;
|
|
62540
62552
|
}
|
|
62541
62553
|
if (toolName === "finish_exploration") {
|
|
62554
|
+
const frontier = mode === "survey" ? state2.getFrontier() : [];
|
|
62555
|
+
const budgetRemainingPct = (maxIterations - iteration) / maxIterations;
|
|
62556
|
+
if (frontier.length > 0 && budgetRemainingPct >= 0.2 && finishRedirects < MAX_FINISH_REDIRECTS) {
|
|
62557
|
+
finishRedirects++;
|
|
62558
|
+
const targets = frontier.slice(0, 6).join(", ");
|
|
62559
|
+
log2(`
|
|
62560
|
+
finish_exploration bounced (${finishRedirects}/${MAX_FINISH_REDIRECTS}): ${frontier.length} frontier screen(s) still unvisited, ${Math.round(budgetRemainingPct * 100)}% budget left.`);
|
|
62561
|
+
pushResult({
|
|
62562
|
+
acknowledged: false,
|
|
62563
|
+
keepExploring: true,
|
|
62564
|
+
reason: `Not done yet \u2014 ${frontier.length} screen(s) were seen via transitions but never visited, and ${Math.round(budgetRemainingPct * 100)}% of the iteration budget remains. A survey must map EVERY reachable screen. Navigate into the unvisited screens (e.g. ${targets}), snapshot + capture_screen each, then call finish_exploration only once no unvisited screens remain. If a screen is genuinely unreachable (needs credentials you don't have, or leaves the app), note that and move to the next one.`
|
|
62565
|
+
});
|
|
62566
|
+
continue;
|
|
62567
|
+
}
|
|
62542
62568
|
explorationComplete = true;
|
|
62543
62569
|
summary = String(toolArgs.summary ?? "");
|
|
62544
62570
|
log2(`
|
|
@@ -62757,6 +62783,14 @@ Exploration complete: ${summary}`);
|
|
|
62757
62783
|
...actionResult.error ? { error: actionResult.error } : {},
|
|
62758
62784
|
// Report only the KEY that was filled — never the value.
|
|
62759
62785
|
filledCredentialKey: key,
|
|
62786
|
+
// Code-level reinforcement of the login sequence: filling a field does
|
|
62787
|
+
// NOT submit the form. The observed failure was fill-without-submit —
|
|
62788
|
+
// the model filled email+password then wandered off (into demo content)
|
|
62789
|
+
// without ever tapping Sign In, so it never reached the authenticated
|
|
62790
|
+
// app. This nudge fires on every credential fill so the model is
|
|
62791
|
+
// reminded to complete + verify the login rather than treating the fill
|
|
62792
|
+
// as "logged in".
|
|
62793
|
+
nextStep: "Credential filled \u2014 this does NOT log you in. Once all credential fields are filled, tap the submit button (Sign In / Log In / Continue), then re-snapshot and confirm an authenticated screen (home/feed/account/Sign out) appeared before exploring further.",
|
|
62760
62794
|
screenId,
|
|
62761
62795
|
observation,
|
|
62762
62796
|
...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
|
|
@@ -67134,7 +67168,7 @@ ${statePacket}` },
|
|
|
67134
67168
|
return out;
|
|
67135
67169
|
}
|
|
67136
67170
|
function assembleTest(args) {
|
|
67137
|
-
const { scenario, recording, ctx, platform: platform3, log: log2 } = args;
|
|
67171
|
+
const { scenario, recording, ctx, platform: platform3, appDataCleared, log: log2 } = args;
|
|
67138
67172
|
const actions = collapseRetryCycles(recording.actions);
|
|
67139
67173
|
if (actions.length < recording.actions.length) {
|
|
67140
67174
|
log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
|
|
@@ -67151,7 +67185,8 @@ ${statePacket}` },
|
|
|
67151
67185
|
sessionName: recording.finish?.name ?? scenario.name,
|
|
67152
67186
|
medium,
|
|
67153
67187
|
target,
|
|
67154
|
-
actions
|
|
67188
|
+
actions,
|
|
67189
|
+
appResetOnLaunch: appDataCleared
|
|
67155
67190
|
};
|
|
67156
67191
|
let compiled;
|
|
67157
67192
|
try {
|
|
@@ -67257,7 +67292,12 @@ ${statePacket}` },
|
|
|
67257
67292
|
log2(`
|
|
67258
67293
|
-- Generating scenario ${i + 1}/${scenarios.length}: "${scenario.name}" --`);
|
|
67259
67294
|
try {
|
|
67260
|
-
await driver.
|
|
67295
|
+
const appDataCleared = await driver.resetAppState?.().catch(() => false) ?? false;
|
|
67296
|
+
if (appDataCleared) {
|
|
67297
|
+
log2(" app data cleared \u2014 scenario records from a deterministic cold (logged-out) state.");
|
|
67298
|
+
} else {
|
|
67299
|
+
await driver.relaunch();
|
|
67300
|
+
}
|
|
67261
67301
|
const recording = await runScenarioLoop({
|
|
67262
67302
|
scenario,
|
|
67263
67303
|
driver,
|
|
@@ -67274,7 +67314,7 @@ ${statePacket}` },
|
|
|
67274
67314
|
log: log2
|
|
67275
67315
|
});
|
|
67276
67316
|
consecutiveInfraFailures = 0;
|
|
67277
|
-
const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
|
|
67317
|
+
const test = assembleTest({ scenario, recording, ctx, platform: platform3, appDataCleared, log: log2 });
|
|
67278
67318
|
if (!test) {
|
|
67279
67319
|
log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
|
|
67280
67320
|
continue;
|
|
@@ -313745,6 +313785,8 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
313745
313785
|
};
|
|
313746
313786
|
}
|
|
313747
313787
|
log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
|
|
313788
|
+
const exploreAppDataCleared = await driver.resetAppState?.().catch(() => false) ?? false;
|
|
313789
|
+
log2(exploreAppDataCleared ? "App data cleared \u2014 exploring from a deterministic cold (logged-out) state." : "App data reset unavailable (platform/device policy) \u2014 exploring the app in its current state.");
|
|
313748
313790
|
const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
|
|
313749
313791
|
const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
|
|
313750
313792
|
const existingScreens = compactExistingScreens(ctx.existingSiteMap);
|
|
@@ -328434,7 +328476,7 @@ var require_package4 = __commonJS({
|
|
|
328434
328476
|
"package.json"(exports, module) {
|
|
328435
328477
|
module.exports = {
|
|
328436
328478
|
name: "@validate.qa/runner",
|
|
328437
|
-
version: "1.0.
|
|
328479
|
+
version: "1.0.20",
|
|
328438
328480
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
328439
328481
|
bin: {
|
|
328440
328482
|
"validate-runner": "dist/cli.js"
|
|
@@ -328572,9 +328614,9 @@ function shouldUseAuthState(tags, playwrightCode) {
|
|
|
328572
328614
|
|
|
328573
328615
|
// src/services/appium-executor.ts
|
|
328574
328616
|
init_dist();
|
|
328575
|
-
|
|
328576
|
-
|
|
328577
|
-
|
|
328617
|
+
|
|
328618
|
+
// src/services/mobile/appium-actions.ts
|
|
328619
|
+
init_dist();
|
|
328578
328620
|
|
|
328579
328621
|
// src/services/appium-lifecycle.ts
|
|
328580
328622
|
import { spawn, execFileSync } from "child_process";
|
|
@@ -328859,6 +328901,234 @@ function resetServerState(port = APPIUM_PORT, restartCount = 0) {
|
|
|
328859
328901
|
};
|
|
328860
328902
|
}
|
|
328861
328903
|
|
|
328904
|
+
// src/services/mobile/appium-actions.ts
|
|
328905
|
+
var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
|
|
328906
|
+
var ELEMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
328907
|
+
async function appiumRequest(path, options) {
|
|
328908
|
+
const url = `http://localhost:${getAppiumState().port}${path}`;
|
|
328909
|
+
const res = await fetch(url, {
|
|
328910
|
+
...options,
|
|
328911
|
+
signal: options?.signal ?? AbortSignal.timeout(APPIUM_REQUEST_TIMEOUT_MS),
|
|
328912
|
+
headers: { "Content-Type": "application/json", ...options?.headers }
|
|
328913
|
+
});
|
|
328914
|
+
if (!res.ok) {
|
|
328915
|
+
const body = await res.text().catch(() => "");
|
|
328916
|
+
throw new Error(`Appium error ${res.status}: ${body.slice(0, 200)}`);
|
|
328917
|
+
}
|
|
328918
|
+
return res.json();
|
|
328919
|
+
}
|
|
328920
|
+
function readValue(result) {
|
|
328921
|
+
if (result && typeof result === "object" && "value" in result) {
|
|
328922
|
+
return result.value;
|
|
328923
|
+
}
|
|
328924
|
+
return void 0;
|
|
328925
|
+
}
|
|
328926
|
+
async function getScreenshot(sessionPath) {
|
|
328927
|
+
try {
|
|
328928
|
+
const value = readValue(await appiumRequest(`${sessionPath}/screenshot`));
|
|
328929
|
+
return typeof value === "string" ? value : null;
|
|
328930
|
+
} catch {
|
|
328931
|
+
return null;
|
|
328932
|
+
}
|
|
328933
|
+
}
|
|
328934
|
+
async function getPageSource(sessionPath) {
|
|
328935
|
+
try {
|
|
328936
|
+
const value = readValue(await appiumRequest(`${sessionPath}/source`));
|
|
328937
|
+
return typeof value === "string" ? value : null;
|
|
328938
|
+
} catch {
|
|
328939
|
+
return null;
|
|
328940
|
+
}
|
|
328941
|
+
}
|
|
328942
|
+
async function getWindowSize(sessionPath) {
|
|
328943
|
+
const retries = 3;
|
|
328944
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
328945
|
+
try {
|
|
328946
|
+
const value = readValue(await appiumRequest(`${sessionPath}/window/rect`));
|
|
328947
|
+
if (value && typeof value === "object") {
|
|
328948
|
+
const w = value.width;
|
|
328949
|
+
const h = value.height;
|
|
328950
|
+
if (typeof w === "number" && typeof h === "number" && w > 0 && h > 0) {
|
|
328951
|
+
return { width: w, height: h };
|
|
328952
|
+
}
|
|
328953
|
+
}
|
|
328954
|
+
} catch {
|
|
328955
|
+
}
|
|
328956
|
+
if (attempt < retries - 1) {
|
|
328957
|
+
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100));
|
|
328958
|
+
}
|
|
328959
|
+
}
|
|
328960
|
+
return null;
|
|
328961
|
+
}
|
|
328962
|
+
async function getActiveElementId(sessionPath) {
|
|
328963
|
+
try {
|
|
328964
|
+
const value = readValue(await appiumRequest(`${sessionPath}/element/active`));
|
|
328965
|
+
if (!value || typeof value !== "object") return null;
|
|
328966
|
+
const v = value;
|
|
328967
|
+
const elementId = v.ELEMENT ?? v["element-6066-11e4-a52e-4f735466cecf"] ?? null;
|
|
328968
|
+
if (typeof elementId !== "string" || !ELEMENT_ID_PATTERN.test(elementId)) {
|
|
328969
|
+
return null;
|
|
328970
|
+
}
|
|
328971
|
+
return elementId;
|
|
328972
|
+
} catch {
|
|
328973
|
+
return null;
|
|
328974
|
+
}
|
|
328975
|
+
}
|
|
328976
|
+
function normaliseBounds(bounds) {
|
|
328977
|
+
if (!bounds || typeof bounds !== "object" || typeof bounds.x !== "number" || typeof bounds.y !== "number" || !Number.isFinite(bounds.x) || !Number.isFinite(bounds.y)) {
|
|
328978
|
+
return null;
|
|
328979
|
+
}
|
|
328980
|
+
const b = bounds;
|
|
328981
|
+
const width = typeof b.width === "number" && Number.isFinite(b.width) ? b.width : 0;
|
|
328982
|
+
const height = typeof b.height === "number" && Number.isFinite(b.height) ? b.height : 0;
|
|
328983
|
+
return { x: b.x, y: b.y, width, height };
|
|
328984
|
+
}
|
|
328985
|
+
async function tapBoundsCenter(sessionPath, bounds) {
|
|
328986
|
+
const box = normaliseBounds(bounds);
|
|
328987
|
+
if (!box) return false;
|
|
328988
|
+
const tapX = Math.round(box.x + box.width / 2);
|
|
328989
|
+
const tapY = Math.round(box.y + box.height / 2);
|
|
328990
|
+
await appiumRequest(`${sessionPath}/actions`, {
|
|
328991
|
+
method: "POST",
|
|
328992
|
+
body: JSON.stringify({
|
|
328993
|
+
actions: [{
|
|
328994
|
+
type: "pointer",
|
|
328995
|
+
id: "finger1",
|
|
328996
|
+
parameters: { pointerType: "touch" },
|
|
328997
|
+
actions: [
|
|
328998
|
+
// Note: button: 0 is used because Appium's WebDriver protocol
|
|
328999
|
+
// translates mouse button events to touch events. Standard approach
|
|
329000
|
+
// for touch actions in Appium, despite the mouse button semantics.
|
|
329001
|
+
{ type: "pointerMove", duration: 0, x: tapX, y: tapY },
|
|
329002
|
+
{ type: "pointerDown", button: 0 },
|
|
329003
|
+
{ type: "pause", duration: 100 },
|
|
329004
|
+
{ type: "pointerUp", button: 0 }
|
|
329005
|
+
]
|
|
329006
|
+
}]
|
|
329007
|
+
})
|
|
329008
|
+
});
|
|
329009
|
+
return true;
|
|
329010
|
+
}
|
|
329011
|
+
async function tapAt(sessionPath, bounds) {
|
|
329012
|
+
const ok = await tapBoundsCenter(sessionPath, bounds);
|
|
329013
|
+
if (!ok) {
|
|
329014
|
+
throw new Error("TAP requires usable bounds (finite x/y)");
|
|
329015
|
+
}
|
|
329016
|
+
}
|
|
329017
|
+
function shouldResetMobileAppData(platform3, deviceId, env = process.env) {
|
|
329018
|
+
if (platform3 !== "ANDROID") return false;
|
|
329019
|
+
const policy = (env.VALIDATEQA_MOBILE_APP_RESET ?? "").trim().toLowerCase();
|
|
329020
|
+
if (policy === "never") return false;
|
|
329021
|
+
if (policy === "always") return true;
|
|
329022
|
+
return !!deviceId && deviceId.startsWith("emulator-");
|
|
329023
|
+
}
|
|
329024
|
+
async function getElementRect(sessionPath, elementId) {
|
|
329025
|
+
try {
|
|
329026
|
+
const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
|
|
329027
|
+
if (!value || typeof value !== "object") return null;
|
|
329028
|
+
const r = value;
|
|
329029
|
+
if (typeof r.x !== "number" || typeof r.y !== "number" || typeof r.width !== "number" || typeof r.height !== "number" || !Number.isFinite(r.x) || !Number.isFinite(r.y)) {
|
|
329030
|
+
return null;
|
|
329031
|
+
}
|
|
329032
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
329033
|
+
} catch {
|
|
329034
|
+
return null;
|
|
329035
|
+
}
|
|
329036
|
+
}
|
|
329037
|
+
function rectMatchesIntendedBounds(rect, bounds) {
|
|
329038
|
+
if (bounds.width <= 0 || bounds.height <= 0) return false;
|
|
329039
|
+
const rectCenterX = rect.x + rect.width / 2;
|
|
329040
|
+
const rectCenterY = rect.y + rect.height / 2;
|
|
329041
|
+
const boundsCenterX = bounds.x + bounds.width / 2;
|
|
329042
|
+
const boundsCenterY = bounds.y + bounds.height / 2;
|
|
329043
|
+
const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
|
|
329044
|
+
const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
|
|
329045
|
+
return rectCenterInBounds || boundsCenterInRect;
|
|
329046
|
+
}
|
|
329047
|
+
async function resolveTypeTargetElementId(sessionPath, bounds) {
|
|
329048
|
+
const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
|
|
329049
|
+
let elementId = await getActiveElementId(sessionPath);
|
|
329050
|
+
if (elementId && box && box.width > 0 && box.height > 0) {
|
|
329051
|
+
const rect = await getElementRect(sessionPath, elementId);
|
|
329052
|
+
if (!rect || !rectMatchesIntendedBounds(rect, box)) {
|
|
329053
|
+
elementId = null;
|
|
329054
|
+
}
|
|
329055
|
+
}
|
|
329056
|
+
if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
|
|
329057
|
+
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
329058
|
+
elementId = await getActiveElementId(sessionPath);
|
|
329059
|
+
}
|
|
329060
|
+
return elementId;
|
|
329061
|
+
}
|
|
329062
|
+
async function typeText(sessionPath, value, bounds) {
|
|
329063
|
+
const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
|
|
329064
|
+
if (!elementId) {
|
|
329065
|
+
throw new Error("No focused element available for TYPE. Tap an input field first.");
|
|
329066
|
+
}
|
|
329067
|
+
await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
|
|
329068
|
+
method: "POST",
|
|
329069
|
+
body: JSON.stringify({ text: value })
|
|
329070
|
+
});
|
|
329071
|
+
}
|
|
329072
|
+
async function clearField(sessionPath, bounds) {
|
|
329073
|
+
const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
|
|
329074
|
+
if (!elementId) {
|
|
329075
|
+
throw new Error("No focused element available for CLEAR. Tap an input field first.");
|
|
329076
|
+
}
|
|
329077
|
+
await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
|
|
329078
|
+
method: "POST",
|
|
329079
|
+
body: JSON.stringify({})
|
|
329080
|
+
});
|
|
329081
|
+
}
|
|
329082
|
+
async function swipe(sessionPath, direction, distance, windowSize) {
|
|
329083
|
+
const dist = clampMobileSwipeDistance(distance);
|
|
329084
|
+
const { startX, startY, endX, endY } = computeMobileSwipePointers(
|
|
329085
|
+
windowSize.width,
|
|
329086
|
+
windowSize.height,
|
|
329087
|
+
direction,
|
|
329088
|
+
dist
|
|
329089
|
+
);
|
|
329090
|
+
await appiumRequest(`${sessionPath}/actions`, {
|
|
329091
|
+
method: "POST",
|
|
329092
|
+
body: JSON.stringify({
|
|
329093
|
+
actions: [{
|
|
329094
|
+
type: "pointer",
|
|
329095
|
+
id: "finger1",
|
|
329096
|
+
parameters: { pointerType: "touch" },
|
|
329097
|
+
actions: [
|
|
329098
|
+
{ type: "pointerMove", duration: 0, x: startX, y: startY },
|
|
329099
|
+
{ type: "pointerDown", button: 0 },
|
|
329100
|
+
{ type: "pointerMove", duration: 300, x: endX, y: endY },
|
|
329101
|
+
{ type: "pointerUp", button: 0 }
|
|
329102
|
+
]
|
|
329103
|
+
}]
|
|
329104
|
+
})
|
|
329105
|
+
});
|
|
329106
|
+
}
|
|
329107
|
+
async function pressBack(sessionPath) {
|
|
329108
|
+
await appiumRequest(`${sessionPath}/back`, { method: "POST", body: "{}" });
|
|
329109
|
+
}
|
|
329110
|
+
async function pressHome(sessionPath, platform3) {
|
|
329111
|
+
if (platform3 === "IOS") {
|
|
329112
|
+
await appiumRequest(`${sessionPath}/execute/sync`, {
|
|
329113
|
+
method: "POST",
|
|
329114
|
+
body: JSON.stringify({
|
|
329115
|
+
script: "mobile: pressButton",
|
|
329116
|
+
args: [{ name: "home" }]
|
|
329117
|
+
})
|
|
329118
|
+
});
|
|
329119
|
+
return;
|
|
329120
|
+
}
|
|
329121
|
+
await appiumRequest(`${sessionPath}/appium/device/press_keycode`, {
|
|
329122
|
+
method: "POST",
|
|
329123
|
+
body: JSON.stringify({ keycode: 3 })
|
|
329124
|
+
});
|
|
329125
|
+
}
|
|
329126
|
+
|
|
329127
|
+
// src/services/appium-executor.ts
|
|
329128
|
+
var import_runner_core8 = __toESM(require_dist2());
|
|
329129
|
+
import { execFile as execFile2 } from "child_process";
|
|
329130
|
+
import { promisify as promisify2 } from "util";
|
|
329131
|
+
|
|
328862
329132
|
// src/services/device-discovery.ts
|
|
328863
329133
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
328864
329134
|
import { platform } from "os";
|
|
@@ -330024,7 +330294,7 @@ async function wdRequest(path, options) {
|
|
|
330024
330294
|
}
|
|
330025
330295
|
return res.json();
|
|
330026
330296
|
}
|
|
330027
|
-
async function
|
|
330297
|
+
async function getWindowSize2(sessionId, retries = 3) {
|
|
330028
330298
|
for (let attempt = 0; attempt < retries; attempt++) {
|
|
330029
330299
|
try {
|
|
330030
330300
|
const result = await wdRequest(`/session/${sessionId}/window/rect`);
|
|
@@ -330200,6 +330470,26 @@ async function freshLaunchTargetApp(sessionId, platform3, appId, log2) {
|
|
|
330200
330470
|
log2(` Fresh launch: activate failed (${err instanceof Error ? err.message : String(err)})`);
|
|
330201
330471
|
}
|
|
330202
330472
|
}
|
|
330473
|
+
async function waitForColdBootContent(sessionId, log2, timeoutMs = 2e4) {
|
|
330474
|
+
const deadline = Date.now() + timeoutMs;
|
|
330475
|
+
const MIN_ELEMENTS = 3;
|
|
330476
|
+
let lastCount = 0;
|
|
330477
|
+
while (Date.now() < deadline) {
|
|
330478
|
+
try {
|
|
330479
|
+
const res = await wdRequest(`/session/${sessionId}/source`);
|
|
330480
|
+
const xml = typeof res?.value === "string" ? res.value : "";
|
|
330481
|
+
const count = (xml.match(/<(android\.|XCUIElementType)/g) ?? []).length;
|
|
330482
|
+
lastCount = count;
|
|
330483
|
+
if (count >= MIN_ELEMENTS) {
|
|
330484
|
+
log2(` Cold-boot ready: UI tree has ${count} node(s).`);
|
|
330485
|
+
return;
|
|
330486
|
+
}
|
|
330487
|
+
} catch {
|
|
330488
|
+
}
|
|
330489
|
+
await new Promise((r) => setTimeout(r, 750));
|
|
330490
|
+
}
|
|
330491
|
+
log2(` Cold-boot wait timed out after ${timeoutMs}ms (last tree had ${lastCount} node(s)) \u2014 proceeding anyway.`);
|
|
330492
|
+
}
|
|
330203
330493
|
async function restoreTargetAppIfExternal(sessionId, target, log2, source, failOnExternal) {
|
|
330204
330494
|
let boundary = null;
|
|
330205
330495
|
try {
|
|
@@ -330447,7 +330737,7 @@ async function tapCoordinates(sessionId, bounds) {
|
|
|
330447
330737
|
})
|
|
330448
330738
|
});
|
|
330449
330739
|
}
|
|
330450
|
-
async function
|
|
330740
|
+
async function getActiveElementId2(sessionId) {
|
|
330451
330741
|
try {
|
|
330452
330742
|
const activeEl = await wdRequest(`/session/${sessionId}/element/active`);
|
|
330453
330743
|
return activeEl?.value?.ELEMENT || activeEl?.value?.["element-6066-11e4-a52e-4f735466cecf"] || null;
|
|
@@ -330533,7 +330823,7 @@ async function executeType(sessionId, step, context) {
|
|
|
330533
330823
|
await tapCoordinates(sessionId, bounds);
|
|
330534
330824
|
tappedBounds = true;
|
|
330535
330825
|
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
330536
|
-
elementId = await
|
|
330826
|
+
elementId = await getActiveElementId2(sessionId);
|
|
330537
330827
|
if (elementId) {
|
|
330538
330828
|
lastInputError = await trySendElementValue(sessionId, elementId, value);
|
|
330539
330829
|
if (!lastInputError) return;
|
|
@@ -330555,10 +330845,10 @@ async function executeType(sessionId, step, context) {
|
|
|
330555
330845
|
await tapCoordinates(sessionId, bounds);
|
|
330556
330846
|
tappedBounds = true;
|
|
330557
330847
|
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
330558
|
-
elementId = await
|
|
330848
|
+
elementId = await getActiveElementId2(sessionId);
|
|
330559
330849
|
}
|
|
330560
330850
|
if (!elementId) {
|
|
330561
|
-
elementId = await
|
|
330851
|
+
elementId = await getActiveElementId2(sessionId);
|
|
330562
330852
|
}
|
|
330563
330853
|
if (elementId) {
|
|
330564
330854
|
lastInputError = await trySendElementValue(sessionId, elementId, value);
|
|
@@ -330608,10 +330898,10 @@ async function executeClear(sessionId, step, platform3, isCrossPlatform = false)
|
|
|
330608
330898
|
if (!elementId && bounds) {
|
|
330609
330899
|
await tapCoordinates(sessionId, bounds);
|
|
330610
330900
|
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
330611
|
-
elementId = await
|
|
330901
|
+
elementId = await getActiveElementId2(sessionId);
|
|
330612
330902
|
}
|
|
330613
330903
|
if (!elementId) {
|
|
330614
|
-
elementId = await
|
|
330904
|
+
elementId = await getActiveElementId2(sessionId);
|
|
330615
330905
|
}
|
|
330616
330906
|
if (!elementId) {
|
|
330617
330907
|
throw new Error("CLEAR step could not resolve a target or focused element");
|
|
@@ -330627,7 +330917,7 @@ async function executeSwipe(sessionId, step) {
|
|
|
330627
330917
|
let screenW;
|
|
330628
330918
|
let screenH;
|
|
330629
330919
|
try {
|
|
330630
|
-
const windowSize = await
|
|
330920
|
+
const windowSize = await getWindowSize2(sessionId);
|
|
330631
330921
|
screenW = windowSize.width;
|
|
330632
330922
|
screenH = windowSize.height;
|
|
330633
330923
|
} catch (err) {
|
|
@@ -330842,8 +331132,25 @@ async function executeMobileTest(options) {
|
|
|
330842
331132
|
}
|
|
330843
331133
|
}
|
|
330844
331134
|
recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
|
|
331135
|
+
const launchStepForReset = runSteps.find((s) => s.action === "launchApp");
|
|
331136
|
+
const recordedFromClearedState = launchStepForReset?.metadata?.appReset === "cleared";
|
|
331137
|
+
if (recordedFromClearedState) {
|
|
331138
|
+
if (shouldResetMobileAppData(options.target.platform, selectedDevice.id)) {
|
|
331139
|
+
try {
|
|
331140
|
+
await executeMobileScript(sessionId, "mobile: clearApp", [{ appId: options.target.appId }]);
|
|
331141
|
+
log2("App data cleared before launch (test was recorded from a cold logged-out state).");
|
|
331142
|
+
} catch (err) {
|
|
331143
|
+
log2(`App-data clear failed (continuing with plain fresh launch): ${err instanceof Error ? err.message : String(err)}`);
|
|
331144
|
+
}
|
|
331145
|
+
} else {
|
|
331146
|
+
log2("Test expects a cleared app state, but app-data reset is disabled by policy (physical device / VALIDATEQA_MOBILE_APP_RESET=never) \u2014 replaying against the current app state.");
|
|
331147
|
+
}
|
|
331148
|
+
}
|
|
330845
331149
|
log2("Opening a fresh app instance for the test...");
|
|
330846
331150
|
await freshLaunchTargetApp(sessionId, options.target.platform, options.target.appId, log2);
|
|
331151
|
+
if (recordedFromClearedState) {
|
|
331152
|
+
await waitForColdBootContent(sessionId, log2);
|
|
331153
|
+
}
|
|
330847
331154
|
if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
|
|
330848
331155
|
try {
|
|
330849
331156
|
log2(`Opening deep link: ${options.target.deeplink}`);
|
|
@@ -331358,225 +331665,6 @@ import { homedir as homedir2 } from "os";
|
|
|
331358
331665
|
import { join as join3, dirname } from "path";
|
|
331359
331666
|
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
331360
331667
|
init_dist();
|
|
331361
|
-
|
|
331362
|
-
// src/services/mobile/appium-actions.ts
|
|
331363
|
-
init_dist();
|
|
331364
|
-
var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
|
|
331365
|
-
var ELEMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
331366
|
-
async function appiumRequest(path, options) {
|
|
331367
|
-
const url = `http://localhost:${getAppiumState().port}${path}`;
|
|
331368
|
-
const res = await fetch(url, {
|
|
331369
|
-
...options,
|
|
331370
|
-
signal: options?.signal ?? AbortSignal.timeout(APPIUM_REQUEST_TIMEOUT_MS),
|
|
331371
|
-
headers: { "Content-Type": "application/json", ...options?.headers }
|
|
331372
|
-
});
|
|
331373
|
-
if (!res.ok) {
|
|
331374
|
-
const body = await res.text().catch(() => "");
|
|
331375
|
-
throw new Error(`Appium error ${res.status}: ${body.slice(0, 200)}`);
|
|
331376
|
-
}
|
|
331377
|
-
return res.json();
|
|
331378
|
-
}
|
|
331379
|
-
function readValue(result) {
|
|
331380
|
-
if (result && typeof result === "object" && "value" in result) {
|
|
331381
|
-
return result.value;
|
|
331382
|
-
}
|
|
331383
|
-
return void 0;
|
|
331384
|
-
}
|
|
331385
|
-
async function getScreenshot(sessionPath) {
|
|
331386
|
-
try {
|
|
331387
|
-
const value = readValue(await appiumRequest(`${sessionPath}/screenshot`));
|
|
331388
|
-
return typeof value === "string" ? value : null;
|
|
331389
|
-
} catch {
|
|
331390
|
-
return null;
|
|
331391
|
-
}
|
|
331392
|
-
}
|
|
331393
|
-
async function getPageSource(sessionPath) {
|
|
331394
|
-
try {
|
|
331395
|
-
const value = readValue(await appiumRequest(`${sessionPath}/source`));
|
|
331396
|
-
return typeof value === "string" ? value : null;
|
|
331397
|
-
} catch {
|
|
331398
|
-
return null;
|
|
331399
|
-
}
|
|
331400
|
-
}
|
|
331401
|
-
async function getWindowSize2(sessionPath) {
|
|
331402
|
-
const retries = 3;
|
|
331403
|
-
for (let attempt = 0; attempt < retries; attempt++) {
|
|
331404
|
-
try {
|
|
331405
|
-
const value = readValue(await appiumRequest(`${sessionPath}/window/rect`));
|
|
331406
|
-
if (value && typeof value === "object") {
|
|
331407
|
-
const w = value.width;
|
|
331408
|
-
const h = value.height;
|
|
331409
|
-
if (typeof w === "number" && typeof h === "number" && w > 0 && h > 0) {
|
|
331410
|
-
return { width: w, height: h };
|
|
331411
|
-
}
|
|
331412
|
-
}
|
|
331413
|
-
} catch {
|
|
331414
|
-
}
|
|
331415
|
-
if (attempt < retries - 1) {
|
|
331416
|
-
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100));
|
|
331417
|
-
}
|
|
331418
|
-
}
|
|
331419
|
-
return null;
|
|
331420
|
-
}
|
|
331421
|
-
async function getActiveElementId2(sessionPath) {
|
|
331422
|
-
try {
|
|
331423
|
-
const value = readValue(await appiumRequest(`${sessionPath}/element/active`));
|
|
331424
|
-
if (!value || typeof value !== "object") return null;
|
|
331425
|
-
const v = value;
|
|
331426
|
-
const elementId = v.ELEMENT ?? v["element-6066-11e4-a52e-4f735466cecf"] ?? null;
|
|
331427
|
-
if (typeof elementId !== "string" || !ELEMENT_ID_PATTERN.test(elementId)) {
|
|
331428
|
-
return null;
|
|
331429
|
-
}
|
|
331430
|
-
return elementId;
|
|
331431
|
-
} catch {
|
|
331432
|
-
return null;
|
|
331433
|
-
}
|
|
331434
|
-
}
|
|
331435
|
-
function normaliseBounds(bounds) {
|
|
331436
|
-
if (!bounds || typeof bounds !== "object" || typeof bounds.x !== "number" || typeof bounds.y !== "number" || !Number.isFinite(bounds.x) || !Number.isFinite(bounds.y)) {
|
|
331437
|
-
return null;
|
|
331438
|
-
}
|
|
331439
|
-
const b = bounds;
|
|
331440
|
-
const width = typeof b.width === "number" && Number.isFinite(b.width) ? b.width : 0;
|
|
331441
|
-
const height = typeof b.height === "number" && Number.isFinite(b.height) ? b.height : 0;
|
|
331442
|
-
return { x: b.x, y: b.y, width, height };
|
|
331443
|
-
}
|
|
331444
|
-
async function tapBoundsCenter(sessionPath, bounds) {
|
|
331445
|
-
const box = normaliseBounds(bounds);
|
|
331446
|
-
if (!box) return false;
|
|
331447
|
-
const tapX = Math.round(box.x + box.width / 2);
|
|
331448
|
-
const tapY = Math.round(box.y + box.height / 2);
|
|
331449
|
-
await appiumRequest(`${sessionPath}/actions`, {
|
|
331450
|
-
method: "POST",
|
|
331451
|
-
body: JSON.stringify({
|
|
331452
|
-
actions: [{
|
|
331453
|
-
type: "pointer",
|
|
331454
|
-
id: "finger1",
|
|
331455
|
-
parameters: { pointerType: "touch" },
|
|
331456
|
-
actions: [
|
|
331457
|
-
// Note: button: 0 is used because Appium's WebDriver protocol
|
|
331458
|
-
// translates mouse button events to touch events. Standard approach
|
|
331459
|
-
// for touch actions in Appium, despite the mouse button semantics.
|
|
331460
|
-
{ type: "pointerMove", duration: 0, x: tapX, y: tapY },
|
|
331461
|
-
{ type: "pointerDown", button: 0 },
|
|
331462
|
-
{ type: "pause", duration: 100 },
|
|
331463
|
-
{ type: "pointerUp", button: 0 }
|
|
331464
|
-
]
|
|
331465
|
-
}]
|
|
331466
|
-
})
|
|
331467
|
-
});
|
|
331468
|
-
return true;
|
|
331469
|
-
}
|
|
331470
|
-
async function tapAt(sessionPath, bounds) {
|
|
331471
|
-
const ok = await tapBoundsCenter(sessionPath, bounds);
|
|
331472
|
-
if (!ok) {
|
|
331473
|
-
throw new Error("TAP requires usable bounds (finite x/y)");
|
|
331474
|
-
}
|
|
331475
|
-
}
|
|
331476
|
-
async function getElementRect(sessionPath, elementId) {
|
|
331477
|
-
try {
|
|
331478
|
-
const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
|
|
331479
|
-
if (!value || typeof value !== "object") return null;
|
|
331480
|
-
const r = value;
|
|
331481
|
-
if (typeof r.x !== "number" || typeof r.y !== "number" || typeof r.width !== "number" || typeof r.height !== "number" || !Number.isFinite(r.x) || !Number.isFinite(r.y)) {
|
|
331482
|
-
return null;
|
|
331483
|
-
}
|
|
331484
|
-
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
331485
|
-
} catch {
|
|
331486
|
-
return null;
|
|
331487
|
-
}
|
|
331488
|
-
}
|
|
331489
|
-
function rectMatchesIntendedBounds(rect, bounds) {
|
|
331490
|
-
if (bounds.width <= 0 || bounds.height <= 0) return false;
|
|
331491
|
-
const rectCenterX = rect.x + rect.width / 2;
|
|
331492
|
-
const rectCenterY = rect.y + rect.height / 2;
|
|
331493
|
-
const boundsCenterX = bounds.x + bounds.width / 2;
|
|
331494
|
-
const boundsCenterY = bounds.y + bounds.height / 2;
|
|
331495
|
-
const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
|
|
331496
|
-
const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
|
|
331497
|
-
return rectCenterInBounds || boundsCenterInRect;
|
|
331498
|
-
}
|
|
331499
|
-
async function resolveTypeTargetElementId(sessionPath, bounds) {
|
|
331500
|
-
const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
|
|
331501
|
-
let elementId = await getActiveElementId2(sessionPath);
|
|
331502
|
-
if (elementId && box && box.width > 0 && box.height > 0) {
|
|
331503
|
-
const rect = await getElementRect(sessionPath, elementId);
|
|
331504
|
-
if (!rect || !rectMatchesIntendedBounds(rect, box)) {
|
|
331505
|
-
elementId = null;
|
|
331506
|
-
}
|
|
331507
|
-
}
|
|
331508
|
-
if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
|
|
331509
|
-
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
331510
|
-
elementId = await getActiveElementId2(sessionPath);
|
|
331511
|
-
}
|
|
331512
|
-
return elementId;
|
|
331513
|
-
}
|
|
331514
|
-
async function typeText(sessionPath, value, bounds) {
|
|
331515
|
-
const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
|
|
331516
|
-
if (!elementId) {
|
|
331517
|
-
throw new Error("No focused element available for TYPE. Tap an input field first.");
|
|
331518
|
-
}
|
|
331519
|
-
await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
|
|
331520
|
-
method: "POST",
|
|
331521
|
-
body: JSON.stringify({ text: value })
|
|
331522
|
-
});
|
|
331523
|
-
}
|
|
331524
|
-
async function clearField(sessionPath, bounds) {
|
|
331525
|
-
const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
|
|
331526
|
-
if (!elementId) {
|
|
331527
|
-
throw new Error("No focused element available for CLEAR. Tap an input field first.");
|
|
331528
|
-
}
|
|
331529
|
-
await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
|
|
331530
|
-
method: "POST",
|
|
331531
|
-
body: JSON.stringify({})
|
|
331532
|
-
});
|
|
331533
|
-
}
|
|
331534
|
-
async function swipe(sessionPath, direction, distance, windowSize) {
|
|
331535
|
-
const dist = clampMobileSwipeDistance(distance);
|
|
331536
|
-
const { startX, startY, endX, endY } = computeMobileSwipePointers(
|
|
331537
|
-
windowSize.width,
|
|
331538
|
-
windowSize.height,
|
|
331539
|
-
direction,
|
|
331540
|
-
dist
|
|
331541
|
-
);
|
|
331542
|
-
await appiumRequest(`${sessionPath}/actions`, {
|
|
331543
|
-
method: "POST",
|
|
331544
|
-
body: JSON.stringify({
|
|
331545
|
-
actions: [{
|
|
331546
|
-
type: "pointer",
|
|
331547
|
-
id: "finger1",
|
|
331548
|
-
parameters: { pointerType: "touch" },
|
|
331549
|
-
actions: [
|
|
331550
|
-
{ type: "pointerMove", duration: 0, x: startX, y: startY },
|
|
331551
|
-
{ type: "pointerDown", button: 0 },
|
|
331552
|
-
{ type: "pointerMove", duration: 300, x: endX, y: endY },
|
|
331553
|
-
{ type: "pointerUp", button: 0 }
|
|
331554
|
-
]
|
|
331555
|
-
}]
|
|
331556
|
-
})
|
|
331557
|
-
});
|
|
331558
|
-
}
|
|
331559
|
-
async function pressBack(sessionPath) {
|
|
331560
|
-
await appiumRequest(`${sessionPath}/back`, { method: "POST", body: "{}" });
|
|
331561
|
-
}
|
|
331562
|
-
async function pressHome(sessionPath, platform3) {
|
|
331563
|
-
if (platform3 === "IOS") {
|
|
331564
|
-
await appiumRequest(`${sessionPath}/execute/sync`, {
|
|
331565
|
-
method: "POST",
|
|
331566
|
-
body: JSON.stringify({
|
|
331567
|
-
script: "mobile: pressButton",
|
|
331568
|
-
args: [{ name: "home" }]
|
|
331569
|
-
})
|
|
331570
|
-
});
|
|
331571
|
-
return;
|
|
331572
|
-
}
|
|
331573
|
-
await appiumRequest(`${sessionPath}/appium/device/press_keycode`, {
|
|
331574
|
-
method: "POST",
|
|
331575
|
-
body: JSON.stringify({ keycode: 3 })
|
|
331576
|
-
});
|
|
331577
|
-
}
|
|
331578
|
-
|
|
331579
|
-
// src/services/mobile-bridge.ts
|
|
331580
331668
|
var MOBILE_BRIDGE_PORT2 = 9515;
|
|
331581
331669
|
var MAX_REQUEST_BODY_BYTES = 64 * 1024;
|
|
331582
331670
|
var MAX_TEXT_INPUT_LENGTH = 4e3;
|
|
@@ -331670,7 +331758,7 @@ async function getPageSource2() {
|
|
|
331670
331758
|
async function getWindowSize3() {
|
|
331671
331759
|
if (!state.appiumSessionId) return null;
|
|
331672
331760
|
validateSessionId(state.appiumSessionId);
|
|
331673
|
-
return
|
|
331761
|
+
return getWindowSize(`/session/${state.appiumSessionId}`);
|
|
331674
331762
|
}
|
|
331675
331763
|
var BRIDGE_PROCESS_MARKER = "validateqa-mobile-bridge";
|
|
331676
331764
|
var BRIDGE_PORT = MOBILE_BRIDGE_PORT2;
|
|
@@ -332734,6 +332822,7 @@ var MobileBridgeDriver = class {
|
|
|
332734
332822
|
sessionId;
|
|
332735
332823
|
platform;
|
|
332736
332824
|
appId;
|
|
332825
|
+
allowAppReset;
|
|
332737
332826
|
constructor(config) {
|
|
332738
332827
|
if (!APPIUM_SESSION_ID_PATTERN2.test(config.sessionId)) {
|
|
332739
332828
|
throw new Error("MobileBridgeDriver: invalid Appium session id");
|
|
@@ -332747,6 +332836,7 @@ var MobileBridgeDriver = class {
|
|
|
332747
332836
|
this.sessionId = config.sessionId;
|
|
332748
332837
|
this.platform = config.platform;
|
|
332749
332838
|
this.appId = config.appId;
|
|
332839
|
+
this.allowAppReset = config.allowAppReset === true;
|
|
332750
332840
|
}
|
|
332751
332841
|
/** WebDriver session path (`/session/<id>`), the prefix every primitive needs. */
|
|
332752
332842
|
get sessionPath() {
|
|
@@ -332808,7 +332898,7 @@ var MobileBridgeDriver = class {
|
|
|
332808
332898
|
const [xml, screenshot, windowSize] = await Promise.all([
|
|
332809
332899
|
getPageSource(this.sessionPath),
|
|
332810
332900
|
getScreenshot(this.sessionPath),
|
|
332811
|
-
|
|
332901
|
+
getWindowSize(this.sessionPath)
|
|
332812
332902
|
]);
|
|
332813
332903
|
const source = xml ?? "";
|
|
332814
332904
|
const screenSignal = await this.enrichScreenSignal(source);
|
|
@@ -332821,7 +332911,7 @@ var MobileBridgeDriver = class {
|
|
|
332821
332911
|
}
|
|
332822
332912
|
/** Device window size; null when unreadable (swipe geometry degrades gracefully). */
|
|
332823
332913
|
async windowSize() {
|
|
332824
|
-
return
|
|
332914
|
+
return getWindowSize(this.sessionPath);
|
|
332825
332915
|
}
|
|
332826
332916
|
// ── Action result wrapper ──────────────────────────────
|
|
332827
332917
|
/**
|
|
@@ -332863,7 +332953,7 @@ var MobileBridgeDriver = class {
|
|
|
332863
332953
|
* hard error so we never fire an off-screen gesture.
|
|
332864
332954
|
*/
|
|
332865
332955
|
async swipe(direction, distance) {
|
|
332866
|
-
const size = await
|
|
332956
|
+
const size = await getWindowSize(this.sessionPath);
|
|
332867
332957
|
if (!size) {
|
|
332868
332958
|
return { ok: false, error: "Could not determine device screen dimensions for swipe." };
|
|
332869
332959
|
}
|
|
@@ -332888,6 +332978,22 @@ var MobileBridgeDriver = class {
|
|
|
332888
332978
|
await this.executeScript("mobile: terminateApp", [args]);
|
|
332889
332979
|
await this.executeScript("mobile: activateApp", [args]);
|
|
332890
332980
|
}
|
|
332981
|
+
/**
|
|
332982
|
+
* Deterministic state reset: clear app data + relaunch (Android emulators
|
|
332983
|
+
* only, per the runner-computed allowAppReset policy). Returns false when
|
|
332984
|
+
* unsupported/disallowed or when clearApp throws — callers fall back to
|
|
332985
|
+
* relaunch(), preserving today's behavior.
|
|
332986
|
+
*/
|
|
332987
|
+
async resetAppState() {
|
|
332988
|
+
if (!this.allowAppReset || this.platform !== "ANDROID") return false;
|
|
332989
|
+
try {
|
|
332990
|
+
await this.executeScript("mobile: clearApp", [{ appId: this.appId }]);
|
|
332991
|
+
await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
|
|
332992
|
+
return true;
|
|
332993
|
+
} catch {
|
|
332994
|
+
return false;
|
|
332995
|
+
}
|
|
332996
|
+
}
|
|
332891
332997
|
/** (Re)launch the target app (activateApp) from a backgrounded/closed state. */
|
|
332892
332998
|
async launchApp() {
|
|
332893
332999
|
await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
|
|
@@ -335029,7 +335135,11 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
335029
335135
|
const driver = new MobileBridgeDriver({
|
|
335030
335136
|
sessionId: session.sessionId,
|
|
335031
335137
|
platform: platform3,
|
|
335032
|
-
appId: session.appId
|
|
335138
|
+
appId: session.appId,
|
|
335139
|
+
// Deterministic-state authorization: Android emulators only by
|
|
335140
|
+
// default (VALIDATEQA_MOBILE_APP_RESET overrides) — a personal
|
|
335141
|
+
// physical device must never have its app data wiped.
|
|
335142
|
+
allowAppReset: shouldResetMobileAppData(platform3, session.deviceId)
|
|
335033
335143
|
});
|
|
335034
335144
|
discoveryResult = await (0, import_runner_core4.runMobileDiscoveryOnRunner)(ctx, driver, {
|
|
335035
335145
|
onLog: mobileOnLog,
|