@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.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);
@@ -66070,6 +66073,8 @@ ${constraints.join("\n")}`);
66070
66073
  function buildMobileFallbackScenarios(input) {
66071
66074
  if (input.mode === "survey")
66072
66075
  return [];
66076
+ if (input.featureName?.trim())
66077
+ return [];
66073
66078
  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);
66074
66079
  if (orderedScreens.length === 0)
66075
66080
  return [];
@@ -66217,6 +66222,15 @@ ${constraints.join("\n")}`);
66217
66222
  fallbackReason: result.fallbackReason ?? "Mobile planner produced no scenarios"
66218
66223
  };
66219
66224
  }
66225
+ if (input.featureName?.trim()) {
66226
+ return {
66227
+ ...result,
66228
+ featureGroups: mobileFeatureGroups,
66229
+ 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(" | "),
66230
+ fallbackUsed: true,
66231
+ fallbackReason: result.fallbackReason ?? `Planner produced no scenarios for feature "${input.featureName}"`
66232
+ };
66233
+ }
66220
66234
  }
66221
66235
  if (input.mode === "survey" && result.featureGroups.length === 0 && mobileFeatureGroups.length > 0) {
66222
66236
  return {
@@ -66497,7 +66511,8 @@ Max iterations: ${MAX_SCENARIO_ITERATIONS}. You will be warned near the end.`;
66497
66511
  This app has stored login credentials under these keys:
66498
66512
  ${keyList}
66499
66513
 
66500
- 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.
66514
+ When a scenario needs a signed-in session, complete the FULL login sequence \u2014 filling the fields alone does NOT log you in:
66515
+ 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).
66501
66516
 
66502
66517
  SECURITY RULES:
66503
66518
  - 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.
@@ -67165,7 +67180,7 @@ ${statePacket}` },
67165
67180
  return out;
67166
67181
  }
