@validate.qa/runner 1.0.19 → 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.
Files changed (3) hide show
  1. package/dist/cli.js +322 -243
  2. package/dist/cli.mjs +322 -243
  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);
@@ -67160,7 +67163,7 @@ ${statePacket}` },
67160
67163
  return out;
67161
67164
  }
67162
67165
  function assembleTest(args) {
67163
- const { scenario, recording, ctx, platform: platform3, log: log2 } = args;
67166
+ const { scenario, recording, ctx, platform: platform3, appDataCleared, log: log2 } = args;
67164
67167
  const actions = collapseRetryCycles(recording.actions);
67165
67168
  if (actions.length < recording.actions.length) {
67166
67169
  log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
@@ -67177,7 +67180,8 @@ ${statePacket}` },
67177
67180
  sessionName: recording.finish?.name ?? scenario.name,
67178
67181
  medium,
67179
67182
  target,
67180
- actions
67183
+ actions,
67184
+ appResetOnLaunch: appDataCleared
67181
67185
  };
67182
67186
  let compiled;
67183
67187
  try {
@@ -67283,7 +67287,12 @@ ${statePacket}` },
67283
67287
  log2(`
67284
67288
  -- Generating scenario ${i + 1}/${scenarios.length}: "${scenario.name}" --`);
67285
67289
  try {
67286
- await driver.relaunch();
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
+ }
67287
67296
  const recording = await runScenarioLoop({
67288
67297
  scenario,
67289
67298
  driver,
@@ -67300,7 +67309,7 @@ ${statePacket}` },
67300
67309
  log: log2
67301
67310
  });
67302
67311
  consecutiveInfraFailures = 0;
