@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.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);
@@ -67165,7 +67168,7 @@ ${statePacket}` },
67165
67168
  return out;
67166
67169
  }
67167
67170
  function assembleTest(args) {
67168
- const { scenario, recording, ctx, platform: platform3, log: log2 } = args;
67171
+ const { scenario, recording, ctx, platform: platform3, appDataCleared, log: log2 } = args;
67169
67172
  const actions = collapseRetryCycles(recording.actions);
67170
67173
  if (actions.length < recording.actions.length) {
67171
67174
  log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
@@ -67182,7 +67185,8 @@ ${statePacket}` },
67182
67185
  sessionName: recording.finish?.name ?? scenario.name,
67183
67186
  medium,
67184
67187
  target,
67185
- actions
67188
+ actions,
67189
+ appResetOnLaunch: appDataCleared
67186
67190
  };
67187
67191
  let compiled;
67188
67192
  try {
@@ -67288,7 +67292,12 @@ ${statePacket}` },
67288
67292
  log2(`
67289
67293
  -- Generating scenario ${i + 1}/${scenarios.length}: "${scenario.name}" --`);
67290
67294
  try {
67291
- await driver.relaunch();
67295
+ const appDataCleared = await driver.resetAppState?.().catch(() => false) ?? false;
67296
+ if (appDataCleared) {
67297
+ log2(" app data cleared \u2014 scenario records from a deterministic cold (logged-out) state.");
67298
+ } else {
67299
+ await driver.relaunch();
67300
+ }
67292
67301
  const recording = await runScenarioLoop({
67293
67302
  scenario,
67294
67303
  driver,
@@ -67305,7 +67314,7 @@ ${statePacket}` },
67305
67314
  log: log2
67306
67315
  });
67307
67316
  consecutiveInfraFailures = 0;
