@validate.qa/runner 1.0.79 → 1.0.80

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 +188 -17
  2. package/dist/cli.mjs +188 -17
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1907,7 +1907,7 @@ function isValidLiveFrame(frame) {
1907
1907
  return false;
1908
1908
  return true;
1909
1909
  }
1910
- var readEnvInt, LIVE_FRAME_PUSH_MAX_BASE64_BYTES, LIVE_THUMBNAIL_MAX_BASE64_BYTES, LIVE_FRAME_MAX_DIMENSION, LIVE_FRAME_JPEG_QUALITY, LIVE_FRAME_MIN_PUSH_INTERVAL_MS, LIVE_FRAME_UNFOCUSED_INTERVAL_MS, LIVE_FRAME_STALE_MS, LIVE_FRAME_MAX_SESSIONS_PER_PROJECT, MOBILE_MJPEG_SETTINGS, MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS, MOBILE_MJPEG_MAX_FRAME_BYTES;
1910
+ var readEnvInt, LIVE_FRAME_PUSH_MAX_BASE64_BYTES, LIVE_THUMBNAIL_MAX_BASE64_BYTES, LIVE_FRAME_MAX_DIMENSION, LIVE_FRAME_JPEG_QUALITY, LIVE_FRAME_MIN_PUSH_INTERVAL_MS, LIVE_FRAME_UNFOCUSED_INTERVAL_MS, LIVE_FRAME_IDLE_PUSH_INTERVAL_MS, LIVE_FRAME_STALE_MS, LIVE_FRAME_MAX_SESSIONS_PER_PROJECT, MOBILE_MJPEG_SETTINGS, MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS, MOBILE_MJPEG_MAX_FRAME_BYTES;
1911
1911
  var init_live_frame = __esm({
1912
1912
  "../shared/dist/live-frame.js"() {
1913
1913
  "use strict";
@@ -1923,6 +1923,7 @@ var init_live_frame = __esm({
1923
1923
  LIVE_FRAME_JPEG_QUALITY = readEnvInt("LIVE_FRAME_JPEG_QUALITY", 55);
1924
1924
  LIVE_FRAME_MIN_PUSH_INTERVAL_MS = readEnvInt("LIVE_FRAME_MIN_PUSH_INTERVAL_MS", 125);
1925
1925
  LIVE_FRAME_UNFOCUSED_INTERVAL_MS = readEnvInt("LIVE_FRAME_UNFOCUSED_INTERVAL_MS", 2e3);
1926
+ LIVE_FRAME_IDLE_PUSH_INTERVAL_MS = readEnvInt("LIVE_FRAME_IDLE_PUSH_INTERVAL_MS", 5e3);
1926
1927
  LIVE_FRAME_STALE_MS = readEnvInt("LIVE_FRAME_STALE_MS", 3e4);
1927
1928
  LIVE_FRAME_MAX_SESSIONS_PER_PROJECT = readEnvInt("LIVE_FRAME_MAX_SESSIONS_PER_PROJECT", 32);
1928
1929
  MOBILE_MJPEG_SETTINGS = {
@@ -6360,6 +6361,7 @@ __export(dist_exports, {
6360
6361
  GROUP_LABELS: () => GROUP_LABELS,
6361
6362
  LEGACY_TTS_MODEL_MAP: () => LEGACY_TTS_MODEL_MAP,
6362
6363
  LEGACY_TTS_VOICE_ID_MAP: () => LEGACY_TTS_VOICE_ID_MAP,
6364
+ LIVE_FRAME_IDLE_PUSH_INTERVAL_MS: () => LIVE_FRAME_IDLE_PUSH_INTERVAL_MS,
6363
6365
  LIVE_FRAME_JPEG_QUALITY: () => LIVE_FRAME_JPEG_QUALITY,
6364
6366
  LIVE_FRAME_MAX_DIMENSION: () => LIVE_FRAME_MAX_DIMENSION,
6365
6367
  LIVE_FRAME_MAX_SESSIONS_PER_PROJECT: () => LIVE_FRAME_MAX_SESSIONS_PER_PROJECT,
@@ -321051,6 +321053,7 @@ var require_primary_action_ledger = __commonJS({
321051
321053
  exports2.isLedgerActive = isLedgerActive;
321052
321054
  exports2.isGateEnforcing = isGateEnforcing;
321053
321055
  exports2.isSteeringActive = isSteeringActive;
321056
+ exports2.isPrimaryActionCoverageGapEnabled = isPrimaryActionCoverageGapEnabled;
321054
321057
  exports2.leadingVerb = leadingVerb;
321055
321058
  exports2.classifyPrimaryAction = classifyPrimaryAction;
321056
321059
  exports2.containerScopeOf = containerScopeOf;
@@ -321065,6 +321068,8 @@ var require_primary_action_ledger = __commonJS({
321065
321068
  exports2.classifyActionOutcome = classifyActionOutcome;
321066
321069
  exports2.detectPrimaryActions = detectPrimaryActions;
321067
321070
  exports2.summarizeLedger = summarizeLedger;
321071
+ exports2.summarizeUnexercisedPrimaries = summarizeUnexercisedPrimaries;
321072
+ exports2.buildUnexercisedPrimaryCoverageGaps = buildUnexercisedPrimaryCoverageGaps;
321068
321073
  var reveal_diff_js_1 = require_reveal_diff();
321069
321074
  var exploration_parser_js_1 = require_exploration_parser();
321070
321075
  function getPrimaryActionGateMode(env = process.env) {
@@ -321084,6 +321089,10 @@ var require_primary_action_ledger = __commonJS({
321084
321089
  function isSteeringActive(mode) {
321085
321090
  return mode === "advisory" || mode === "enforce";
321086
321091
  }
321092
+ function isPrimaryActionCoverageGapEnabled(env = process.env) {
321093
+ const raw = (env.DISCOVERY_PRIMARY_ACTION_COVERAGE_GAP ?? "").trim().toLowerCase();
321094
+ return raw === "1" || raw === "true" || raw === "on" || raw === "enabled";
321095
+ }
321087
321096
  exports2.PRIMARY_ACTION_VERB_RE = /^(run|analyze|analyse|generate|regenerate|process|submit|save|create|send|upload|search|export|import|calculate|recalculate|compute|convert|start|scan|translate|transcribe|render|publish|sync|deploy|enrich|summar(?:ize|ise))\b/i;
321088
321097
  exports2.AMBIGUOUS_ACTION_VERB_RE = /^(continue|next|apply|go|proceed|confirm|update|add|edit|ok|done|finish|complete|approve|enable|activate)\b/i;
321089
321098
  exports2.DESTRUCTIVE_ACTION_VERB_RE = /^(delete|remove|archive|reset|disconnect|deactivate|destroy|purge|revoke|discard|drop|wipe|erase|unsubscribe|cancel|clear|trash)\b/i;
@@ -321347,6 +321356,47 @@ var require_primary_action_ledger = __commonJS({
321347
321356
  }
321348
321357
  return `actions=${c.total} primary{detected=${c.detected} attempted=${c.attempted} captured=${c.captured} blocked=${c.blocked} exempt=${c.exempt}} ambiguous=${c.ambiguous}`;
321349
321358
  }
321359
+ function summarizeUnexercisedPrimaries(ledger, routeInScope, cap = 12) {
321360
+ const out = [];
321361
+ for (const e of getBlockingActionDebt(ledger, routeInScope)) {
321362
+ if (e.status !== "DETECTED" && e.status !== "ATTEMPTED")
321363
+ continue;
321364
+ out.push({ label: e.label, route: e.route, containerScope: e.containerScope, status: e.status });
321365
+ if (out.length >= cap)
321366
+ break;
321367
+ }
321368
+ return out;
321369
+ }
321370
+ var REGEXP_META_RE = /[.*+?^${}()|[\]\\]/g;
321371
+ function labelCoveredByPlan(normLabel, plannedLower) {
321372
+ if (!normLabel.includes(" "))
321373
+ return false;
321374
+ const escaped = normLabel.replace(REGEXP_META_RE, "\\$&");
321375
+ return new RegExp(`(^|\\W)${escaped}($|\\W)`).test(plannedLower);
321376
+ }
321377
+ function buildUnexercisedPrimaryCoverageGaps(unexercised, plannedScenarioText, nowIso) {
321378
+ const gaps = [];
321379
+ const seen = /* @__PURE__ */ new Set();
321380
+ const planned = plannedScenarioText.toLowerCase();
321381
+ for (const a of unexercised) {
321382
+ const norm = normLabelKey(a.label);
321383
+ const key = `${norm}|${a.route}`;
321384
+ if (seen.has(key))
321385
+ continue;
321386
+ seen.add(key);
321387
+ if (labelCoveredByPlan(norm, planned))
321388
+ continue;
321389
+ const clickedNote = a.status === "ATTEMPTED" ? "clicked, but no result state was captured" : "never clicked";
321390
+ gaps.push({
321391
+ scenarioName: `Exercise "${a.label}"`,
321392
+ reason: `Primary action "${a.label}" was detected on ${a.route} but never exercised to an observed result (${clickedNote}).`,
321393
+ suggestion: `Exercise "${a.label}" to a captured result \u2014 open its result modal/panel and capture it BEFORE closing \u2014 or schedule a deep run scoped to this action, so a real test can be authored.`,
321394
+ unmetPrerequisites: [],
321395
+ detectedAt: nowIso
321396
+ });
321397
+ }
321398
+ return gaps;
321399
+ }
321350
321400
  }
321351
321401
  });
321352
321402
 
@@ -322835,6 +322885,9 @@ var require_exploration_prompt_builder = __commonJS({
322835
322885
  "These are the feature's core actions \u2014 what it EXISTS to do. You are on a PROVISIONED TEST ACCOUNT, so exercising them is the point of this exploration. For each: click it, wait for it to finish (an async job can take 10-60s \u2014 keep waiting and capturing, do not give up early), then capture_page the result (a new panel, row, score, status, or route change).",
322836
322886
  `Spending in-app COMPUTE credits is expected and budgeted \u2014 running an Analyze / Generate / Run / Process ONCE to observe its output is exactly what you should do; an "N of M used" usage or AI-credit meter is NOT a reason to skip. These were auto-screened, but YOU are the final check: if you can see a specific listed action actually charges REAL money at a checkout (Buy / Upgrade / Subscribe), makes an irreversible or destructive change (Delete / Wipe / Deploy to production), or sends a message to real people (Send email / Invite), SKIP just that one \u2014 note it as blocked with the reason and move on. Don't finish until every other action below has produced an observed outcome:`
322837
322887
  ];
322888
+ if ((0, primary_action_ledger_js_1.isPrimaryActionCoverageGapEnabled)()) {
322889
+ out.push("If the result opens in a modal, dialog, or panel, call capture_page WHILE IT IS STILL OPEN \u2014 do not close or dismiss it first, or the result state is never recorded and the action does not count as covered. An action reachable only from inside this feature's own result modal is in-scope: exercise it to a captured result before closing, even if it generates content that belongs to another builder.");
322890
+ }
322838
322891
  for (const e of debt.slice(0, 8)) {
322839
322892
  const note = e.status === "ATTEMPTED" ? " \u2014 clicked but no result captured yet" : "";
322840
322893
  out.push(` - "${e.label}" on ${e.route}${note}`);
@@ -326655,6 +326708,7 @@ ${inbox_tools_js_1.EMAIL_VERIFICATION_UNAVAILABLE_OVERRIDE_BLOCK}` : ""}`;
326655
326708
  const primaryActionMode = (0, primary_action_ledger_js_1.getPrimaryActionGateMode)();
326656
326709
  const primaryActionLedgerActive = (0, primary_action_ledger_js_1.isLedgerActive)(primaryActionMode) && options?.mode === "deep";
326657
326710
  const primaryActionEnforcing = primaryActionLedgerActive && (0, primary_action_ledger_js_1.isGateEnforcing)(primaryActionMode);
326711
+ const primaryActionCoverageGapEnabled = primaryActionLedgerActive && (0, primary_action_ledger_js_1.isPrimaryActionCoverageGapEnabled)();
326658
326712
  const prevRouteSignatures = /* @__PURE__ */ new Map();
326659
326713
  const prevInteractionCountsByRoute = /* @__PURE__ */ new Map();
326660
326714
  let previousCaptureRoute = null;
@@ -326665,6 +326719,16 @@ ${inbox_tools_js_1.EMAIL_VERIFICATION_UNAVAILABLE_OVERRIDE_BLOCK}` : ""}`;
326665
326719
  let finishBounce = (0, survey_link_frontier_js_1.initialFinishBounceState)();
326666
326720
  let lastLandedPageUrl = null;
326667
326721
  const state2 = (0, exploration_state_js_1.createExplorationState)();
326722
+ const buildUnexercisedPrimarySummary = () => {
326723
+ if (!primaryActionCoverageGapEnabled)
326724
+ return void 0;
326725
+ const fa = options?.focusAreas ?? [];
326726
+ if (fa.length === 0)
326727
+ return void 0;
326728
+ const otherRoutes = options?.featureContext?.otherFeatures?.flatMap((f) => f.pages);
326729
+ const summary2 = (0, primary_action_ledger_js_1.summarizeUnexercisedPrimaries)(state2.actionLedger, (r) => (0, exploration_parser_js_1.routeIsWithinFocusAreas)(r, fa, otherRoutes), 12);
326730
+ return summary2.length > 0 ? summary2 : void 0;
326731
+ };
326668
326732
  function ingestLinksFromText(rawText) {
326669
326733
  if (!linkFrontierEnabled || !rawText || !rawText.includes("- /url:"))
326670
326734
  return;
@@ -328895,6 +328959,7 @@ Exploration ended after ${iteration} iterations without calling finish_explorati
328895
328959
  summary: state2.summary || summary,
328896
328960
  collectedPages: state2.collectedPages,
328897
328961
  collectedTransitions: state2.collectedTransitions,
328962
+ unexercisedPrimaryActions: buildUnexercisedPrimarySummary(),
328898
328963
  iterationTimeline: iterationTimeline.length > 0 ? iterationTimeline : void 0
328899
328964
  };
328900
328965
  }
@@ -328989,6 +329054,7 @@ Fatal error: ${msg}`);
328989
329054
  summary: `Fatal error: ${msg}`,
328990
329055
  collectedPages: state2.collectedPages,
328991
329056
  collectedTransitions: state2.collectedTransitions,
329057
+ unexercisedPrimaryActions: buildUnexercisedPrimarySummary(),
328992
329058
  iterationTimeline: iterationTimeline.length > 0 ? iterationTimeline : void 0,
328993
329059
  truncated: true,
328994
329060
  truncatedReason,
@@ -337175,6 +337241,7 @@ var require_discover_agent = __commonJS({
337175
337241
  var discover_planner_js_1 = require_discover_planner();
337176
337242
  var discover_generator_js_1 = require_discover_generator();
337177
337243
  var discover_ai_planner_js_1 = require_discover_ai_planner();
337244
+ var primary_action_ledger_js_1 = require_primary_action_ledger();
337178
337245
  var discovery_lint_flags_js_1 = require_discovery_lint_flags();
337179
337246
  var verification_inbox_js_1 = require_verification_inbox();
337180
337247
  var inbox_tools_js_1 = require_inbox_tools();
@@ -338477,6 +338544,16 @@ Plan: ${planResult.scenarios.length} scenarios (budget: ${featureTestBudget})`);
338477
338544
  checkpointFallbackReason = planResult.fallbackReason;
338478
338545
  plannerWarning = planResult.warning;
338479
338546
  plannerCoverageGaps = planResult.coverageGaps;
338547
+ const unexercisedPrimaries = exploreResult.unexercisedPrimaryActions ?? [];
338548
+ if (unexercisedPrimaries.length > 0) {
338549
+ const plannedText = planResult.scenarios.map((s) => `${s.name} ${s.goal ?? ""} ${(s.targetPages ?? []).join(" ")}`).join(" ");
338550
+ const ledgerGaps = (0, primary_action_ledger_js_1.buildUnexercisedPrimaryCoverageGaps)(unexercisedPrimaries, plannedText, (/* @__PURE__ */ new Date()).toISOString());
338551
+ if (ledgerGaps.length > 0) {
338552
+ plannerCoverageGaps = [...plannerCoverageGaps ?? [], ...ledgerGaps];
338553
+ }
338554
+ const suppressed = unexercisedPrimaries.length - ledgerGaps.length;
338555
+ logs2.push(` [primary-action-ledger] ${unexercisedPrimaries.length} in-scope primary action(s) un-exercised; surfaced ${ledgerGaps.length} as coverage gap(s)${suppressed > 0 ? ` (${suppressed} already covered by the plan / deduped)` : ""}`);
338556
+ }
338480
338557
  }
338481
338558
  if (callbackOptions?.onPlanReady) {
338482
338559
  try {
@@ -353260,7 +353337,7 @@ var require_package4 = __commonJS({
353260
353337
  "package.json"(exports2, module2) {
353261
353338
  module2.exports = {
353262
353339
  name: "@validate.qa/runner",
353263
- version: "1.0.79",
353340
+ version: "1.0.80",
353264
353341
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
353265
353342
  bin: {
353266
353343
  "validate-runner": "dist/cli.js"
@@ -353529,6 +353606,9 @@ async function enforceRunnerUpToDate(currentVersion, opts = {}) {
353529
353606
  return result;
353530
353607
  }
353531
353608
 
353609
+ // src/runner.ts
353610
+ init_dist();
353611
+
353532
353612
  // src/services/appium-executor.ts
353533
353613
  init_dist();
353534
353614
 
@@ -358890,6 +358970,7 @@ function startMjpegStream(options) {
358890
358970
  // src/services/mobile-bridge.ts
358891
358971
  init_dist();
358892
358972
  var MOBILE_BRIDGE_PORT2 = 9515;
358973
+ var RELAY_DEMAND_POLL_MS = 2e3;
358893
358974
  var MAX_REQUEST_BODY_BYTES = 64 * 1024;
358894
358975
  var MAX_TEXT_INPUT_LENGTH = 4e3;
358895
358976
  var MAX_APP_ID_LENGTH = 255;
@@ -359401,6 +359482,18 @@ async function handleSessionStop(_req, res) {
359401
359482
  sendJSON(res, 200, { ok: true });
359402
359483
  }
359403
359484
  var runnerChannels = /* @__PURE__ */ new Map();
359485
+ var relayDemandTimer = null;
359486
+ function setMirrorRelay(deviceId, relay) {
359487
+ const ch = runnerChannels.get(deviceId);
359488
+ if (!ch) return;
359489
+ ch.relay = relay;
359490
+ if (relay) {
359491
+ ensureRelayDemandSupervisor();
359492
+ if (hasChannelDemand(ch)) ensureChannelPolling(ch);
359493
+ } else {
359494
+ stopChannelCaptureIfIdle(ch);
359495
+ }
359496
+ }
359404
359497
  function readDeviceIdParam(req) {
359405
359498
  try {
359406
359499
  const url = new URL(req.url || "", "http://localhost");
@@ -359432,7 +359525,7 @@ function writeFrameToChannelClient(ch, client, frame) {
359432
359525
  } catch {
359433
359526
  }
359434
359527
  ch.clients.delete(client);
359435
- stopChannelPollingIfIdle(ch);
359528
+ stopChannelCaptureIfIdle(ch);
359436
359529
  return;
359437
359530
  }
359438
359531
  client.write(`data: ${JSON.stringify(frame)}
@@ -359444,6 +359537,12 @@ function writeFrameToChannelClient(ch, client, frame) {
359444
359537
  function broadcastChannelFrame(ch, frame) {
359445
359538
  ch.lastFrame = frame;
359446
359539
  for (const client of ch.clients) writeFrameToChannelClient(ch, client, frame);
359540
+ if (ch.relay && frame.screenshot) {
359541
+ try {
359542
+ ch.relay.onFrame(frame.screenshot, frame.mimeType ?? "image/png");
359543
+ } catch {
359544
+ }
359545
+ }
359447
359546
  }
359448
359547
  function tryStartChannelMjpeg(ch) {
359449
359548
  if (ch.mjpeg || ch.mjpegStarting || ch.mjpegUnavailable) return;
@@ -359481,7 +359580,7 @@ function tryStartChannelMjpeg(ch) {
359481
359580
  ch.mjpeg = null;
359482
359581
  ch.mjpegStarting = false;
359483
359582
  ch.mjpegUnavailable = true;
359484
- if (wasActive && ch.clients.size > 0) ensureChannelPolling(ch);
359583
+ if (wasActive && hasChannelDemand(ch)) ensureChannelPolling(ch);
359485
359584
  }
359486
359585
  });
359487
359586
  const firstFrameDeadline = setTimeout(() => {
@@ -359489,12 +359588,35 @@ function tryStartChannelMjpeg(ch) {
359489
359588
  ch.mjpegStarting = false;
359490
359589
  ch.mjpegUnavailable = true;
359491
359590
  handle.stop();
359492
- if (ch.clients.size > 0) ensureChannelPolling(ch);
359591
+ if (hasChannelDemand(ch)) ensureChannelPolling(ch);
359493
359592
  }, MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS);
359494
359593
  firstFrameDeadline.unref?.();
359495
359594
  }
359595
+ function hasChannelDemand(ch) {
359596
+ if (ch.clients.size > 0) return true;
359597
+ if (!ch.relay) return false;
359598
+ try {
359599
+ return ch.relay.wanted();
359600
+ } catch {
359601
+ return false;
359602
+ }
359603
+ }
359604
+ function ensureRelayDemandSupervisor() {
359605
+ if (relayDemandTimer) return;
359606
+ relayDemandTimer = setInterval(() => {
359607
+ for (const ch of [...runnerChannels.values()]) {
359608
+ if (hasChannelDemand(ch)) ensureChannelPolling(ch);
359609
+ else stopChannelCaptureIfIdle(ch);
359610
+ }
359611
+ if (runnerChannels.size === 0 && relayDemandTimer) {
359612
+ clearInterval(relayDemandTimer);
359613
+ relayDemandTimer = null;
359614
+ }
359615
+ }, RELAY_DEMAND_POLL_MS);
359616
+ relayDemandTimer.unref?.();
359617
+ }
359496
359618
  async function pollRunnerChannel(ch) {
359497
- if (ch.clients.size === 0 || ch.polling) return;
359619
+ if (!hasChannelDemand(ch) || ch.polling) return;
359498
359620
  if (ch.mjpeg) return;
359499
359621
  ch.polling = true;
359500
359622
  try {
@@ -359525,6 +359647,7 @@ async function pollRunnerChannel(ch) {
359525
359647
  }
359526
359648
  }
359527
359649
  function ensureChannelPolling(ch) {
359650
+ if (!hasChannelDemand(ch)) return;
359528
359651
  tryStartChannelMjpeg(ch);
359529
359652
  if (ch.mjpeg || ch.pollTimer) return;
359530
359653
  ch.pollTimer = setInterval(() => {
@@ -359532,8 +359655,8 @@ function ensureChannelPolling(ch) {
359532
359655
  }, MOBILE_SCREENSHOT_INTERVAL_MS);
359533
359656
  ch.pollTimer.unref?.();
359534
359657
  }
359535
- function stopChannelPollingIfIdle(ch) {
359536
- if (ch.clients.size > 0) return;
359658
+ function stopChannelCaptureIfIdle(ch) {
359659
+ if (hasChannelDemand(ch)) return;
359537
359660
  if (ch.pollTimer) {
359538
359661
  clearInterval(ch.pollTimer);
359539
359662
  ch.pollTimer = null;
@@ -359552,6 +359675,7 @@ function closeRunnerChannel(ch) {
359552
359675
  ch.mjpeg.stop();
359553
359676
  ch.mjpeg = null;
359554
359677
  }
359678
+ ch.relay = null;
359555
359679
  for (const client of ch.clients) {
359556
359680
  try {
359557
359681
  client.end();
@@ -359560,7 +359684,7 @@ function closeRunnerChannel(ch) {
359560
359684
  }
359561
359685
  ch.clients.clear();
359562
359686
  ch.lastFrame = null;
359563
- runnerChannels.delete(ch.deviceId);
359687
+ if (runnerChannels.get(ch.deviceId) === ch) runnerChannels.delete(ch.deviceId);
359564
359688
  }
359565
359689
  function closeAllRunnerChannels() {
359566
359690
  for (const ch of [...runnerChannels.values()]) closeRunnerChannel(ch);
@@ -359582,7 +359706,7 @@ async function serveRunnerMirror(req, res, ch) {
359582
359706
  }
359583
359707
  req.on("close", () => {
359584
359708
  ch.clients.delete(res);
359585
- stopChannelPollingIfIdle(ch);
359709
+ stopChannelCaptureIfIdle(ch);
359586
359710
  });
359587
359711
  }
359588
359712
  async function handleMirror(req, res) {
@@ -360140,7 +360264,8 @@ function attachMirrorSession(sessionId, platform3, deviceId) {
360140
360264
  mjpeg: null,
360141
360265
  mjpegLastFrameAt: 0,
360142
360266
  mjpegStarting: false,
360143
- mjpegUnavailable: false
360267
+ mjpegUnavailable: false,
360268
+ relay: null
360144
360269
  };
360145
360270
  ch.sessionId = sessionId;
360146
360271
  ch.platform = platform3;
@@ -360154,7 +360279,7 @@ function attachMirrorSession(sessionId, platform3, deviceId) {
360154
360279
  runnerChannels.set(deviceId, ch);
360155
360280
  void applyMjpegSettings(`/session/${sessionId}`, MOBILE_MJPEG_SETTINGS).catch(() => {
360156
360281
  });
360157
- if (ch.clients.size > 0) {
360282
+ if (hasChannelDemand(ch)) {
360158
360283
  ensureChannelPolling(ch);
360159
360284
  void pollRunnerChannel(ch);
360160
360285
  }
@@ -361126,6 +361251,13 @@ function attachMobileDiscoveryNetworkContext(calls, result, appId) {
361126
361251
  }));
361127
361252
  }
361128
361253
  var runnerPackage = require_package4();
361254
+ var MIRROR_RELAY_FRESH_MS = 3e3;
361255
+ var liveFrameWatchedByProject = /* @__PURE__ */ new Map();
361256
+ var liveFrameIdleProbeAt = /* @__PURE__ */ new Map();
361257
+ var LIVE_FRAME_IDLE_PROBE_MAX_ENTRIES = 256;
361258
+ function isLiveFrameWatched(projectId) {
361259
+ return !projectId || liveFrameWatchedByProject.get(projectId) !== false;
361260
+ }
361129
361261
  var SECRET_VAR_PATTERN = /password|secret|token|api[_]?key|pass\b|credential|bearer|pat\b/i;
361130
361262
  var IS_CLOUD = parseBoolFlag(process.env.RUNNER_CLOUD);
361131
361263
  var RUNNER_NAME = runnerPackage.name ?? "@validate.qa/runner";
@@ -361903,6 +362035,22 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
361903
362035
  testCaseId: run2.testCaseId ?? void 0
361904
362036
  });
361905
362037
  requestLiveBrowserHeartbeat(true);
362038
+ let lastMirrorRelayFrameAt = 0;
362039
+ const attachMirrorWithRelay = (sessionId, platform3, deviceId) => {
362040
+ attachMirrorSession(sessionId, platform3, deviceId);
362041
+ if (!deviceId) return;
362042
+ setMirrorRelay(deviceId, {
362043
+ onFrame: (base64, mimeType) => {
362044
+ lastMirrorRelayFrameAt = Date.now();
362045
+ liveBrowserHandle.updateSnapshot({
362046
+ thumbnailJpegBase64: base64,
362047
+ thumbnailCapturedAt: Date.now(),
362048
+ mimeType
362049
+ });
362050
+ },
362051
+ wanted: () => isLiveFrameWatched(run2.projectId)
362052
+ });
362053
+ };
361906
362054
  const liveBrowserHeartbeatInterval = setInterval(() => {
361907
362055
  liveBrowserHandle.patch({});
361908
362056
  requestLiveBrowserHeartbeat();
@@ -362806,7 +362954,7 @@ ${finalVerifyResult.logs ?? ""}`;
362806
362954
  deeplink: harvestTarget.deeplink
362807
362955
  },
362808
362956
  onSessionReady: (sessionId, deviceId, sessionPlatform) => {
362809
- attachMirrorSession(sessionId, sessionPlatform, deviceId);
362957
+ attachMirrorWithRelay(sessionId, sessionPlatform, deviceId);
362810
362958
  const name = getCachedDeviceSnapshot().find((d) => d.id === deviceId)?.name;
362811
362959
  liveBrowserHandle.patch({ deviceId, ...name ? { deviceName: name } : {} });
362812
362960
  requestLiveBrowserHeartbeat(true);
@@ -363276,7 +363424,7 @@ ${finalVerifyResult.logs ?? ""}`;
363276
363424
  }
363277
363425
  );
363278
363426
  mobileOnLog(`Appium session created: ${session.sessionId} on device ${session.deviceId}`);
363279
- attachMirrorSession(session.sessionId, platform3, session.deviceId);
363427
+ attachMirrorWithRelay(session.sessionId, platform3, session.deviceId);
363280
363428
  const driver = new MobileBridgeDriver({
363281
363429
  sessionId: session.sessionId,
363282
363430
  platform: platform3,
@@ -363313,7 +363461,7 @@ ${finalVerifyResult.logs ?? ""}`;
363313
363461
  throw new Error("discovery teardown started during session recovery \u2014 replacement session closed");
363314
363462
  }
363315
363463
  session = replacement;
363316
- attachMirrorSession(replacement.sessionId, platform3, replacement.deviceId);
363464
+ attachMirrorWithRelay(replacement.sessionId, platform3, replacement.deviceId);
363317
363465
  liveBrowserHandle.patch({
363318
363466
  deviceId: replacement.deviceId,
363319
363467
  deviceName: replacement.deviceName
@@ -363334,6 +363482,10 @@ ${finalVerifyResult.logs ?? ""}`;
363334
363482
  // UI a reliable mobile fallback when the direct bridge SSE is
363335
363483
  // still opening or blocked by the user's network/tunnel.
363336
363484
  onScreenshot: (base64) => {
363485
+ if (Date.now() - lastMirrorRelayFrameAt < MIRROR_RELAY_FRESH_MS) {
363486
+ liveBrowserHandle.updateSnapshot({ currentUrl: mobileTarget.appId });
363487
+ return;
363488
+ }
363337
363489
  liveBrowserHandle.updateSnapshot({
363338
363490
  currentUrl: mobileTarget.appId,
363339
363491
  thumbnailJpegBase64: base64,
@@ -363806,7 +363958,7 @@ ${finalVerifyResult.logs ?? ""}`;
363806
363958
  deeplink: target.deeplink
363807
363959
  });
363808
363960
  liveSession = opened;
363809
- attachMirrorSession(opened.sessionId, target.platform, opened.deviceId);
363961
+ attachMirrorWithRelay(opened.sessionId, target.platform, opened.deviceId);
363810
363962
  bridgeDriver = new MobileBridgeDriver({
363811
363963
  sessionId: opened.sessionId,
363812
363964
  platform: target.platform,
@@ -363996,7 +364148,7 @@ ${finalVerifyResult.logs ?? ""}`;
363996
364148
  // Stream this run to its OWN per-device dashboard tile (keyed by the
363997
364149
  // leased device id) so N parallel Appium runs each show a live mirror.
363998
364150
  onSessionReady: (sessionId, deviceId, sessionPlatform) => {
363999
- attachMirrorSession(sessionId, sessionPlatform, deviceId);
364151
+ attachMirrorWithRelay(sessionId, sessionPlatform, deviceId);
364000
364152
  requestLiveBrowserHeartbeat(true);
364001
364153
  },
364002
364154
  onSessionEnding: (deviceId) => {
@@ -364588,12 +364740,31 @@ async function startRunner(opts) {
364588
364740
  const publishLiveFrame = (frame) => {
364589
364741
  if (!frame.projectId) return;
364590
364742
  if (liveFramePushInFlight.has(frame.sessionId)) return;
364743
+ if (!isLiveFrameWatched(frame.projectId)) {
364744
+ const lastProbeAt = liveFrameIdleProbeAt.get(frame.sessionId) ?? 0;
364745
+ if (frame.capturedAt - lastProbeAt < LIVE_FRAME_IDLE_PUSH_INTERVAL_MS) return;
364746
+ if (liveFrameIdleProbeAt.size >= LIVE_FRAME_IDLE_PROBE_MAX_ENTRIES) {
364747
+ const expiredBefore = frame.capturedAt - LIVE_FRAME_IDLE_PUSH_INTERVAL_MS;
364748
+ for (const [sessionId, probedAt] of liveFrameIdleProbeAt) {
364749
+ if (probedAt >= expiredBefore) break;
364750
+ liveFrameIdleProbeAt.delete(sessionId);
364751
+ }
364752
+ }
364753
+ liveFrameIdleProbeAt.set(frame.sessionId, frame.capturedAt);
364754
+ }
364591
364755
  liveFramePushInFlight.add(frame.sessionId);
364756
+ const frameProjectId = frame.projectId;
364592
364757
  void fetch(`${serverUrl}/api/runner/live-frame`, {
364593
364758
  method: "POST",
364594
364759
  headers,
364595
364760
  body: JSON.stringify(frame),
364596
364761
  signal: AbortSignal.timeout(5e3)
364762
+ }).then(async (res) => {
364763
+ if (!res.ok) return;
364764
+ const ack = await res.json().catch(() => null);
364765
+ if (ack && typeof ack.watching === "boolean") {
364766
+ liveFrameWatchedByProject.set(frameProjectId, ack.watching);
364767
+ }
364597
364768
  }).catch(() => {
364598
364769
  }).finally(() => {
364599
364770
  liveFramePushInFlight.delete(frame.sessionId);
package/dist/cli.mjs CHANGED
@@ -1912,7 +1912,7 @@ function isValidLiveFrame(frame) {
1912
1912
  return false;
1913
1913
  return true;
1914
1914
  }
1915
- var readEnvInt, LIVE_FRAME_PUSH_MAX_BASE64_BYTES, LIVE_THUMBNAIL_MAX_BASE64_BYTES, LIVE_FRAME_MAX_DIMENSION, LIVE_FRAME_JPEG_QUALITY, LIVE_FRAME_MIN_PUSH_INTERVAL_MS, LIVE_FRAME_UNFOCUSED_INTERVAL_MS, LIVE_FRAME_STALE_MS, LIVE_FRAME_MAX_SESSIONS_PER_PROJECT, MOBILE_MJPEG_SETTINGS, MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS, MOBILE_MJPEG_MAX_FRAME_BYTES;
1915
+ var readEnvInt, LIVE_FRAME_PUSH_MAX_BASE64_BYTES, LIVE_THUMBNAIL_MAX_BASE64_BYTES, LIVE_FRAME_MAX_DIMENSION, LIVE_FRAME_JPEG_QUALITY, LIVE_FRAME_MIN_PUSH_INTERVAL_MS, LIVE_FRAME_UNFOCUSED_INTERVAL_MS, LIVE_FRAME_IDLE_PUSH_INTERVAL_MS, LIVE_FRAME_STALE_MS, LIVE_FRAME_MAX_SESSIONS_PER_PROJECT, MOBILE_MJPEG_SETTINGS, MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS, MOBILE_MJPEG_MAX_FRAME_BYTES;
1916
1916
  var init_live_frame = __esm({
1917
1917
  "../shared/dist/live-frame.js"() {
1918
1918
  "use strict";
@@ -1928,6 +1928,7 @@ var init_live_frame = __esm({
1928
1928
  LIVE_FRAME_JPEG_QUALITY = readEnvInt("LIVE_FRAME_JPEG_QUALITY", 55);
1929
1929
  LIVE_FRAME_MIN_PUSH_INTERVAL_MS = readEnvInt("LIVE_FRAME_MIN_PUSH_INTERVAL_MS", 125);
1930
1930
  LIVE_FRAME_UNFOCUSED_INTERVAL_MS = readEnvInt("LIVE_FRAME_UNFOCUSED_INTERVAL_MS", 2e3);
1931
+ LIVE_FRAME_IDLE_PUSH_INTERVAL_MS = readEnvInt("LIVE_FRAME_IDLE_PUSH_INTERVAL_MS", 5e3);
1931
1932
  LIVE_FRAME_STALE_MS = readEnvInt("LIVE_FRAME_STALE_MS", 3e4);
1932
1933
  LIVE_FRAME_MAX_SESSIONS_PER_PROJECT = readEnvInt("LIVE_FRAME_MAX_SESSIONS_PER_PROJECT", 32);
1933
1934
  MOBILE_MJPEG_SETTINGS = {
@@ -6365,6 +6366,7 @@ __export(dist_exports, {
6365
6366
  GROUP_LABELS: () => GROUP_LABELS,
6366
6367
  LEGACY_TTS_MODEL_MAP: () => LEGACY_TTS_MODEL_MAP,
6367
6368
  LEGACY_TTS_VOICE_ID_MAP: () => LEGACY_TTS_VOICE_ID_MAP,
6369
+ LIVE_FRAME_IDLE_PUSH_INTERVAL_MS: () => LIVE_FRAME_IDLE_PUSH_INTERVAL_MS,
6368
6370
  LIVE_FRAME_JPEG_QUALITY: () => LIVE_FRAME_JPEG_QUALITY,
6369
6371
  LIVE_FRAME_MAX_DIMENSION: () => LIVE_FRAME_MAX_DIMENSION,
6370
6372
  LIVE_FRAME_MAX_SESSIONS_PER_PROJECT: () => LIVE_FRAME_MAX_SESSIONS_PER_PROJECT,
@@ -321056,6 +321058,7 @@ var require_primary_action_ledger = __commonJS({
321056
321058
  exports.isLedgerActive = isLedgerActive;
321057
321059
  exports.isGateEnforcing = isGateEnforcing;
321058
321060
  exports.isSteeringActive = isSteeringActive;
321061
+ exports.isPrimaryActionCoverageGapEnabled = isPrimaryActionCoverageGapEnabled;
321059
321062
  exports.leadingVerb = leadingVerb;
321060
321063
  exports.classifyPrimaryAction = classifyPrimaryAction;
321061
321064
  exports.containerScopeOf = containerScopeOf;
@@ -321070,6 +321073,8 @@ var require_primary_action_ledger = __commonJS({
321070
321073
  exports.classifyActionOutcome = classifyActionOutcome;
321071
321074
  exports.detectPrimaryActions = detectPrimaryActions;
321072
321075
  exports.summarizeLedger = summarizeLedger;
321076
+ exports.summarizeUnexercisedPrimaries = summarizeUnexercisedPrimaries;
321077
+ exports.buildUnexercisedPrimaryCoverageGaps = buildUnexercisedPrimaryCoverageGaps;
321073
321078
  var reveal_diff_js_1 = require_reveal_diff();
321074
321079
  var exploration_parser_js_1 = require_exploration_parser();
321075
321080
  function getPrimaryActionGateMode(env = process.env) {
@@ -321089,6 +321094,10 @@ var require_primary_action_ledger = __commonJS({
321089
321094
  function isSteeringActive(mode) {
321090
321095
  return mode === "advisory" || mode === "enforce";
321091
321096
  }
321097
+ function isPrimaryActionCoverageGapEnabled(env = process.env) {
321098
+ const raw = (env.DISCOVERY_PRIMARY_ACTION_COVERAGE_GAP ?? "").trim().toLowerCase();
321099
+ return raw === "1" || raw === "true" || raw === "on" || raw === "enabled";
321100
+ }
321092
321101
  exports.PRIMARY_ACTION_VERB_RE = /^(run|analyze|analyse|generate|regenerate|process|submit|save|create|send|upload|search|export|import|calculate|recalculate|compute|convert|start|scan|translate|transcribe|render|publish|sync|deploy|enrich|summar(?:ize|ise))\b/i;
321093
321102
  exports.AMBIGUOUS_ACTION_VERB_RE = /^(continue|next|apply|go|proceed|confirm|update|add|edit|ok|done|finish|complete|approve|enable|activate)\b/i;
321094
321103
  exports.DESTRUCTIVE_ACTION_VERB_RE = /^(delete|remove|archive|reset|disconnect|deactivate|destroy|purge|revoke|discard|drop|wipe|erase|unsubscribe|cancel|clear|trash)\b/i;
@@ -321352,6 +321361,47 @@ var require_primary_action_ledger = __commonJS({
321352
321361
  }
321353
321362
  return `actions=${c.total} primary{detected=${c.detected} attempted=${c.attempted} captured=${c.captured} blocked=${c.blocked} exempt=${c.exempt}} ambiguous=${c.ambiguous}`;
321354
321363
  }
321364
+ function summarizeUnexercisedPrimaries(ledger, routeInScope, cap = 12) {
321365
+ const out = [];
321366
+ for (const e of getBlockingActionDebt(ledger, routeInScope)) {
321367
+ if (e.status !== "DETECTED" && e.status !== "ATTEMPTED")
321368
+ continue;
321369
+ out.push({ label: e.label, route: e.route, containerScope: e.containerScope, status: e.status });
321370
+ if (out.length >= cap)
321371
+ break;
321372
+ }
321373
+ return out;
321374
+ }
321375
+ var REGEXP_META_RE = /[.*+?^${}()|[\]\\]/g;
321376
+ function labelCoveredByPlan(normLabel, plannedLower) {
321377
+ if (!normLabel.includes(" "))
321378
+ return false;
321379
+ const escaped = normLabel.replace(REGEXP_META_RE, "\\$&");
321380
+ return new RegExp(`(^|\\W)${escaped}($|\\W)`).test(plannedLower);
321381
+ }
321382
+ function buildUnexercisedPrimaryCoverageGaps(unexercised, plannedScenarioText, nowIso) {
321383
+ const gaps = [];
321384
+ const seen = /* @__PURE__ */ new Set();
321385
+ const planned = plannedScenarioText.toLowerCase();
321386
+ for (const a of unexercised) {
321387
+ const norm = normLabelKey(a.label);
321388
+ const key = `${norm}|${a.route}`;
321389
+ if (seen.has(key))
321390
+ continue;
321391
+ seen.add(key);
321392
+ if (labelCoveredByPlan(norm, planned))
321393
+ continue;
321394
+ const clickedNote = a.status === "ATTEMPTED" ? "clicked, but no result state was captured" : "never clicked";
321395
+ gaps.push({
321396
+ scenarioName: `Exercise "${a.label}"`,
321397
+ reason: `Primary action "${a.label}" was detected on ${a.route} but never exercised to an observed result (${clickedNote}).`,
321398
+ suggestion: `Exercise "${a.label}" to a captured result \u2014 open its result modal/panel and capture it BEFORE closing \u2014 or schedule a deep run scoped to this action, so a real test can be authored.`,
321399
+ unmetPrerequisites: [],
321400
+ detectedAt: nowIso
321401
+ });
321402
+ }
321403
+ return gaps;
321404
+ }
321355
321405
  }
321356
321406
  });
321357
321407
 
@@ -322840,6 +322890,9 @@ var require_exploration_prompt_builder = __commonJS({
322840
322890
  "These are the feature's core actions \u2014 what it EXISTS to do. You are on a PROVISIONED TEST ACCOUNT, so exercising them is the point of this exploration. For each: click it, wait for it to finish (an async job can take 10-60s \u2014 keep waiting and capturing, do not give up early), then capture_page the result (a new panel, row, score, status, or route change).",
322841
322891
  `Spending in-app COMPUTE credits is expected and budgeted \u2014 running an Analyze / Generate / Run / Process ONCE to observe its output is exactly what you should do; an "N of M used" usage or AI-credit meter is NOT a reason to skip. These were auto-screened, but YOU are the final check: if you can see a specific listed action actually charges REAL money at a checkout (Buy / Upgrade / Subscribe), makes an irreversible or destructive change (Delete / Wipe / Deploy to production), or sends a message to real people (Send email / Invite), SKIP just that one \u2014 note it as blocked with the reason and move on. Don't finish until every other action below has produced an observed outcome:`
322842
322892
  ];
322893
+ if ((0, primary_action_ledger_js_1.isPrimaryActionCoverageGapEnabled)()) {
322894
+ out.push("If the result opens in a modal, dialog, or panel, call capture_page WHILE IT IS STILL OPEN \u2014 do not close or dismiss it first, or the result state is never recorded and the action does not count as covered. An action reachable only from inside this feature's own result modal is in-scope: exercise it to a captured result before closing, even if it generates content that belongs to another builder.");
322895
+ }
322843
322896
  for (const e of debt.slice(0, 8)) {
322844
322897
  const note = e.status === "ATTEMPTED" ? " \u2014 clicked but no result captured yet" : "";
322845
322898
  out.push(` - "${e.label}" on ${e.route}${note}`);
@@ -326660,6 +326713,7 @@ ${inbox_tools_js_1.EMAIL_VERIFICATION_UNAVAILABLE_OVERRIDE_BLOCK}` : ""}`;
326660
326713
  const primaryActionMode = (0, primary_action_ledger_js_1.getPrimaryActionGateMode)();
326661
326714
  const primaryActionLedgerActive = (0, primary_action_ledger_js_1.isLedgerActive)(primaryActionMode) && options?.mode === "deep";
326662
326715
  const primaryActionEnforcing = primaryActionLedgerActive && (0, primary_action_ledger_js_1.isGateEnforcing)(primaryActionMode);
326716
+ const primaryActionCoverageGapEnabled = primaryActionLedgerActive && (0, primary_action_ledger_js_1.isPrimaryActionCoverageGapEnabled)();
326663
326717
  const prevRouteSignatures = /* @__PURE__ */ new Map();
326664
326718
  const prevInteractionCountsByRoute = /* @__PURE__ */ new Map();
326665
326719
  let previousCaptureRoute = null;
@@ -326670,6 +326724,16 @@ ${inbox_tools_js_1.EMAIL_VERIFICATION_UNAVAILABLE_OVERRIDE_BLOCK}` : ""}`;
326670
326724
  let finishBounce = (0, survey_link_frontier_js_1.initialFinishBounceState)();
326671
326725
  let lastLandedPageUrl = null;
326672
326726
  const state2 = (0, exploration_state_js_1.createExplorationState)();
326727
+ const buildUnexercisedPrimarySummary = () => {
326728
+ if (!primaryActionCoverageGapEnabled)
326729
+ return void 0;
326730
+ const fa = options?.focusAreas ?? [];
326731
+ if (fa.length === 0)
326732
+ return void 0;
326733
+ const otherRoutes = options?.featureContext?.otherFeatures?.flatMap((f) => f.pages);
326734
+ const summary2 = (0, primary_action_ledger_js_1.summarizeUnexercisedPrimaries)(state2.actionLedger, (r) => (0, exploration_parser_js_1.routeIsWithinFocusAreas)(r, fa, otherRoutes), 12);
326735
+ return summary2.length > 0 ? summary2 : void 0;
326736
+ };
326673
326737
  function ingestLinksFromText(rawText) {
326674
326738
  if (!linkFrontierEnabled || !rawText || !rawText.includes("- /url:"))
326675
326739
  return;
@@ -328900,6 +328964,7 @@ Exploration ended after ${iteration} iterations without calling finish_explorati
328900
328964
  summary: state2.summary || summary,
328901
328965
  collectedPages: state2.collectedPages,
328902
328966
  collectedTransitions: state2.collectedTransitions,
328967
+ unexercisedPrimaryActions: buildUnexercisedPrimarySummary(),
328903
328968
  iterationTimeline: iterationTimeline.length > 0 ? iterationTimeline : void 0
328904
328969
  };
328905
328970
  }
@@ -328994,6 +329059,7 @@ Fatal error: ${msg}`);
328994
329059
  summary: `Fatal error: ${msg}`,
328995
329060
  collectedPages: state2.collectedPages,
328996
329061
  collectedTransitions: state2.collectedTransitions,
329062
+ unexercisedPrimaryActions: buildUnexercisedPrimarySummary(),
328997
329063
  iterationTimeline: iterationTimeline.length > 0 ? iterationTimeline : void 0,
328998
329064
  truncated: true,
328999
329065
  truncatedReason,
@@ -337180,6 +337246,7 @@ var require_discover_agent = __commonJS({
337180
337246
  var discover_planner_js_1 = require_discover_planner();
337181
337247
  var discover_generator_js_1 = require_discover_generator();
337182
337248
  var discover_ai_planner_js_1 = require_discover_ai_planner();
337249
+ var primary_action_ledger_js_1 = require_primary_action_ledger();
337183
337250
  var discovery_lint_flags_js_1 = require_discovery_lint_flags();
337184
337251
  var verification_inbox_js_1 = require_verification_inbox();
337185
337252
  var inbox_tools_js_1 = require_inbox_tools();
@@ -338482,6 +338549,16 @@ Plan: ${planResult.scenarios.length} scenarios (budget: ${featureTestBudget})`);
338482
338549
  checkpointFallbackReason = planResult.fallbackReason;
338483
338550
  plannerWarning = planResult.warning;
338484
338551
  plannerCoverageGaps = planResult.coverageGaps;
338552
+ const unexercisedPrimaries = exploreResult.unexercisedPrimaryActions ?? [];
338553
+ if (unexercisedPrimaries.length > 0) {
338554
+ const plannedText = planResult.scenarios.map((s) => `${s.name} ${s.goal ?? ""} ${(s.targetPages ?? []).join(" ")}`).join(" ");
338555
+ const ledgerGaps = (0, primary_action_ledger_js_1.buildUnexercisedPrimaryCoverageGaps)(unexercisedPrimaries, plannedText, (/* @__PURE__ */ new Date()).toISOString());
338556
+ if (ledgerGaps.length > 0) {
338557
+ plannerCoverageGaps = [...plannerCoverageGaps ?? [], ...ledgerGaps];
338558
+ }
338559
+ const suppressed = unexercisedPrimaries.length - ledgerGaps.length;
338560
+ logs2.push(` [primary-action-ledger] ${unexercisedPrimaries.length} in-scope primary action(s) un-exercised; surfaced ${ledgerGaps.length} as coverage gap(s)${suppressed > 0 ? ` (${suppressed} already covered by the plan / deduped)` : ""}`);
338561
+ }
338485
338562
  }
338486
338563
  if (callbackOptions?.onPlanReady) {
338487
338564
  try {
@@ -353265,7 +353342,7 @@ var require_package4 = __commonJS({
353265
353342
  "package.json"(exports, module) {
353266
353343
  module.exports = {
353267
353344
  name: "@validate.qa/runner",
353268
- version: "1.0.79",
353345
+ version: "1.0.80",
353269
353346
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
353270
353347
  bin: {
353271
353348
  "validate-runner": "dist/cli.js"
@@ -353534,6 +353611,9 @@ async function enforceRunnerUpToDate(currentVersion, opts = {}) {
353534
353611
  return result;
353535
353612
  }
353536
353613
 
353614
+ // src/runner.ts
353615
+ init_dist();
353616
+
353537
353617
  // src/services/appium-executor.ts
353538
353618
  init_dist();
353539
353619
 
@@ -358898,6 +358978,7 @@ function startMjpegStream(options) {
358898
358978
  // src/services/mobile-bridge.ts
358899
358979
  init_dist();
358900
358980
  var MOBILE_BRIDGE_PORT2 = 9515;
358981
+ var RELAY_DEMAND_POLL_MS = 2e3;
358901
358982
  var MAX_REQUEST_BODY_BYTES = 64 * 1024;
358902
358983
  var MAX_TEXT_INPUT_LENGTH = 4e3;
358903
358984
  var MAX_APP_ID_LENGTH = 255;
@@ -359409,6 +359490,18 @@ async function handleSessionStop(_req, res) {
359409
359490
  sendJSON(res, 200, { ok: true });
359410
359491
  }
359411
359492
  var runnerChannels = /* @__PURE__ */ new Map();
359493
+ var relayDemandTimer = null;
359494
+ function setMirrorRelay(deviceId, relay) {
359495
+ const ch = runnerChannels.get(deviceId);
359496
+ if (!ch) return;
359497
+ ch.relay = relay;
359498
+ if (relay) {
359499
+ ensureRelayDemandSupervisor();
359500
+ if (hasChannelDemand(ch)) ensureChannelPolling(ch);
359501
+ } else {
359502
+ stopChannelCaptureIfIdle(ch);
359503
+ }
359504
+ }
359412
359505
  function readDeviceIdParam(req) {
359413
359506
  try {
359414
359507
  const url = new URL(req.url || "", "http://localhost");
@@ -359440,7 +359533,7 @@ function writeFrameToChannelClient(ch, client, frame) {
359440
359533
  } catch {
359441
359534
  }
359442
359535
  ch.clients.delete(client);
359443
- stopChannelPollingIfIdle(ch);
359536
+ stopChannelCaptureIfIdle(ch);
359444
359537
  return;
359445
359538
  }
359446
359539
  client.write(`data: ${JSON.stringify(frame)}
@@ -359452,6 +359545,12 @@ function writeFrameToChannelClient(ch, client, frame) {
359452
359545
  function broadcastChannelFrame(ch, frame) {
359453
359546
  ch.lastFrame = frame;
359454
359547
  for (const client of ch.clients) writeFrameToChannelClient(ch, client, frame);
359548
+ if (ch.relay && frame.screenshot) {
359549
+ try {
359550
+ ch.relay.onFrame(frame.screenshot, frame.mimeType ?? "image/png");
359551
+ } catch {
359552
+ }
359553
+ }
359455
359554
  }
359456
359555
  function tryStartChannelMjpeg(ch) {
359457
359556
  if (ch.mjpeg || ch.mjpegStarting || ch.mjpegUnavailable) return;
@@ -359489,7 +359588,7 @@ function tryStartChannelMjpeg(ch) {
359489
359588
  ch.mjpeg = null;
359490
359589
  ch.mjpegStarting = false;
359491
359590
  ch.mjpegUnavailable = true;
359492
- if (wasActive && ch.clients.size > 0) ensureChannelPolling(ch);
359591
+ if (wasActive && hasChannelDemand(ch)) ensureChannelPolling(ch);
359493
359592
  }
359494
359593
  });
359495
359594
  const firstFrameDeadline = setTimeout(() => {
@@ -359497,12 +359596,35 @@ function tryStartChannelMjpeg(ch) {
359497
359596
  ch.mjpegStarting = false;
359498
359597
  ch.mjpegUnavailable = true;
359499
359598
  handle.stop();
359500
- if (ch.clients.size > 0) ensureChannelPolling(ch);
359599
+ if (hasChannelDemand(ch)) ensureChannelPolling(ch);
359501
359600
  }, MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS);
359502
359601
  firstFrameDeadline.unref?.();
359503
359602
  }
359603
+ function hasChannelDemand(ch) {
359604
+ if (ch.clients.size > 0) return true;
359605
+ if (!ch.relay) return false;
359606
+ try {
359607
+ return ch.relay.wanted();
359608
+ } catch {
359609
+ return false;
359610
+ }
359611
+ }
359612
+ function ensureRelayDemandSupervisor() {
359613
+ if (relayDemandTimer) return;
359614
+ relayDemandTimer = setInterval(() => {
359615
+ for (const ch of [...runnerChannels.values()]) {
359616
+ if (hasChannelDemand(ch)) ensureChannelPolling(ch);
359617
+ else stopChannelCaptureIfIdle(ch);
359618
+ }
359619
+ if (runnerChannels.size === 0 && relayDemandTimer) {
359620
+ clearInterval(relayDemandTimer);
359621
+ relayDemandTimer = null;
359622
+ }
359623
+ }, RELAY_DEMAND_POLL_MS);
359624
+ relayDemandTimer.unref?.();
359625
+ }
359504
359626
  async function pollRunnerChannel(ch) {
359505
- if (ch.clients.size === 0 || ch.polling) return;
359627
+ if (!hasChannelDemand(ch) || ch.polling) return;
359506
359628
  if (ch.mjpeg) return;
359507
359629
  ch.polling = true;
359508
359630
  try {
@@ -359533,6 +359655,7 @@ async function pollRunnerChannel(ch) {
359533
359655
  }
359534
359656
  }
359535
359657
  function ensureChannelPolling(ch) {
359658
+ if (!hasChannelDemand(ch)) return;
359536
359659
  tryStartChannelMjpeg(ch);
359537
359660
  if (ch.mjpeg || ch.pollTimer) return;
359538
359661
  ch.pollTimer = setInterval(() => {
@@ -359540,8 +359663,8 @@ function ensureChannelPolling(ch) {
359540
359663
  }, MOBILE_SCREENSHOT_INTERVAL_MS);
359541
359664
  ch.pollTimer.unref?.();
359542
359665
  }
359543
- function stopChannelPollingIfIdle(ch) {
359544
- if (ch.clients.size > 0) return;
359666
+ function stopChannelCaptureIfIdle(ch) {
359667
+ if (hasChannelDemand(ch)) return;
359545
359668
  if (ch.pollTimer) {
359546
359669
  clearInterval(ch.pollTimer);
359547
359670
  ch.pollTimer = null;
@@ -359560,6 +359683,7 @@ function closeRunnerChannel(ch) {
359560
359683
  ch.mjpeg.stop();
359561
359684
  ch.mjpeg = null;
359562
359685
  }
359686
+ ch.relay = null;
359563
359687
  for (const client of ch.clients) {
359564
359688
  try {
359565
359689
  client.end();
@@ -359568,7 +359692,7 @@ function closeRunnerChannel(ch) {
359568
359692
  }
359569
359693
  ch.clients.clear();
359570
359694
  ch.lastFrame = null;
359571
- runnerChannels.delete(ch.deviceId);
359695
+ if (runnerChannels.get(ch.deviceId) === ch) runnerChannels.delete(ch.deviceId);
359572
359696
  }
359573
359697
  function closeAllRunnerChannels() {
359574
359698
  for (const ch of [...runnerChannels.values()]) closeRunnerChannel(ch);
@@ -359590,7 +359714,7 @@ async function serveRunnerMirror(req, res, ch) {
359590
359714
  }
359591
359715
  req.on("close", () => {
359592
359716
  ch.clients.delete(res);
359593
- stopChannelPollingIfIdle(ch);
359717
+ stopChannelCaptureIfIdle(ch);
359594
359718
  });
359595
359719
  }
359596
359720
  async function handleMirror(req, res) {
@@ -360148,7 +360272,8 @@ function attachMirrorSession(sessionId, platform3, deviceId) {
360148
360272
  mjpeg: null,
360149
360273
  mjpegLastFrameAt: 0,
360150
360274
  mjpegStarting: false,
360151
- mjpegUnavailable: false
360275
+ mjpegUnavailable: false,
360276
+ relay: null
360152
360277
  };
360153
360278
  ch.sessionId = sessionId;
360154
360279
  ch.platform = platform3;
@@ -360162,7 +360287,7 @@ function attachMirrorSession(sessionId, platform3, deviceId) {
360162
360287
  runnerChannels.set(deviceId, ch);
360163
360288
  void applyMjpegSettings(`/session/${sessionId}`, MOBILE_MJPEG_SETTINGS).catch(() => {
360164
360289
  });
360165
- if (ch.clients.size > 0) {
360290
+ if (hasChannelDemand(ch)) {
360166
360291
  ensureChannelPolling(ch);
360167
360292
  void pollRunnerChannel(ch);
360168
360293
  }
@@ -361134,6 +361259,13 @@ function attachMobileDiscoveryNetworkContext(calls, result, appId) {
361134
361259
  }));
361135
361260
  }
361136
361261
  var runnerPackage = require_package4();
361262
+ var MIRROR_RELAY_FRESH_MS = 3e3;
361263
+ var liveFrameWatchedByProject = /* @__PURE__ */ new Map();
361264
+ var liveFrameIdleProbeAt = /* @__PURE__ */ new Map();
361265
+ var LIVE_FRAME_IDLE_PROBE_MAX_ENTRIES = 256;
361266
+ function isLiveFrameWatched(projectId) {
361267
+ return !projectId || liveFrameWatchedByProject.get(projectId) !== false;
361268
+ }
361137
361269
  var SECRET_VAR_PATTERN = /password|secret|token|api[_]?key|pass\b|credential|bearer|pat\b/i;
361138
361270
  var IS_CLOUD = parseBoolFlag(process.env.RUNNER_CLOUD);
361139
361271
  var RUNNER_NAME = runnerPackage.name ?? "@validate.qa/runner";
@@ -361911,6 +362043,22 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
361911
362043
  testCaseId: run2.testCaseId ?? void 0
361912
362044
  });
361913
362045
  requestLiveBrowserHeartbeat(true);
362046
+ let lastMirrorRelayFrameAt = 0;
362047
+ const attachMirrorWithRelay = (sessionId, platform3, deviceId) => {
362048
+ attachMirrorSession(sessionId, platform3, deviceId);
362049
+ if (!deviceId) return;
362050
+ setMirrorRelay(deviceId, {
362051
+ onFrame: (base64, mimeType) => {
362052
+ lastMirrorRelayFrameAt = Date.now();
362053
+ liveBrowserHandle.updateSnapshot({
362054
+ thumbnailJpegBase64: base64,
362055
+ thumbnailCapturedAt: Date.now(),
362056
+ mimeType
362057
+ });
362058
+ },
362059
+ wanted: () => isLiveFrameWatched(run2.projectId)
362060
+ });
362061
+ };
361914
362062
  const liveBrowserHeartbeatInterval = setInterval(() => {
361915
362063
  liveBrowserHandle.patch({});
361916
362064
  requestLiveBrowserHeartbeat();
@@ -362814,7 +362962,7 @@ ${finalVerifyResult.logs ?? ""}`;
362814
362962
  deeplink: harvestTarget.deeplink
362815
362963
  },
362816
362964
  onSessionReady: (sessionId, deviceId, sessionPlatform) => {
362817
- attachMirrorSession(sessionId, sessionPlatform, deviceId);
362965
+ attachMirrorWithRelay(sessionId, sessionPlatform, deviceId);
362818
362966
  const name = getCachedDeviceSnapshot().find((d) => d.id === deviceId)?.name;
362819
362967
  liveBrowserHandle.patch({ deviceId, ...name ? { deviceName: name } : {} });
362820
362968
  requestLiveBrowserHeartbeat(true);
@@ -363284,7 +363432,7 @@ ${finalVerifyResult.logs ?? ""}`;
363284
363432
  }
363285
363433
  );
363286
363434
  mobileOnLog(`Appium session created: ${session.sessionId} on device ${session.deviceId}`);
363287
- attachMirrorSession(session.sessionId, platform3, session.deviceId);
363435
+ attachMirrorWithRelay(session.sessionId, platform3, session.deviceId);
363288
363436
  const driver = new MobileBridgeDriver({
363289
363437
  sessionId: session.sessionId,
363290
363438
  platform: platform3,
@@ -363321,7 +363469,7 @@ ${finalVerifyResult.logs ?? ""}`;
363321
363469
  throw new Error("discovery teardown started during session recovery \u2014 replacement session closed");
363322
363470
  }
363323
363471
  session = replacement;
363324
- attachMirrorSession(replacement.sessionId, platform3, replacement.deviceId);
363472
+ attachMirrorWithRelay(replacement.sessionId, platform3, replacement.deviceId);
363325
363473
  liveBrowserHandle.patch({
363326
363474
  deviceId: replacement.deviceId,
363327
363475
  deviceName: replacement.deviceName
@@ -363342,6 +363490,10 @@ ${finalVerifyResult.logs ?? ""}`;
363342
363490
  // UI a reliable mobile fallback when the direct bridge SSE is
363343
363491
  // still opening or blocked by the user's network/tunnel.
363344
363492
  onScreenshot: (base64) => {
363493
+ if (Date.now() - lastMirrorRelayFrameAt < MIRROR_RELAY_FRESH_MS) {
363494
+ liveBrowserHandle.updateSnapshot({ currentUrl: mobileTarget.appId });
363495
+ return;
363496
+ }
363345
363497
  liveBrowserHandle.updateSnapshot({
363346
363498
  currentUrl: mobileTarget.appId,
363347
363499
  thumbnailJpegBase64: base64,
@@ -363814,7 +363966,7 @@ ${finalVerifyResult.logs ?? ""}`;
363814
363966
  deeplink: target.deeplink
363815
363967
  });
363816
363968
  liveSession = opened;
363817
- attachMirrorSession(opened.sessionId, target.platform, opened.deviceId);
363969
+ attachMirrorWithRelay(opened.sessionId, target.platform, opened.deviceId);
363818
363970
  bridgeDriver = new MobileBridgeDriver({
363819
363971
  sessionId: opened.sessionId,
363820
363972
  platform: target.platform,
@@ -364004,7 +364156,7 @@ ${finalVerifyResult.logs ?? ""}`;
364004
364156
  // Stream this run to its OWN per-device dashboard tile (keyed by the
364005
364157
  // leased device id) so N parallel Appium runs each show a live mirror.
364006
364158
  onSessionReady: (sessionId, deviceId, sessionPlatform) => {
364007
- attachMirrorSession(sessionId, sessionPlatform, deviceId);
364159
+ attachMirrorWithRelay(sessionId, sessionPlatform, deviceId);
364008
364160
  requestLiveBrowserHeartbeat(true);
364009
364161
  },
364010
364162
  onSessionEnding: (deviceId) => {
@@ -364596,12 +364748,31 @@ async function startRunner(opts) {
364596
364748
  const publishLiveFrame = (frame) => {
364597
364749
  if (!frame.projectId) return;
364598
364750
  if (liveFramePushInFlight.has(frame.sessionId)) return;
364751
+ if (!isLiveFrameWatched(frame.projectId)) {
364752
+ const lastProbeAt = liveFrameIdleProbeAt.get(frame.sessionId) ?? 0;
364753
+ if (frame.capturedAt - lastProbeAt < LIVE_FRAME_IDLE_PUSH_INTERVAL_MS) return;
364754
+ if (liveFrameIdleProbeAt.size >= LIVE_FRAME_IDLE_PROBE_MAX_ENTRIES) {
364755
+ const expiredBefore = frame.capturedAt - LIVE_FRAME_IDLE_PUSH_INTERVAL_MS;
364756
+ for (const [sessionId, probedAt] of liveFrameIdleProbeAt) {
364757
+ if (probedAt >= expiredBefore) break;
364758
+ liveFrameIdleProbeAt.delete(sessionId);
364759
+ }
364760
+ }
364761
+ liveFrameIdleProbeAt.set(frame.sessionId, frame.capturedAt);
364762
+ }
364599
364763
  liveFramePushInFlight.add(frame.sessionId);
364764
+ const frameProjectId = frame.projectId;
364600
364765
  void fetch(`${serverUrl}/api/runner/live-frame`, {
364601
364766
  method: "POST",
364602
364767
  headers,
364603
364768
  body: JSON.stringify(frame),
364604
364769
  signal: AbortSignal.timeout(5e3)
364770
+ }).then(async (res) => {
364771
+ if (!res.ok) return;
364772
+ const ack = await res.json().catch(() => null);
364773
+ if (ack && typeof ack.watching === "boolean") {
364774
+ liveFrameWatchedByProject.set(frameProjectId, ack.watching);
364775
+ }
364605
364776
  }).catch(() => {
364606
364777
  }).finally(() => {
364607
364778
  liveFramePushInFlight.delete(frame.sessionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@validate.qa/runner",
3
- "version": "1.0.79",
3
+ "version": "1.0.80",
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"