@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.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);
@@ -62045,7 +62048,7 @@ var require_mobile_explorer = __commonJS({
62045
62048
  }
62046
62049
  sections.push("");
62047
62050
  if (mode === "survey") {
62048
- 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.");
62051
+ 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.`);
62049
62052
  } else {
62050
62053
  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.");
62051
62054
  }
@@ -62103,7 +62106,14 @@ var require_mobile_explorer = __commonJS({
62103
62106
  This app has stored login credentials under these keys:
62104
62107
  ${keyList}
62105
62108
 
62106
- 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.
62109
+ 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:
62110
+ 1. Reach the email/password sign-in form (tap "Sign in with Email" / "Login" from the landing screen if needed).
62111
+ 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.
62112
+ 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.
62113
+ 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).
62114
+ 5. Only AFTER you have confirmed you are signed in, map the authenticated app.
62115
+
62116
+ 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.
62107
62117
 
62108
62118
  SECURITY RULES:
62109
62119
  - 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.
@@ -62410,6 +62420,8 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
62410
62420
  let summary = "";
62411
62421
  let truncated = false;
62412
62422
  let truncatedReason;
62423
+ let finishRedirects = 0;
62424
+ const MAX_FINISH_REDIRECTS = 2;
62413
62425
  try {
62414
62426
  try {
62415
62427
  const seed = await driver.snapshot();
@@ -62539,6 +62551,20 @@ ${statePacket}` }
62539
62551
  continue;
62540
62552
  }
