@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.js
CHANGED
|
@@ -1527,7 +1527,7 @@ function buildMobileStep(action, order) {
|
|
|
1527
1527
|
}
|
|
1528
1528
|
return step;
|
|
1529
1529
|
}
|
|
1530
|
-
function buildLaunchStep(target, medium) {
|
|
1530
|
+
function buildLaunchStep(target, medium, appResetOnLaunch = false) {
|
|
1531
1531
|
const screenName = target.deeplink ? "DeepLinkEntry" : `${target.platform === "IOS" ? "IOS" : "Android"}Launch`;
|
|
1532
1532
|
const description = target.launchMode === "DEEP_LINK" ? `Launch ${target.appId} via deep link` : `Launch ${target.appId} on ${medium === "IOS_NATIVE" ? "iOS" : "Android"}`;
|
|
1533
1533
|
return {
|
|
@@ -1544,7 +1544,10 @@ function buildLaunchStep(target, medium) {
|
|
|
1544
1544
|
platform: target.platform,
|
|
1545
1545
|
deeplink: target.deeplink,
|
|
1546
1546
|
deviceId: target.recordedDeviceId,
|
|
1547
|
-
platformVersion: target.platformVersion
|
|
1547
|
+
platformVersion: target.platformVersion,
|
|
1548
|
+
// Recorded from a cleared app-data state: replay must clear before launch
|
|
1549
|
+
// (policy-gated in the executor) to recreate the recording's entry state.
|
|
1550
|
+
...appResetOnLaunch ? { appReset: "cleared" } : {}
|
|
1548
1551
|
}
|
|
1549
1552
|
};
|
|
1550
1553
|
}
|
|
@@ -1795,7 +1798,7 @@ function buildAppiumCode(testName, target, steps) {
|
|
|
1795
1798
|
function generateMobileTestFromSession(input) {
|
|
1796
1799
|
const steps = [];
|
|
1797
1800
|
if (input.target) {
|
|
1798
|
-
steps.push(buildLaunchStep(input.target, input.medium));
|
|
1801
|
+
steps.push(buildLaunchStep(input.target, input.medium, input.appResetOnLaunch === true));
|
|
1799
1802
|
}
|
|
1800
1803
|
for (const action of input.actions) {
|
|
1801
1804
|
const step = buildMobileStep(action, steps.length);
|
|
@@ -62040,7 +62043,7 @@ var require_mobile_explorer = __commonJS({
|
|
|
62040
62043
|
}
|
|
62041
62044
|
sections.push("");
|
|
62042
62045
|
if (mode === "survey") {
|
|
62043
|
-
sections.push(
|
|
62046
|
+
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.`);
|
|
62044
62047
|
} else {
|
|
62045
62048
|
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.");
|
|
62046
62049
|
}
|
|
@@ -62098,7 +62101,14 @@ var require_mobile_explorer = __commonJS({
|
|
|
62098
62101
|
This app has stored login credentials under these keys:
|
|
62099
62102
|
${keyList}
|
|
62100
62103
|
|
|
62101
|
-
|
|
62104
|
+
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:
|
|
62105
|
+
1. Reach the email/password sign-in form (tap "Sign in with Email" / "Login" from the landing screen if needed).
|
|
62106
|
+
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.
|
|
62107
|
+
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.
|
|
62108
|
+
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).
|
|
62109
|
+
5. Only AFTER you have confirmed you are signed in, map the authenticated app.
|
|
62110
|
+
|
|
62111
|
+
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.
|
|
62102
62112
|
|
|
62103
62113
|
SECURITY RULES:
|
|
62104
62114
|
- 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.
|
|
@@ -62405,6 +62415,8 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
|
|
|
62405
62415
|
let summary = "";
|
|
62406
62416
|
let truncated = false;
|
|
62407
62417
|
let truncatedReason;
|
|
62418
|
+
let finishRedirects = 0;
|
|
62419
|
+
const MAX_FINISH_REDIRECTS = 2;
|
|
62408
62420
|
try {
|
|
62409
62421
|
try {
|
|
62410
62422
|
const seed = await driver.snapshot();
|
|
@@ -62534,6 +62546,20 @@ ${statePacket}` }
|
|
|
62534
62546
|
continue;
|
|
62535
62547
|
}
|
|
62536
62548
|
if (toolName === "finish_exploration") {
|
|
62549
|
+
const frontier = mode === "survey" ? state2.getFrontier() : [];
|
|
62550
|
+
const budgetRemainingPct = (maxIterations - iteration) / maxIterations;
|
|
62551
|
+
if (frontier.length > 0 && budgetRemainingPct >= 0.2 && finishRedirects < MAX_FINISH_REDIRECTS) {
|
|
62552
|
+
finishRedirects++;
|
|
62553
|
+
const targets = frontier.slice(0, 6).join(", ");
|
|
62554
|
+
log2(`
|
|
62555
|
+
finish_exploration bounced (${finishRedirects}/${MAX_FINISH_REDIRECTS}): ${frontier.length} frontier screen(s) still unvisited, ${Math.round(budgetRemainingPct * 100)}% budget left.`);
|
|
62556
|
+
pushResult({
|
|
62557
|
+
acknowledged: false,
|
|
62558
|
+
keepExploring: true,
|
|
62559
|
+
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.`
|
|
62560
|
+
});
|
|
62561
|
+
continue;
|
|
62562
|
+
}
|
|
62537
62563
|
explorationComplete = true;
|
|
62538
62564
|
summary = String(toolArgs.summary ?? "");
|
|
62539
62565
|
log2(`
|
|
@@ -62752,6 +62778,14 @@ Exploration complete: ${summary}`);
|
|
|
62752
62778
|
...actionResult.error ? { error: actionResult.error } : {},
|
|
62753
62779
|
// Report only the KEY that was filled — never the value.
|
|
62754
62780
|
filledCredentialKey: key,
|
|
62781
|
+
// Code-level reinforcement of the login sequence: filling a field does
|
|
62782
|
+
// NOT submit the form. The observed failure was fill-without-submit —
|
|
62783
|
+
// the model filled email+password then wandered off (into demo content)
|
|
62784
|
+
// without ever tapping Sign In, so it never reached the authenticated
|
|
62785
|
+
// app. This nudge fires on every credential fill so the model is
|
|
62786
|
+
// reminded to complete + verify the login rather than treating the fill
|
|
62787
|
+
// as "logged in".
|
|
62788
|
+
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.",
|
|
62755
62789
|
screenId,
|
|
62756
62790
|
observation,
|
|
62757
62791
|
...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
|
|
@@ -67129,7 +67163,7 @@ ${statePacket}` },
|
|
|
67129
67163
|
return out;
|
|
67130
67164
|
}
|
|
67131
67165
|
function assembleTest(args) {
|
|
67132
|
-
const { scenario, recording, ctx, platform: platform3, log: log2 } = args;
|
|
67166
|
+
const { scenario, recording, ctx, platform: platform3, appDataCleared, log: log2 } = args;
|
|
67133
67167
|
const actions = collapseRetryCycles(recording.actions);
|
|
67134
67168
|
if (actions.length < recording.actions.length) {
|
|
67135
67169
|
log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
|
|
@@ -67146,7 +67180,8 @@ ${statePacket}` },
|
|
|
67146
67180
|
sessionName: recording.finish?.name ?? scenario.name,
|
|
67147
67181
|
medium,
|
|
67148
67182
|
target,
|
|
67149
|
-
actions
|
|
67183
|
+
actions,
|
|
67184
|
+
appResetOnLaunch: appDataCleared
|
|
67150
67185
|
};
|
|
67151
67186
|
let compiled;
|
|
67152
67187
|
try {
|
|
@@ -67252,7 +67287,12 @@ ${statePacket}` },
|
|
|
67252
67287
|
log2(`
|
|
67253
67288
|
-- Generating scenario ${i + 1}/${scenarios.length}: "${scenario.name}" --`);
|
|
67254
67289
|
try {
|
|
67255
|
-
await driver.
|
|
67290
|
+
const appDataCleared = await driver.resetAppState?.().catch(() => false) ?? false;
|
|
67291
|
+
if (appDataCleared) {
|
|
67292
|
+
log2(" app data cleared \u2014 scenario records from a deterministic cold (logged-out) state.");
|
|
67293
|
+
} else {
|
|
67294
|
+
await driver.relaunch();
|
|
67295
|
+
}
|
|
67256
67296
|
const recording = await runScenarioLoop({
|
|
67257
67297
|
scenario,
|
|
67258
67298
|
driver,
|
|
@@ -67269,7 +67309,7 @@ ${statePacket}` },
|
|
|
67269
67309
|
log: log2
|
|
67270
67310
|
});
|
|
67271
67311
|
consecutiveInfraFailures = 0;
|
|
67272
|
-
const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
|
|
67312
|
+
const test = assembleTest({ scenario, recording, ctx, platform: platform3, appDataCleared, log: log2 });
|
|
67273
67313
|
if (!test) {
|
|
67274
67314
|
log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
|
|
67275
67315
|
continue;
|
|
@@ -313740,6 +313780,8 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
313740
313780
|
};
|
|
313741
313781
|
}
|
|
313742
313782
|
log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
|
|
313783
|
+
const exploreAppDataCleared = await driver.resetAppState?.().catch(() => false) ?? false;
|
|
313784
|
+
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.");
|
|
313743
313785
|
const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
|
|
313744
313786
|
const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
|
|
313745
313787
|
const existingScreens = compactExistingScreens(ctx.existingSiteMap);
|
|
@@ -328429,7 +328471,7 @@ var require_package4 = __commonJS({
|
|
|
328429
328471
|
"package.json"(exports2, module2) {
|
|
328430
328472
|
module2.exports = {
|
|
328431
328473
|
name: "@validate.qa/runner",
|
|
328432
|
-
version: "1.0.
|
|
328474
|
+
version: "1.0.20",
|
|
328433
328475
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
328434
328476
|
bin: {
|
|
328435
328477
|
"validate-runner": "dist/cli.js"
|
|
@@ -328567,9 +328609,9 @@ function shouldUseAuthState(tags, playwrightCode) {
|
|
|
328567
328609
|
|
|
328568
328610
|
// src/services/appium-executor.ts
|
|
328569
328611
|
init_dist();
|
|
328570
|
-
|
|
328571
|
-
|
|
328572
|
-
|
|
328612
|
+
|
|
328613
|
+
// src/services/mobile/appium-actions.ts
|
|
328614
|
+
init_dist();
|
|
328573
328615
|
|
|
328574
328616
|
// src/services/appium-lifecycle.ts
|
|
328575
328617
|
var import_child_process = require("child_process");
|
|
@@ -328854,6 +328896,234 @@ function resetServerState(port = APPIUM_PORT, restartCount = 0) {
|
|
|
328854
328896
|
};
|
|
328855
328897
|
}
|
|
328856
328898
|
|
|
328899
|
+
// src/services/mobile/appium-actions.ts
|
|
328900
|
+
var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
|
|
328901
|
+
var ELEMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
328902
|
+
async function appiumRequest(path, options) {
|
|
328903
|
+
const url = `http://localhost:${getAppiumState().port}${path}`;
|
|
328904
|
+
const res = await fetch(url, {
|
|
328905
|
+
...options,
|
|
328906
|
+
signal: options?.signal ?? AbortSignal.timeout(APPIUM_REQUEST_TIMEOUT_MS),
|
|
328907
|
+
headers: { "Content-Type": "application/json", ...options?.headers }
|
|
328908
|
+
});
|
|
328909
|
+
if (!res.ok) {
|
|
328910
|
+
const body = await res.text().catch(() => "");
|
|
328911
|
+
throw new Error(`Appium error ${res.status}: ${body.slice(0, 200)}`);
|
|
328912
|
+
}
|
|
328913
|
+
return res.json();
|
|
328914
|
+
}
|
|
328915
|
+
function readValue(result) {
|
|
328916
|
+
if (result && typeof result === "object" && "value" in result) {
|
|
328917
|
+
return result.value;
|
|
328918
|
+
}
|
|
328919
|
+
return void 0;
|
|
328920
|
+
}
|
|
328921
|
+
async function getScreenshot(sessionPath) {
|
|
328922
|
+
try {
|
|
328923
|
+
const value = readValue(await appiumRequest(`${sessionPath}/screenshot`));
|
|
328924
|
+
return typeof value === "string" ? value : null;
|
|
328925
|
+
} catch {
|
|
328926
|
+
return null;
|
|
328927
|
+
}
|
|
328928
|
+
}
|
|
328929
|
+
async function getPageSource(sessionPath) {
|
|
328930
|
+
try {
|
|
328931
|
+
const value = readValue(await appiumRequest(`${sessionPath}/source`));
|
|
328932
|
+
return typeof value === "string" ? value : null;
|
|
328933
|
+
} catch {
|
|
328934
|
+
return null;
|
|
328935
|
+
}
|
|
328936
|
+
}
|
|
328937
|
+
async function getWindowSize(sessionPath) {
|
|
328938
|
+
const retries = 3;
|
|
328939
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
328940
|
+
try {
|
|
328941
|
+
const value = readValue(await appiumRequest(`${sessionPath}/window/rect`));
|
|
328942
|
+
if (value && typeof value === "object") {
|
|
328943
|
+
const w = value.width;
|
|
328944
|
+
const h = value.height;
|
|
328945
|
+
if (typeof w === "number" && typeof h === "number" && w > 0 && h > 0) {
|
|
328946
|
+
return { width: w, height: h };
|
|
328947
|
+
}
|
|
328948
|
+
}
|
|
328949
|
+
} catch {
|
|
328950
|
+
}
|
|
328951
|
+
if (attempt < retries - 1) {
|
|
328952
|
+
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100));
|
|
328953
|
+
}
|
|
328954
|
+
}
|
|
328955
|
+
return null;
|
|
328956
|
+
}
|
|
328957
|
+
async function getActiveElementId(sessionPath) {
|
|
328958
|
+
try {
|
|
328959
|
+
const value = readValue(await appiumRequest(`${sessionPath}/element/active`));
|
|
328960
|
+
if (!value || typeof value !== "object") return null;
|
|
328961
|
+
const v = value;
|
|
328962
|
+
const elementId = v.ELEMENT ?? v["element-6066-11e4-a52e-4f735466cecf"] ?? null;
|
|
328963
|
+
if (typeof elementId !== "string" || !ELEMENT_ID_PATTERN.test(elementId)) {
|
|
328964
|
+
return null;
|
|
328965
|
+
}
|
|
328966
|
+
return elementId;
|
|
328967
|
+
} catch {
|
|
328968
|
+
return null;
|
|
328969
|
+
}
|
|
328970
|
+
}
|
|
328971
|
+
function normaliseBounds(bounds) {
|
|
328972
|
+
if (!bounds || typeof bounds !== "object" || typeof bounds.x !== "number" || typeof bounds.y !== "number" || !Number.isFinite(bounds.x) || !Number.isFinite(bounds.y)) {
|
|
328973
|
+
return null;
|
|
328974
|
+
}
|
|
328975
|
+
const b = bounds;
|
|
328976
|
+
const width = typeof b.width === "number" && Number.isFinite(b.width) ? b.width : 0;
|
|
328977
|
+
const height = typeof b.height === "number" && Number.isFinite(b.height) ? b.height : 0;
|
|
328978
|
+
return { x: b.x, y: b.y, width, height };
|
|
328979
|
+
}
|
|
328980
|
+
async function tapBoundsCenter(sessionPath, bounds) {
|
|
328981
|
+
const box = normaliseBounds(bounds);
|
|
328982
|
+
if (!box) return false;
|
|
328983
|
+
const tapX = Math.round(box.x + box.width / 2);
|
|
328984
|
+
const tapY = Math.round(box.y + box.height / 2);
|
|
328985
|
+
await appiumRequest(`${sessionPath}/actions`, {
|
|
328986
|
+
method: "POST",
|
|
328987
|
+
body: JSON.stringify({
|
|
328988
|
+
actions: [{
|
|
328989
|
+
type: "pointer",
|
|
328990
|
+
id: "finger1",
|
|
328991
|
+
parameters: { pointerType: "touch" },
|
|
328992
|
+
actions: [
|
|
328993
|
+
// Note: button: 0 is used because Appium's WebDriver protocol
|
|
328994
|
+
// translates mouse button events to touch events. Standard approach
|
|
328995
|
+
// for touch actions in Appium, despite the mouse button semantics.
|
|
328996
|
+
{ type: "pointerMove", duration: 0, x: tapX, y: tapY },
|
|
328997
|
+
{ type: "pointerDown", button: 0 },
|
|
328998
|
+
{ type: "pause", duration: 100 },
|
|
328999
|
+
{ type: "pointerUp", button: 0 }
|
|
329000
|
+
]
|
|
329001
|
+
}]
|
|
329002
|
+
})
|
|
329003
|
+
});
|
|
329004
|
+
return true;
|
|
329005
|
+
}
|
|
329006
|
+
async function tapAt(sessionPath, bounds) {
|
|
329007
|
+
const ok = await tapBoundsCenter(sessionPath, bounds);
|
|
329008
|
+
if (!ok) {
|
|
329009
|
+
throw new Error("TAP requires usable bounds (finite x/y)");
|
|
329010
|
+
}
|
|
329011
|
+
}
|
|
329012
|
+
function shouldResetMobileAppData(platform3, deviceId, env = process.env) {
|
|
329013
|
+
if (platform3 !== "ANDROID") return false;
|
|
329014
|
+
const policy = (env.VALIDATEQA_MOBILE_APP_RESET ?? "").trim().toLowerCase();
|
|
329015
|
+
if (policy === "never") return false;
|
|
329016
|
+
if (policy === "always") return true;
|
|
329017
|
+
return !!deviceId && deviceId.startsWith("emulator-");
|
|
329018
|
+
}
|
|
329019
|
+
async function getElementRect(sessionPath, elementId) {
|
|
329020
|
+
try {
|
|
329021
|
+
const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
|
|
329022
|
+
if (!value || typeof value !== "object") return null;
|
|
329023
|
+
const r = value;
|
|
329024
|
+
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)) {
|
|
329025
|
+
return null;
|
|
329026
|
+
}
|
|
329027
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
329028
|
+
} catch {
|
|
329029
|
+
return null;
|
|
329030
|
+
}
|
|
329031
|
+
}
|
|
329032
|
+
function rectMatchesIntendedBounds(rect, bounds) {
|
|
329033
|
+
if (bounds.width <= 0 || bounds.height <= 0) return false;
|
|
329034
|
+
const rectCenterX = rect.x + rect.width / 2;
|
|
329035
|
+
const rectCenterY = rect.y + rect.height / 2;
|
|
329036
|
+
const boundsCenterX = bounds.x + bounds.width / 2;
|
|
329037
|
+
const boundsCenterY = bounds.y + bounds.height / 2;
|
|
329038
|
+
const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
|
|
329039
|
+
const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
|
|
329040
|
+
return rectCenterInBounds || boundsCenterInRect;
|
|
329041
|
+
}
|
|
329042
|
+
async function resolveTypeTargetElementId(sessionPath, bounds) {
|
|
329043
|
+
const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
|
|
329044
|
+
let elementId = await getActiveElementId(sessionPath);
|
|
329045
|
+
if (elementId && box && box.width > 0 && box.height > 0) {
|
|
329046
|
+
const rect = await getElementRect(sessionPath, elementId);
|
|
329047
|
+
if (!rect || !rectMatchesIntendedBounds(rect, box)) {
|
|
329048
|
+
elementId = null;
|
|
329049
|
+
}
|
|
329050
|
+
}
|
|
329051
|
+
if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
|
|
329052
|
+
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
329053
|
+
elementId = await getActiveElementId(sessionPath);
|
|
329054
|
+
}
|
|
329055
|
+
return elementId;
|
|
329056
|
+
}
|
|
329057
|
+
async function typeText(sessionPath, value, bounds) {
|
|
329058
|
+
const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
|
|
329059
|
+
if (!elementId) {
|
|
329060
|
+
throw new Error("No focused element available for TYPE. Tap an input field first.");
|
|
329061
|
+
}
|
|
329062
|
+
await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
|
|
329063
|
+
method: "POST",
|
|
329064
|
+
body: JSON.stringify({ text: value })
|
|
329065
|
+
});
|
|
329066
|
+
}
|
|
329067
|
+
async function clearField(sessionPath, bounds) {
|
|
329068
|
+
const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
|
|
329069
|
+
if (!elementId) {
|
|
329070
|
+
throw new Error("No focused element available for CLEAR. Tap an input field first.");
|
|
329071
|
+
}
|
|
329072
|
+
await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
|
|
329073
|
+
method: "POST",
|
|
329074
|
+
body: JSON.stringify({})
|
|
329075
|
+
});
|
|
329076
|
+
}
|
|
329077
|
+
async function swipe(sessionPath, direction, distance, windowSize) {
|
|
329078
|
+
const dist = clampMobileSwipeDistance(distance);
|
|
329079
|
+
const { startX, startY, endX, endY } = computeMobileSwipePointers(
|
|
329080
|
+
windowSize.width,
|
|
329081
|
+
windowSize.height,
|
|
329082
|
+
direction,
|
|
329083
|
+
dist
|
|
329084
|
+
);
|
|
329085
|
+
await appiumRequest(`${sessionPath}/actions`, {
|
|
329086
|
+
method: "POST",
|
|
329087
|
+
body: JSON.stringify({
|
|
329088
|
+
actions: [{
|
|
329089
|
+
type: "pointer",
|
|
329090
|
+
id: "finger1",
|
|
329091
|
+
parameters: { pointerType: "touch" },
|
|
329092
|
+
actions: [
|
|
329093
|
+
{ type: "pointerMove", duration: 0, x: startX, y: startY },
|
|
329094
|
+
{ type: "pointerDown", button: 0 },
|
|
329095
|
+
{ type: "pointerMove", duration: 300, x: endX, y: endY },
|
|
329096
|
+
{ type: "pointerUp", button: 0 }
|
|
329097
|
+
]
|
|
329098
|
+
}]
|
|
329099
|
+
})
|
|
329100
|
+
});
|
|
329101
|
+
}
|
|
329102
|
+
async function pressBack(sessionPath) {
|
|
329103
|
+
await appiumRequest(`${sessionPath}/back`, { method: "POST", body: "{}" });
|
|
329104
|
+
}
|
|
329105
|
+
async function pressHome(sessionPath, platform3) {
|
|
329106
|
+
if (platform3 === "IOS") {
|
|
329107
|
+
await appiumRequest(`${sessionPath}/execute/sync`, {
|
|
329108
|
+
method: "POST",
|
|
329109
|
+
body: JSON.stringify({
|
|
329110
|
+
script: "mobile: pressButton",
|
|
329111
|
+
args: [{ name: "home" }]
|
|
329112
|
+
})
|
|
329113
|
+
});
|
|
329114
|
+
return;
|
|
329115
|
+
}
|
|
329116
|
+
await appiumRequest(`${sessionPath}/appium/device/press_keycode`, {
|
|
329117
|
+
method: "POST",
|
|
329118
|
+
body: JSON.stringify({ keycode: 3 })
|
|
329119
|
+
});
|
|
329120
|
+
}
|
|
329121
|
+
|
|
329122
|
+
// src/services/appium-executor.ts
|
|
329123
|
+
var import_node_child_process2 = require("child_process");
|
|
329124
|
+
var import_node_util2 = require("util");
|
|
329125
|
+
var import_runner_core8 = __toESM(require_dist2());
|
|
329126
|
+
|
|
328857
329127
|
// src/services/device-discovery.ts
|
|
328858
329128
|
var import_child_process2 = require("child_process");
|
|
328859
329129
|
var import_os2 = require("os");
|
|
@@ -330016,7 +330286,7 @@ async function wdRequest(path, options) {
|
|
|
330016
330286
|
}
|
|
330017
330287
|
return res.json();
|
|
330018
330288
|
}
|
|
330019
|
-
async function
|
|
330289
|
+
async function getWindowSize2(sessionId, retries = 3) {
|
|
330020
330290
|
for (let attempt = 0; attempt < retries; attempt++) {
|
|
330021
330291
|
try {
|
|
330022
330292
|
const result = await wdRequest(`/session/${sessionId}/window/rect`);
|
|
@@ -330192,6 +330462,26 @@ async function freshLaunchTargetApp(sessionId, platform3, appId, log2) {
|
|
|
330192
330462
|
log2(` Fresh launch: activate failed (${err instanceof Error ? err.message : String(err)})`);
|
|
330193
330463
|
}
|
|
330194
330464
|
}
|
|
330465
|
+
async function waitForColdBootContent(sessionId, log2, timeoutMs = 2e4) {
|
|
330466
|
+
const deadline = Date.now() + timeoutMs;
|
|
330467
|
+
const MIN_ELEMENTS = 3;
|
|
330468
|
+
let lastCount = 0;
|
|
330469
|
+
while (Date.now() < deadline) {
|
|
330470
|
+
try {
|
|
330471
|
+
const res = await wdRequest(`/session/${sessionId}/source`);
|
|
330472
|
+
const xml = typeof res?.value === "string" ? res.value : "";
|
|
330473
|
+
const count = (xml.match(/<(android\.|XCUIElementType)/g) ?? []).length;
|
|
330474
|
+
lastCount = count;
|
|
330475
|
+
if (count >= MIN_ELEMENTS) {
|
|
330476
|
+
log2(` Cold-boot ready: UI tree has ${count} node(s).`);
|
|
330477
|
+
return;
|
|
330478
|
+
}
|
|
330479
|
+
} catch {
|
|
330480
|
+
}
|
|
330481
|
+
await new Promise((r) => setTimeout(r, 750));
|
|
330482
|
+
}
|
|
330483
|
+
log2(` Cold-boot wait timed out after ${timeoutMs}ms (last tree had ${lastCount} node(s)) \u2014 proceeding anyway.`);
|
|
330484
|
+
}
|
|
330195
330485
|
async function restoreTargetAppIfExternal(sessionId, target, log2, source, failOnExternal) {
|
|
330196
330486
|
let boundary = null;
|
|
330197
330487
|
try {
|
|
@@ -330439,7 +330729,7 @@ async function tapCoordinates(sessionId, bounds) {
|
|
|
330439
330729
|
})
|
|
330440
330730
|
});
|
|
330441
330731
|
}
|
|
330442
|
-
async function
|
|
330732
|
+
async function getActiveElementId2(sessionId) {
|
|
330443
330733
|
try {
|
|
330444
330734
|
const activeEl = await wdRequest(`/session/${sessionId}/element/active`);
|
|
330445
330735
|
return activeEl?.value?.ELEMENT || activeEl?.value?.["element-6066-11e4-a52e-4f735466cecf"] || null;
|
|
@@ -330525,7 +330815,7 @@ async function executeType(sessionId, step, context) {
|
|
|
330525
330815
|
await tapCoordinates(sessionId, bounds);
|
|
330526
330816
|
tappedBounds = true;
|
|
330527
330817
|
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
330528
|
-
elementId = await
|
|
330818
|
+
elementId = await getActiveElementId2(sessionId);
|
|
330529
330819
|
if (elementId) {
|
|
330530
330820
|
lastInputError = await trySendElementValue(sessionId, elementId, value);
|
|
330531
330821
|
if (!lastInputError) return;
|
|
@@ -330547,10 +330837,10 @@ async function executeType(sessionId, step, context) {
|
|
|
330547
330837
|
await tapCoordinates(sessionId, bounds);
|
|
330548
330838
|
tappedBounds = true;
|
|
330549
330839
|
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
330550
|
-
elementId = await
|
|
330840
|
+
elementId = await getActiveElementId2(sessionId);
|
|
330551
330841
|
}
|
|
330552
330842
|
if (!elementId) {
|
|
330553
|
-
elementId = await
|
|
330843
|
+
elementId = await getActiveElementId2(sessionId);
|
|
330554
330844
|
}
|
|
330555
330845
|
if (elementId) {
|
|
330556
330846
|
lastInputError = await trySendElementValue(sessionId, elementId, value);
|
|
@@ -330600,10 +330890,10 @@ async function executeClear(sessionId, step, platform3, isCrossPlatform = false)
|
|
|
330600
330890
|
if (!elementId && bounds) {
|
|
330601
330891
|
await tapCoordinates(sessionId, bounds);
|
|
330602
330892
|
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
330603
|
-
elementId = await
|
|
330893
|
+
elementId = await getActiveElementId2(sessionId);
|
|
330604
330894
|
}
|
|
330605
330895
|
if (!elementId) {
|
|
330606
|
-
elementId = await
|
|
330896
|
+
elementId = await getActiveElementId2(sessionId);
|
|
330607
330897
|
}
|
|
330608
330898
|
if (!elementId) {
|
|
330609
330899
|
throw new Error("CLEAR step could not resolve a target or focused element");
|
|
@@ -330619,7 +330909,7 @@ async function executeSwipe(sessionId, step) {
|
|
|
330619
330909
|
let screenW;
|
|
330620
330910
|
let screenH;
|
|
330621
330911
|
try {
|
|
330622
|
-
const windowSize = await
|
|
330912
|
+
const windowSize = await getWindowSize2(sessionId);
|
|
330623
330913
|
screenW = windowSize.width;
|
|
330624
330914
|
screenH = windowSize.height;
|
|
330625
330915
|
} catch (err) {
|
|
@@ -330834,8 +331124,25 @@ async function executeMobileTest(options) {
|
|
|
330834
331124
|
}
|
|
330835
331125
|
}
|
|
330836
331126
|
recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
|
|
331127
|
+
const launchStepForReset = runSteps.find((s) => s.action === "launchApp");
|
|
331128
|
+
const recordedFromClearedState = launchStepForReset?.metadata?.appReset === "cleared";
|
|
331129
|
+
if (recordedFromClearedState) {
|
|
331130
|
+
if (shouldResetMobileAppData(options.target.platform, selectedDevice.id)) {
|
|
331131
|
+
try {
|
|
331132
|
+
await executeMobileScript(sessionId, "mobile: clearApp", [{ appId: options.target.appId }]);
|
|
331133
|
+
log2("App data cleared before launch (test was recorded from a cold logged-out state).");
|
|
331134
|
+
} catch (err) {
|
|
331135
|
+
log2(`App-data clear failed (continuing with plain fresh launch): ${err instanceof Error ? err.message : String(err)}`);
|
|
331136
|
+
}
|
|
331137
|
+
} else {
|
|
331138
|
+
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.");
|
|
331139
|
+
}
|
|
331140
|
+
}
|
|
330837
331141
|
log2("Opening a fresh app instance for the test...");
|
|
330838
331142
|
await freshLaunchTargetApp(sessionId, options.target.platform, options.target.appId, log2);
|
|
331143
|
+
if (recordedFromClearedState) {
|
|
331144
|
+
await waitForColdBootContent(sessionId, log2);
|
|
331145
|
+
}
|
|
330839
331146
|
if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
|
|
330840
331147
|
try {
|
|
330841
331148
|
log2(`Opening deep link: ${options.target.deeplink}`);
|
|
@@ -331350,225 +331657,6 @@ var import_os3 = require("os");
|
|
|
331350
331657
|
var import_path2 = require("path");
|
|
331351
331658
|
var import_fs2 = require("fs");
|
|
331352
331659
|
init_dist();
|
|
331353
|
-
|
|
331354
|
-
// src/services/mobile/appium-actions.ts
|
|
331355
|
-
init_dist();
|
|
331356
|
-
var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
|
|
331357
|
-
var ELEMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
331358
|
-
async function appiumRequest(path, options) {
|
|
331359
|
-
const url = `http://localhost:${getAppiumState().port}${path}`;
|
|
331360
|
-
const res = await fetch(url, {
|
|
331361
|
-
...options,
|
|
331362
|
-
signal: options?.signal ?? AbortSignal.timeout(APPIUM_REQUEST_TIMEOUT_MS),
|
|
331363
|
-
headers: { "Content-Type": "application/json", ...options?.headers }
|
|
331364
|
-
});
|
|
331365
|
-
if (!res.ok) {
|
|
331366
|
-
const body = await res.text().catch(() => "");
|
|
331367
|
-
throw new Error(`Appium error ${res.status}: ${body.slice(0, 200)}`);
|
|
331368
|
-
}
|
|
331369
|
-
return res.json();
|
|
331370
|
-
}
|
|
331371
|
-
function readValue(result) {
|
|
331372
|
-
if (result && typeof result === "object" && "value" in result) {
|
|
331373
|
-
return result.value;
|
|
331374
|
-
}
|
|
331375
|
-
return void 0;
|
|
331376
|
-
}
|
|
331377
|
-
async function getScreenshot(sessionPath) {
|
|
331378
|
-
try {
|
|
331379
|
-
const value = readValue(await appiumRequest(`${sessionPath}/screenshot`));
|
|
331380
|
-
return typeof value === "string" ? value : null;
|
|
331381
|
-
} catch {
|
|
331382
|
-
return null;
|
|
331383
|
-
}
|
|
331384
|
-
}
|
|
331385
|
-
async function getPageSource(sessionPath) {
|
|
331386
|
-
try {
|
|
331387
|
-
const value = readValue(await appiumRequest(`${sessionPath}/source`));
|
|
331388
|
-
return typeof value === "string" ? value : null;
|
|
331389
|
-
} catch {
|
|
331390
|
-
return null;
|
|
331391
|
-
}
|
|
331392
|
-
}
|
|
331393
|
-
async function getWindowSize2(sessionPath) {
|
|
331394
|
-
const retries = 3;
|
|
331395
|
-
for (let attempt = 0; attempt < retries; attempt++) {
|
|
331396
|
-
try {
|
|
331397
|
-
const value = readValue(await appiumRequest(`${sessionPath}/window/rect`));
|
|
331398
|
-
if (value && typeof value === "object") {
|
|
331399
|
-
const w = value.width;
|
|
331400
|
-
const h = value.height;
|
|
331401
|
-
if (typeof w === "number" && typeof h === "number" && w > 0 && h > 0) {
|
|
331402
|
-
return { width: w, height: h };
|
|
331403
|
-
}
|
|
331404
|
-
}
|
|
331405
|
-
} catch {
|
|
331406
|
-
}
|
|
331407
|
-
if (attempt < retries - 1) {
|
|
331408
|
-
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100));
|
|
331409
|
-
}
|
|
331410
|
-
}
|
|
331411
|
-
return null;
|
|
331412
|
-
}
|
|
331413
|
-
async function getActiveElementId2(sessionPath) {
|
|
331414
|
-
try {
|
|
331415
|
-
const value = readValue(await appiumRequest(`${sessionPath}/element/active`));
|
|
331416
|
-
if (!value || typeof value !== "object") return null;
|
|
331417
|
-
const v = value;
|
|
331418
|
-
const elementId = v.ELEMENT ?? v["element-6066-11e4-a52e-4f735466cecf"] ?? null;
|
|
331419
|
-
if (typeof elementId !== "string" || !ELEMENT_ID_PATTERN.test(elementId)) {
|
|
331420
|
-
return null;
|
|
331421
|
-
}
|
|
331422
|
-
return elementId;
|
|
331423
|
-
} catch {
|
|
331424
|
-
return null;
|
|
331425
|
-
}
|
|
331426
|
-
}
|
|
331427
|
-
function normaliseBounds(bounds) {
|
|
331428
|
-
if (!bounds || typeof bounds !== "object" || typeof bounds.x !== "number" || typeof bounds.y !== "number" || !Number.isFinite(bounds.x) || !Number.isFinite(bounds.y)) {
|
|
331429
|
-
return null;
|
|
331430
|
-
}
|
|
331431
|
-
const b = bounds;
|
|
331432
|
-
const width = typeof b.width === "number" && Number.isFinite(b.width) ? b.width : 0;
|
|
331433
|
-
const height = typeof b.height === "number" && Number.isFinite(b.height) ? b.height : 0;
|
|
331434
|
-
return { x: b.x, y: b.y, width, height };
|
|
331435
|
-
}
|
|
331436
|
-
async function tapBoundsCenter(sessionPath, bounds) {
|
|
331437
|
-
const box = normaliseBounds(bounds);
|
|
331438
|
-
if (!box) return false;
|
|
331439
|
-
const tapX = Math.round(box.x + box.width / 2);
|
|
331440
|
-
const tapY = Math.round(box.y + box.height / 2);
|
|
331441
|
-
await appiumRequest(`${sessionPath}/actions`, {
|
|
331442
|
-
method: "POST",
|
|
331443
|
-
body: JSON.stringify({
|
|
331444
|
-
actions: [{
|
|
331445
|
-
type: "pointer",
|
|
331446
|
-
id: "finger1",
|
|
331447
|
-
parameters: { pointerType: "touch" },
|
|
331448
|
-
actions: [
|
|
331449
|
-
// Note: button: 0 is used because Appium's WebDriver protocol
|
|
331450
|
-
// translates mouse button events to touch events. Standard approach
|
|
331451
|
-
// for touch actions in Appium, despite the mouse button semantics.
|
|
331452
|
-
{ type: "pointerMove", duration: 0, x: tapX, y: tapY },
|
|
331453
|
-
{ type: "pointerDown", button: 0 },
|
|
331454
|
-
{ type: "pause", duration: 100 },
|
|
331455
|
-
{ type: "pointerUp", button: 0 }
|
|
331456
|
-
]
|
|
331457
|
-
}]
|
|
331458
|
-
})
|
|
331459
|
-
});
|
|
331460
|
-
return true;
|
|
331461
|
-
}
|
|
331462
|
-
async function tapAt(sessionPath, bounds) {
|
|
331463
|
-
const ok = await tapBoundsCenter(sessionPath, bounds);
|
|
331464
|
-
if (!ok) {
|
|
331465
|
-
throw new Error("TAP requires usable bounds (finite x/y)");
|
|
331466
|
-
}
|
|
331467
|
-
}
|
|
331468
|
-
async function getElementRect(sessionPath, elementId) {
|
|
331469
|
-
try {
|
|
331470
|
-
const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
|
|
331471
|
-
if (!value || typeof value !== "object") return null;
|
|
331472
|
-
const r = value;
|
|
331473
|
-
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)) {
|
|
331474
|
-
return null;
|
|
331475
|
-
}
|
|
331476
|
-
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
331477
|
-
} catch {
|
|
331478
|
-
return null;
|
|
331479
|
-
}
|
|
331480
|
-
}
|
|
331481
|
-
function rectMatchesIntendedBounds(rect, bounds) {
|
|
331482
|
-
if (bounds.width <= 0 || bounds.height <= 0) return false;
|
|
331483
|
-
const rectCenterX = rect.x + rect.width / 2;
|
|
331484
|
-
const rectCenterY = rect.y + rect.height / 2;
|
|
331485
|
-
const boundsCenterX = bounds.x + bounds.width / 2;
|
|
331486
|
-
const boundsCenterY = bounds.y + bounds.height / 2;
|
|
331487
|
-
const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
|
|
331488
|
-
const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
|
|
331489
|
-
return rectCenterInBounds || boundsCenterInRect;
|
|
331490
|
-
}
|
|
331491
|
-
async function resolveTypeTargetElementId(sessionPath, bounds) {
|
|
331492
|
-
const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
|
|
331493
|
-
let elementId = await getActiveElementId2(sessionPath);
|
|
331494
|
-
if (elementId && box && box.width > 0 && box.height > 0) {
|
|
331495
|
-
const rect = await getElementRect(sessionPath, elementId);
|
|
331496
|
-
if (!rect || !rectMatchesIntendedBounds(rect, box)) {
|
|
331497
|
-
elementId = null;
|
|
331498
|
-
}
|
|
331499
|
-
}
|
|
331500
|
-
if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
|
|
331501
|
-
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
331502
|
-
elementId = await getActiveElementId2(sessionPath);
|
|
331503
|
-
}
|
|
331504
|
-
return elementId;
|
|
331505
|
-
}
|
|
331506
|
-
async function typeText(sessionPath, value, bounds) {
|
|
331507
|
-
const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
|
|
331508
|
-
if (!elementId) {
|
|
331509
|
-
throw new Error("No focused element available for TYPE. Tap an input field first.");
|
|
331510
|
-
}
|
|
331511
|
-
await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
|
|
331512
|
-
method: "POST",
|
|
331513
|
-
body: JSON.stringify({ text: value })
|
|
331514
|
-
});
|
|
331515
|
-
}
|
|
331516
|
-
async function clearField(sessionPath, bounds) {
|
|
331517
|
-
const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
|
|
331518
|
-
if (!elementId) {
|
|
331519
|
-
throw new Error("No focused element available for CLEAR. Tap an input field first.");
|
|
331520
|
-
}
|
|
331521
|
-
await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
|
|
331522
|
-
method: "POST",
|
|
331523
|
-
body: JSON.stringify({})
|
|
331524
|
-
});
|
|
331525
|
-
}
|
|
331526
|
-
async function swipe(sessionPath, direction, distance, windowSize) {
|
|
331527
|
-
const dist = clampMobileSwipeDistance(distance);
|
|
331528
|
-
const { startX, startY, endX, endY } = computeMobileSwipePointers(
|
|
331529
|
-
windowSize.width,
|
|
331530
|
-
windowSize.height,
|
|
331531
|
-
direction,
|
|
331532
|
-
dist
|
|
331533
|
-
);
|
|
331534
|
-
await appiumRequest(`${sessionPath}/actions`, {
|
|
331535
|
-
method: "POST",
|
|
331536
|
-
body: JSON.stringify({
|
|
331537
|
-
actions: [{
|
|
331538
|
-
type: "pointer",
|
|
331539
|
-
id: "finger1",
|
|
331540
|
-
parameters: { pointerType: "touch" },
|
|
331541
|
-
actions: [
|
|
331542
|
-
{ type: "pointerMove", duration: 0, x: startX, y: startY },
|
|
331543
|
-
{ type: "pointerDown", button: 0 },
|
|
331544
|
-
{ type: "pointerMove", duration: 300, x: endX, y: endY },
|
|
331545
|
-
{ type: "pointerUp", button: 0 }
|
|
331546
|
-
]
|
|
331547
|
-
}]
|
|
331548
|
-
})
|
|
331549
|
-
});
|
|
331550
|
-
}
|
|
331551
|
-
async function pressBack(sessionPath) {
|
|
331552
|
-
await appiumRequest(`${sessionPath}/back`, { method: "POST", body: "{}" });
|
|
331553
|
-
}
|
|
331554
|
-
async function pressHome(sessionPath, platform3) {
|
|
331555
|
-
if (platform3 === "IOS") {
|
|
331556
|
-
await appiumRequest(`${sessionPath}/execute/sync`, {
|
|
331557
|
-
method: "POST",
|
|
331558
|
-
body: JSON.stringify({
|
|
331559
|
-
script: "mobile: pressButton",
|
|
331560
|
-
args: [{ name: "home" }]
|
|
331561
|
-
})
|
|
331562
|
-
});
|
|
331563
|
-
return;
|
|
331564
|
-
}
|
|
331565
|
-
await appiumRequest(`${sessionPath}/appium/device/press_keycode`, {
|
|
331566
|
-
method: "POST",
|
|
331567
|
-
body: JSON.stringify({ keycode: 3 })
|
|
331568
|
-
});
|
|
331569
|
-
}
|
|
331570
|
-
|
|
331571
|
-
// src/services/mobile-bridge.ts
|
|
331572
331660
|
var MOBILE_BRIDGE_PORT2 = 9515;
|
|
331573
331661
|
var MAX_REQUEST_BODY_BYTES = 64 * 1024;
|
|
331574
331662
|
var MAX_TEXT_INPUT_LENGTH = 4e3;
|
|
@@ -331662,7 +331750,7 @@ async function getPageSource2() {
|
|
|
331662
331750
|
async function getWindowSize3() {
|
|
331663
331751
|
if (!state.appiumSessionId) return null;
|
|
331664
331752
|
validateSessionId(state.appiumSessionId);
|
|
331665
|
-
return
|
|
331753
|
+
return getWindowSize(`/session/${state.appiumSessionId}`);
|
|
331666
331754
|
}
|
|
331667
331755
|
var BRIDGE_PROCESS_MARKER = "validateqa-mobile-bridge";
|
|
331668
331756
|
var BRIDGE_PORT = MOBILE_BRIDGE_PORT2;
|
|
@@ -332726,6 +332814,7 @@ var MobileBridgeDriver = class {
|
|
|
332726
332814
|
sessionId;
|
|
332727
332815
|
platform;
|
|
332728
332816
|
appId;
|
|
332817
|
+
allowAppReset;
|
|
332729
332818
|
constructor(config) {
|
|
332730
332819
|
if (!APPIUM_SESSION_ID_PATTERN2.test(config.sessionId)) {
|
|
332731
332820
|
throw new Error("MobileBridgeDriver: invalid Appium session id");
|
|
@@ -332739,6 +332828,7 @@ var MobileBridgeDriver = class {
|
|
|
332739
332828
|
this.sessionId = config.sessionId;
|
|
332740
332829
|
this.platform = config.platform;
|
|
332741
332830
|
this.appId = config.appId;
|
|
332831
|
+
this.allowAppReset = config.allowAppReset === true;
|
|
332742
332832
|
}
|
|
332743
332833
|
/** WebDriver session path (`/session/<id>`), the prefix every primitive needs. */
|
|
332744
332834
|
get sessionPath() {
|
|
@@ -332800,7 +332890,7 @@ var MobileBridgeDriver = class {
|
|
|
332800
332890
|
const [xml, screenshot, windowSize] = await Promise.all([
|
|
332801
332891
|
getPageSource(this.sessionPath),
|
|
332802
332892
|
getScreenshot(this.sessionPath),
|
|
332803
|
-
|
|
332893
|
+
getWindowSize(this.sessionPath)
|
|
332804
332894
|
]);
|
|
332805
332895
|
const source = xml ?? "";
|
|
332806
332896
|
const screenSignal = await this.enrichScreenSignal(source);
|
|
@@ -332813,7 +332903,7 @@ var MobileBridgeDriver = class {
|
|
|
332813
332903
|
}
|
|
332814
332904
|
/** Device window size; null when unreadable (swipe geometry degrades gracefully). */
|
|
332815
332905
|
async windowSize() {
|
|
332816
|
-
return
|
|
332906
|
+
return getWindowSize(this.sessionPath);
|
|
332817
332907
|
}
|
|
332818
332908
|
// ── Action result wrapper ──────────────────────────────
|
|
332819
332909
|
/**
|
|
@@ -332855,7 +332945,7 @@ var MobileBridgeDriver = class {
|
|
|
332855
332945
|
* hard error so we never fire an off-screen gesture.
|
|
332856
332946
|
*/
|
|
332857
332947
|
async swipe(direction, distance) {
|
|
332858
|
-
const size = await
|
|
332948
|
+
const size = await getWindowSize(this.sessionPath);
|
|
332859
332949
|
if (!size) {
|
|
332860
332950
|
return { ok: false, error: "Could not determine device screen dimensions for swipe." };
|
|
332861
332951
|
}
|
|
@@ -332880,6 +332970,22 @@ var MobileBridgeDriver = class {
|
|
|
332880
332970
|
await this.executeScript("mobile: terminateApp", [args]);
|
|
332881
332971
|
await this.executeScript("mobile: activateApp", [args]);
|
|
332882
332972
|
}
|
|
332973
|
+
/**
|
|
332974
|
+
* Deterministic state reset: clear app data + relaunch (Android emulators
|
|
332975
|
+
* only, per the runner-computed allowAppReset policy). Returns false when
|
|
332976
|
+
* unsupported/disallowed or when clearApp throws — callers fall back to
|
|
332977
|
+
* relaunch(), preserving today's behavior.
|
|
332978
|
+
*/
|
|
332979
|
+
async resetAppState() {
|
|
332980
|
+
if (!this.allowAppReset || this.platform !== "ANDROID") return false;
|
|
332981
|
+
try {
|
|
332982
|
+
await this.executeScript("mobile: clearApp", [{ appId: this.appId }]);
|
|
332983
|
+
await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
|
|
332984
|
+
return true;
|
|
332985
|
+
} catch {
|
|
332986
|
+
return false;
|
|
332987
|
+
}
|
|
332988
|
+
}
|
|
332883
332989
|
/** (Re)launch the target app (activateApp) from a backgrounded/closed state. */
|
|
332884
332990
|
async launchApp() {
|
|
332885
332991
|
await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
|
|
@@ -335021,7 +335127,11 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
335021
335127
|
const driver = new MobileBridgeDriver({
|
|
335022
335128
|
sessionId: session.sessionId,
|
|
335023
335129
|
platform: platform3,
|
|
335024
|
-
appId: session.appId
|
|
335130
|
+
appId: session.appId,
|
|
335131
|
+
// Deterministic-state authorization: Android emulators only by
|
|
335132
|
+
// default (VALIDATEQA_MOBILE_APP_RESET overrides) — a personal
|
|
335133
|
+
// physical device must never have its app data wiped.
|
|
335134
|
+
allowAppReset: shouldResetMobileAppData(platform3, session.deviceId)
|
|
335025
335135
|
});
|
|
335026
335136
|
discoveryResult = await (0, import_runner_core4.runMobileDiscoveryOnRunner)(ctx, driver, {
|
|
335027
335137
|
onLog: mobileOnLog,
|