@validate.qa/runner 1.0.18 → 1.0.20

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