62541
62553
  if (toolName === "finish_exploration") {
62554
+ const frontier = mode === "survey" ? state2.getFrontier() : [];
62555
+ const budgetRemainingPct = (maxIterations - iteration) / maxIterations;
62556
+ if (frontier.length > 0 && budgetRemainingPct >= 0.2 && finishRedirects < MAX_FINISH_REDIRECTS) {
62557
+ finishRedirects++;
62558
+ const targets = frontier.slice(0, 6).join(", ");
62559
+ log2(`
62560
+ finish_exploration bounced (${finishRedirects}/${MAX_FINISH_REDIRECTS}): ${frontier.length} frontier screen(s) still unvisited, ${Math.round(budgetRemainingPct * 100)}% budget left.`);
62561
+ pushResult({
62562
+ acknowledged: false,
62563
+ keepExploring: true,
62564
+ 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.`
62565
+ });
62566
+ continue;
62567
+ }
62542
62568
  explorationComplete = true;
62543
62569
  summary = String(toolArgs.summary ?? "");
62544
62570
  log2(`
@@ -62757,6 +62783,14 @@ Exploration complete: ${summary}`);
62757
62783
  ...actionResult.error ? { error: actionResult.error } : {},
62758
62784
  // Report only the KEY that was filled — never the value.
62759
62785
  filledCredentialKey: key,
62786
+ // Code-level reinforcement of the login sequence: filling a field does
62787
+ // NOT submit the form. The observed failure was fill-without-submit —
62788
+ // the model filled email+password then wandered off (into demo content)
62789
+ // without ever tapping Sign In, so it never reached the authenticated
62790
+ // app. This nudge fires on every credential fill so the model is
62791
+ // reminded to complete + verify the login rather than treating the fill
62792
+ // as "logged in".
62793
+ 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.",
62760
62794
  screenId,
62761
62795
  observation,
62762
62796
  ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
@@ -67134,7 +67168,7 @@ ${statePacket}` },
67134
67168
  return out;
67135
67169
  }
67136
67170
  function assembleTest(args) {
67137
- const { scenario, recording, ctx, platform: platform3, log: log2 } = args;
67171
+ const { scenario, recording, ctx, platform: platform3, appDataCleared, log: log2 } = args;
67138
67172
  const actions = collapseRetryCycles(recording.actions);
67139
67173
  if (actions.length < recording.actions.length) {
67140
67174
  log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
@@ -67151,7 +67185,8 @@ ${statePacket}` },
67151
67185
  sessionName: recording.finish?.name ?? scenario.name,
67152
67186
  medium,
67153
67187
  target,
67154
- actions
67188
+ actions,
67189
+ appResetOnLaunch: appDataCleared
67155
67190
  };
67156
67191
  let compiled;
67157
67192
  try {
@@ -67257,7 +67292,12 @@ ${statePacket}` },
67257
67292
  log2(`
67258
67293
  -- Generating scenario ${i + 1}/${scenarios.length}: "${scenario.name}" --`);
67259
67294
  try {
67260
- 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
+ }
67261
67301
  const recording = await runScenarioLoop({
67262
67302
  scenario,
67263
67303
  driver,
@@ -67274,7 +67314,7 @@ ${statePacket}` },
67274
67314
  log: log2
67275
67315
  });
67276
67316
  consecutiveInfraFailures = 0;
67277
- const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
67317
+ const test = assembleTest({ scenario, recording, ctx, platform: platform3, appDataCleared, log: log2 });
67278
67318
  if (!test) {
67279
67319
  log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
67280
67320
  continue;
@@ -313745,6 +313785,8 @@ var require_mobile_discovery_agent = __commonJS({
313745
313785
  };
313746
313786
  }
313747
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.");
313748
313790
  const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
313749
313791
  const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
313750
313792
  const existingScreens = compactExistingScreens(ctx.existingSiteMap);
@@ -328434,7 +328476,7 @@ var require_package4 = __commonJS({
328434
328476
  "package.json"(exports, module) {
328435
328477
  module.exports = {
328436
328478
  name: "@validate.qa/runner",
328437
- version: "1.0.18",
328479
+ version: "1.0.20",
328438
328480
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328439
328481
  bin: {
328440
328482
  "validate-runner": "dist/cli.js"
@@ -328572,9 +328614,9 @@ function shouldUseAuthState(tags, playwrightCode) {
328572
328614
 
328573
328615
  // src/services/appium-executor.ts
328574
328616
  init_dist();
328575
- var import_runner_core8 = __toESM(require_dist2());
328576
- import { execFile as execFile2 } from "child_process";
328577
- import { promisify as promisify2 } from "util";
328617
+
328618
+ // src/services/mobile/appium-actions.ts
328619
+ init_dist();
328578
328620
 
328579
328621
  // src/services/appium-lifecycle.ts
328580
328622
  import { spawn, execFileSync } from "child_process";
@@ -328859,6 +328901,234 @@ function resetServerState(port = APPIUM_PORT, restartCount = 0) {
328859
328901
  };
328860
328902
  }
328861
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
+
328862
329132
  // src/services/device-discovery.ts
328863
329133
  import { execFileSync as execFileSync2 } from "child_process";
328864
329134
  import { platform } from "os";
@@ -330024,7 +330294,7 @@ async function wdRequest(path, options) {
330024
330294
  }
330025
330295
  return res.json();
330026
330296
  }
330027
- async function getWindowSize(sessionId, retries = 3) {
330297
+ async function getWindowSize2(sessionId, retries = 3) {
330028
330298
  for (let attempt = 0; attempt < retries; attempt++) {
330029
330299
  try {
330030
330300
  const result = await wdRequest(`/session/${sessionId}/window/rect`);
@@ -330200,6 +330470,26 @@ async function freshLaunchTargetApp(sessionId, platform3, appId, log2) {
330200
330470
  log2(` Fresh launch: activate failed (${err instanceof Error ? err.message : String(err)})`);
330201
330471
  }
330202
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
+ }
330203
330493
  async function restoreTargetAppIfExternal(sessionId, target, log2, source, failOnExternal) {
330204
330494
  let boundary = null;
330205
330495
  try {
@@ -330447,7 +330737,7 @@ async function tapCoordinates(sessionId, bounds) {
330447
330737
  })
330448
330738
  });
330449
330739
  }
330450
- async function getActiveElementId(sessionId) {
330740
+ async function getActiveElementId2(sessionId) {
330451
330741
  try {
330452
330742
  const activeEl = await wdRequest(`/session/${sessionId}/element/active`);
330453
330743
  return activeEl?.value?.ELEMENT || activeEl?.value?.["element-6066-11e4-a52e-4f735466cecf"] || null;
@@ -330533,7 +330823,7 @@ async function executeType(sessionId, step, context) {
330533
330823
  await tapCoordinates(sessionId, bounds);
330534
330824
  tappedBounds = true;
330535
330825
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330536
- elementId = await getActiveElementId(sessionId);
330826
+ elementId = await getActiveElementId2(sessionId);
330537
330827
  if (elementId) {
330538
330828
  lastInputError = await trySendElementValue(sessionId, elementId, value);
330539
330829
  if (!lastInputError) return;
@@ -330555,10 +330845,10 @@ async function executeType(sessionId, step, context) {
330555
330845
  await tapCoordinates(sessionId, bounds);
330556
330846
  tappedBounds = true;
330557
330847
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330558
- elementId = await getActiveElementId(sessionId);
330848
+ elementId = await getActiveElementId2(sessionId);
330559
330849
  }
330560
330850
  if (!elementId) {
330561
- elementId = await getActiveElementId(sessionId);
330851
+ elementId = await getActiveElementId2(sessionId);
330562
330852
  }
330563
330853
  if (elementId) {
330564
330854
  lastInputError = await trySendElementValue(sessionId, elementId, value);
@@ -330608,10 +330898,10 @@ async function executeClear(sessionId, step, platform3, isCrossPlatform = false)
330608
330898
  if (!elementId && bounds) {
330609
330899
  await tapCoordinates(sessionId, bounds);
330610
330900
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
330611
- elementId = await getActiveElementId(sessionId);
330901
+ elementId = await getActiveElementId2(sessionId);
330612
330902
  }
330613
330903
  if (!elementId) {
330614
- elementId = await getActiveElementId(sessionId);
330904
+ elementId = await getActiveElementId2(sessionId);
330615
330905
  }
330616
330906
  if (!elementId) {
330617
330907
  throw new Error("CLEAR step could not resolve a target or focused element");
@@ -330627,7 +330917,7 @@ async function executeSwipe(sessionId, step) {
330627
330917
  let screenW;
330628
330918
  let screenH;
330629
330919
  try {
330630
- const windowSize = await getWindowSize(sessionId);
330920
+ const windowSize = await getWindowSize2(sessionId);
330631
330921
  screenW = windowSize.width;
330632
330922
  screenH = windowSize.height;
330633
330923
  } catch (err) {
@@ -330842,8 +331132,25 @@ async function executeMobileTest(options) {
330842
331132
  }
330843
331133
  }
330844
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
+ }
330845
331149
  log2("Opening a fresh app instance for the test...");
330846
331150
  await freshLaunchTargetApp(sessionId, options.target.platform, options.target.appId, log2);
331151
+ if (recordedFromClearedState) {
331152
+ await waitForColdBootContent(sessionId, log2);
331153
+ }
330847
331154
  if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
330848
331155
  try {
330849
331156
  log2(`Opening deep link: ${options.target.deeplink}`);
@@ -331358,225 +331665,6 @@ import { homedir as homedir2 } from "os";
331358
331665
  import { join as join3, dirname } from "path";
331359
331666
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2, mkdirSync as mkdirSync2 } from "fs";
331360
331667
  init_dist();
331361
-
331362
- // src/services/mobile/appium-actions.ts
331363
- init_dist();
331364
- var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
331365
- var ELEMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
331366
- async function appiumRequest(path, options) {
331367
- const url = `http://localhost:${getAppiumState().port}${path}`;
331368
- const res = await fetch(url, {
331369
- ...options,
331370
- signal: options?.signal ?? AbortSignal.timeout(APPIUM_REQUEST_TIMEOUT_MS),
331371
- headers: { "Content-Type": "application/json", ...options?.headers }
331372
- });
331373
- if (!res.ok) {
331374
- const body = await res.text().catch(() => "");
331375
- throw new Error(`Appium error ${res.status}: ${body.slice(0, 200)}`);
331376
- }
331377
- return res.json();
331378
- }
331379
- function readValue(result) {
331380
- if (result && typeof result === "object" && "value" in result) {
331381
- return result.value;
331382
- }
331383
- return void 0;
331384
- }
331385
- async function getScreenshot(sessionPath) {
331386
- try {
331387
- const value = readValue(await appiumRequest(`${sessionPath}/screenshot`));
331388
- return typeof value === "string" ? value : null;
331389
- } catch {
331390
- return null;
331391
- }
331392
- }
331393
- async function getPageSource(sessionPath) {
331394
- try {
331395
- const value = readValue(await appiumRequest(`${sessionPath}/source`));
331396
- return typeof value === "string" ? value : null;
331397
- } catch {
331398
- return null;
331399
- }
331400
- }
331401
- async function getWindowSize2(sessionPath) {
331402
- const retries = 3;
331403
- for (let attempt = 0; attempt < retries; attempt++) {
331404
- try {
331405
- const value = readValue(await appiumRequest(`${sessionPath}/window/rect`));
331406
- if (value && typeof value === "object") {
331407
- const w = value.width;
331408
- const h = value.height;
331409
- if (typeof w === "number" && typeof h === "number" && w > 0 && h > 0) {
331410
- return { width: w, height: h };
331411
- }
331412
- }
331413
- } catch {
331414
- }
331415
- if (attempt < retries - 1) {
331416
- await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100));
331417
- }
331418
- }
331419
- return null;
331420
- }
331421
- async function getActiveElementId2(sessionPath) {
331422
- try {
331423
- const value = readValue(await appiumRequest(`${sessionPath}/element/active`));
331424
- if (!value || typeof value !== "object") return null;
331425
- const v = value;
331426
- const elementId = v.ELEMENT ?? v["element-6066-11e4-a52e-4f735466cecf"] ?? null;
331427
- if (typeof elementId !== "string" || !ELEMENT_ID_PATTERN.test(elementId)) {
331428
- return null;
331429
- }
331430
- return elementId;
331431
- } catch {
331432
- return null;
331433
- }
331434
- }
331435
- function normaliseBounds(bounds) {
331436
- if (!bounds || typeof bounds !== "object" || typeof bounds.x !== "number" || typeof bounds.y !== "number" || !Number.isFinite(bounds.x) || !Number.isFinite(bounds.y)) {
331437
- return null;
331438
- }
331439
- const b = bounds;
331440
- const width = typeof b.width === "number" && Number.isFinite(b.width) ? b.width : 0;
331441
- const height = typeof b.height === "number" && Number.isFinite(b.height) ? b.height : 0;
331442
- return { x: b.x, y: b.y, width, height };
331443
- }
331444
- async function tapBoundsCenter(sessionPath, bounds) {
331445
- const box = normaliseBounds(bounds);
331446
- if (!box) return false;
331447
- const tapX = Math.round(box.x + box.width / 2);
331448
- const tapY = Math.round(box.y + box.height / 2);
331449
- await appiumRequest(`${sessionPath}/actions`, {
331450
- method: "POST",
331451
- body: JSON.stringify({
331452
- actions: [{
331453
- type: "pointer",
331454
- id: "finger1",
331455
- parameters: { pointerType: "touch" },
331456
- actions: [
331457
- // Note: button: 0 is used because Appium's WebDriver protocol
331458
- // translates mouse button events to touch events. Standard approach
331459
- // for touch actions in Appium, despite the mouse button semantics.
331460
- { type: "pointerMove", duration: 0, x: tapX, y: tapY },
331461
- { type: "pointerDown", button: 0 },
331462
- { type: "pause", duration: 100 },
331463
- { type: "pointerUp", button: 0 }
331464
- ]
331465
- }]
331466
- })
331467
- });
331468
- return true;
331469
- }
331470
- async function tapAt(sessionPath, bounds) {
331471
- const ok = await tapBoundsCenter(sessionPath, bounds);
331472
- if (!ok) {
331473
- throw new Error("TAP requires usable bounds (finite x/y)");
331474
- }
331475
- }
331476
- async function getElementRect(sessionPath, elementId) {
331477
- try {
331478
- const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
331479
- if (!value || typeof value !== "object") return null;
331480
- const r = value;
331481
- 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)) {
331482
- return null;
331483
- }
331484
- return { x: r.x, y: r.y, width: r.width, height: r.height };
331485
- } catch {
331486
- return null;
331487
- }
331488
- }
331489
- function rectMatchesIntendedBounds(rect, bounds) {
331490
- if (bounds.width <= 0 || bounds.height <= 0) return false;
331491
- const rectCenterX = rect.x + rect.width / 2;
331492
- const rectCenterY = rect.y + rect.height / 2;
331493
- const boundsCenterX = bounds.x + bounds.width / 2;
331494
- const boundsCenterY = bounds.y + bounds.height / 2;
331495
- const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
331496
- const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
331497
- return rectCenterInBounds || boundsCenterInRect;
331498
- }
331499
- async function resolveTypeTargetElementId(sessionPath, bounds) {
331500
- const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
331501
- let elementId = await getActiveElementId2(sessionPath);
331502
- if (elementId && box && box.width > 0 && box.height > 0) {
331503
- const rect = await getElementRect(sessionPath, elementId);
331504
- if (!rect || !rectMatchesIntendedBounds(rect, box)) {
331505
- elementId = null;
331506
- }
331507
- }
331508
- if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
331509
- await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
331510
- elementId = await getActiveElementId2(sessionPath);
331511
- }
331512
- return elementId;
331513
- }
331514
- async function typeText(sessionPath, value, bounds) {
331515
- const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
331516
- if (!elementId) {
331517
- throw new Error("No focused element available for TYPE. Tap an input field first.");
331518
- }
331519
- await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
331520
- method: "POST",
331521
- body: JSON.stringify({ text: value })
331522
- });
331523
- }
331524
- async function clearField(sessionPath, bounds) {
331525
- const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
331526
- if (!elementId) {
331527
- throw new Error("No focused element available for CLEAR. Tap an input field first.");
331528
- }
331529
- await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
331530
- method: "POST",
331531
- body: JSON.stringify({})
331532
- });
331533
- }
331534
- async function swipe(sessionPath, direction, distance, windowSize) {
331535
- const dist = clampMobileSwipeDistance(distance);
331536
- const { startX, startY, endX, endY } = computeMobileSwipePointers(
331537
- windowSize.width,
331538
- windowSize.height,
331539
- direction,
331540
- dist
331541
- );
331542
- await appiumRequest(`${sessionPath}/actions`, {
331543
- method: "POST",
331544
- body: JSON.stringify({
331545
- actions: [{
331546
- type: "pointer",
331547
- id: "finger1",
331548
- parameters: { pointerType: "touch" },
331549
- actions: [
331550
- { type: "pointerMove", duration: 0, x: startX, y: startY },
331551
- { type: "pointerDown", button: 0 },
331552
- { type: "pointerMove", duration: 300, x: endX, y: endY },
331553
- { type: "pointerUp", button: 0 }
331554
- ]
331555
- }]
331556
- })
331557
- });
331558
- }
331559
- async function pressBack(sessionPath) {
331560
- await appiumRequest(`${sessionPath}/back`, { method: "POST", body: "{}" });
331561
- }
331562
- async function pressHome(sessionPath, platform3) {
331563
- if (platform3 === "IOS") {
331564
- await appiumRequest(`${sessionPath}/execute/sync`, {
331565
- method: "POST",
331566
- body: JSON.stringify({
331567
- script: "mobile: pressButton",
331568
- args: [{ name: "home" }]
331569
- })
331570
- });
331571
- return;
331572
- }
331573
- await appiumRequest(`${sessionPath}/appium/device/press_keycode`, {
331574
- method: "POST",
331575
- body: JSON.stringify({ keycode: 3 })
331576
- });
331577
- }
331578
-
331579
- // src/services/mobile-bridge.ts
331580
331668
  var MOBILE_BRIDGE_PORT2 = 9515;
