@validate.qa/runner 1.0.19 → 1.0.21

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.
Files changed (3) hide show
  1. package/dist/cli.js +335 -244
  2. package/dist/cli.mjs +335 -244
  3. 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);
@@ -66065,6 +66068,8 @@ ${constraints.join("\n")}`);
66065
66068
  function buildMobileFallbackScenarios(input) {
66066
66069
  if (input.mode === "survey")
66067
66070
  return [];
66071
+ if (input.featureName?.trim())
66072
+ return [];
66068
66073
  const orderedScreens = targetAppScreens(input.graph, input.appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").filter((state2) => state2.visited || state2.firstSeenIteration === 0).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66069
66074
  if (orderedScreens.length === 0)
66070
66075
  return [];
@@ -66212,6 +66217,15 @@ ${constraints.join("\n")}`);
66212
66217
  fallbackReason: result.fallbackReason ?? "Mobile planner produced no scenarios"
66213
66218
  };
66214
66219
  }
66220
+ if (input.featureName?.trim()) {
66221
+ return {
66222
+ ...result,
66223
+ featureGroups: mobileFeatureGroups,
66224
+ warning: [result.warning, `Deep discovery could not plan any scenarios for "${input.featureName}" \u2014 the explorer did not reach that feature's screens this run. If the feature requires a signed-in session, verify TEST_USER_EMAIL / TEST_USER_PASSWORD are valid for this app, then re-run.`].filter(Boolean).join(" | "),
66225
+ fallbackUsed: true,
66226
+ fallbackReason: result.fallbackReason ?? `Planner produced no scenarios for feature "${input.featureName}"`
66227
+ };
66228
+ }
66215
66229
  }
66216
66230
  if (input.mode === "survey" && result.featureGroups.length === 0 && mobileFeatureGroups.length > 0) {
66217
66231
  return {
@@ -66492,7 +66506,8 @@ Max iterations: ${MAX_SCENARIO_ITERATIONS}. You will be warned near the end.`;
66492
66506
  This app has stored login credentials under these keys:
66493
66507
  ${keyList}
66494
66508
 
66495
- When a scenario needs a signed-in session or fills a login/sign-up form, fill each credential field with \`mobile_fill_credential\`: pass the field ref and the KEY (e.g. \`{ ref: "e5", credentialKey: "TEST_USER_EMAIL" }\`). The runner types the real value on the device and the generated test stores a \`{{KEY}}\` placeholder that replay resolves \u2014 you never see, log, or echo the secret.
66509
+ When a scenario needs a signed-in session, complete the FULL login sequence \u2014 filling the fields alone does NOT log you in:
66510
+ 1. Reach the email/password form. 2. Fill each credential field with \`mobile_fill_credential\` (pass the field ref and the KEY, e.g. \`{ ref: "e5", credentialKey: "TEST_USER_EMAIL" }\`) \u2014 the runner types the real value on the device and the generated test stores a \`{{KEY}}\` placeholder that replay resolves; you never see the secret. 3. TAP THE SUBMIT button (Sign In / Log In / Continue). 4. Re-snapshot and VERIFY an authenticated screen appeared. If login fails twice, do NOT keep retrying or wander: call finish_generate with verified:false and say the login could not be completed (the credentials may be invalid for this app).
66496
66511
 
66497
66512
  SECURITY RULES:
66498
66513
  - NEVER call \`mobile_type\` with a real 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.
@@ -67160,7 +67175,7 @@ ${statePacket}` },
67160
67175
  return out;
67161
67176
  }