67303
- const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
67312
+ const test = assembleTest({ scenario, recording, ctx, platform: platform3, appDataCleared, log: log2 });
67304
67313
  if (!test) {
67305
67314
  log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
67306
67315
  continue;
@@ -313771,6 +313780,8 @@ var require_mobile_discovery_agent = __commonJS({
313771
313780
  };
313772
313781
  }
313773
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.");
313774
313785
  const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
313775
313786
  const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
313776
313787
  const existingScreens = compactExistingScreens(ctx.existingSiteMap);
@@ -328460,7 +328471,7 @@ var require_package4 = __commonJS({
328460
328471
  "package.json"(exports2, module2) {
328461
328472
  module2.exports = {
328462
328473
  name: "@validate.qa/runner",
328463
- version: "1.0.19",
328474
+ version: "1.0.20",
328464
328475
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328465
328476
  bin: {
328466
328477
  "validate-runner": "dist/cli.js"
@@ -328598,9 +328609,9 @@ function shouldUseAuthState(tags, playwrightCode) {
328598
328609
 
328599
328610
  // src/services/appium-executor.ts
328600
328611
  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());
328612
+
328613
+ // src/services/mobile/appium-actions.ts
328614
+ init_dist();
328604
328615
 
328605
328616
  // src/services/appium-lifecycle.ts
328606
328617
  var import_child_process = require("child_process");
@@ -328885,6 +328896,234 @@ function resetServerState(port = APPIUM_PORT, restartCount = 0) {
328885
328896
  };
328886
328897
  }
328887
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
+
328888
329127
  // src/services/device-discovery.ts
328889
329128
  var import_child_process2 = require("child_process");
328890
329129
  var import_os2 = require("os");
@@ -330047,7 +330286,7 @@ async function wdRequest(path, options) {
330047
330286
  }
330048
330287
  return res.json();
330049
330288
  }
330050
- async function getWindowSize(sessionId, retries = 3) {
330289
+ async function getWindowSize2(sessionId, retries = 3) {
330051
330290
  for (let attempt = 0; attempt < retries; attempt++) {
330052
330291
  try {
330053
330292
  const result = await wdRequest(`/session/${sessionId}/window/rect`);
@@ -330223,6 +330462,26 @@ async function freshLaunchTargetApp(sessionId, platform3, appId, log2) {
330223
330462
  log2(` Fresh launch: activate failed (${err instanceof Error ? err.message : String(err)})`);
330224
330463
  }
330225
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
+ }
330226
330485
  async function restoreTargetAppIfExternal(sessionId, target, log2, source, failOnExternal) {
330227
330486
  let boundary = null;
330228
330487
  try {
@@ -330470,7 +330729,7 @@ async function tapCoordinates(sessionId, bounds) {
330470
330729
  })
330471
330730
  });
330472
330731
  }
330473
- async function getActiveElementId(sessionId) {
330732
+ async function getActiveElementId2(sessionId) {
330474
330733
  try {
330475
330734
  const activeEl = await wdRequest(`/session/${sessionId}/element/active`);
330476
330735
  return activeEl?.value?.ELEMENT || activeEl?.value?.["element-6066-11e4-a52e-4f735466cecf"] || null;
@@ -330556,7 +330815,7 @@ async function executeType(sessionId, step, context) {
330556
330815
  await tapCoordinates(sessionId, bounds);
330557
330816
  tappedBounds = true;
330558
330817
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330559
- elementId = await getActiveElementId(sessionId);
330818
+ elementId = await getActiveElementId2(sessionId);
330560
330819
  if (elementId) {
330561
330820
  lastInputError = await trySendElementValue(sessionId, elementId, value);
330562
330821
  if (!lastInputError) return;
@@ -330578,10 +330837,10 @@ async function executeType(sessionId, step, context) {
330578
330837
  await tapCoordinates(sessionId, bounds);
330579
330838
  tappedBounds = true;
330580
330839
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330581
- elementId = await getActiveElementId(sessionId);
330840
+ elementId = await getActiveElementId2(sessionId);
330582
330841
  }
330583
330842
  if (!elementId) {
330584
- elementId = await getActiveElementId(sessionId);
330843
+ elementId = await getActiveElementId2(sessionId);
330585
330844
  }
330586
330845
  if (elementId) {
330587
330846
  lastInputError = await trySendElementValue(sessionId, elementId, value);
@@ -330631,10 +330890,10 @@ async function executeClear(sessionId, step, platform3, isCrossPlatform = false)
330631
330890
  if (!elementId && bounds) {
330632
330891
  await tapCoordinates(sessionId, bounds);
330633
330892
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330634
- elementId = await getActiveElementId(sessionId);
330893
+ elementId = await getActiveElementId2(sessionId);
330635
330894
  }
330636
330895
  if (!elementId) {
330637
- elementId = await getActiveElementId(sessionId);
330896
+ elementId = await getActiveElementId2(sessionId);
330638
330897
  }
330639
330898
  if (!elementId) {
330640
330899
  throw new Error("CLEAR step could not resolve a target or focused element");
@@ -330650,7 +330909,7 @@ async function executeSwipe(sessionId, step) {
330650
330909
  let screenW;
330651
330910
  let screenH;
330652
330911
  try {
330653
- const windowSize = await getWindowSize(sessionId);
330912
+ const windowSize = await getWindowSize2(sessionId);
330654
330913
  screenW = windowSize.width;
330655
330914
  screenH = windowSize.height;
330656
330915
  } catch (err) {
@@ -330865,8 +331124,25 @@ async function executeMobileTest(options) {
330865
331124
  }
330866
331125
  }
330867
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
+ }
330868
331141
  log2("Opening a fresh app instance for the test...");
330869
331142
  await freshLaunchTargetApp(sessionId, options.target.platform, options.target.appId, log2);
331143
+ if (recordedFromClearedState) {
331144
+ await waitForColdBootContent(sessionId, log2);
331145
+ }
330870
331146
  if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
330871
331147
  try {
330872
331148
  log2(`Opening deep link: ${options.target.deeplink}`);
@@ -331381,225 +331657,6 @@ var import_os3 = require("os");
331381
331657
  var import_path2 = require("path");
331382
331658
  var import_fs2 = require("fs");
331383
331659
  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
331660
  var MOBILE_BRIDGE_PORT2 = 9515;
331604
331661
  var MAX_REQUEST_BODY_BYTES = 64 * 1024;
331605
331662
  var MAX_TEXT_INPUT_LENGTH = 4e3;
@@ -331693,7 +331750,7 @@ async function getPageSource2() {
331693
331750
  async function getWindowSize3() {
331694
331751
  if (!state.appiumSessionId) return null;
331695
331752
  validateSessionId(state.appiumSessionId);
331696
- return getWindowSize2(`/session/${state.appiumSessionId}`);
331753
+ return getWindowSize(`/session/${state.appiumSessionId}`);
331697
331754
  }
331698
331755
  var BRIDGE_PROCESS_MARKER = "validateqa-mobile-bridge";
331699
331756
  var BRIDGE_PORT = MOBILE_BRIDGE_PORT2;
@@ -332757,6 +332814,7 @@ var MobileBridgeDriver = class {
332757
332814
  sessionId;
332758
332815
  platform;
332759
332816
  appId;
332817
+ allowAppReset;
332760
332818
  constructor(config) {
332761
332819
  if (!APPIUM_SESSION_ID_PATTERN2.test(config.sessionId)) {
332762
332820
  throw new Error("MobileBridgeDriver: invalid Appium session id");
@@ -332770,6 +332828,7 @@ var MobileBridgeDriver = class {
332770
332828
  this.sessionId = config.sessionId;
332771
332829
  this.platform = config.platform;
332772
332830
  this.appId = config.appId;
332831
+ this.allowAppReset = config.allowAppReset === true;
332773
332832
  }
332774
332833
  /** WebDriver session path (`/session/<id>`), the prefix every primitive needs. */
332775
332834
  get sessionPath() {
@@ -332831,7 +332890,7 @@ var MobileBridgeDriver = class {
332831
332890
  const [xml, screenshot, windowSize] = await Promise.all([
332832
332891
  getPageSource(this.sessionPath),
332833
332892
  getScreenshot(this.sessionPath),
332834
- getWindowSize2(this.sessionPath)
332893
+ getWindowSize(this.sessionPath)
332835
332894
  ]);
332836
332895
  const source = xml ?? "";
332837
332896
  const screenSignal = await this.enrichScreenSignal(source);
@@ -332844,7 +332903,7 @@ var MobileBridgeDriver = class {
332844
332903
  }
332845
332904
  /** Device window size; null when unreadable (swipe geometry degrades gracefully). */
332846
332905
  async windowSize() {
332847
- return getWindowSize2(this.sessionPath);
332906
+ return getWindowSize(this.sessionPath);
332848
332907
  }
332849
332908
  // ── Action result wrapper ──────────────────────────────
332850
332909
  /**
@@ -332886,7 +332945,7 @@ var MobileBridgeDriver = class {
332886
332945
  * hard error so we never fire an off-screen gesture.
332887
332946
  */
332888
332947
  async swipe(direction, distance) {
332889
- const size = await getWindowSize2(this.sessionPath);
332948
+ const size = await getWindowSize(this.sessionPath);
332890
332949
  if (!size) {
332891
332950
  return { ok: false, error: "Could not determine device screen dimensions for swipe." };
332892
332951
  }
@@ -332911,6 +332970,22 @@ var MobileBridgeDriver = class {
332911
332970
  await this.executeScript("mobile: terminateApp", [args]);
332912
332971
  await this.executeScript("mobile: activateApp", [args]);
332913
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
+ }
332914
332989
  /** (Re)launch the target app (activateApp) from a backgrounded/closed state. */
332915
332990
  async launchApp() {
332916
332991
  await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
@@ -335052,7 +335127,11 @@ ${finalVerifyResult.logs ?? ""}`;
335052
335127
  const driver = new MobileBridgeDriver({
335053
335128
  sessionId: session.sessionId,
335054
335129
  platform: platform3,
335055
- 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)
335056
335135
  });
335057
335136
  discoveryResult = await (0, import_runner_core4.runMobileDiscoveryOnRunner)(ctx, driver, {
335058
335137
  onLog: mobileOnLog,