331581
331669
  var MAX_REQUEST_BODY_BYTES = 64 * 1024;
331582
331670
  var MAX_TEXT_INPUT_LENGTH = 4e3;
@@ -331670,7 +331758,7 @@ async function getPageSource2() {
331670
331758
  async function getWindowSize3() {
331671
331759
  if (!state.appiumSessionId) return null;
331672
331760
  validateSessionId(state.appiumSessionId);
331673
- return getWindowSize2(`/session/${state.appiumSessionId}`);
331761
+ return getWindowSize(`/session/${state.appiumSessionId}`);
331674
331762
  }
331675
331763
  var BRIDGE_PROCESS_MARKER = "validateqa-mobile-bridge";
331676
331764
  var BRIDGE_PORT = MOBILE_BRIDGE_PORT2;
@@ -332734,6 +332822,7 @@ var MobileBridgeDriver = class {
332734
332822
  sessionId;
332735
332823
  platform;
332736
332824
  appId;
332825
+ allowAppReset;
332737
332826
  constructor(config) {
332738
332827
  if (!APPIUM_SESSION_ID_PATTERN2.test(config.sessionId)) {
332739
332828
  throw new Error("MobileBridgeDriver: invalid Appium session id");
@@ -332747,6 +332836,7 @@ var MobileBridgeDriver = class {
332747
332836
  this.sessionId = config.sessionId;
332748
332837
  this.platform = config.platform;
332749
332838
  this.appId = config.appId;
332839
+ this.allowAppReset = config.allowAppReset === true;
332750
332840
  }
332751
332841
  /** WebDriver session path (`/session/<id>`), the prefix every primitive needs. */
332752
332842
  get sessionPath() {
@@ -332808,7 +332898,7 @@ var MobileBridgeDriver = class {
332808
332898
  const [xml, screenshot, windowSize] = await Promise.all([
332809
332899
  getPageSource(this.sessionPath),
332810
332900
  getScreenshot(this.sessionPath),
332811
- getWindowSize2(this.sessionPath)
332901
+ getWindowSize(this.sessionPath)
332812
332902
  ]);
332813
332903
  const source = xml ?? "";
332814
332904
  const screenSignal = await this.enrichScreenSignal(source);
@@ -332821,7 +332911,7 @@ var MobileBridgeDriver = class {
332821
332911
  }
332822
332912
  /** Device window size; null when unreadable (swipe geometry degrades gracefully). */
332823
332913
  async windowSize() {
332824
- return getWindowSize2(this.sessionPath);
332914
+ return getWindowSize(this.sessionPath);
332825
332915
  }
332826
332916
  // ── Action result wrapper ──────────────────────────────
332827
332917
  /**
@@ -332863,7 +332953,7 @@ var MobileBridgeDriver = class {
332863
332953
  * hard error so we never fire an off-screen gesture.
332864
332954
  */
332865
332955
  async swipe(direction, distance) {
332866
- const size = await getWindowSize2(this.sessionPath);
332956
+ const size = await getWindowSize(this.sessionPath);
332867
332957
  if (!size) {
332868
332958
  return { ok: false, error: "Could not determine device screen dimensions for swipe." };
332869
332959
  }
@@ -332888,6 +332978,22 @@ var MobileBridgeDriver = class {
332888
332978
  await this.executeScript("mobile: terminateApp", [args]);
332889
332979
  await this.executeScript("mobile: activateApp", [args]);
332890
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
+ }
332891
332997
  /** (Re)launch the target app (activateApp) from a backgrounded/closed state. */
332892
332998
  async launchApp() {
332893
332999
  await this.executeScript("mobile: activateApp", [this.appLifecycleArgs()]);
@@ -335029,7 +335135,11 @@ ${finalVerifyResult.logs ?? ""}`;
335029
335135
  const driver = new MobileBridgeDriver({
335030
335136
  sessionId: session.sessionId,
335031
335137
  platform: platform3,
335032
- 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)
335033
335143
  });
335034
335144
  discoveryResult = await (0, import_runner_core4.runMobileDiscoveryOnRunner)(ctx, driver, {
335035
335145
  onLog: mobileOnLog,