67162
67177
  function assembleTest(args) {
67163
- const { scenario, recording, ctx, platform: platform3, log: log2 } = args;
67178
+ const { scenario, recording, ctx, platform: platform3, appDataCleared, log: log2 } = args;
67164
67179
  const actions = collapseRetryCycles(recording.actions);
67165
67180
  if (actions.length < recording.actions.length) {
67166
67181
  log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
@@ -67177,7 +67192,8 @@ ${statePacket}` },
67177
67192
  sessionName: recording.finish?.name ?? scenario.name,
67178
67193
  medium,
67179
67194
  target,
67180
- actions
67195
+ actions,
67196
+ appResetOnLaunch: appDataCleared
67181
67197
  };
67182
67198
  let compiled;
67183
67199
  try {
@@ -67283,7 +67299,12 @@ ${statePacket}` },
67283
67299
  log2(`
67284
67300
  -- Generating scenario ${i + 1}/${scenarios.length}: "${scenario.name}" --`);
67285
67301
  try {
67286
- await driver.relaunch();
67302
+ const appDataCleared = await driver.resetAppState?.().catch(() => false) ?? false;
67303
+ if (appDataCleared) {
67304
+ log2(" app data cleared \u2014 scenario records from a deterministic cold (logged-out) state.");
67305
+ } else {
67306
+ await driver.relaunch();
67307
+ }
67287
67308
  const recording = await runScenarioLoop({
67288
67309
  scenario,
67289
67310
  driver,
@@ -67300,7 +67321,7 @@ ${statePacket}` },
67300
67321
  log: log2
67301
67322
  });
67302
67323
  consecutiveInfraFailures = 0;
67303
- const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
67324
+ const test = assembleTest({ scenario, recording, ctx, platform: platform3, appDataCleared, log: log2 });
67304
67325
  if (!test) {
67305
67326
  log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
67306
67327
  continue;
@@ -313771,6 +313792,8 @@ var require_mobile_discovery_agent = __commonJS({
313771
313792
  };
313772
313793
  }
313773
313794
  log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
313795
+ const exploreAppDataCleared = await driver.resetAppState?.().catch(() => false) ?? false;
313796
+ 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.");
313774
313797
  const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
313775
313798
  const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
313776
313799
  const existingScreens = compactExistingScreens(ctx.existingSiteMap);
@@ -328460,7 +328483,7 @@ var require_package4 = __commonJS({
328460
328483
  "package.json"(exports2, module2) {
328461
328484
  module2.exports = {
328462
328485
  name: "@validate.qa/runner",
328463
- version: "1.0.19",
328486
+ version: "1.0.21",
328464
328487
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328465
328488
  bin: {
328466
328489
  "validate-runner": "dist/cli.js"
@@ -328598,9 +328621,9 @@ function shouldUseAuthState(tags, playwrightCode) {
328598
328621
 
328599
328622
  // src/services/appium-executor.ts
328600
328623
  init_dist();
328601
- var import_node_child_process2 = require("child_process");
328602
- var import_node_util2 = require("util");
328603
- var import_runner_core8 = __toESM(require_dist2());
328624
+
328625
+ // src/services/mobile/appium-actions.ts
328626
+ init_dist();
328604
328627
 
328605
328628
  // src/services/appium-lifecycle.ts
328606
328629
  var import_child_process = require("child_process");
@@ -328885,6 +328908,234 @@ function resetServerState(port = APPIUM_PORT, restartCount = 0) {
328885
328908
  };
328886
328909
  }
328887
328910
 
328911
+ // src/services/mobile/appium-actions.ts
328912
+ var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
328913
+ var ELEMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
328914
+ async function appiumRequest(path, options) {
328915
+ const url = `http://localhost:${getAppiumState().port}${path}`;
328916
+ const res = await fetch(url, {
328917
+ ...options,
328918
+ signal: options?.signal ?? AbortSignal.timeout(APPIUM_REQUEST_TIMEOUT_MS),
328919
+ headers: { "Content-Type": "application/json", ...options?.headers }
328920
+ });
328921
+ if (!res.ok) {
328922
+ const body = await res.text().catch(() => "");
328923
+ throw new Error(`Appium error ${res.status}: ${body.slice(0, 200)}`);
328924
+ }
328925
+ return res.json();
328926
+ }
328927
+ function readValue(result) {
328928
+ if (result && typeof result === "object" && "value" in result) {
328929
+ return result.value;
328930
+ }
328931
+ return void 0;
328932
+ }
328933
+ async function getScreenshot(sessionPath) {
328934
+ try {
328935
+ const value = readValue(await appiumRequest(`${sessionPath}/screenshot`));
328936
+ return typeof value === "string" ? value : null;
328937
+ } catch {
328938
+ return null;
328939
+ }
328940
+ }
328941
+ async function getPageSource(sessionPath) {
328942
+ try {
328943
+ const value = readValue(await appiumRequest(`${sessionPath}/source`));
328944
+ return typeof value === "string" ? value : null;
328945
+ } catch {
328946
+ return null;
328947
+ }
328948
+ }
328949
+ async function getWindowSize(sessionPath) {
328950
+ const retries = 3;
328951
+ for (let attempt = 0; attempt < retries; attempt++) {
328952
+ try {
328953
+ const value = readValue(await appiumRequest(`${sessionPath}/window/rect`));
328954
+ if (value && typeof value === "object") {
328955
+ const w = value.width;
328956
+ const h = value.height;
328957
+ if (typeof w === "number" && typeof h === "number" && w > 0 && h > 0) {
328958
+ return { width: w, height: h };
328959
+ }
328960
+ }
328961
+ } catch {
328962
+ }
328963
+ if (attempt < retries - 1) {
328964
+ await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100));
328965
+ }
328966
+ }
328967
+ return null;
328968
+ }
328969
+ async function getActiveElementId(sessionPath) {
328970
+ try {
328971
+ const value = readValue(await appiumRequest(`${sessionPath}/element/active`));
328972
+ if (!value || typeof value !== "object") return null;
328973
+ const v = value;
328974
+ const elementId = v.ELEMENT ?? v["element-6066-11e4-a52e-4f735466cecf"] ?? null;
328975
+ if (typeof elementId !== "string" || !ELEMENT_ID_PATTERN.test(elementId)) {
328976
+ return null;
328977
+ }
328978
+ return elementId;
328979
+ } catch {
328980
+ return null;
328981
+ }
328982
+ }
328983
+ function normaliseBounds(bounds) {
328984
+ if (!bounds || typeof bounds !== "object" || typeof bounds.x !== "number" || typeof bounds.y !== "number" || !Number.isFinite(bounds.x) || !Number.isFinite(bounds.y)) {
328985
+ return null;
328986
+ }
328987
+ const b = bounds;
328988
+ const width = typeof b.width === "number" && Number.isFinite(b.width) ? b.width : 0;
328989
+ const height = typeof b.height === "number" && Number.isFinite(b.height) ? b.height : 0;
328990
+ return { x: b.x, y: b.y, width, height };
328991
+ }
328992
+ async function tapBoundsCenter(sessionPath, bounds) {
328993
+ const box = normaliseBounds(bounds);
328994
+ if (!box) return false;
328995
+ const tapX = Math.round(box.x + box.width / 2);
328996
+ const tapY = Math.round(box.y + box.height / 2);
328997
+ await appiumRequest(`${sessionPath}/actions`, {
328998
+ method: "POST",
328999
+ body: JSON.stringify({
329000
+ actions: [{
329001
+ type: "pointer",
329002
+ id: "finger1",
329003
+ parameters: { pointerType: "touch" },
329004
+ actions: [
329005
+ // Note: button: 0 is used because Appium's WebDriver protocol
329006
+ // translates mouse button events to touch events. Standard approach
329007
+ // for touch actions in Appium, despite the mouse button semantics.
329008
+ { type: "pointerMove", duration: 0, x: tapX, y: tapY },
329009
+ { type: "pointerDown", button: 0 },
329010
+ { type: "pause", duration: 100 },
329011
+ { type: "pointerUp", button: 0 }
329012
+ ]
329013
+ }]
329014
+ })
329015
+ });
329016
+ return true;
329017
+ }
329018
+ async function tapAt(sessionPath, bounds) {
329019
+ const ok = await tapBoundsCenter(sessionPath, bounds);
329020
+ if (!ok) {
329021
+ throw new Error("TAP requires usable bounds (finite x/y)");
329022
+ }
329023
+ }
329024
+ function shouldResetMobileAppData(platform3, deviceId, env = process.env) {
329025
+ if (platform3 !== "ANDROID") return false;
329026
+ const policy = (env.VALIDATEQA_MOBILE_APP_RESET ?? "").trim().toLowerCase();
329027
+ if (policy === "never") return false;
329028
+ if (policy === "always") return true;
329029
+ return !!deviceId && deviceId.startsWith("emulator-");
329030
+ }
329031
+ async function getElementRect(sessionPath, elementId) {
329032
+ try {
329033
+ const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
329034
+ if (!value || typeof value !== "object") return null;
329035
+ const r = value;
329036
+ 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)) {
329037
+ return null;
329038
+ }
329039
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
329040
+ } catch {
329041
+ return null;
329042
+ }
329043
+ }
329044
+ function rectMatchesIntendedBounds(rect, bounds) {
329045
+ if (bounds.width <= 0 || bounds.height <= 0) return false;
329046
+ const rectCenterX = rect.x + rect.width / 2;
329047
+ const rectCenterY = rect.y + rect.height / 2;
329048
+ const boundsCenterX = bounds.x + bounds.width / 2;
329049
+ const boundsCenterY = bounds.y + bounds.height / 2;
329050
+ const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
329051
+ const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
329052
+ return rectCenterInBounds || boundsCenterInRect;
329053
+ }
329054
+ async function resolveTypeTargetElementId(sessionPath, bounds) {
329055
+ const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
329056
+ let elementId = await getActiveElementId(sessionPath);
329057
+ if (elementId && box && box.width > 0 && box.height > 0) {
329058
+ const rect = await getElementRect(sessionPath, elementId);
329059
+ if (!rect || !rectMatchesIntendedBounds(rect, box)) {
329060
+ elementId = null;
329061
+ }
329062
+ }
329063
+ if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
329064
+ await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
329065
+ elementId = await getActiveElementId(sessionPath);
329066
+ }
329067
+ return elementId;
329068
+ }
329069
+ async function typeText(sessionPath, value, bounds) {
329070
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
329071
+ if (!elementId) {
329072
+ throw new Error("No focused element available for TYPE. Tap an input field first.");
329073
+ }
329074
+ await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
329075
+ method: "POST",
329076
+ body: JSON.stringify({ text: value })
329077
+ });
329078
+ }
329079
+ async function clearField(sessionPath, bounds) {
329080
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
329081
+ if (!elementId) {
329082
+ throw new Error("No focused element available for CLEAR. Tap an input field first.");
329083
+ }
329084
+ await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
329085
+ method: "POST",
329086
+ body: JSON.stringify({})
329087
+ });
329088
+ }
329089
+ async function swipe(sessionPath, direction, distance, windowSize) {
329090
+ const dist = clampMobileSwipeDistance(distance);
329091
+ const { startX, startY, endX, endY } = computeMobileSwipePointers(
329092
+ windowSize.width,
329093
+ windowSize.height,
329094
+ direction,
329095
+ dist
329096
+ );
329097
+ await appiumRequest(`${sessionPath}/actions`, {
329098
+ method: "POST",
329099
+ body: JSON.stringify({
329100
+ actions: [{
329101
+ type: "pointer",
329102
+ id: "finger1",
329103
+ parameters: { pointerType: "touch" },
329104
+ actions: [
329105
+ { type: "pointerMove", duration: 0, x: startX, y: startY },
329106
+ { type: "pointerDown", button: 0 },
329107
+ { type: "pointerMove", duration: 300, x: endX, y: endY },
329108
+ { type: "pointerUp", button: 0 }
329109
+ ]
329110
+ }]
329111
+ })
329112
+ });
329113
+ }
329114
+ async function pressBack(sessionPath) {
329115
+ await appiumRequest(`${sessionPath}/back`, { method: "POST", body: "{}" });
329116
+ }
329117
+ async function pressHome(sessionPath, platform3) {
329118
+ if (platform3 === "IOS") {
329119
+ await appiumRequest(`${sessionPath}/execute/sync`, {
329120
+ method: "POST",
329121
+ body: JSON.stringify({
329122
+ script: "mobile: pressButton",
329123
+ args: [{ name: "home" }]
329124
+ })
329125
+ });
329126
+ return;
329127
+ }
329128
+ await appiumRequest(`${sessionPath}/appium/device/press_keycode`, {
329129
+ method: "POST",
329130
+ body: JSON.stringify({ keycode: 3 })
329131
+ });
329132
+ }
329133
+
329134
+ // src/services/appium-executor.ts
329135
+ var import_node_child_process2 = require("child_process");
329136
+ var import_node_util2 = require("util");
329137
+ var import_runner_core8 = __toESM(require_dist2());
329138
+
328888
329139
  // src/services/device-discovery.ts