67308
- const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
67317
+ const test = assembleTest({ scenario, recording, ctx, platform: platform3, appDataCleared, log: log2 });
67309
67318
  if (!test) {
67310
67319
  log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
67311
67320
  continue;
@@ -313776,6 +313785,8 @@ var require_mobile_discovery_agent = __commonJS({
313776
313785
  };
313777
313786
  }
313778
313787
  log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
313788
+ const exploreAppDataCleared = await driver.resetAppState?.().catch(() => false) ?? false;
313789
+ log2(exploreAppDataCleared ? "App data cleared \u2014 exploring from a deterministic cold (logged-out) state." : "App data reset unavailable (platform/device policy) \u2014 exploring the app in its current state.");
313779
313790
  const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
313780
313791
  const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
313781
313792
  const existingScreens = compactExistingScreens(ctx.existingSiteMap);
@@ -328465,7 +328476,7 @@ var require_package4 = __commonJS({
328465
328476
  "package.json"(exports, module) {
328466
328477
  module.exports = {
328467
328478
  name: "@validate.qa/runner",
328468
- version: "1.0.19",
328479
+ version: "1.0.20",
328469
328480
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328470
328481
  bin: {
328471
328482
  "validate-runner": "dist/cli.js"
@@ -328603,9 +328614,9 @@ function shouldUseAuthState(tags, playwrightCode) {
328603
328614
 
328604
328615
  // src/services/appium-executor.ts
328605
328616
  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";
328617
+
328618
+ // src/services/mobile/appium-actions.ts
328619
+ init_dist();
328609
328620
 
328610
328621
  // src/services/appium-lifecycle.ts
328611
328622
  import { spawn, execFileSync } from "child_process";
@@ -328890,6 +328901,234 @@ function resetServerState(port = APPIUM_PORT, restartCount = 0) {
328890
328901
  };
328891
328902
  }
328892
328903
 
328904
+ // src/services/mobile/appium-actions.ts
328905
+ var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
328906
+ var ELEMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
328907
+ async function appiumRequest(path, options) {
328908
+ const url = `http://localhost:${getAppiumState().port}${path}`;
328909
+ const res = await fetch(url, {
328910
+ ...options,
328911
+ signal: options?.signal ?? AbortSignal.timeout(APPIUM_REQUEST_TIMEOUT_MS),
328912
+ headers: { "Content-Type": "application/json", ...options?.headers }
328913
+ });
328914
+ if (!res.ok) {
328915
+ const body = await res.text().catch(() => "");
328916
+ throw new Error(`Appium error ${res.status}: ${body.slice(0, 200)}`);
328917
+ }
328918
+ return res.json();
328919
+ }
328920
+ function readValue(result) {
328921
+ if (result && typeof result === "object" && "value" in result) {
328922
+ return result.value;
328923
+ }
328924
+ return void 0;
328925
+ }
328926
+ async function getScreenshot(sessionPath) {
328927
+ try {
328928
+ const value = readValue(await appiumRequest(`${sessionPath}/screenshot`));
328929
+ return typeof value === "string" ? value : null;
328930
+ } catch {
328931
+ return null;
328932
+ }
328933
+ }
328934
+ async function getPageSource(sessionPath) {
328935
+ try {
328936
+ const value = readValue(await appiumRequest(`${sessionPath}/source`));
328937
+ return typeof value === "string" ? value : null;
328938
+ } catch {
328939
+ return null;
328940
+ }
328941
+ }
328942
+ async function getWindowSize(sessionPath) {
328943
+ const retries = 3;
328944
+ for (let attempt = 0; attempt < retries; attempt++) {
328945
+ try {
328946
+ const value = readValue(await appiumRequest(`${sessionPath}/window/rect`));
328947
+ if (value && typeof value === "object") {
328948
+ const w = value.width;
328949
+ const h = value.height;
328950
+ if (typeof w === "number" && typeof h === "number" && w > 0 && h > 0) {
328951
+ return { width: w, height: h };
328952
+ }
328953
+ }
328954
+ } catch {
328955
+ }
328956
+ if (attempt < retries - 1) {
328957
+ await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100));
328958
+ }
328959
+ }
328960
+ return null;
328961
+ }
328962
+ async function getActiveElementId(sessionPath) {
328963
+ try {
328964
+ const value = readValue(await appiumRequest(`${sessionPath}/element/active`));
328965
+ if (!value || typeof value !== "object") return null;
328966
+ const v = value;
328967
+ const elementId = v.ELEMENT ?? v["element-6066-11e4-a52e-4f735466cecf"] ?? null;
328968
+ if (typeof elementId !== "string" || !ELEMENT_ID_PATTERN.test(elementId)) {
328969
+ return null;
328970
+ }
328971
+ return elementId;
328972
+ } catch {
328973
+ return null;
328974
+ }
328975
+ }
328976
+ function normaliseBounds(bounds) {
328977
+ if (!bounds || typeof bounds !== "object" || typeof bounds.x !== "number" || typeof bounds.y !== "number" || !Number.isFinite(bounds.x) || !Number.isFinite(bounds.y)) {
328978
+ return null;
328979
+ }
328980
+ const b = bounds;
328981
+ const width = typeof b.width === "number" && Number.isFinite(b.width) ? b.width : 0;
328982
+ const height = typeof b.height === "number" && Number.isFinite(b.height) ? b.height : 0;
328983
+ return { x: b.x, y: b.y, width, height };
328984
+ }
328985
+ async function tapBoundsCenter(sessionPath, bounds) {
328986
+ const box = normaliseBounds(bounds);
328987
+ if (!box) return false;
328988
+ const tapX = Math.round(box.x + box.width / 2);
328989
+ const tapY = Math.round(box.y + box.height / 2);
328990
+ await appiumRequest(`${sessionPath}/actions`, {
328991
+ method: "POST",
328992
+ body: JSON.stringify({
328993
+ actions: [{
328994
+ type: "pointer",
328995
+ id: "finger1",
328996
+ parameters: { pointerType: "touch" },
328997
+ actions: [
328998
+ // Note: button: 0 is used because Appium's WebDriver protocol
328999
+ // translates mouse button events to touch events. Standard approach
329000
+ // for touch actions in Appium, despite the mouse button semantics.
329001
+ { type: "pointerMove", duration: 0, x: tapX, y: tapY },
329002
+ { type: "pointerDown", button: 0 },
329003
+ { type: "pause", duration: 100 },
329004
+ { type: "pointerUp", button: 0 }
329005
+ ]
329006
+ }]
329007
+ })
329008
+ });
329009
+ return true;
329010
+ }
329011
+ async function tapAt(sessionPath, bounds) {
329012
+ const ok = await tapBoundsCenter(sessionPath, bounds);
329013
+ if (!ok) {
329014
+ throw new Error("TAP requires usable bounds (finite x/y)");
329015
+ }
329016
+ }
329017
+ function shouldResetMobileAppData(platform3, deviceId, env = process.env) {
329018
+ if (platform3 !== "ANDROID") return false;
329019
+ const policy = (env.VALIDATEQA_MOBILE_APP_RESET ?? "").trim().toLowerCase();
329020
+ if (policy === "never") return false;
329021
+ if (policy === "always") return true;
329022
+ return !!deviceId && deviceId.startsWith("emulator-");
329023
+ }
329024
+ async function getElementRect(sessionPath, elementId) {
329025
+ try {
329026
+ const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
329027
+ if (!value || typeof value !== "object") return null;
329028
+ const r = value;
329029
+ if (typeof r.x !== "number" || typeof r.y !== "number" || typeof r.width !== "number" || typeof r.height !== "number" || !Number.isFinite(r.x) || !Number.isFinite(r.y)) {
329030
+ return null;
329031
+ }
329032
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
329033
+ } catch {
329034
+ return null;
329035
+ }
329036
+ }
329037
+ function rectMatchesIntendedBounds(rect, bounds) {
329038
+ if (bounds.width <= 0 || bounds.height <= 0) return false;
329039
+ const rectCenterX = rect.x + rect.width / 2;
329040
+ const rectCenterY = rect.y + rect.height / 2;
329041
+ const boundsCenterX = bounds.x + bounds.width / 2;
329042
+ const boundsCenterY = bounds.y + bounds.height / 2;
329043
+ const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
329044
+ const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
329045
+ return rectCenterInBounds || boundsCenterInRect;
329046
+ }
329047
+ async function resolveTypeTargetElementId(sessionPath, bounds) {
329048
+ const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
329049
+ let elementId = await getActiveElementId(sessionPath);
329050
+ if (elementId && box && box.width > 0 && box.height > 0) {
329051
+ const rect = await getElementRect(sessionPath, elementId);
329052
+ if (!rect || !rectMatchesIntendedBounds(rect, box)) {
329053
+ elementId = null;
329054
+ }
329055
+ }
329056
+ if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
329057
+ await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
329058
+ elementId = await getActiveElementId(sessionPath);
329059
+ }
329060
+ return elementId;
329061
+ }
329062
+ async function typeText(sessionPath, value, bounds) {
329063
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
329064
+ if (!elementId) {
329065
+ throw new Error("No focused element available for TYPE. Tap an input field first.");
329066
+ }
329067
+ await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
329068
+ method: "POST",
329069
+ body: JSON.stringify({ text: value })
329070
+ });
329071
+ }
329072
+ async function clearField(sessionPath, bounds) {
329073
+ const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
329074
+ if (!elementId) {
329075
+ throw new Error("No focused element available for CLEAR. Tap an input field first.");
329076
+ }
329077
+ await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
329078
+ method: "POST",
329079
+ body: JSON.stringify({})
329080
+ });
329081
+ }
329082
+ async function swipe(sessionPath, direction, distance, windowSize) {
329083
+ const dist = clampMobileSwipeDistance(distance);
329084
+ const { startX, startY, endX, endY } = computeMobileSwipePointers(
329085
+ windowSize.width,
329086
+ windowSize.height,
329087
+ direction,
329088
+ dist
329089
+ );
329090
+ await appiumRequest(`${sessionPath}/actions`, {
329091
+ method: "POST",
329092
+ body: JSON.stringify({
329093
+ actions: [{
329094
+ type: "pointer",
329095
+ id: "finger1",
329096
+ parameters: { pointerType: "touch" },
329097
+ actions: [
329098
+ { type: "pointerMove", duration: 0, x: startX, y: startY },
329099
+ { type: "pointerDown", button: 0 },
329100
+ { type: "pointerMove", duration: 300, x: endX, y: endY },
329101
+ { type: "pointerUp", button: 0 }
329102
+ ]
329103
+ }]
329104
+ })
329105
+ });
329106
+ }
329107
+ async function pressBack(sessionPath) {
329108
+ await appiumRequest(`${sessionPath}/back`, { method: "POST", body: "{}" });
329109
+ }
329110
+ async function pressHome(sessionPath, platform3) {
329111
+ if (platform3 === "IOS") {
329112
+ await appiumRequest(`${sessionPath}/execute/sync`, {
329113
+ method: "POST",
329114
+ body: JSON.stringify({
329115
+ script: "mobile: pressButton",
329116
+ args: [{ name: "home" }]
329117
+ })
329118
+ });
329119
+ return;
329120
+ }
329121
+ await appiumRequest(`${sessionPath}/appium/device/press_keycode`, {
329122
+ method: "POST",
329123
+ body: JSON.stringify({ keycode: 3 })
329124
+ });
329125
+ }
329126
+
329127
+ // src/services/appium-executor.ts
329128
+ var import_runner_core8 = __toESM(require_dist2());
329129
+ import { execFile as execFile2 } from "child_process";
329130
+ import { promisify as promisify2 } from "util";
329131
+
328893
329132
  // src/services/device-discovery.ts