67167
67182
  function assembleTest(args) {
67168
- const { scenario, recording, ctx, platform: platform3, log: log2 } = args;
67183
+ const { scenario, recording, ctx, platform: platform3, appDataCleared, log: log2 } = args;
67169
67184
  const actions = collapseRetryCycles(recording.actions);
67170
67185
  if (actions.length < recording.actions.length) {
67171
67186
  log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
@@ -67182,7 +67197,8 @@ ${statePacket}` },
67182
67197
  sessionName: recording.finish?.name ?? scenario.name,
67183
67198
  medium,
67184
67199
  target,
67185
- actions
67200
+ actions,
67201
+ appResetOnLaunch: appDataCleared
67186
67202
  };
67187
67203
  let compiled;
67188
67204
  try {
@@ -67288,7 +67304,12 @@ ${statePacket}` },
67288
67304
  log2(`
67289
67305
  -- Generating scenario ${i + 1}/${scenarios.length}: "${scenario.name}" --`);
67290
67306
  try {
67291
- await driver.relaunch();
67307
+ const appDataCleared = await driver.resetAppState?.().catch(() => false) ?? false;
67308
+ if (appDataCleared) {
67309
+ log2(" app data cleared \u2014 scenario records from a deterministic cold (logged-out) state.");
67310
+ } else {
67311
+ await driver.relaunch();
67312
+ }
67292
67313
  const recording = await runScenarioLoop({
67293
67314
  scenario,
67294
67315
  driver,
@@ -67305,7 +67326,7 @@ ${statePacket}` },
67305
67326
  log: log2
67306
67327
  });
67307
67328
  consecutiveInfraFailures = 0;
67308
- const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
67329
+ const test = assembleTest({ scenario, recording, ctx, platform: platform3, appDataCleared, log: log2 });
67309
67330
  if (!test) {
67310
67331
  log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
67311
67332
  continue;
@@ -313776,6 +313797,8 @@ var require_mobile_discovery_agent = __commonJS({
313776
313797
  };
313777
313798
  }
313778
313799
  log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
313800
+ const exploreAppDataCleared = await driver.resetAppState?.().catch(() => false) ?? false;
313801
+ 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.");
313779
313802
  const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
313780
313803
  const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
313781
313804
  const existingScreens = compactExistingScreens(ctx.existingSiteMap);
@@ -328465,7 +328488,7 @@ var require_package4 = __commonJS({
328465
328488
  "package.json"(exports, module) {
328466
328489
  module.exports = {
328467
328490
  name: "@validate.qa/runner",
328468
- version: "1.0.19",
328491
+ version: "1.0.21",
328469
328492
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328470
328493
  bin: {
328471
328494
  "validate-runner": "dist/cli.js"
@@ -328603,9 +328626,9 @@ function shouldUseAuthState(tags, playwrightCode) {
328603
328626
 
328604
328627
  // src/services/appium-executor.ts
328605
328628
  init_dist();
328606
- var import_runner_core8 = __toESM(require_dist2());
328607
- import { execFile as execFile2 } from "child_process";
328608
- import { promisify as promisify2 } from "util";
328629
+
328630
+ // src/services/mobile/appium-actions.ts
328631
+ init_dist();
328609
328632
 
328610
328633
  // src/services/appium-lifecycle.ts
328611
328634
  import { spawn, execFileSync } from "child_process";
@@ -328890,6 +328913,234 @@ function resetServerState(port = APPIUM_PORT, restartCount = 0) {
328890
328913
  };
328891
328914
  }
328892
328915
 
328916
+ // src/services/mobile/appium-actions.ts
328917
+ var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
328918
+ var ELEMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
328919
+ async function appiumRequest(path, options) {
328920
+ const url = `http://localhost:${getAppiumState().port}${path}`;
328921
+ const res = await fetch(url, {
328922
+ ...options,
328923
+ signal: options?.signal ?? AbortSignal.timeout(APPIUM_REQUEST_TIMEOUT_MS),
328924
+ headers: { "Content-Type": "application/json", ...options?.headers }
328925
+ });
328926
+ if (!res.ok) {
328927
+ const body = await res.text().catch(() => "");
328928
+ throw new Error(`Appium error ${res.status}: ${body.slice(0, 200)}`);
328929
+ }
328930
+ return res.json();
328931
+ }
328932
+ function readValue(result) {
328933
+ if (result && typeof result === "object" && "value" in result) {
328934
+ return result.value;
328935
+ }
328936
+ return void 0;
328937
+ }
328938
+ async function getScreenshot(sessionPath) {
328939
+ try {
328940
+ const value = readValue(await appiumRequest(`${sessionPath}/screenshot`));
328941
+ return typeof value === "string" ? value : null;
328942
+ } catch {
328943
+ return null;
328944
+ }
328945
+ }
328946
+ async function getPageSource(sessionPath) {
328947
+ try {
328948
+ const value = readValue(await appiumRequest(`${sessionPath}/source`));
328949
+ return typeof value === "string" ? value : null;
328950
+ } catch {
328951
+ return null;
328952
+ }
328953
+ }
328954
+ async function getWindowSize(sessionPath) {
328955
+ const retries = 3;
328956
+ for (let attempt = 0; attempt < retries; attempt++) {
328957
+ try {
328958
+ const value = readValue(await appiumRequest(`${sessionPath}/window/rect`));
328959
+ if (value && typeof value === "object") {
328960
+ const w = value.width;
328961
+ const h = value.height;
328962
+ if (typeof w === "number" && typeof h === "number" && w > 0 && h > 0) {
328963
+ return { width: w, height: h };
328964
+ }
328965
+ }
328966
+ } catch {
328967
+ }
328968
+ if (attempt < retries - 1) {
328969
+ await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100));
328970
+ }
328971
+ }
328972
+ return null;
328973
+ }
328974
+ async function getActiveElementId(sessionPath) {
328975
+ try {
328976
+ const value = readValue(await appiumRequest(`${sessionPath}/element/active`));
328977
+ if (!value || typeof value !== "object") return null;
328978
+ const v = value;
328979
+ const elementId = v.ELEMENT ?? v["element-6066-11e4-a52e-4f735466cecf"] ?? null;
328980
+ if (typeof elementId !== "string" || !ELEMENT_ID_PATTERN.test(elementId)) {
328981
+ return null;
328982
+ }
328983
+ return elementId;
328984
+ } catch {
328985
+ return null;
328986
+ }
328987
+ }
328988
+ function normaliseBounds(bounds) {
328989
+ if (!bounds || typeof bounds !== "object" || typeof bounds.x !== "number" || typeof bounds.y !== "number" || !Number.isFinite(bounds.x) || !Number.isFinite(bounds.y)) {
328990
+ return null;
328991
+ }
328992
+ const b = bounds;
328993
+ const width = typeof b.width === "number" && Number.isFinite(b.width) ? b.width : 0;
328994
+ const height = typeof b.height === "number" && Number.isFinite(b.height) ? b.height : 0;
328995
+ return { x: b.x, y: b.y, width, height };
328996
+ }
328997
+ async function tapBoundsCenter(sessionPath, bounds) {
328998
+ const box = normaliseBounds(bounds);
328999
+ if (!box) return false;
329000
+ const tapX = Math.round(box.x + box.width / 2);
329001
+ const tapY = Math.round(box.y + box.height / 2);
329002
+ await appiumRequest(`${sessionPath}/actions`, {
329003
+ method: "POST",
329004
+ body: JSON.stringify({
329005
+ actions: [{
329006
+ type: "pointer",
329007
+ id: "finger1",
329008
+ parameters: { pointerType: "touch" },
329009
+ actions: [
329010
+ // Note: button: 0 is used because Appium's WebDriver protocol
329011
+ // translates mouse button events to touch events. Standard approach
329012
+ // for touch actions in Appium, despite the mouse button semantics.
329013
+ { type: "pointerMove", duration: 0, x: tapX, y: tapY },
329014
+ { type: "pointerDown", button: 0 },
329015
+ { type: "pause", duration: 100 },
329016
+ { type: "pointerUp", button: 0 }
329017
+ ]
329018
+ }]
329019
+ })
329020
+ });
329021
+ return true;
329022
+ }
329023
+ async function tapAt(sessionPath, bounds) {
329024
+ const ok = await tapBoundsCenter(sessionPath, bounds);
329025
+ if (!ok) {
329026
+ throw new Error("TAP requires usable bounds (finite x/y)");
329027
+ }
329028
+ }
329029
+ function shouldResetMobileAppData(platform3, deviceId, env = process.env) {
329030
+ if (platform3 !== "ANDROID") return false;
329031
+ const policy = (env.VALIDATEQA_MOBILE_APP_RESET ?? "").trim().toLowerCase();
329032
+ if (policy === "never") return false;
329033
+ if (policy === "always") return true;
329034
+ return !!deviceId && deviceId.startsWith("emulator-");
329035
+ }
329036
+ async function getElementRect(sessionPath, elementId) {
329037
+ try {
329038
+ const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
329039
+ if (!value || typeof value !== "object") return null;
329040
+ const r = value;
329041
+ 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)) {
329042
+ return null;
329043
+ }
329044
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
329045
+ } catch {
329046
+ return null;
329047
+ }
329048
+ }
329049
+ function rectMatchesIntendedBounds(rect, bounds) {
329050
+ if (bounds.width <= 0 || bounds.height <= 0) return false;
329051
+ const rectCenterX = rect.x + rect.width / 2;
329052
+ const rectCenterY = rect.y + rect.height / 2;
329053
+ const boundsCenterX = bounds.x + bounds.width / 2;
329054
+ const boundsCenterY = bounds.y + bounds.height / 2;
329055
+ const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
329056
+ const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
329057
+ return rectCenterInBounds || boundsCenterInRect;
329058
+ }
329059
+ async function resolveTypeTargetElementId(sessionPath, bounds) {
329060
+ const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
329061
+ let elementId = await getActiveElementId(sessionPath);
329062
+ if (elementId && box && box.width > 0 && box.height > 0) {
329063
+ const rect = await getElementRect(sessionPath, elementId);
329064
+ if (!rect || !rectMatchesIntendedBounds(rect, box)) {
329065
+ elementId = null;
329066
+ }
329067
+ }
329068
+ if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
329069
+ await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
329070
+ elementId = await getActiveElementId(sessionPath);
329071
+ }
329072
+ return elementId;
329073
+ }
329074
+ async function typeText(sessionPath, value, bounds) {
329075
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
329076
+ if (!elementId) {
329077
+ throw new Error("No focused element available for TYPE. Tap an input field first.");
329078
+ }
329079
+ await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
329080
+ method: "POST",
329081
+ body: JSON.stringify({ text: value })
329082
+ });
329083
+ }
329084
+ async function clearField(sessionPath, bounds) {
329085
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
329086
+ if (!elementId) {
329087
+ throw new Error("No focused element available for CLEAR. Tap an input field first.");
329088
+ }
329089
+ await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
329090
+ method: "POST",
329091
+ body: JSON.stringify({})
329092
+ });
329093
+ }
329094
+ async function swipe(sessionPath, direction, distance, windowSize) {
329095
+ const dist = clampMobileSwipeDistance(distance);
329096
+ const { startX, startY, endX, endY } = computeMobileSwipePointers(
329097
+ windowSize.width,
329098
+ windowSize.height,
329099
+ direction,
329100
+ dist
329101
+ );
329102
+ await appiumRequest(`${sessionPath}/actions`, {
329103
+ method: "POST",
329104
+ body: JSON.stringify({
329105
+ actions: [{
329106
+ type: "pointer",
329107
+ id: "finger1",
329108
+ parameters: { pointerType: "touch" },
329109
+ actions: [
329110
+ { type: "pointerMove", duration: 0, x: startX, y: startY },
329111
+ { type: "pointerDown", button: 0 },
329112
+ { type: "pointerMove", duration: 300, x: endX, y: endY },
329113
+ { type: "pointerUp", button: 0 }
329114
+ ]
329115
+ }]
329116
+ })
329117
+ });
329118
+ }
329119
+ async function pressBack(sessionPath) {
329120
+ await appiumRequest(`${sessionPath}/back`, { method: "POST", body: "{}" });
329121
+ }
329122
+ async function pressHome(sessionPath, platform3) {
329123
+ if (platform3 === "IOS") {
329124
+ await appiumRequest(`${sessionPath}/execute/sync`, {
329125
+ method: "POST",
329126
+ body: JSON.stringify({
329127
+ script: "mobile: pressButton",
329128
+ args: [{ name: "home" }]
329129
+ })
329130
+ });
329131
+ return;
329132
+ }
329133
+ await appiumRequest(`${sessionPath}/appium/device/press_keycode`, {
329134
+ method: "POST",
329135
+ body: JSON.stringify({ keycode: 3 })
329136
+ });
329137
+ }
329138
+
329139
+ // src/services/appium-executor.ts
329140
+ var import_runner_core8 = __toESM(require_dist2());
329141
+ import { execFile as execFile2 } from "child_process";
329142
+ import { promisify as promisify2 } from "util";
329143
+
328893
329144
  // src/services/device-discovery.ts
