@validate.qa/runner 1.0.13 → 1.0.14

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 +145 -11
  2. package/dist/cli.mjs +145 -11
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1357,6 +1357,9 @@ var init_constants = __esm({
1357
1357
  function toJsStringLiteral(value) {
1358
1358
  return JSON.stringify(value);
1359
1359
  }
1360
+ function toInlineCommentText(value) {
1361
+ return value.replace(/[\r\n\u2028\u2029]+/g, " ");
1362
+ }
1360
1363
  function isPlainObject(value) {
1361
1364
  return typeof value === "object" && value !== null && !Array.isArray(value);
1362
1365
  }
@@ -1562,7 +1565,7 @@ function buildAppiumCode(testName, target, steps) {
1562
1565
  `// 3. A booted device/simulator matching the capabilities below`,
1563
1566
  `// This is NOT a WDIO testrunner spec: it uses the standalone remote() client`,
1564
1567
  `// (driver.$, no auto-injected $/expect/describe globals) so it has no harness`,
1565
- `// dependency and runs with a plain \`tsx ${testName}.spec.ts\`.`,
1568
+ `// dependency and runs with a plain \`tsx ${toInlineCommentText(testName)}.spec.ts\`.`,
1566
1569
  `import { remote } from 'webdriverio';`,
1567
1570
  ``,
1568
1571
  `// Resolve the first selector that exists, mirroring the runner's locator`,
@@ -1645,7 +1648,7 @@ function buildAppiumCode(testName, target, steps) {
1645
1648
  ` // Canonical execution source: steps JSON`
1646
1649
  ];
1647
1650
  const stepLines = steps.flatMap((step) => {
1648
- const lines = [` // Step ${step.order + 1}: ${step.description}`];
1651
+ const lines = [` // Step ${step.order + 1}: ${toInlineCommentText(step.description)}`];
1649
1652
  const candidates = selectorCandidatesForStep(step);
1650
1653
  const candidatesLiteral = `[${candidates.map(toJsStringLiteral).join(", ")}]`;
1651
1654
  const hasSelector = candidates.length > 0;
@@ -9696,7 +9699,26 @@ ${statePacket}` },
9696
9699
  steps: compiled.steps,
9697
9700
  playwrightCode: "",
9698
9701
  verified,
9699
- tags
9702
+ tags,
9703
+ // Carry the planner scenario identity so incremental/resume can match an
9704
+ // already-generated scenario reliably (scenarioRef.name === the plan's
9705
+ // scenario name). Without this the resume skip-set falls back to the
9706
+ // model-chosen test name and may regenerate completed scenarios. Mobile has
9707
+ // no live command/locator ledger, so evidence is an empty (but valid) bundle
9708
+ // — scenarioRef is the load-bearing field for the skip-set.
9709
+ healingContext: {
9710
+ scenarioRef: {
9711
+ name: scenario.name,
9712
+ featureGroup: scenario.featureGroup,
9713
+ goal: scenario.goal,
9714
+ type: scenario.type,
9715
+ variant: scenario.variant,
9716
+ entryRoute: scenario.entryRoute,
9717
+ startFromLanding: scenario.startFromLanding,
9718
+ targetPages: [...scenario.targetPages]
9719
+ },
9720
+ evidence: { observedLocators: [], observedTransitions: [] }
9721
+ }
9700
9722
  };
9701
9723
  return test;
9702
9724
  }
@@ -326838,7 +326860,7 @@ var require_package4 = __commonJS({
326838
326860
  "package.json"(exports2, module2) {
326839
326861
  module2.exports = {
326840
326862
  name: "@validate.qa/runner",
326841
- version: "1.0.13",
326863
+ version: "1.0.14",
326842
326864
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
326843
326865
  bin: {
326844
326866
  "validate-runner": "dist/cli.js"
@@ -327557,6 +327579,8 @@ var DEVICE_CMD_TIMEOUT_MS = 15e3;
327557
327579
  var CA_KEY_BITS = 2048;
327558
327580
  var ANDROID_EMULATOR_HOST_LOOPBACK = "10.0.2.2";
327559
327581
  var PENDING_CLEANUP_DIR = (0, import_node_path.join)((0, import_node_os.tmpdir)(), "validateqa-mobile-capture-pending");
327582
+ var IOS_SIM_HOST_PROXY_ENV = "VALIDATEQA_MOBILE_IOS_SIM_HOST_PROXY";
327583
+ var STALE_MARKER_RECONCILE_MS = 6 * 60 * 60 * 1e3;
327560
327584
  function envFlag(name) {
327561
327585
  const value = (process.env[name] ?? "").trim().toLowerCase();
327562
327586
  return value === "1" || value === "true" || value === "yes" || value === "on";
@@ -328026,7 +328050,7 @@ function hostProxyPriorFromMarker(marker) {
328026
328050
  }
328027
328051
  };
328028
328052
  }
328029
- function hostProxyControlSupported(platform3, isPhysical, platformName = process.platform) {
328053
+ function hostProxyControlSupported(platform3, isPhysical, platformName = process.platform, optedIn = envFlag(IOS_SIM_HOST_PROXY_ENV)) {
328030
328054
  if (platform3 !== "IOS") return { supported: false };
328031
328055
  if (isPhysical) {
328032
328056
  return { supported: false, reason: "physical iOS device requires a manual Wi-Fi HTTP proxy; host proxy not applied" };
@@ -328034,28 +328058,46 @@ function hostProxyControlSupported(platform3, isPhysical, platformName = process
328034
328058
  if (platformName !== "darwin") {
328035
328059
  return { supported: false, reason: `iOS simulator host proxy is only controllable on macOS (host is ${platformName})` };
328036
328060
  }
328061
+ if (!optedIn) {
328062
+ return {
328063
+ supported: false,
328064
+ reason: `iOS simulator HTTPS body capture is disabled: it requires repointing the macOS SYSTEM-WIDE HTTP/HTTPS proxy, which would break host HTTPS (Safari/Chrome/Electron) and capture the host's own traffic for this run. Not applied without consent \u2014 set ${IOS_SIM_HOST_PROXY_ENV}=1 to enable it.`
328065
+ };
328066
+ }
328037
328067
  return { supported: true };
328038
328068
  }
328039
328069
  async function iosSimConfigureHostProxy(host, port, onLog) {
328070
+ let service;
328071
+ let prior;
328040
328072
  try {
328041
328073
  const { stdout: order } = await execFileAsync("networksetup", ["-listnetworkserviceorder"], {
328042
328074
  timeout: DEVICE_CMD_TIMEOUT_MS
328043
328075
  });
328044
- const service = parsePrimaryNetworkService(order);
328045
- if (!service) {
328076
+ const svc = parsePrimaryNetworkService(order);
328077
+ if (!svc) {
328046
328078
  return { configured: false, reason: "iOS simulator host proxy could not be configured: no active macOS network service found" };
328047
328079
  }
328080
+ service = svc;
328048
328081
  const [{ stdout: webOut }, { stdout: secureOut }] = await Promise.all([
328049
328082
  execFileAsync("networksetup", ["-getwebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS }),
328050
328083
  execFileAsync("networksetup", ["-getsecurewebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS })
328051
328084
  ]);
328052
- const prior = { web: parseWebProxyState(webOut), secure: parseWebProxyState(secureOut) };
328085
+ prior = { web: parseWebProxyState(webOut), secure: parseWebProxyState(secureOut) };
328053
328086
  for (const args of buildHostProxySetCommands(service, host, port)) {
328054
328087
  await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
328055
328088
  }
328056
328089
  onLog?.(`[mobile-net] macOS host proxy set on "${service}" \u2192 ${host}:${port} (iOS simulator shares the host network).`);
328057
328090
  return { configured: true, service, prior };
328058
328091
  } catch (err) {
328092
+ if (service && prior) {
328093
+ onLog?.(`[mobile-net] host proxy set failed mid-way \u2014 rolling back "${service}" to prior state: ${errMsg(err)}`);
328094
+ for (const args of buildHostProxyRestoreCommands(service, prior)) {
328095
+ try {
328096
+ await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
328097
+ } catch {
328098
+ }
328099
+ }
328100
+ }
328059
328101
  return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg(err)}` };
328060
328102
  }
328061
328103
  }
@@ -330895,6 +330937,79 @@ function printDoctorReport(report) {
330895
330937
  console.log("");
330896
330938
  }
330897
330939
 
330940
+ // src/result-payload-budget.ts
330941
+ var SERVER_RESULT_BODY_LIMIT_BYTES = 50 * 1024 * 1024;
330942
+ var RESULT_BODY_BUDGET_BYTES = 48 * 1024 * 1024;
330943
+ var SERVER_VIDEO_STORE_CAP_BYTES = 24 * 1024 * 1024;
330944
+ function serializedBytes(value) {
330945
+ return Buffer.byteLength(JSON.stringify(value) ?? "", "utf8");
330946
+ }
330947
+ function budgetResultPayload(result, maxBytes = RESULT_BODY_BUDGET_BYTES, videoStoreCapBytes = SERVER_VIDEO_STORE_CAP_BYTES) {
330948
+ const oversizedVideo = typeof result.video === "string" && result.video.length > videoStoreCapBytes;
330949
+ let bytes = serializedBytes(result);
330950
+ if (bytes <= maxBytes && !oversizedVideo) {
330951
+ return { payload: result, shed: [], bytes };
330952
+ }
330953
+ const shed = [];
330954
+ let payload = result;
330955
+ const drop = (fields, label) => {
330956
+ const next = { ...payload };
330957
+ for (const field of fields) next[field] = void 0;
330958
+ payload = next;
330959
+ shed.push(label);
330960
+ bytes = serializedBytes(payload);
330961
+ };
330962
+ if ((bytes > maxBytes || oversizedVideo) && payload.video != null) {
330963
+ drop(["video", "videoMimeType"], "video");
330964
+ }
330965
+ if (bytes > maxBytes && Array.isArray(payload.screenshots) && payload.screenshots.length > 0) {
330966
+ const shots = [...payload.screenshots];
330967
+ const bySizeDesc = shots.map((shot, i) => ({ i, len: typeof shot === "string" ? shot.length : 0 })).sort((a, b) => b.len - a.len);
330968
+ const overshoot = bytes - maxBytes;
330969
+ let shaved = 0;
330970
+ let blanked = 0;
330971
+ for (const { i, len } of bySizeDesc) {
330972
+ if (shaved >= overshoot || len === 0) break;
330973
+ if (shots[i] === "") continue;
330974
+ shots[i] = "";
330975
+ shaved += len;
330976
+ blanked++;
330977
+ }
330978
+ if (blanked > 0) {
330979
+ payload = { ...payload, screenshots: shots };
330980
+ bytes = serializedBytes(payload);
330981
+ shed.push(blanked === shots.length ? "screenshots" : `${blanked}/${shots.length} screenshots`);
330982
+ }
330983
+ }
330984
+ if (bytes > maxBytes && payload.harApiCalls != null) drop(["harApiCalls"], "harApiCalls");
330985
+ if (bytes > maxBytes && payload.apiCalls != null) drop(["apiCalls"], "apiCalls");
330986
+ if (shed.length > 0) {
330987
+ const note = `
330988
+
330989
+ \u26A0 [runner] Result media trimmed before upload \u2014 payload exceeded the ${Math.round(maxBytes / (1024 * 1024))}MB server limit; dropped: ${shed.join(", ")}.`;
330990
+ payload = {
330991
+ ...payload,
330992
+ logs: (typeof payload.logs === "string" ? payload.logs : "") + note
330993
+ };
330994
+ bytes = serializedBytes(payload);
330995
+ }
330996
+ if (bytes > maxBytes && typeof payload.logs === "string" && payload.logs.length > 4096) {
330997
+ const over = bytes - maxBytes;
330998
+ const targetLen = Math.max(2048, payload.logs.length - over - 1024);
330999
+ if (targetLen < payload.logs.length) {
331000
+ const half = Math.floor(targetLen / 2);
331001
+ const head = payload.logs.slice(0, half);
331002
+ const tail = payload.logs.slice(payload.logs.length - half);
331003
+ payload = { ...payload, logs: `${head}
331004
+ \u2026 [logs truncated to fit upload] \u2026
331005
+ ${tail}` };
331006
+ if (!shed.includes("logs (truncated)")) shed.push("logs (truncated)");
331007
+ bytes = serializedBytes(payload);
331008
+ }
331009
+ }
331010
+ return { payload, shed, bytes };
331011
+ }
331012
+
330898
331013
  // src/runner.ts
330899
331014
  init_dist();
330900
331015
  var import_runner_core12 = __toESM(require_dist2());
@@ -331063,15 +331178,33 @@ async function claimRun(serverUrl, headers, runId) {
331063
331178
  async function postResult(serverUrl, headers, runId, result) {
331064
331179
  const MAX_ATTEMPTS = 3;
331065
331180
  let lastError;
331181
+ const budgeted = budgetResultPayload(result);
331182
+ if (budgeted.shed.length > 0) {
331183
+ console.warn(
331184
+ `[runner] Result payload for run ${runId} exceeded the upload budget (${Math.round(budgeted.bytes / (1024 * 1024))}MB after trim); dropped: ${budgeted.shed.join(", ")}.`
331185
+ );
331186
+ }
331187
+ let payload = budgeted.payload;
331066
331188
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
331067
331189
  try {
331068
331190
  const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/result`, {
331069
331191
  method: "POST",
331070
331192
  headers,
331071
- body: JSON.stringify(result),
331193
+ body: JSON.stringify(payload),
331072
331194
  signal: AbortSignal.timeout(6e4)
331073
331195
  });
331074
331196
  if (res.ok) return;
331197
+ if (res.status === 413) {
331198
+ const minimal = budgetResultPayload(payload, 0);
331199
+ if (minimal.shed.length > 0) {
331200
+ console.warn(
331201
+ `[runner] Server rejected result for run ${runId} as too large (413); retrying with a verdict-only payload (dropped: ${minimal.shed.join(", ")}).`
331202
+ );
331203
+ payload = minimal.payload;
331204
+ lastError = new Error("HTTP 413 (payload trimmed, retrying)");
331205
+ continue;
331206
+ }
331207
+ }
331075
331208
  lastError = new Error(`HTTP ${res.status}`);
331076
331209
  } catch (err) {
331077
331210
  lastError = err instanceof Error ? err : new Error(String(err));
@@ -332606,9 +332739,10 @@ ${finalVerifyResult.logs ?? ""}`;
332606
332739
  onPlanReady
332607
332740
  });
332608
332741
  } finally {
332609
- if (capture) {
332742
+ const activeCapture = capture;
332743
+ if (activeCapture) {
332610
332744
  try {
332611
- const apiCalls = await capture.stop();
332745
+ const apiCalls = await activeCapture.stop();
332612
332746
  if (discoveryResult) {
332613
332747
  discoveryResult.apiCalls = apiCalls.length ? attachMobileDiscoveryNetworkContext(apiCalls, discoveryResult, session?.appId ?? mobileTarget.appId) : void 0;
332614
332748
  }
package/dist/cli.mjs CHANGED
@@ -1362,6 +1362,9 @@ var init_constants = __esm({
1362
1362
  function toJsStringLiteral(value) {
1363
1363
  return JSON.stringify(value);
1364
1364
  }
1365
+ function toInlineCommentText(value) {
1366
+ return value.replace(/[\r\n\u2028\u2029]+/g, " ");
1367
+ }
1365
1368
  function isPlainObject(value) {
1366
1369
  return typeof value === "object" && value !== null && !Array.isArray(value);
1367
1370
  }
@@ -1567,7 +1570,7 @@ function buildAppiumCode(testName, target, steps) {
1567
1570
  `// 3. A booted device/simulator matching the capabilities below`,
1568
1571
  `// This is NOT a WDIO testrunner spec: it uses the standalone remote() client`,
1569
1572
  `// (driver.$, no auto-injected $/expect/describe globals) so it has no harness`,
1570
- `// dependency and runs with a plain \`tsx ${testName}.spec.ts\`.`,
1573
+ `// dependency and runs with a plain \`tsx ${toInlineCommentText(testName)}.spec.ts\`.`,
1571
1574
  `import { remote } from 'webdriverio';`,
1572
1575
  ``,
1573
1576
  `// Resolve the first selector that exists, mirroring the runner's locator`,
@@ -1650,7 +1653,7 @@ function buildAppiumCode(testName, target, steps) {
1650
1653
  ` // Canonical execution source: steps JSON`
1651
1654
  ];
1652
1655
  const stepLines = steps.flatMap((step) => {
1653
- const lines = [` // Step ${step.order + 1}: ${step.description}`];
1656
+ const lines = [` // Step ${step.order + 1}: ${toInlineCommentText(step.description)}`];
1654
1657
  const candidates = selectorCandidatesForStep(step);
1655
1658
  const candidatesLiteral = `[${candidates.map(toJsStringLiteral).join(", ")}]`;
1656
1659
  const hasSelector = candidates.length > 0;
@@ -9701,7 +9704,26 @@ ${statePacket}` },
9701
9704
  steps: compiled.steps,
9702
9705
  playwrightCode: "",
9703
9706
  verified,
9704
- tags
9707
+ tags,
9708
+ // Carry the planner scenario identity so incremental/resume can match an
9709
+ // already-generated scenario reliably (scenarioRef.name === the plan's
9710
+ // scenario name). Without this the resume skip-set falls back to the
9711
+ // model-chosen test name and may regenerate completed scenarios. Mobile has
9712
+ // no live command/locator ledger, so evidence is an empty (but valid) bundle
9713
+ // — scenarioRef is the load-bearing field for the skip-set.
9714
+ healingContext: {
9715
+ scenarioRef: {
9716
+ name: scenario.name,
9717
+ featureGroup: scenario.featureGroup,
9718
+ goal: scenario.goal,
9719
+ type: scenario.type,
9720
+ variant: scenario.variant,
9721
+ entryRoute: scenario.entryRoute,
9722
+ startFromLanding: scenario.startFromLanding,
9723
+ targetPages: [...scenario.targetPages]
9724
+ },
9725
+ evidence: { observedLocators: [], observedTransitions: [] }
9726
+ }
9705
9727
  };
9706
9728
  return test;
9707
9729
  }
@@ -326843,7 +326865,7 @@ var require_package4 = __commonJS({
326843
326865
  "package.json"(exports, module) {
326844
326866
  module.exports = {
326845
326867
  name: "@validate.qa/runner",
326846
- version: "1.0.13",
326868
+ version: "1.0.14",
326847
326869
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
326848
326870
  bin: {
326849
326871
  "validate-runner": "dist/cli.js"
@@ -327565,6 +327587,8 @@ var DEVICE_CMD_TIMEOUT_MS = 15e3;
327565
327587
  var CA_KEY_BITS = 2048;
327566
327588
  var ANDROID_EMULATOR_HOST_LOOPBACK = "10.0.2.2";
327567
327589
  var PENDING_CLEANUP_DIR = join2(tmpdir(), "validateqa-mobile-capture-pending");
327590
+ var IOS_SIM_HOST_PROXY_ENV = "VALIDATEQA_MOBILE_IOS_SIM_HOST_PROXY";
327591
+ var STALE_MARKER_RECONCILE_MS = 6 * 60 * 60 * 1e3;
327568
327592
  function envFlag(name) {
327569
327593
  const value = (process.env[name] ?? "").trim().toLowerCase();
327570
327594
  return value === "1" || value === "true" || value === "yes" || value === "on";
@@ -328034,7 +328058,7 @@ function hostProxyPriorFromMarker(marker) {
328034
328058
  }
328035
328059
  };
328036
328060
  }
328037
- function hostProxyControlSupported(platform3, isPhysical, platformName = process.platform) {
328061
+ function hostProxyControlSupported(platform3, isPhysical, platformName = process.platform, optedIn = envFlag(IOS_SIM_HOST_PROXY_ENV)) {
328038
328062
  if (platform3 !== "IOS") return { supported: false };
328039
328063
  if (isPhysical) {
328040
328064
  return { supported: false, reason: "physical iOS device requires a manual Wi-Fi HTTP proxy; host proxy not applied" };
@@ -328042,28 +328066,46 @@ function hostProxyControlSupported(platform3, isPhysical, platformName = process
328042
328066
  if (platformName !== "darwin") {
328043
328067
  return { supported: false, reason: `iOS simulator host proxy is only controllable on macOS (host is ${platformName})` };
328044
328068
  }
328069
+ if (!optedIn) {
328070
+ return {
328071
+ supported: false,
328072
+ reason: `iOS simulator HTTPS body capture is disabled: it requires repointing the macOS SYSTEM-WIDE HTTP/HTTPS proxy, which would break host HTTPS (Safari/Chrome/Electron) and capture the host's own traffic for this run. Not applied without consent \u2014 set ${IOS_SIM_HOST_PROXY_ENV}=1 to enable it.`
328073
+ };
328074
+ }
328045
328075
  return { supported: true };
328046
328076
  }
328047
328077
  async function iosSimConfigureHostProxy(host, port, onLog) {
328078
+ let service;
328079
+ let prior;
328048
328080
  try {
328049
328081
  const { stdout: order } = await execFileAsync("networksetup", ["-listnetworkserviceorder"], {
328050
328082
  timeout: DEVICE_CMD_TIMEOUT_MS
328051
328083
  });
328052
- const service = parsePrimaryNetworkService(order);
328053
- if (!service) {
328084
+ const svc = parsePrimaryNetworkService(order);
328085
+ if (!svc) {
328054
328086
  return { configured: false, reason: "iOS simulator host proxy could not be configured: no active macOS network service found" };
328055
328087
  }
328088
+ service = svc;
328056
328089
  const [{ stdout: webOut }, { stdout: secureOut }] = await Promise.all([
328057
328090
  execFileAsync("networksetup", ["-getwebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS }),
328058
328091
  execFileAsync("networksetup", ["-getsecurewebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS })
328059
328092
  ]);
328060
- const prior = { web: parseWebProxyState(webOut), secure: parseWebProxyState(secureOut) };
328093
+ prior = { web: parseWebProxyState(webOut), secure: parseWebProxyState(secureOut) };
328061
328094
  for (const args of buildHostProxySetCommands(service, host, port)) {
328062
328095
  await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
328063
328096
  }
328064
328097
  onLog?.(`[mobile-net] macOS host proxy set on "${service}" \u2192 ${host}:${port} (iOS simulator shares the host network).`);
328065
328098
  return { configured: true, service, prior };
328066
328099
  } catch (err) {
328100
+ if (service && prior) {
328101
+ onLog?.(`[mobile-net] host proxy set failed mid-way \u2014 rolling back "${service}" to prior state: ${errMsg(err)}`);
328102
+ for (const args of buildHostProxyRestoreCommands(service, prior)) {
328103
+ try {
328104
+ await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
328105
+ } catch {
328106
+ }
328107
+ }
328108
+ }
328067
328109
  return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg(err)}` };
328068
328110
  }
328069
328111
  }
@@ -330903,6 +330945,79 @@ function printDoctorReport(report) {
330903
330945
  console.log("");
330904
330946
  }
330905
330947
 
330948
+ // src/result-payload-budget.ts
330949
+ var SERVER_RESULT_BODY_LIMIT_BYTES = 50 * 1024 * 1024;
330950
+ var RESULT_BODY_BUDGET_BYTES = 48 * 1024 * 1024;
330951
+ var SERVER_VIDEO_STORE_CAP_BYTES = 24 * 1024 * 1024;
330952
+ function serializedBytes(value) {
330953
+ return Buffer.byteLength(JSON.stringify(value) ?? "", "utf8");
330954
+ }
330955
+ function budgetResultPayload(result, maxBytes = RESULT_BODY_BUDGET_BYTES, videoStoreCapBytes = SERVER_VIDEO_STORE_CAP_BYTES) {
330956
+ const oversizedVideo = typeof result.video === "string" && result.video.length > videoStoreCapBytes;
330957
+ let bytes = serializedBytes(result);
330958
+ if (bytes <= maxBytes && !oversizedVideo) {
330959
+ return { payload: result, shed: [], bytes };
330960
+ }
330961
+ const shed = [];
330962
+ let payload = result;
330963
+ const drop = (fields, label) => {
330964
+ const next = { ...payload };
330965
+ for (const field of fields) next[field] = void 0;
330966
+ payload = next;
330967
+ shed.push(label);
330968
+ bytes = serializedBytes(payload);
330969
+ };
330970
+ if ((bytes > maxBytes || oversizedVideo) && payload.video != null) {
330971
+ drop(["video", "videoMimeType"], "video");
330972
+ }
330973
+ if (bytes > maxBytes && Array.isArray(payload.screenshots) && payload.screenshots.length > 0) {
330974
+ const shots = [...payload.screenshots];
330975
+ const bySizeDesc = shots.map((shot, i) => ({ i, len: typeof shot === "string" ? shot.length : 0 })).sort((a, b) => b.len - a.len);
330976
+ const overshoot = bytes - maxBytes;
330977
+ let shaved = 0;
330978
+ let blanked = 0;
330979
+ for (const { i, len } of bySizeDesc) {
330980
+ if (shaved >= overshoot || len === 0) break;
330981
+ if (shots[i] === "") continue;
330982
+ shots[i] = "";
330983
+ shaved += len;
330984
+ blanked++;
330985
+ }
330986
+ if (blanked > 0) {
330987
+ payload = { ...payload, screenshots: shots };
330988
+ bytes = serializedBytes(payload);
330989
+ shed.push(blanked === shots.length ? "screenshots" : `${blanked}/${shots.length} screenshots`);
330990
+ }
330991
+ }
330992
+ if (bytes > maxBytes && payload.harApiCalls != null) drop(["harApiCalls"], "harApiCalls");
330993
+ if (bytes > maxBytes && payload.apiCalls != null) drop(["apiCalls"], "apiCalls");
330994
+ if (shed.length > 0) {
330995
+ const note = `
330996
+
330997
+ \u26A0 [runner] Result media trimmed before upload \u2014 payload exceeded the ${Math.round(maxBytes / (1024 * 1024))}MB server limit; dropped: ${shed.join(", ")}.`;
330998
+ payload = {
330999
+ ...payload,
331000
+ logs: (typeof payload.logs === "string" ? payload.logs : "") + note
331001
+ };
331002
+ bytes = serializedBytes(payload);
331003
+ }
331004
+ if (bytes > maxBytes && typeof payload.logs === "string" && payload.logs.length > 4096) {
331005
+ const over = bytes - maxBytes;
331006
+ const targetLen = Math.max(2048, payload.logs.length - over - 1024);
331007
+ if (targetLen < payload.logs.length) {
331008
+ const half = Math.floor(targetLen / 2);
331009
+ const head = payload.logs.slice(0, half);
331010
+ const tail = payload.logs.slice(payload.logs.length - half);
331011
+ payload = { ...payload, logs: `${head}
331012
+ \u2026 [logs truncated to fit upload] \u2026
331013
+ ${tail}` };
331014
+ if (!shed.includes("logs (truncated)")) shed.push("logs (truncated)");
331015
+ bytes = serializedBytes(payload);
331016
+ }
331017
+ }
331018
+ return { payload, shed, bytes };
331019
+ }
331020
+
330906
331021
  // src/runner.ts
330907
331022
  init_dist();
330908
331023
  var import_runner_core12 = __toESM(require_dist2());
@@ -331071,15 +331186,33 @@ async function claimRun(serverUrl, headers, runId) {
331071
331186
  async function postResult(serverUrl, headers, runId, result) {
331072
331187
  const MAX_ATTEMPTS = 3;
331073
331188
  let lastError;
331189
+ const budgeted = budgetResultPayload(result);
331190
+ if (budgeted.shed.length > 0) {
331191
+ console.warn(
331192
+ `[runner] Result payload for run ${runId} exceeded the upload budget (${Math.round(budgeted.bytes / (1024 * 1024))}MB after trim); dropped: ${budgeted.shed.join(", ")}.`
331193
+ );
331194
+ }
331195
+ let payload = budgeted.payload;
331074
331196
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
331075
331197
  try {
331076
331198
  const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/result`, {
331077
331199
  method: "POST",
331078
331200
  headers,
331079
- body: JSON.stringify(result),
331201
+ body: JSON.stringify(payload),
331080
331202
  signal: AbortSignal.timeout(6e4)
331081
331203
  });
331082
331204
  if (res.ok) return;
331205
+ if (res.status === 413) {
331206
+ const minimal = budgetResultPayload(payload, 0);
331207
+ if (minimal.shed.length > 0) {
331208
+ console.warn(
331209
+ `[runner] Server rejected result for run ${runId} as too large (413); retrying with a verdict-only payload (dropped: ${minimal.shed.join(", ")}).`
331210
+ );
331211
+ payload = minimal.payload;
331212
+ lastError = new Error("HTTP 413 (payload trimmed, retrying)");
331213
+ continue;
331214
+ }
331215
+ }
331083
331216
  lastError = new Error(`HTTP ${res.status}`);
331084
331217
  } catch (err) {
331085
331218
  lastError = err instanceof Error ? err : new Error(String(err));
@@ -332614,9 +332747,10 @@ ${finalVerifyResult.logs ?? ""}`;
332614
332747
  onPlanReady
332615
332748
  });
332616
332749
  } finally {
332617
- if (capture) {
332750
+ const activeCapture = capture;
332751
+ if (activeCapture) {
332618
332752
  try {
332619
- const apiCalls = await capture.stop();
332753
+ const apiCalls = await activeCapture.stop();
332620
332754
  if (discoveryResult) {
332621
332755
  discoveryResult.apiCalls = apiCalls.length ? attachMobileDiscoveryNetworkContext(apiCalls, discoveryResult, session?.appId ?? mobileTarget.appId) : void 0;
332622
332756
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@validate.qa/runner",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "description": "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
5
5
  "bin": {
6
6
  "validate-runner": "dist/cli.js"