328889
329140
  var import_child_process2 = require("child_process");
328890
329141
  var import_os2 = require("os");
@@ -330047,7 +330298,7 @@ async function wdRequest(path, options) {
330047
330298
  }
330048
330299
  return res.json();
330049
330300
  }
330050
- async function getWindowSize(sessionId, retries = 3) {
330301
+ async function getWindowSize2(sessionId, retries = 3) {
330051
330302
  for (let attempt = 0; attempt < retries; attempt++) {
330052
330303
  try {
330053
330304
  const result = await wdRequest(`/session/${sessionId}/window/rect`);
@@ -330223,6 +330474,26 @@ async function freshLaunchTargetApp(sessionId, platform3, appId, log2) {
330223
330474
  log2(` Fresh launch: activate failed (${err instanceof Error ? err.message : String(err)})`);
330224
330475
  }
330225
330476
  }
330477
+ async function waitForColdBootContent(sessionId, log2, timeoutMs = 2e4) {
330478
+ const deadline = Date.now() + timeoutMs;
330479
+ const MIN_ELEMENTS = 3;
330480
+ let lastCount = 0;
330481
+ while (Date.now() < deadline) {
330482
+ try {
330483
+ const res = await wdRequest(`/session/${sessionId}/source`);
330484
+ const xml = typeof res?.value === "string" ? res.value : "";
330485
+ const count = (xml.match(/<(android\.|XCUIElementType)/g) ?? []).length;
330486
+ lastCount = count;
330487
+ if (count >= MIN_ELEMENTS) {
330488
+ log2(` Cold-boot ready: UI tree has ${count} node(s).`);
330489
+ return;
330490
+ }
330491
+ } catch {
330492
+ }
330493
+ await new Promise((r) => setTimeout(r, 750));
330494
+ }
330495
+ log2(` Cold-boot wait timed out after ${timeoutMs}ms (last tree had ${lastCount} node(s)) \u2014 proceeding anyway.`);
330496
+ }
330226
330497
  async function restoreTargetAppIfExternal(sessionId, target, log2, source, failOnExternal) {
330227
330498
  let boundary = null;
330228
330499
  try {
@@ -330470,7 +330741,7 @@ async function tapCoordinates(sessionId, bounds) {
330470
330741
  })
330471
330742
  });
330472
330743
  }
330473
- async function getActiveElementId(sessionId) {
330744
+ async function getActiveElementId2(sessionId) {
330474
330745
  try {
330475
330746
  const activeEl = await wdRequest(`/session/${sessionId}/element/active`);
330476
330747
  return activeEl?.value?.ELEMENT || activeEl?.value?.["element-6066-11e4-a52e-4f735466cecf"] || null;
@@ -330556,7 +330827,7 @@ async function executeType(sessionId, step, context) {
330556
330827
  await tapCoordinates(sessionId, bounds);
330557
330828
  tappedBounds = true;
330558
330829
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330559
- elementId = await getActiveElementId(sessionId);
330830
+ elementId = await getActiveElementId2(sessionId);
330560
330831
  if (elementId) {
330561
330832
  lastInputError = await trySendElementValue(sessionId, elementId, value);
330562
330833
  if (!lastInputError) return;
@@ -330578,10 +330849,10 @@ async function executeType(sessionId, step, context) {
330578
330849
  await tapCoordinates(sessionId, bounds);
330579
330850
  tappedBounds = true;
330580
330851
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330581
- elementId = await getActiveElementId(sessionId);
330852
+ elementId = await getActiveElementId2(sessionId);
330582
330853
  }
330583
330854
  if (!elementId) {
330584
- elementId = await getActiveElementId(sessionId);
330855
+ elementId = await getActiveElementId2(sessionId);
330585
330856
  }
330586
330857
  if (elementId) {
330587
330858
  lastInputError = await trySendElementValue(sessionId, elementId, value);
@@ -330631,10 +330902,10 @@ async function executeClear(sessionId, step, platform3, isCrossPlatform = false)
330631
330902
  if (!elementId && bounds) {
330632
330903
  await tapCoordinates(sessionId, bounds);
330633
330904
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330634
- elementId = await getActiveElementId(sessionId);
330905
+ elementId = await getActiveElementId2(sessionId);
330635
330906
  }
330636
330907
  if (!elementId) {
330637
- elementId = await getActiveElementId(sessionId);
330908
+ elementId = await getActiveElementId2(sessionId);
330638
330909
  }
330639
330910
  if (!elementId) {
330640
330911
  throw new Error("CLEAR step could not resolve a target or focused element");
@@ -330650,7 +330921,7 @@ async function executeSwipe(sessionId, step) {
330650
330921
  let screenW;
330651
330922
  let screenH;
330652
330923
  try {
330653
- const windowSize = await getWindowSize(sessionId);
330924
+ const windowSize = await getWindowSize2(sessionId);
330654
330925
  screenW = windowSize.width;
330655
330926
  screenH = windowSize.height;
330656
330927
  } catch (err) {
@@ -330865,8 +331136,25 @@ async function executeMobileTest(options) {
330865
331136
  }
330866
331137
  }
330867
331138
  recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
331139
+ const launchStepForReset = runSteps.find((s) => s.action === "launchApp");
331140
+ const recordedFromClearedState = launchStepForReset?.metadata?.appReset === "cleared";
331141
+ if (recordedFromClearedState) {
331142
+ if (shouldResetMobileAppData(options.target.platform, selectedDevice.id)) {
331143
+ try {
331144
+ await executeMobileScript(sessionId, "mobile: clearApp", [{ appId: options.target.appId }]);
331145
+ log2("App data cleared before launch (test was recorded from a cold logged-out state).");
331146
+ } catch (err) {
331147
+ log2(`App-data clear failed (continuing with plain fresh launch): ${err instanceof Error ? err.message : String(err)}`);
331148
+ }
331149
+ } else {
331150
+ 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.");
331151
+ }
331152
+ }
330868
331153
  log2("Opening a fresh app instance for the test...");
330869
331154
  await freshLaunchTargetApp(sessionId, options.target.platform, options.target.appId, log2);
331155
+ if (recordedFromClearedState) {
331156
+ await waitForColdBootContent(sessionId, log2);
331157
+ }
330870
331158
  if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
330871
331159
  try {
330872
331160
  log2(`Opening deep link: ${options.target.deeplink}`);
@@ -331381,225 +331669,6 @@ var import_os3 = require("os");
331381
331669
  var import_path2 = require("path");
331382
331670
  var import_fs2 = require("fs");
331383
331671
  init_dist();
331384
-
331385
- // src/services/mobile/appium-actions.ts
331386
- init_dist();
331387
- var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
331388
- var ELEMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
331389
- async function appiumRequest(path, options) {
331390
- const url = `http://localhost:${getAppiumState().port}${path}`;
331391
- const res = await fetch(url, {
331392
- ...options,
331393
- signal: options?.signal ?? AbortSignal.timeout(APPIUM_REQUEST_TIMEOUT_MS),
331394
- headers: { "Content-Type": "application/json", ...options?.headers }
331395
- });
331396
- if (!res.ok) {
331397
- const body = await res.text().catch(() => "");
331398
- throw new Error(`Appium error ${res.status}: ${body.slice(0, 200)}`);
331399
- }
331400
- return res.json();
331401
- }
331402
- function readValue(result) {
331403
- if (result && typeof result === "object" && "value" in result) {
331404
- return result.value;
331405
- }
331406
- return void 0;
331407
- }
331408
- async function getScreenshot(sessionPath) {
331409
- try {
331410
- const value = readValue(await appiumRequest(`${sessionPath}/screenshot`));
331411
- return typeof value === "string" ? value : null;
331412
- } catch {
331413
- return null;
331414
- }
331415
- }
331416
- async function getPageSource(sessionPath) {
331417
- try {
331418
- const value = readValue(await appiumRequest(`${sessionPath}/source`));
331419
- return typeof value === "string" ? value : null;
331420
- } catch {
331421
- return null;
331422
- }
331423
- }
331424
- async function getWindowSize2(sessionPath) {
331425
- const retries = 3;
331426
- for (let attempt = 0; attempt < retries; attempt++) {
331427
- try {
331428
- const value = readValue(await appiumRequest(`${sessionPath}/window/rect`));
331429
- if (value && typeof value === "object") {
331430
- const w = value.width;
331431
- const h = value.height;
331432
- if (typeof w === "number" && typeof h === "number" && w > 0 && h > 0) {
331433
- return { width: w, height: h };
331434
- }
331435
- }
331436
- } catch {
331437
- }
331438
- if (attempt < retries - 1) {
331439
- await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100));
331440
- }
331441
- }
331442
- return null;
331443
- }
331444
- async function getActiveElementId2(sessionPath) {
331445
- try {
331446
- const value = readValue(await appiumRequest(`${sessionPath}/element/active`));
331447
- if (!value || typeof value !== "object") return null;
331448
- const v = value;
331449
- const elementId = v.ELEMENT ?? v["element-6066-11e4-a52e-4f735466cecf"] ?? null;
331450
- if (typeof elementId !== "string" || !ELEMENT_ID_PATTERN.test(elementId)) {
331451
- return null;
331452
- }
331453
- return elementId;
331454
- } catch {
331455
- return null;
331456
- }
331457
- }
331458
- function normaliseBounds(bounds) {
331459
- if (!bounds || typeof bounds !== "object" || typeof bounds.x !== "number" || typeof bounds.y !== "number" || !Number.isFinite(bounds.x) || !Number.isFinite(bounds.y)) {
331460
- return null;
331461
- }
331462
- const b = bounds;
331463
- const width = typeof b.width === "number" && Number.isFinite(b.width) ? b.width : 0;
331464
- const height = typeof b.height === "number" && Number.isFinite(b.height) ? b.height : 0;
331465
- return { x: b.x, y: b.y, width, height };
331466
- }
331467
- async function tapBoundsCenter(sessionPath, bounds) {
331468
- const box = normaliseBounds(bounds);
331469
- if (!box) return false;
331470
- const tapX = Math.round(box.x + box.width / 2);
331471
- const tapY = Math.round(box.y + box.height / 2);
331472
- await appiumRequest(`${sessionPath}/actions`, {
331473
- method: "POST",
331474
- body: JSON.stringify({
331475
- actions: [{
331476
- type: "pointer",
331477
- id: "finger1",
331478
- parameters: { pointerType: "touch" },
331479
- actions: [
331480
- // Note: button: 0 is used because Appium's WebDriver protocol
331481
- // translates mouse button events to touch events. Standard approach
331482
- // for touch actions in Appium, despite the mouse button semantics.
331483
- { type: "pointerMove", duration: 0, x: tapX, y: tapY },
331484
- { type: "pointerDown", button: 0 },
331485
- { type: "pause", duration: 100 },
331486
- { type: "pointerUp", button: 0 }
331487
- ]
331488
- }]
331489
- })
331490
- });
331491
- return true;
331492
- }
331493
- async function tapAt(sessionPath, bounds) {
331494
- const ok = await tapBoundsCenter(sessionPath, bounds);
331495
- if (!ok) {
331496
- throw new Error("TAP requires usable bounds (finite x/y)");
331497
- }
331498
- }
331499
- async function getElementRect(sessionPath, elementId) {
331500
- try {
331501
- const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
331502
- if (!value || typeof value !== "object") return null;
331503
- const r = value;
331504
- 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)) {
331505
- return null;
331506
- }
331507
- return { x: r.x, y: r.y, width: r.width, height: r.height };
331508
- } catch {
331509
- return null;
331510
- }
331511
- }
331512
- function rectMatchesIntendedBounds(rect, bounds) {
331513
- if (bounds.width <= 0 || bounds.height <= 0) return false;
331514
- const rectCenterX = rect.x + rect.width / 2;
331515
- const rectCenterY = rect.y + rect.height / 2;
331516
- const boundsCenterX = bounds.x + bounds.width / 2;
331517
- const boundsCenterY = bounds.y + bounds.height / 2;
331518
- const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
331519
- const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
331520
- return rectCenterInBounds || boundsCenterInRect;
331521
- }
331522
- async function resolveTypeTargetElementId(sessionPath, bounds) {
331523
- const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
331524
- let elementId = await getActiveElementId2(sessionPath);
331525
- if (elementId && box && box.width > 0 && box.height > 0) {
331526
- const rect = await getElementRect(sessionPath, elementId);
331527
- if (!rect || !rectMatchesIntendedBounds(rect, box)) {
331528
- elementId = null;
331529
- }
331530
- }
331531
- if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
331532
- await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
331533
- elementId = await getActiveElementId2(sessionPath);
331534
- }
331535
- return elementId;
331536
- }
331537
- async function typeText(sessionPath, value, bounds) {
331538
- const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
331539
- if (!elementId) {
331540
- throw new Error("No focused element available for TYPE. Tap an input field first.");
331541
- }
331542
- await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
331543
- method: "POST",
331544
- body: JSON.stringify({ text: value })
331545
- });
331546
- }
331547
- async function clearField(sessionPath, bounds) {
331548
- const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
331549
- if (!elementId) {
331550
- throw new Error("No focused element available for CLEAR. Tap an input field first.");
331551
- }
331552
- await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
331553
- method: "POST",
331554
- body: JSON.stringify({})
331555
- });
331556
- }
331557
- async function swipe(sessionPath, direction, distance, windowSize) {
331558
- const dist = clampMobileSwipeDistance(distance);
331559
- const { startX, startY, endX, endY } = computeMobileSwipePointers(
331560
- windowSize.width,
331561
- windowSize.height,
331562
- direction,
331563
- dist
331564
- );
331565
- await appiumRequest(`${sessionPath}/actions`, {
331566
- method: "POST",
331567
- body: JSON.stringify({
331568
- actions: [{
331569
- type: "pointer",
331570
- id: "finger1",
331571
- parameters: { pointerType: "touch" },
331572
- actions: [
331573
- { type: "pointerMove", duration: 0, x: startX, y: startY },
331574
- { type: "pointerDown", button: 0 },
331575
- { type: "pointerMove", duration: 300, x: endX, y: endY },
331576
- { type: "pointerUp", button: 0 }
331577
- ]
331578
- }]
331579
- })
331580
- });
331581
- }
331582
- async function pressBack(sessionPath) {
331583
- await appiumRequest(`${sessionPath}/back`, { method: "POST", body: "{}" });
331584
- }
331585
- async function pressHome(sessionPath, platform3) {
331586
- if (platform3 === "IOS") {
331587
- await appiumRequest(`${sessionPath}/execute/sync`, {
331588
- method: "POST",
331589
- body: JSON.stringify({
331590
- script: "mobile: pressButton",
331591
- args: [{ name: "home" }]
331592
- })
331593
- });
331594
- return;
331595
- }
331596
- await appiumRequest(`${sessionPath}/appium/device/press_keycode`, {
331597
- method: "POST",
331598
- body: JSON.stringify({ keycode: 3 })
331599
- });
331600
- }
331601
-
331602
- // src/services/mobile-bridge.ts
331603
331672
  var MOBILE_BRIDGE_PORT2 = 9515;
331604
331673
  var MAX_REQUEST_BODY_BYTES = 64 * 1024;
331605
331674
  var MAX_TEXT_INPUT_LENGTH = 4e3;
@@ -331693,7 +331762,7 @@ async function getPageSource2() {
331693
331762
  async function getWindowSize3() {
331694
331763
  if (!state.appiumSessionId) return null;
331695
331764
  validateSessionId(state.appiumSessionId);
331696
- return getWindowSize2(`/session/${state.appiumSessionId}`);
331765
+ return getWindowSize(`/session/${state.appiumSessionId}`);
331697
331766
  }
331698
331767
  var BRIDGE_PROCESS_MARKER = "validateqa-mobile-bridge";
331699
331768
  var BRIDGE_PORT = MOBILE_BRIDGE_PORT2;
@@ -332757,6 +332826,7 @@ var MobileBridgeDriver = class {
332757
332826
  sessionId;
332758
332827
  platform;
332759
332828
  appId;
332829
+ allowAppReset;
332760
332830
  constructor(config) {
332761
332831
  if (!APPIUM_SESSION_ID_PATTERN2.test(config.sessionId)) {
332762
332832
  throw new Error("MobileBridgeDriver: invalid Appium session id");
@@ -332770,6 +332840,7 @@ var MobileBridgeDriver = class {
332770
332840
  this.sessionId = config.sessionId;
332771
332841
  this.platform = config.platform;
332772
332842
  this.appId = config.appId;
332843
+ this.allowAppReset = config.allowAppReset === true;
332773
332844
  }
332774
332845
  /** WebDriver session path (`/session/<id>`), the prefix every primitive needs. */
332775
332846
  get sessionPath() {
@@ -332831,7 +332902,7 @@ var MobileBridgeDriver = class {
332831
332902
  const [xml, screenshot, windowSize] = await Promise.all([
332832
332903
  getPageSource(this.sessionPath),
332833
332904
  getScreenshot(this.sessionPath),
332834
- getWindowSize2(this.sessionPath)
332905
+ getWindowSize(this.sessionPath)
332835
332906
  ]);
332836
332907
  const source = xml ?? "";
332837
332908
  const screenSignal = await this.enrichScreenSignal(source);
@@ -332844,7 +332915,7 @@ var MobileBridgeDriver = class {
332844
332915
  }
332845
332916
  /** Device window size; null when unreadable (swipe geometry degrades gracefully). */
332846
332917
  async windowSize() {
332847
- return getWindowSize2(this.sessionPath);
332918
+ return getWindowSize(this.sessionPath);
332848
332919
  }
332849
332920
  // ── Action result wrapper ──────────────────────────────
332850
332921
  /**
@@ -332886,7 +332957,7 @@ var MobileBridgeDriver = class {
332886
332957
  * hard error so we never fire an off-screen gesture.
332887
332958
  */
332888
332959
  async swipe(direction, distance) {
332889
- const size = await getWindowSize2(this.sessionPath);
332960
+ const size = await getWindowSize(this.sessionPath);
332890
332961
  if (!size) {
332891
332962
  return { ok: false, error: "Could not determine device screen dimensions for swipe." };
332892
332963
  }
@@ -332911,6 +332982,22 @@ var MobileBridgeDriver = class {
332911
332982
  await this.executeScript("mobile: terminateApp", [args]);
332912
332983
  await this.executeScript("mobile: activateApp", [args]);
332913
332984
  }
332985
+ /**
332986
+ * Deterministic state reset: clear app data + relaunch (Android emulators
332987
+ * only, per the runner-computed allowAppReset policy). Returns false when
332988
+ * unsupported/disallowed or when clearApp throws — callers fall back to
332989
+ * relaunch(), preserving today's behavior.
332990
+ */
332991
+ async resetAppState() {
332992
+ if (!this.allowAppReset || this.platform !== "ANDROID") return false;
332993
+ try {
332994
+ await this.executeScript("mobile: clearApp", [{ appId: this.appId }]);
332995
+ await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
332996
+ return true;
332997
+ } catch {
332998
+ return false;
332999
+ }
333000
+ }
332914
333001
  /** (Re)launch the target app (activateApp) from a backgrounded/closed state. */
332915
333002
  async launchApp() {
332916
333003
  await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
@@ -335052,7 +335139,11 @@ ${finalVerifyResult.logs ?? ""}`;
335052
335139
  const driver = new MobileBridgeDriver({
335053
335140
  sessionId: session.sessionId,
335054
335141
  platform: platform3,
335055
- appId: session.appId
335142
+ appId: session.appId,
335143
+ // Deterministic-state authorization: Android emulators only by
335144
+ // default (VALIDATEQA_MOBILE_APP_RESET overrides) — a personal
335145
+ // physical device must never have its app data wiped.
335146
+ allowAppReset: shouldResetMobileAppData(platform3, session.deviceId)
335056
335147
  });
335057
335148
  discoveryResult = await (0, import_runner_core4.runMobileDiscoveryOnRunner)(ctx, driver, {
335058
335149
  onLog: mobileOnLog,