328894
329133
  import { execFileSync as execFileSync2 } from "child_process";
328895
329134
  import { platform } from "os";
@@ -330055,7 +330294,7 @@ async function wdRequest(path, options) {
330055
330294
  }
330056
330295
  return res.json();
330057
330296
  }
330058
- async function getWindowSize(sessionId, retries = 3) {
330297
+ async function getWindowSize2(sessionId, retries = 3) {
330059
330298
  for (let attempt = 0; attempt < retries; attempt++) {
330060
330299
  try {
330061
330300
  const result = await wdRequest(`/session/${sessionId}/window/rect`);
@@ -330231,6 +330470,26 @@ async function freshLaunchTargetApp(sessionId, platform3, appId, log2) {
330231
330470
  log2(` Fresh launch: activate failed (${err instanceof Error ? err.message : String(err)})`);
330232
330471
  }
330233
330472
  }
330473
+ async function waitForColdBootContent(sessionId, log2, timeoutMs = 2e4) {
330474
+ const deadline = Date.now() + timeoutMs;
330475
+ const MIN_ELEMENTS = 3;
330476
+ let lastCount = 0;
330477
+ while (Date.now() < deadline) {
330478
+ try {
330479
+ const res = await wdRequest(`/session/${sessionId}/source`);
330480
+ const xml = typeof res?.value === "string" ? res.value : "";
330481
+ const count = (xml.match(/<(android\.|XCUIElementType)/g) ?? []).length;
330482
+ lastCount = count;
330483
+ if (count >= MIN_ELEMENTS) {
330484
+ log2(` Cold-boot ready: UI tree has ${count} node(s).`);
330485
+ return;
330486
+ }
330487
+ } catch {
330488
+ }
330489
+ await new Promise((r) => setTimeout(r, 750));
330490
+ }
330491
+ log2(` Cold-boot wait timed out after ${timeoutMs}ms (last tree had ${lastCount} node(s)) \u2014 proceeding anyway.`);
330492
+ }
330234
330493
  async function restoreTargetAppIfExternal(sessionId, target, log2, source, failOnExternal) {
330235
330494
  let boundary = null;
330236
330495
  try {
@@ -330478,7 +330737,7 @@ async function tapCoordinates(sessionId, bounds) {
330478
330737
  })
330479
330738
  });
330480
330739
  }
330481
- async function getActiveElementId(sessionId) {
330740
+ async function getActiveElementId2(sessionId) {
330482
330741
  try {
330483
330742
  const activeEl = await wdRequest(`/session/${sessionId}/element/active`);
330484
330743
  return activeEl?.value?.ELEMENT || activeEl?.value?.["element-6066-11e4-a52e-4f735466cecf"] || null;
@@ -330564,7 +330823,7 @@ async function executeType(sessionId, step, context) {
330564
330823
  await tapCoordinates(sessionId, bounds);
330565
330824
  tappedBounds = true;
330566
330825
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330567
- elementId = await getActiveElementId(sessionId);
330826
+ elementId = await getActiveElementId2(sessionId);
330568
330827
  if (elementId) {
330569
330828
  lastInputError = await trySendElementValue(sessionId, elementId, value);
330570
330829
  if (!lastInputError) return;
@@ -330586,10 +330845,10 @@ async function executeType(sessionId, step, context) {
330586
330845
  await tapCoordinates(sessionId, bounds);
330587
330846
  tappedBounds = true;
330588
330847
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330589
- elementId = await getActiveElementId(sessionId);
330848
+ elementId = await getActiveElementId2(sessionId);
330590
330849
  }
330591
330850
  if (!elementId) {
330592
- elementId = await getActiveElementId(sessionId);
330851
+ elementId = await getActiveElementId2(sessionId);
330593
330852
  }
330594
330853
  if (elementId) {
330595
330854
  lastInputError = await trySendElementValue(sessionId, elementId, value);
@@ -330639,10 +330898,10 @@ async function executeClear(sessionId, step, platform3, isCrossPlatform = false)
330639
330898
  if (!elementId && bounds) {
330640
330899
  await tapCoordinates(sessionId, bounds);
330641
330900
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330642
- elementId = await getActiveElementId(sessionId);
330901
+ elementId = await getActiveElementId2(sessionId);
330643
330902
  }
330644
330903
  if (!elementId) {
330645
- elementId = await getActiveElementId(sessionId);
330904
+ elementId = await getActiveElementId2(sessionId);
330646
330905
  }
330647
330906
  if (!elementId) {
330648
330907
  throw new Error("CLEAR step could not resolve a target or focused element");
@@ -330658,7 +330917,7 @@ async function executeSwipe(sessionId, step) {
330658
330917
  let screenW;
330659
330918
  let screenH;
330660
330919
  try {
330661
- const windowSize = await getWindowSize(sessionId);
330920
+ const windowSize = await getWindowSize2(sessionId);
330662
330921
  screenW = windowSize.width;
330663
330922
  screenH = windowSize.height;
330664
330923
  } catch (err) {
@@ -330873,8 +331132,25 @@ async function executeMobileTest(options) {
330873
331132
  }
330874
331133
  }
330875
331134
  recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
331135
+ const launchStepForReset = runSteps.find((s) => s.action === "launchApp");
331136
+ const recordedFromClearedState = launchStepForReset?.metadata?.appReset === "cleared";
331137
+ if (recordedFromClearedState) {
331138
+ if (shouldResetMobileAppData(options.target.platform, selectedDevice.id)) {
331139
+ try {
331140
+ await executeMobileScript(sessionId, "mobile: clearApp", [{ appId: options.target.appId }]);
331141
+ log2("App data cleared before launch (test was recorded from a cold logged-out state).");
331142
+ } catch (err) {
331143
+ log2(`App-data clear failed (continuing with plain fresh launch): ${err instanceof Error ? err.message : String(err)}`);
331144
+ }
331145
+ } else {
331146
+ log2("Test expects a cleared app state, but app-data reset is disabled by policy (physical device / VALIDATEQA_MOBILE_APP_RESET=never) \u2014 replaying against the current app state.");
331147
+ }
331148
+ }
330876
331149
  log2("Opening a fresh app instance for the test...");
330877
331150
  await freshLaunchTargetApp(sessionId, options.target.platform, options.target.appId, log2);
331151
+ if (recordedFromClearedState) {
331152
+ await waitForColdBootContent(sessionId, log2);
331153
+ }
330878
331154
  if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
330879
331155
  try {
330880
331156
  log2(`Opening deep link: ${options.target.deeplink}`);
@@ -331389,225 +331665,6 @@ import { homedir as homedir2 } from "os";
331389
331665
  import { join as join3, dirname } from "path";
331390
331666
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2, mkdirSync as mkdirSync2 } from "fs";
331391
331667
  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
331668
  var MOBILE_BRIDGE_PORT2 = 9515;
331612
331669
  var MAX_REQUEST_BODY_BYTES = 64 * 1024;
331613
331670
  var MAX_TEXT_INPUT_LENGTH = 4e3;
@@ -331701,7 +331758,7 @@ async function getPageSource2() {
331701
331758
  async function getWindowSize3() {
331702
331759
  if (!state.appiumSessionId) return null;
331703
331760
  validateSessionId(state.appiumSessionId);
331704
- return getWindowSize2(`/session/${state.appiumSessionId}`);
331761
+ return getWindowSize(`/session/${state.appiumSessionId}`);
331705
331762
  }
331706
331763
  var BRIDGE_PROCESS_MARKER = "validateqa-mobile-bridge";
331707
331764
  var BRIDGE_PORT = MOBILE_BRIDGE_PORT2;
@@ -332765,6 +332822,7 @@ var MobileBridgeDriver = class {
332765
332822
  sessionId;
332766
332823
  platform;
332767
332824
  appId;
332825
+ allowAppReset;
332768
332826
  constructor(config) {
332769
332827
  if (!APPIUM_SESSION_ID_PATTERN2.test(config.sessionId)) {
332770
332828
  throw new Error("MobileBridgeDriver: invalid Appium session id");
@@ -332778,6 +332836,7 @@ var MobileBridgeDriver = class {
332778
332836
  this.sessionId = config.sessionId;
332779
332837
  this.platform = config.platform;
332780
332838
  this.appId = config.appId;
332839
+ this.allowAppReset = config.allowAppReset === true;
332781
332840
  }
332782
332841
  /** WebDriver session path (`/session/<id>`), the prefix every primitive needs. */
332783
332842
  get sessionPath() {
@@ -332839,7 +332898,7 @@ var MobileBridgeDriver = class {
332839
332898
  const [xml, screenshot, windowSize] = await Promise.all([
332840
332899
  getPageSource(this.sessionPath),
332841
332900
  getScreenshot(this.sessionPath),
332842
- getWindowSize2(this.sessionPath)
332901
+ getWindowSize(this.sessionPath)
332843
332902
  ]);
332844
332903
  const source = xml ?? "";
332845
332904
  const screenSignal = await this.enrichScreenSignal(source);
@@ -332852,7 +332911,7 @@ var MobileBridgeDriver = class {
332852
332911
  }
332853
332912
  /** Device window size; null when unreadable (swipe geometry degrades gracefully). */
332854
332913
  async windowSize() {
332855
- return getWindowSize2(this.sessionPath);
332914
+ return getWindowSize(this.sessionPath);
332856
332915
  }
332857
332916
  // ── Action result wrapper ──────────────────────────────
332858
332917
  /**
@@ -332894,7 +332953,7 @@ var MobileBridgeDriver = class {
332894
332953
  * hard error so we never fire an off-screen gesture.
332895
332954
  */
332896
332955
  async swipe(direction, distance) {
332897
- const size = await getWindowSize2(this.sessionPath);
332956
+ const size = await getWindowSize(this.sessionPath);
332898
332957
  if (!size) {
332899
332958
  return { ok: false, error: "Could not determine device screen dimensions for swipe." };
332900
332959
  }
@@ -332919,6 +332978,22 @@ var MobileBridgeDriver = class {
332919
332978
  await this.executeScript("mobile: terminateApp", [args]);
332920
332979
  await this.executeScript("mobile: activateApp", [args]);
332921
332980
  }
332981
+ /**
332982
+ * Deterministic state reset: clear app data + relaunch (Android emulators
332983
+ * only, per the runner-computed allowAppReset policy). Returns false when
332984
+ * unsupported/disallowed or when clearApp throws — callers fall back to
332985
+ * relaunch(), preserving today's behavior.
332986
+ */
332987
+ async resetAppState() {
332988
+ if (!this.allowAppReset || this.platform !== "ANDROID") return false;
332989
+ try {
332990
+ await this.executeScript("mobile: clearApp", [{ appId: this.appId }]);
332991
+ await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
332992
+ return true;
332993
+ } catch {
332994
+ return false;
332995
+ }
332996
+ }
332922
332997
  /** (Re)launch the target app (activateApp) from a backgrounded/closed state. */
332923
332998
  async launchApp() {
332924
332999
  await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
@@ -335060,7 +335135,11 @@ ${finalVerifyResult.logs ?? ""}`;
335060
335135
  const driver = new MobileBridgeDriver({
335061
335136
  sessionId: session.sessionId,
335062
335137
  platform: platform3,
335063
- appId: session.appId
335138
+ appId: session.appId,
335139
+ // Deterministic-state authorization: Android emulators only by
335140
+ // default (VALIDATEQA_MOBILE_APP_RESET overrides) — a personal
335141
+ // physical device must never have its app data wiped.
335142
+ allowAppReset: shouldResetMobileAppData(platform3, session.deviceId)
335064
335143
  });
335065
335144
  discoveryResult = await (0, import_runner_core4.runMobileDiscoveryOnRunner)(ctx, driver, {
335066
335145
  onLog: mobileOnLog,