328894
329145
  import { execFileSync as execFileSync2 } from "child_process";
328895
329146
  import { platform } from "os";
@@ -330055,7 +330306,7 @@ async function wdRequest(path, options) {
330055
330306
  }
330056
330307
  return res.json();
330057
330308
  }
330058
- async function getWindowSize(sessionId, retries = 3) {
330309
+ async function getWindowSize2(sessionId, retries = 3) {
330059
330310
  for (let attempt = 0; attempt < retries; attempt++) {
330060
330311
  try {
330061
330312
  const result = await wdRequest(`/session/${sessionId}/window/rect`);
@@ -330231,6 +330482,26 @@ async function freshLaunchTargetApp(sessionId, platform3, appId, log2) {
330231
330482
  log2(` Fresh launch: activate failed (${err instanceof Error ? err.message : String(err)})`);
330232
330483
  }
330233
330484
  }
330485
+ async function waitForColdBootContent(sessionId, log2, timeoutMs = 2e4) {
330486
+ const deadline = Date.now() + timeoutMs;
330487
+ const MIN_ELEMENTS = 3;
330488
+ let lastCount = 0;
330489
+ while (Date.now() < deadline) {
330490
+ try {
330491
+ const res = await wdRequest(`/session/${sessionId}/source`);
330492
+ const xml = typeof res?.value === "string" ? res.value : "";
330493
+ const count = (xml.match(/<(android\.|XCUIElementType)/g) ?? []).length;
330494
+ lastCount = count;
330495
+ if (count >= MIN_ELEMENTS) {
330496
+ log2(` Cold-boot ready: UI tree has ${count} node(s).`);
330497
+ return;
330498
+ }
330499
+ } catch {
330500
+ }
330501
+ await new Promise((r) => setTimeout(r, 750));
330502
+ }
330503
+ log2(` Cold-boot wait timed out after ${timeoutMs}ms (last tree had ${lastCount} node(s)) \u2014 proceeding anyway.`);
330504
+ }
330234
330505
  async function restoreTargetAppIfExternal(sessionId, target, log2, source, failOnExternal) {
330235
330506
  let boundary = null;
330236
330507
  try {
@@ -330478,7 +330749,7 @@ async function tapCoordinates(sessionId, bounds) {
330478
330749
  })
330479
330750
  });
330480
330751
  }
330481
- async function getActiveElementId(sessionId) {
330752
+ async function getActiveElementId2(sessionId) {
330482
330753
  try {
330483
330754
  const activeEl = await wdRequest(`/session/${sessionId}/element/active`);
330484
330755
  return activeEl?.value?.ELEMENT || activeEl?.value?.["element-6066-11e4-a52e-4f735466cecf"] || null;
@@ -330564,7 +330835,7 @@ async function executeType(sessionId, step, context) {
330564
330835
  await tapCoordinates(sessionId, bounds);
330565
330836
  tappedBounds = true;
330566
330837
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330567
- elementId = await getActiveElementId(sessionId);
330838
+ elementId = await getActiveElementId2(sessionId);
330568
330839
  if (elementId) {
330569
330840
  lastInputError = await trySendElementValue(sessionId, elementId, value);
330570
330841
  if (!lastInputError) return;
@@ -330586,10 +330857,10 @@ async function executeType(sessionId, step, context) {
330586
330857
  await tapCoordinates(sessionId, bounds);
330587
330858
  tappedBounds = true;
330588
330859
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330589
- elementId = await getActiveElementId(sessionId);
330860
+ elementId = await getActiveElementId2(sessionId);
330590
330861
  }
330591
330862
  if (!elementId) {
330592
- elementId = await getActiveElementId(sessionId);
330863
+ elementId = await getActiveElementId2(sessionId);
330593
330864
  }
330594
330865
  if (elementId) {
330595
330866
  lastInputError = await trySendElementValue(sessionId, elementId, value);
@@ -330639,10 +330910,10 @@ async function executeClear(sessionId, step, platform3, isCrossPlatform = false)
330639
330910
  if (!elementId && bounds) {
330640
330911
  await tapCoordinates(sessionId, bounds);
330641
330912
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330642
- elementId = await getActiveElementId(sessionId);
330913
+ elementId = await getActiveElementId2(sessionId);
330643
330914
  }
330644
330915
  if (!elementId) {
330645
- elementId = await getActiveElementId(sessionId);
330916
+ elementId = await getActiveElementId2(sessionId);
330646
330917
  }
330647
330918
  if (!elementId) {
330648
330919
  throw new Error("CLEAR step could not resolve a target or focused element");
@@ -330658,7 +330929,7 @@ async function executeSwipe(sessionId, step) {
330658
330929
  let screenW;
330659
330930
  let screenH;
330660
330931
  try {
330661
- const windowSize = await getWindowSize(sessionId);
330932
+ const windowSize = await getWindowSize2(sessionId);
330662
330933
  screenW = windowSize.width;
330663
330934
  screenH = windowSize.height;
330664
330935
  } catch (err) {
@@ -330873,8 +331144,25 @@ async function executeMobileTest(options) {
330873
331144
  }
330874
331145
  }
330875
331146
  recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
331147
+ const launchStepForReset = runSteps.find((s) => s.action === "launchApp");
331148
+ const recordedFromClearedState = launchStepForReset?.metadata?.appReset === "cleared";
331149
+ if (recordedFromClearedState) {
331150
+ if (shouldResetMobileAppData(options.target.platform, selectedDevice.id)) {
331151
+ try {
331152
+ await executeMobileScript(sessionId, "mobile: clearApp", [{ appId: options.target.appId }]);
331153
+ log2("App data cleared before launch (test was recorded from a cold logged-out state).");
331154
+ } catch (err) {
331155
+ log2(`App-data clear failed (continuing with plain fresh launch): ${err instanceof Error ? err.message : String(err)}`);
331156
+ }
331157
+ } else {
331158
+ 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.");
331159
+ }
331160
+ }
330876
331161
  log2("Opening a fresh app instance for the test...");
330877
331162
  await freshLaunchTargetApp(sessionId, options.target.platform, options.target.appId, log2);
331163
+ if (recordedFromClearedState) {
331164
+ await waitForColdBootContent(sessionId, log2);
331165
+ }
330878
331166
  if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
330879
331167
  try {
330880
331168
  log2(`Opening deep link: ${options.target.deeplink}`);
@@ -331389,225 +331677,6 @@ import { homedir as homedir2 } from "os";
331389
331677
  import { join as join3, dirname } from "path";
331390
331678
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2, mkdirSync as mkdirSync2 } from "fs";
331391
331679
  init_dist();
331392
-
331393
- // src/services/mobile/appium-actions.ts
331394
- init_dist();
331395
- var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
331396
- var ELEMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
331397
- async function appiumRequest(path, options) {
331398
- const url = `http://localhost:${getAppiumState().port}${path}`;
331399
- const res = await fetch(url, {
331400
- ...options,
331401
- signal: options?.signal ?? AbortSignal.timeout(APPIUM_REQUEST_TIMEOUT_MS),
331402
- headers: { "Content-Type": "application/json", ...options?.headers }
331403
- });
331404
- if (!res.ok) {
331405
- const body = await res.text().catch(() => "");
331406
- throw new Error(`Appium error ${res.status}: ${body.slice(0, 200)}`);
331407
- }
331408
- return res.json();
331409
- }
331410
- function readValue(result) {
331411
- if (result && typeof result === "object" && "value" in result) {
331412
- return result.value;
331413
- }
331414
- return void 0;
331415
- }
331416
- async function getScreenshot(sessionPath) {
331417
- try {
331418
- const value = readValue(await appiumRequest(`${sessionPath}/screenshot`));
331419
- return typeof value === "string" ? value : null;
331420
- } catch {
331421
- return null;
331422
- }
331423
- }
331424
- async function getPageSource(sessionPath) {
331425
- try {
331426
- const value = readValue(await appiumRequest(`${sessionPath}/source`));
331427
- return typeof value === "string" ? value : null;
331428
- } catch {
331429
- return null;
331430
- }
331431
- }
331432
- async function getWindowSize2(sessionPath) {
331433
- const retries = 3;
331434
- for (let attempt = 0; attempt < retries; attempt++) {
331435
- try {
331436
- const value = readValue(await appiumRequest(`${sessionPath}/window/rect`));
331437
- if (value && typeof value === "object") {
331438
- const w = value.width;
331439
- const h = value.height;
331440
- if (typeof w === "number" && typeof h === "number" && w > 0 && h > 0) {
331441
- return { width: w, height: h };
331442
- }
331443
- }
331444
- } catch {
331445
- }
331446
- if (attempt < retries - 1) {
331447
- await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100));
331448
- }
331449
- }
331450
- return null;
331451
- }
331452
- async function getActiveElementId2(sessionPath) {
331453
- try {
331454
- const value = readValue(await appiumRequest(`${sessionPath}/element/active`));
331455
- if (!value || typeof value !== "object") return null;
331456
- const v = value;
331457
- const elementId = v.ELEMENT ?? v["element-6066-11e4-a52e-4f735466cecf"] ?? null;
331458
- if (typeof elementId !== "string" || !ELEMENT_ID_PATTERN.test(elementId)) {
331459
- return null;
331460
- }
331461
- return elementId;
331462
- } catch {
331463
- return null;
331464
- }
331465
- }
331466
- function normaliseBounds(bounds) {
331467
- if (!bounds || typeof bounds !== "object" || typeof bounds.x !== "number" || typeof bounds.y !== "number" || !Number.isFinite(bounds.x) || !Number.isFinite(bounds.y)) {
331468
- return null;
331469
- }
331470
- const b = bounds;
331471
- const width = typeof b.width === "number" && Number.isFinite(b.width) ? b.width : 0;
331472
- const height = typeof b.height === "number" && Number.isFinite(b.height) ? b.height : 0;
331473
- return { x: b.x, y: b.y, width, height };
331474
- }
331475
- async function tapBoundsCenter(sessionPath, bounds) {
331476
- const box = normaliseBounds(bounds);
331477
- if (!box) return false;
331478
- const tapX = Math.round(box.x + box.width / 2);
331479
- const tapY = Math.round(box.y + box.height / 2);
331480
- await appiumRequest(`${sessionPath}/actions`, {
331481
- method: "POST",
331482
- body: JSON.stringify({
331483
- actions: [{
331484
- type: "pointer",
331485
- id: "finger1",
331486
- parameters: { pointerType: "touch" },
331487
- actions: [
331488
- // Note: button: 0 is used because Appium's WebDriver protocol
331489
- // translates mouse button events to touch events. Standard approach
331490
- // for touch actions in Appium, despite the mouse button semantics.
331491
- { type: "pointerMove", duration: 0, x: tapX, y: tapY },
331492
- { type: "pointerDown", button: 0 },
331493
- { type: "pause", duration: 100 },
331494
- { type: "pointerUp", button: 0 }
331495
- ]
331496
- }]
331497
- })
331498
- });
331499
- return true;
331500
- }
331501
- async function tapAt(sessionPath, bounds) {
331502
- const ok = await tapBoundsCenter(sessionPath, bounds);
331503
- if (!ok) {
331504
- throw new Error("TAP requires usable bounds (finite x/y)");
331505
- }
331506
- }
331507
- async function getElementRect(sessionPath, elementId) {
331508
- try {
331509
- const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
331510
- if (!value || typeof value !== "object") return null;
331511
- const r = value;
331512
- 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)) {
331513
- return null;
331514
- }
331515
- return { x: r.x, y: r.y, width: r.width, height: r.height };
331516
- } catch {
331517
- return null;
331518
- }
331519
- }
331520
- function rectMatchesIntendedBounds(rect, bounds) {
331521
- if (bounds.width <= 0 || bounds.height <= 0) return false;
331522
- const rectCenterX = rect.x + rect.width / 2;
331523
- const rectCenterY = rect.y + rect.height / 2;
331524
- const boundsCenterX = bounds.x + bounds.width / 2;
331525
- const boundsCenterY = bounds.y + bounds.height / 2;
331526
- const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
331527
- const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
331528
- return rectCenterInBounds || boundsCenterInRect;
331529
- }
331530
- async function resolveTypeTargetElementId(sessionPath, bounds) {
331531
- const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
331532
- let elementId = await getActiveElementId2(sessionPath);
331533
- if (elementId && box && box.width > 0 && box.height > 0) {
331534
- const rect = await getElementRect(sessionPath, elementId);
331535
- if (!rect || !rectMatchesIntendedBounds(rect, box)) {
331536
- elementId = null;
331537
- }
331538
- }
331539
- if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
331540
- await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
331541
- elementId = await getActiveElementId2(sessionPath);
331542
- }
331543
- return elementId;
331544
- }
331545
- async function typeText(sessionPath, value, bounds) {
331546
- const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
331547
- if (!elementId) {
331548
- throw new Error("No focused element available for TYPE. Tap an input field first.");
331549
- }
331550
- await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
331551
- method: "POST",
331552
- body: JSON.stringify({ text: value })
331553
- });
331554
- }
331555
- async function clearField(sessionPath, bounds) {
331556
- const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
331557
- if (!elementId) {
331558
- throw new Error("No focused element available for CLEAR. Tap an input field first.");
331559
- }
331560
- await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
331561
- method: "POST",
331562
- body: JSON.stringify({})
331563
- });
331564
- }
331565
- async function swipe(sessionPath, direction, distance, windowSize) {
331566
- const dist = clampMobileSwipeDistance(distance);
331567
- const { startX, startY, endX, endY } = computeMobileSwipePointers(
331568
- windowSize.width,
331569
- windowSize.height,
331570
- direction,
331571
- dist
331572
- );
331573
- await appiumRequest(`${sessionPath}/actions`, {
331574
- method: "POST",
331575
- body: JSON.stringify({
331576
- actions: [{
331577
- type: "pointer",
331578
- id: "finger1",
331579
- parameters: { pointerType: "touch" },
331580
- actions: [
331581
- { type: "pointerMove", duration: 0, x: startX, y: startY },
331582
- { type: "pointerDown", button: 0 },
331583
- { type: "pointerMove", duration: 300, x: endX, y: endY },
331584
- { type: "pointerUp", button: 0 }
331585
- ]
331586
- }]
331587
- })
331588
- });
331589
- }
331590
- async function pressBack(sessionPath) {
331591
- await appiumRequest(`${sessionPath}/back`, { method: "POST", body: "{}" });
331592
- }
331593
- async function pressHome(sessionPath, platform3) {
331594
- if (platform3 === "IOS") {
331595
- await appiumRequest(`${sessionPath}/execute/sync`, {
331596
- method: "POST",
331597
- body: JSON.stringify({
331598
- script: "mobile: pressButton",
331599
- args: [{ name: "home" }]
331600
- })
331601
- });
331602
- return;
331603
- }
331604
- await appiumRequest(`${sessionPath}/appium/device/press_keycode`, {
331605
- method: "POST",
331606
- body: JSON.stringify({ keycode: 3 })
331607
- });
331608
- }
331609
-
331610
- // src/services/mobile-bridge.ts
331611
331680
  var MOBILE_BRIDGE_PORT2 = 9515;
331612
331681
  var MAX_REQUEST_BODY_BYTES = 64 * 1024;
331613
331682
  var MAX_TEXT_INPUT_LENGTH = 4e3;
@@ -331701,7 +331770,7 @@ async function getPageSource2() {
331701
331770
  async function getWindowSize3() {
331702
331771
  if (!state.appiumSessionId) return null;
331703
331772
  validateSessionId(state.appiumSessionId);
331704
- return getWindowSize2(`/session/${state.appiumSessionId}`);
331773
+ return getWindowSize(`/session/${state.appiumSessionId}`);
331705
331774
  }
331706
331775
  var BRIDGE_PROCESS_MARKER = "validateqa-mobile-bridge";
331707
331776
  var BRIDGE_PORT = MOBILE_BRIDGE_PORT2;
@@ -332765,6 +332834,7 @@ var MobileBridgeDriver = class {
332765
332834
  sessionId;
332766
332835
  platform;
332767
332836
  appId;
332837
+ allowAppReset;
332768
332838
  constructor(config) {
332769
332839
  if (!APPIUM_SESSION_ID_PATTERN2.test(config.sessionId)) {
332770
332840
  throw new Error("MobileBridgeDriver: invalid Appium session id");
@@ -332778,6 +332848,7 @@ var MobileBridgeDriver = class {
332778
332848
  this.sessionId = config.sessionId;
332779
332849
  this.platform = config.platform;
332780
332850
  this.appId = config.appId;
332851
+ this.allowAppReset = config.allowAppReset === true;
332781
332852
  }
332782
332853
  /** WebDriver session path (`/session/<id>`), the prefix every primitive needs. */
332783
332854
  get sessionPath() {
@@ -332839,7 +332910,7 @@ var MobileBridgeDriver = class {
332839
332910
  const [xml, screenshot, windowSize] = await Promise.all([
332840
332911
  getPageSource(this.sessionPath),
332841
332912
  getScreenshot(this.sessionPath),
332842
- getWindowSize2(this.sessionPath)
332913
+ getWindowSize(this.sessionPath)
332843
332914
  ]);
332844
332915
  const source = xml ?? "";
332845
332916
  const screenSignal = await this.enrichScreenSignal(source);
@@ -332852,7 +332923,7 @@ var MobileBridgeDriver = class {
332852
332923
  }
332853
332924
  /** Device window size; null when unreadable (swipe geometry degrades gracefully). */
332854
332925
  async windowSize() {
332855
- return getWindowSize2(this.sessionPath);
332926
+ return getWindowSize(this.sessionPath);
332856
332927
  }
332857
332928
  // ── Action result wrapper ──────────────────────────────
332858
332929
  /**
@@ -332894,7 +332965,7 @@ var MobileBridgeDriver = class {
332894
332965
  * hard error so we never fire an off-screen gesture.
332895
332966
  */
332896
332967
  async swipe(direction, distance) {
332897
- const size = await getWindowSize2(this.sessionPath);
332968
+ const size = await getWindowSize(this.sessionPath);
332898
332969
  if (!size) {
332899
332970
  return { ok: false, error: "Could not determine device screen dimensions for swipe." };
332900
332971
  }
@@ -332919,6 +332990,22 @@ var MobileBridgeDriver = class {
332919
332990
  await this.executeScript("mobile: terminateApp", [args]);
332920
332991
  await this.executeScript("mobile: activateApp", [args]);
332921
332992
  }
332993
+ /**
332994
+ * Deterministic state reset: clear app data + relaunch (Android emulators
332995
+ * only, per the runner-computed allowAppReset policy). Returns false when
332996
+ * unsupported/disallowed or when clearApp throws — callers fall back to
332997
+ * relaunch(), preserving today's behavior.
332998
+ */
332999
+ async resetAppState() {
333000
+ if (!this.allowAppReset || this.platform !== "ANDROID") return false;
333001
+ try {
333002
+ await this.executeScript("mobile: clearApp", [{ appId: this.appId }]);
333003
+ await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
333004
+ return true;
333005
+ } catch {
333006
+ return false;
333007
+ }
333008
+ }
332922
333009
  /** (Re)launch the target app (activateApp) from a backgrounded/closed state. */
332923
333010
  async launchApp() {
332924
333011
  await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
@@ -335060,7 +335147,11 @@ ${finalVerifyResult.logs ?? ""}`;
335060
335147
  const driver = new MobileBridgeDriver({
335061
335148
  sessionId: session.sessionId,
335062
335149
  platform: platform3,
335063
- appId: session.appId
335150
+ appId: session.appId,
335151
+ // Deterministic-state authorization: Android emulators only by
335152
+ // default (VALIDATEQA_MOBILE_APP_RESET overrides) — a personal
335153
+ // physical device must never have its app data wiped.
335154
+ allowAppReset: shouldResetMobileAppData(platform3, session.deviceId)
335064
335155
  });
335065
335156
  discoveryResult = await (0, import_runner_core4.runMobileDiscoveryOnRunner)(ctx, driver, {
335066
335157
  onLog: mobileOnLog,