@validate.qa/runner 1.0.86 → 1.0.88

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 +262 -22
  2. package/dist/cli.mjs +262 -22
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -83734,6 +83734,9 @@ ${statePacket}`, redactionCredentials) },
83734
83734
  if (isContentActionScenario(scenario)) {
83735
83735
  return false;
83736
83736
  }
83737
+ if (isFormValidationScenario(scenario)) {
83738
+ return false;
83739
+ }
83737
83740
  const text = scenarioText(scenario);
83738
83741
  const visibilityOnlyGoal = (/\b(entry\s+points?|providers?|provider\s+buttons?|sign[- ]?in\s+options|auth(?:entication)?\s+options|available\s+options)\b/.test(text) || /\b(show|display)\s+(all\s+)?(the\s+)?(providers?|options|buttons?|links?|entry\s+points?)\b/.test(text) || /\b(all\s+(three\s+)?providers?)\b/.test(text)) && !/\b(change|update|create|save|send|register|edit|with\s+valid|successful\s+(sign|log)|complete\s+(the\s+)?(login|sign[- ]?in)|submit\s+(the\s+)?(form|credentials)|enter\s+(an?\s+)?(email|password))\b/.test(text);
83739
83742
  if (visibilityOnlyGoal) {
@@ -83899,8 +83902,8 @@ ${statePacket}`, redactionCredentials) },
83899
83902
  if (isNavChromeThrashTrace(actions)) {
83900
83903
  return "content-action scenario only recorded tab thrash / swipes \u2014 no product action";
83901
83904
  }
83902
- const asserts = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
83903
- if (asserts.length > 0 && asserts.every((a) => VOLATILE_BADGE_ASSERT_RE.test(actionText(a)) || NAV_CHROME_TAP_RE.test(actionText(a)) && !CONTENT_ACTION_VERB_RE.test(actionText(a)))) {
83905
+ const asserts2 = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
83906
+ if (asserts2.length > 0 && asserts2.every((a) => VOLATILE_BADGE_ASSERT_RE.test(actionText(a)) || NAV_CHROME_TAP_RE.test(actionText(a)) && !CONTENT_ACTION_VERB_RE.test(actionText(a)))) {
83904
83907
  return "content-action scenario only asserted tab chrome or a volatile badge count \u2014 assert the action outcome instead";
83905
83908
  }
83906
83909
  return null;
@@ -83937,12 +83940,15 @@ ${statePacket}`, redactionCredentials) },
83937
83940
  const hasValidationAssert = actions.some((a) => {
83938
83941
  if (a.type !== "ASSERT_VISIBLE" && a.type !== "ASSERT_TEXT")
83939
83942
  return false;
83940
- return /\b(error|required|invalid|empty|missing|incorrect|wrong|try again|mismatch|does not match|must match)\b/.test(actionText(a));
83943
+ return /\b(error|required|invalid|empty|missing|incorrect|wrong|try again|mismatch|does not match|must match|cannot be empty|please enter|fill out|is required)\b/.test(actionText(a));
83941
83944
  });
83942
83945
  const emptyFieldIntent = /\b(empty|required|missing|blank)\b/.test(text);
83943
83946
  const invalidValueIntent = /\b(wrong|incorrect|invalid|mismatch|reject)\b/.test(text);
83947
+ if (!hasValidationAssert) {
83948
+ return "validation scenario did not assert a validation/error outcome (commit alone is not proof)";
83949
+ }
83944
83950
  if (emptyFieldIntent && !invalidValueIntent) {
83945
- if (!hasCommit && !hasValidationAssert) {
83951
+ if (!hasCommit) {
83946
83952
  return "validation scenario did not record a form commit or validation outcome";
83947
83953
  }
83948
83954
  return null;
@@ -83950,7 +83956,7 @@ ${statePacket}`, redactionCredentials) },
83950
83956
  if (typedFields && !hasCommit) {
83951
83957
  return "validation scenario filled fields but never committed the form";
83952
83958
  }
83953
- if (!hasCommit && !hasValidationAssert) {
83959
+ if (!hasCommit) {
83954
83960
  return "validation scenario did not record a form commit or validation outcome";
83955
83961
  }
83956
83962
  return null;
@@ -83964,6 +83970,10 @@ ${statePacket}`, redactionCredentials) },
83964
83970
  if (!hasCommit) {
83965
83971
  return "form-mutation happy path did not record a form commit";
83966
83972
  }
83973
+ const asserts2 = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
83974
+ if (asserts2.length > 0 && asserts2.every((a) => NAV_CHROME_TAP_RE.test(actionText(a)) || VOLATILE_BADGE_ASSERT_RE.test(actionText(a)) || isStructuralOnlyAssert(a) || isEphemeralContentAssertText(assertIdentityText(a)))) {
83975
+ return "form-mutation happy path only asserted tab chrome or ephemeral content \u2014 assert the saved/updated product outcome";
83976
+ }
83967
83977
  }
83968
83978
  const isCredentialSignIn = /\b(sign\s*in|log\s*in|login|authenticate|auth)\b/.test(text) && /\b(email|e-mail|password|credential|valid credentials?)\b/.test(text) && !/\b(reset|forgot|recover|logout|log\s*out|sign\s*out|change\s+password|update\s+password)\b/.test(text);
83969
83979
  const typedEmailIndexes = [];
@@ -84056,13 +84066,70 @@ ${statePacket}`, redactionCredentials) },
84056
84066
  if (!actions.some((a) => isProductiveScenarioAction(a, text))) {
84057
84067
  return "happy-path / user-journey scenario did not record a productive product action (only chrome, gestures, or empty work)";
84058
84068
  }
84059
- const asserts = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
84060
- if (asserts.length > 0 && asserts.every((a) => VOLATILE_BADGE_ASSERT_RE.test(actionText(a)) || isStructuralOnlyAssert(a))) {
84069
+ const asserts2 = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
84070
+ if (asserts2.length > 0 && asserts2.every((a) => VOLATILE_BADGE_ASSERT_RE.test(actionText(a)) || isStructuralOnlyAssert(a))) {
84061
84071
  return "happy-path / user-journey scenario only asserted volatile badges or structural roots \u2014 assert a durable feature outcome";
84062
84072
  }
84063
84073
  }
84074
+ const goalFidelity = scenarioGoalActionFidelityReason(text, actions);
84075
+ if (goalFidelity)
84076
+ return goalFidelity;
84077
+ if (actions.some((a) => isBareHierarchyFieldAction(a))) {
84078
+ return "typed or tapped a bare hierarchy/container target with no accessible label \u2014 use a labeled field/control";
84079
+ }
84080
+ const asserts = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
84081
+ if (asserts.length > 0 && asserts.every((a) => isEphemeralContentAssertText(assertIdentityText(a)))) {
84082
+ return "only asserted ephemeral feed/catalog content (session-specific titles/ids) \u2014 assert durable product chrome or stable outcome";
84083
+ }
84064
84084
  return null;
84065
84085
  }
84086
+ function scenarioGoalActionFidelityReason(scenarioTextLower, actions) {
84087
+ const actionBlob = actions.map((a) => actionText(a)).join(" ").toLowerCase();
84088
+ if (/\b(pick|choose|select)\b.*\b(library|gallery|photos?|camera\s*roll|device)\b|\bfrom\s+(the\s+)?(library|gallery|photos?)\b/.test(scenarioTextLower)) {
84089
+ if (!/\b(library|gallery|photos?|camera\s*roll|pick\s+a\s+video|choose\s+(a\s+)?(video|photo|file)|from\s+library|document\s*picker|media\s*picker)\b/i.test(actionBlob)) {
84090
+ return "scenario claims pick-from-library but recording never tapped a library/gallery/picker control";
84091
+ }
84092
+ }
84093
+ if (/\b(style|styles)\b/.test(scenarioTextLower) && /\b(format|aspect|ratio|quality|reels?|9:16|16:9)\b/.test(scenarioTextLower)) {
84094
+ const pickedStyle = /\b(style|cinematic|anime|realistic|no\s*style|cartoon|oil\s*paint)\b/i.test(actionBlob);
84095
+ const pickedFormat = /\b(9:16|16:9|1:1|reels?|aspect|quality|format|720|1080)\b/i.test(actionBlob);
84096
+ if (!pickedStyle || !pickedFormat) {
84097
+ return "scenario claims style+format selection but recording did not select both a style and a format/aspect control";
84098
+ }
84099
+ }
84100
+ if (/\b(open|launch|start)\b.*\b(camera|recording)\b|\brecord\s+a\s+video\b/.test(scenarioTextLower)) {
84101
+ if (!/\b(camera|record|recording|flash|flip\s+camera|capture|shutter)\b/i.test(actionBlob)) {
84102
+ return "scenario claims open/record camera but recording never reached camera controls";
84103
+ }
84104
+ }
84105
+ return null;
84106
+ }
84107
+ function isBareHierarchyFieldAction(action) {
84108
+ if (action.type !== "TYPE" && action.type !== "CLEAR")
84109
+ return false;
84110
+ const sel = (action.selector ?? "").trim();
84111
+ if (!/^\/hierarchy/i.test(sel))
84112
+ return false;
84113
+ const bundle = action.metadata.locatorBundle;
84114
+ const label = (action.metadata.targetLabel ?? "").trim();
84115
+ if (label && !/^element$/i.test(label))
84116
+ return false;
84117
+ const inner = nonEmpty3(action.innerText);
84118
+ if (inner && !/^element$/i.test(inner))
84119
+ return false;
84120
+ if (nonEmpty3(bundle?.text) || nonEmpty3(bundle?.contentDesc) || nonEmpty3(bundle?.accessibilityId)) {
84121
+ return false;
84122
+ }
84123
+ if (nonEmpty3(bundle?.resourceId) && !isStructuralAssertionIdentity(bundle?.resourceId)) {
84124
+ return false;
84125
+ }
84126
+ if (action.type === "TYPE") {
84127
+ const v = (action.value ?? "").replace(/\s+/g, "");
84128
+ if (v.length > 0)
84129
+ return false;
84130
+ }
84131
+ return true;
84132
+ }
84066
84133
  function actionCycleKey(action) {
84067
84134
  return `${action.type}|${action.selector}|${action.value ?? ""}`;
84068
84135
  }
@@ -84332,6 +84399,15 @@ ${statePacket}`, redactionCredentials) },
84332
84399
  if (t.length >= 40 && /\b(error\s+\d{3,}|generation failed|rate limit|quota exceeded)\b/i.test(t)) {
84333
84400
  return true;
84334
84401
  }
84402
+ if (/\b(harness|seed|fixture|demo\s*data|test\s*artist|qa\s*published)\b/i.test(t) && /\d{6,}/.test(t)) {
84403
+ return true;
84404
+ }
84405
+ if (/\bplay\b.+\bby\b.+/i.test(t) && (t.length >= 40 || /\d{6,}/.test(t))) {
84406
+ return true;
84407
+ }
84408
+ if (/^play\s+/i.test(t) && t.length >= 35 && !/\b(home|search|settings|profile|library|playlist|sign\s*in|log\s*in)\b/i.test(t)) {
84409
+ return true;
84410
+ }
84335
84411
  return false;
84336
84412
  }
84337
84413
  function assertIdentityText(action) {
@@ -87668,6 +87744,7 @@ var require_auth_recipe = __commonJS({
87668
87744
  var START_MARKER = `// >>> VALIDATE_QA_AUTH ${exports2.AUTH_MARKER_VERSION} \u2014 generated, do not edit >>>`;
87669
87745
  var END_MARKER = `// <<< VALIDATE_QA_AUTH ${exports2.AUTH_MARKER_VERSION} <<<`;
87670
87746
  var SHARED_AUTH_RUNTIME_ENABLED = `!/^false$/i.test((process.env.SHARED_AUTH_INJECT ?? '').trim())`;
87747
+ var WARM_PROOF_SETTLE_MS = 8e3;
87671
87748
  var AUTH_REGION_RE = /\/\/ >>> VALIDATE_QA_AUTH[^\n]*\r?\n[\s\S]*?\/\/ <<< VALIDATE_QA_AUTH[^\n]*\r?\n?/g;
87672
87749
  function computeRecipeHash(input) {
87673
87750
  const canonical = JSON.stringify({
@@ -87803,12 +87880,20 @@ ${tabs}`);
87803
87880
  const pred = urlProofPredicateExpr(recipe.authProof.route, recipe.authProof.exact);
87804
87881
  lines.push(` await page.waitForURL((u) => !(${pred})(u), { timeout: 2000 }).catch(() => {});`);
87805
87882
  lines.push(` if ((${pred})(new URL(page.url())) && !(await page.locator('input[type="password"]').first().isVisible().catch(() => false))) return;`);
87806
- } else if (loginRoute) {
87807
- lines.push(` if (await ${authProofLocator(recipe.authProof)}.isVisible().catch(() => false) && new URL(page.url()).pathname !== new URL(${JSON.stringify(loginRoute)}, page.url()).pathname) return;`);
87808
87883
  } else {
87809
- lines.push(` if (await ${authProofLocator(recipe.authProof)}.isVisible().catch(() => false)) return;`);
87884
+ const proofSettle = `await ${authProofLocator(recipe.authProof)}.waitFor({ state: 'visible', timeout: ${WARM_PROOF_SETTLE_MS} }).then(() => true).catch(() => false)`;
87885
+ if (loginRoute) {
87886
+ lines.push(` if (new URL(page.url()).pathname !== new URL(${JSON.stringify(loginRoute)}, page.url()).pathname && ${proofSettle}) return;`);
87887
+ } else {
87888
+ lines.push(` if (${proofSettle}) return;`);
87889
+ }
87810
87890
  }
87811
87891
  lines.push(` }`);
87892
+ lines.push(` if (${SHARED_AUTH_RUNTIME_ENABLED}) {`);
87893
+ lines.push(` await page.context().clearCookies().catch(() => {});`);
87894
+ lines.push(` await page.goto(${JSON.stringify(recipe.preNavRoute)}, { waitUntil: 'domcontentloaded' }).catch(() => {});`);
87895
+ lines.push(` await page.evaluate(() => { try { localStorage.clear(); sessionStorage.clear(); } catch {} }).catch(() => {});`);
87896
+ lines.push(` }`);
87812
87897
  }
87813
87898
  lines.push(` await page.goto(${JSON.stringify(recipe.preNavRoute)}, { waitUntil: 'domcontentloaded' });`);
87814
87899
  for (const cmd of recipe.preNavCmds)
@@ -95761,6 +95846,32 @@ var require_agentic_discovery_prompts = __commonJS({
95761
95846
  lines.push("For a pure navigation or page-load scenario (no in-page mutation), URL + one");
95762
95847
  lines.push("structural heading is sufficient.");
95763
95848
  lines.push("");
95849
+ lines.push("**Stop at the gate for expensive or irreversible actions.** Some controls");
95850
+ lines.push("start work that costs the account real money (an AI generation, a paid");
95851
+ lines.push("third-party call, a checkout), takes minutes to finish, or cannot be undone");
95852
+ lines.push("(delete, publish, cancel a subscription). Do NOT press them. The regression");
95853
+ lines.push("risk lives in the wiring, and the wiring is fully provable for free:");
95854
+ lines.push("- Assert the control is present and correctly ENABLED/DISABLED by its inputs.");
95855
+ lines.push("- If it opens a dialog, assert the dialog, its fields and its confirm button,");
95856
+ lines.push(" then press Cancel and assert nothing changed.");
95857
+ lines.push("- Assert the auth / quota wall fires for a caller who should be refused.");
95858
+ lines.push("A test that waits minutes for generated output cannot finish inside the run");
95859
+ lines.push("budget, bills the account on every run, and leaves permanent rows behind. If");
95860
+ lines.push("the ONLY way to prove a scenario is to trigger such an action, drop it as a");
95861
+ lines.push("gap rather than emitting a test that does.");
95862
+ lines.push("");
95863
+ lines.push("**Never sign up a new account.** Use the injected credentials. Registering a");
95864
+ lines.push("`testuser-<timestamp>` account to get past a login wall leaves a permanent,");
95865
+ lines.push("unowned user in the target database on every single run. Registration is in");
95866
+ lines.push("scope ONLY when the scenario under test IS registration, in which case use");
95867
+ lines.push("the provided inbox helper so the account is tracked and reaped.");
95868
+ lines.push("");
95869
+ lines.push("**Write the assertion so the BUG fails it.** When a scenario documents broken");
95870
+ lines.push("behaviour, still assert the CORRECT outcome. A test that passes because the");
95871
+ lines.push("error message / console error / not-found page is present inverts the suite:");
95872
+ lines.push("fixing the bug turns the test red and the fix is reported as a regression.");
95873
+ lines.push("State the intended behaviour, let it fail, and flag it as a bug.");
95874
+ lines.push("");
95764
95875
  lines.push("## Tools");
95765
95876
  lines.push("");
95766
95877
  lines.push("Browser MCP: `browser_navigate`, `browser_snapshot`, `browser_click`,");
@@ -96570,7 +96681,7 @@ var require_discover_generator = __commonJS({
96570
96681
  "../runner-core/dist/services/discover-generator.js"(exports2) {
96571
96682
  "use strict";
96572
96683
  Object.defineProperty(exports2, "__esModule", { value: true });
96573
- exports2.MIN_BUG_DESCRIPTION_LENGTH = exports2.VALID_PLAYWRIGHT_GETBY_METHODS = exports2.BLOCKED_MCP_TOOLS_SCENARIO_GEN = void 0;
96684
+ exports2.MIN_BUG_DESCRIPTION_LENGTH = exports2.NAME_PROHIBITED_ROLES = exports2.VALID_PLAYWRIGHT_GETBY_METHODS = exports2.BLOCKED_MCP_TOOLS_SCENARIO_GEN = void 0;
96574
96685
  exports2.scenarioLooksLikeRegistration = scenarioLooksLikeRegistration;
96575
96686
  exports2.signupFeatureTestingAllowed = signupFeatureTestingAllowed;
96576
96687
  exports2.scenarioTouchesSignupAuthSurface = scenarioTouchesSignupAuthSurface;
@@ -96600,6 +96711,7 @@ var require_discover_generator = __commonJS({
96600
96711
  exports2.scanFragileStructuralLocators = scanFragileStructuralLocators;
96601
96712
  exports2.bannedRoleTokensInSelector = bannedRoleTokensInSelector;
96602
96713
  exports2.scanInvalidRoleTagLocators = scanInvalidRoleTagLocators;
96714
+ exports2.scanNameProhibitedRoleLocators = scanNameProhibitedRoleLocators;
96603
96715
  exports2.scanHealedRoleTagLocators = scanHealedRoleTagLocators;
96604
96716
  exports2.validateFinalActionFidelity = validateFinalActionFidelity;
96605
96717
  exports2.resolveSuspectedBugVerdict = resolveSuspectedBugVerdict;
@@ -99667,10 +99779,55 @@ ${testOpen}`;
99667
99779
  }
99668
99780
  return findings;
99669
99781
  }
99782
+ exports2.NAME_PROHIBITED_ROLES = /* @__PURE__ */ new Set([
99783
+ "caption",
99784
+ "code",
99785
+ "deletion",
99786
+ "emphasis",
99787
+ "generic",
99788
+ "insertion",
99789
+ "mark",
99790
+ "none",
99791
+ "paragraph",
99792
+ "presentation",
99793
+ "strong",
99794
+ "subscript",
99795
+ "superscript",
99796
+ "term",
99797
+ "time"
99798
+ ]);
99799
+ function scanNameProhibitedRoleLocators(code) {
99800
+ const findings = [];
99801
+ const seen = /* @__PURE__ */ new Set();
99802
+ const scanCode = code.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
99803
+ const RE = /getByRole\(\s*(['"`])([a-zA-Z]+)\1\s*,\s*\{[^}]*\bname\s*:/g;
99804
+ for (const rawLine of scanCode.split("\n")) {
99805
+ const line = rawLine.replace(/\/\/.*$/, "");
99806
+ if (line.indexOf("getByRole(") === -1)
99807
+ continue;
99808
+ RE.lastIndex = 0;
99809
+ let m;
99810
+ while ((m = RE.exec(line)) !== null) {
99811
+ const role = m[2].toLowerCase();
99812
+ if (!exports2.NAME_PROHIBITED_ROLES.has(role))
99813
+ continue;
99814
+ const key = `${rawLine.trim()} ${role}`;
99815
+ if (seen.has(key))
99816
+ continue;
99817
+ seen.add(key);
99818
+ findings.push({
99819
+ line: rawLine.trim().slice(0, 200),
99820
+ role,
99821
+ reason: `ARIA prohibits an accessible name on role "${role}", so no element ever exposes one \u2014 getByRole('${role}', { name: \u2026 }) matches ZERO nodes and the assertion hangs to its timeout. Match the text instead: page.getByText('\u2026', { exact: true }), optionally scoped (e.g. page.getByRole('main').getByText('\u2026')). If you need the element itself, select a nameable ancestor (region/listitem/link/button) and assert its text.`
99822
+ });
99823
+ }
99824
+ }
99825
+ return findings;
99826
+ }
99670
99827
  function scanHealedRoleTagLocators(code, opts) {
99671
99828
  const inboxStripped = (0, inbox_stamp_js_1.stripInboxRegions)(code);
99672
99829
  const scanned = opts?.stripAuth === false ? inboxStripped : (0, auth_recipe_js_1.stripAuthRegions)(inboxStripped);
99673
- return scanInvalidRoleTagLocators(scanned);
99830
+ return [...scanInvalidRoleTagLocators(scanned), ...scanNameProhibitedRoleLocators(scanned)];
99674
99831
  }
99675
99832
  function validateFinalActionFidelity(playwrightCode, actionLedger, credentials, opts) {
99676
99833
  const ledgerSigs = [];
@@ -100391,7 +100548,7 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
100391
100548
  continue;
100392
100549
  }
100393
100550
  const roleTagMode = (0, discovery_lint_flags_js_1.roleTagLocatorLintMode)();
100394
- const roleTagFindings = roleTagMode !== "off" ? scanInvalidRoleTagLocators((0, auth_recipe_js_1.stripAuthRegions)((0, inbox_stamp_js_1.stripInboxRegions)(validated[0].playwrightCode))) : [];
100551
+ const roleTagFindings = roleTagMode !== "off" ? scanHealedRoleTagLocators(validated[0].playwrightCode) : [];
100395
100552
  if (roleTagMode === "warn" && roleTagFindings.length > 0) {
100396
100553
  logs2.push(` Role-tag locator gate [warn]: ${roleTagFindings.length} role-as-CSS-tag locator(s) \u2014 ${roleTagFindings.map((f) => sanitizeLedgerText(f.line)).join(" | ").slice(0, 300)}`);
100397
100554
  }
@@ -100404,12 +100561,10 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
100404
100561
  valid: false,
100405
100562
  revisionRequired: true,
100406
100563
  reason: "invalid_role_tag_locator",
100407
- instruction: targeted ? `One or more locators below use an ARIA ROLE as a CSS tag selector, e.g. page.locator('textbox[name^="code"]'). There is no <textbox>/<checkbox>/<heading> HTML element, so page.locator() matches ZERO nodes and the action hangs until the test times out.
100408
- For EACH flagged locator, rewrite it as a real Playwright locator:
100409
- 1. Prefer page.getByRole('<role>', { name: '<accessible name>' }) \u2014 the role is the token you wrongly put in the CSS.
100410
- 2. Or page.getByLabel('<label>'), or a type-scoped CSS like page.locator('input[type="text"]') / input[type="email"].
100411
- 3. For a verification-code field, prefer the injected helper: await __vqaEnterVerificationCode(page, code).
100412
- Keep every other line exactly as-is. Submit the COMPLETE corrected test and call finish_generate again.` : `Your resubmission STILL contains a locator that uses an ARIA role as a CSS tag selector (page.locator('<role>[\u2026]')). This can only ever match nothing. Replace EVERY such locator with page.getByRole('<role>', { name: '\u2026' }) / getByLabel / input[type="\u2026"], or use await __vqaEnterVerificationCode(page, code) for the code field, and resubmit the COMPLETE test.`,
100564
+ instruction: targeted ? `Every locator listed below matches ZERO nodes, so the step hangs until the test times out. Read the \`reason\` on each one \u2014 there are two distinct mistakes:
100565
+ (a) An ARIA ROLE used as a CSS tag selector, e.g. page.locator('textbox[name^="code"]'). There is no <textbox>/<checkbox>/<heading> element. Rewrite as page.getByRole('<role>', { name: '<accessible name>' }), or page.getByLabel('<label>'), or a type-scoped CSS like page.locator('input[type="email"]'). For a verification-code field prefer the injected helper: await __vqaEnterVerificationCode(page, code).
100566
+ (b) A { name: \u2026 } filter on a role whose accessible name ARIA PROHIBITS (paragraph, generic, code, emphasis, strong, term, time, mark, caption, presentation/none, insertion, deletion, sub/superscript). No element with those roles ever exposes a name. Match the text instead: page.getByText('\u2026', { exact: true }), scoped if needed (page.getByRole('main').getByText('\u2026')), or target a nameable ancestor such as region/listitem/link/button.
100567
+ Fix EVERY flagged locator, keep every other line exactly as-is, then submit the COMPLETE corrected test and call finish_generate again.` : `Your resubmission STILL contains a locator that can only ever match nothing \u2014 either an ARIA role used as a CSS tag selector (page.locator('<role>[\u2026]')) or a { name: \u2026 } filter on a name-prohibited role (paragraph/generic/code/emphasis/strong/term/time/\u2026). Replace EVERY one: use getByRole('<role>', { name: '\u2026' }) / getByLabel / input[type="\u2026"] for the first, and getByText('\u2026', { exact: true }) for the second. Resubmit the COMPLETE test.`,
100413
100568
  offendingLocators: roleTagFindings.map((f) => ({
100414
100569
  line: sanitizeLedgerText(f.line),
100415
100570
  reason: f.reason
@@ -353821,7 +353976,7 @@ var require_package4 = __commonJS({
353821
353976
  "package.json"(exports2, module2) {
353822
353977
  module2.exports = {
353823
353978
  name: "@validate.qa/runner",
353824
- version: "1.0.86",
353979
+ version: "1.0.88",
353825
353980
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
353826
353981
  bin: {
353827
353982
  "validate-runner": "dist/cli.js"
@@ -362164,8 +362319,13 @@ var RunnerUpgradeRequiredError = class extends Error {
362164
362319
  this.latestVersion = latestVersion;
362165
362320
  }
362166
362321
  };
362322
+ var PENDING_POLL_TIMEOUT_MS = Math.max(15e3, envInt("VALIDATE_RUNNER_PENDING_TIMEOUT_MS", 9e4));
362323
+ var POLL_STUCK_RECYCLE_MS = Math.max(6e4, envInt("VALIDATE_RUNNER_POLL_STUCK_MS", 5 * 6e4));
362167
362324
  async function fetchPendingRuns(serverUrl, headers) {
362168
- const res = await fetch(`${serverUrl}/api/runner/pending`, { headers, signal: AbortSignal.timeout(3e4) });
362325
+ const res = await fetch(`${serverUrl}/api/runner/pending`, {
362326
+ headers,
362327
+ signal: AbortSignal.timeout(PENDING_POLL_TIMEOUT_MS)
362328
+ });
362169
362329
  if (!res.ok) {
362170
362330
  if (res.status === 401) throw new Error("Runner token is invalid or expired. Run: validate.qa-runner login");
362171
362331
  if (res.status === 426) {
@@ -365578,10 +365738,12 @@ async function startRunner(opts) {
365578
365738
  log.dim(line);
365579
365739
  };
365580
365740
  let lastPendingSummary = "";
365741
+ let lastSuccessfulPollAt = Date.now();
365581
365742
  while (!shuttingDown) {
365582
365743
  try {
365583
365744
  if (activeRuns < MAX_CONCURRENT) {
365584
365745
  const pending = await fetchPendingRuns(serverUrl, headers);
365746
+ lastSuccessfulPollAt = Date.now();
365585
365747
  {
365586
365748
  const pendingIds = new Set(pending.map((r) => r.runId));
365587
365749
  for (const id of [...deferralLogged.keys()]) {
@@ -365786,6 +365948,8 @@ async function startRunner(opts) {
365786
365948
  }
365787
365949
  }
365788
365950
  consecutivePollErrors = 0;
365951
+ } else {
365952
+ lastSuccessfulPollAt = Date.now();
365789
365953
  }
365790
365954
  } catch (err) {
365791
365955
  if (err instanceof RunnerUpgradeRequiredError) {
@@ -365794,6 +365958,13 @@ async function startRunner(opts) {
365794
365958
  }
365795
365959
  const message = err instanceof Error ? err.message : String(err);
365796
365960
  consecutivePollErrors++;
365961
+ const stuckForMs = Date.now() - lastSuccessfulPollAt;
365962
+ if (stuckForMs >= POLL_STUCK_RECYCLE_MS) {
365963
+ log.warn(
365964
+ `Poll stuck for ${Math.round(stuckForMs / 1e3)}s (>= ${Math.round(POLL_STUCK_RECYCLE_MS / 1e3)}s) after error: ${message}. Recycling runner process so the supervisor can restart with a clean loop; orphaned work is re-queued server-side.`
365965
+ );
365966
+ process.exit(78);
365967
+ }
365797
365968
  const backoffMs = Math.min(pollIntervalMs * Math.pow(2, consecutivePollErrors - 1), MAX_BACKOFF_MS);
365798
365969
  if (consecutivePollErrors > 1) {
365799
365970
  log.warn(`Poll error #${consecutivePollErrors} (${message}) - backing off for ${(backoffMs / 1e3).toFixed(1)}s`);
@@ -365846,7 +366017,76 @@ program.command("login").description("Save runner credentials for future use").r
365846
366017
  console.log("");
365847
366018
  console.log("Run: validate-runner start");
365848
366019
  });
365849
- program.command("start").description("Start the local test runner").option("--token <token>", "Runner token (overrides saved credentials)").option("--server <url>", "Server URL (overrides saved credentials)").option("--project <id>", "Project ID (overrides saved credentials)").option("--mode <mode>", "Runner mode: playwright-native (default)", "playwright-native").option("--poll-interval <ms>", "Poll interval in milliseconds", "2000").option("--mobile", "Enable mobile testing (Appium + mobile bridge server)").option("--tunnel", "Enable ngrok tunnel for mobile bridge (allows HTTPS dashboard to connect)").action(async (opts) => {
366020
+ async function superviseRunnerStart(argv) {
366021
+ const { spawn: spawn2 } = await import("child_process");
366022
+ const minBackoffMs = 2e3;
366023
+ const maxBackoffMs = 6e4;
366024
+ let backoffMs = minBackoffMs;
366025
+ let restarts = 0;
366026
+ const windowStart = Date.now();
366027
+ const maxRestartsPerHour = Math.max(1, Number(process.env.VALIDATE_RUNNER_MAX_RESTARTS_PER_HOUR || 30));
366028
+ const childEnv = { ...process.env, VALIDATE_RUNNER_SUPERVISED_CHILD: "1" };
366029
+ const runOnce = () => new Promise((resolve2) => {
366030
+ const child = spawn2(process.execPath, argv, {
366031
+ stdio: "inherit",
366032
+ env: childEnv
366033
+ });
366034
+ const onSignal = (sig) => {
366035
+ try {
366036
+ child.kill(sig);
366037
+ } catch {
366038
+ }
366039
+ };
366040
+ process.once("SIGINT", onSignal);
366041
+ process.once("SIGTERM", onSignal);
366042
+ child.on("exit", (code, signal) => {
366043
+ process.removeListener("SIGINT", onSignal);
366044
+ process.removeListener("SIGTERM", onSignal);
366045
+ if (signal === "SIGINT" || signal === "SIGTERM") {
366046
+ resolve2(0);
366047
+ return;
366048
+ }
366049
+ resolve2(code ?? 1);
366050
+ });
366051
+ });
366052
+ const childArgv = argv.filter((a) => a !== "--supervise" && a !== "--no-supervise");
366053
+ console.log("\u{1F6E1} Runner supervisor enabled \u2014 will auto-restart on crash/stuck exit");
366054
+ console.log(" (disable with --no-supervise or VALIDATE_RUNNER_SUPERVISE=0)");
366055
+ for (; ; ) {
366056
+ const code = await runOnce();
366057
+ if (code === 0) {
366058
+ process.exit(0);
366059
+ }
366060
+ restarts += 1;
366061
+ const elapsedH = (Date.now() - windowStart) / 36e5;
366062
+ if (elapsedH < 1 && restarts > maxRestartsPerHour) {
366063
+ console.error(
366064
+ `\u2717 Supervisor: ${restarts} restarts in under 1h (cap ${maxRestartsPerHour}). Stopping to avoid a crash loop. Fix the underlying issue and re-run start.`
366065
+ );
366066
+ process.exit(1);
366067
+ }
366068
+ console.warn(
366069
+ `\u26A0 Runner exited with code ${code} \u2014 restarting in ${(backoffMs / 1e3).toFixed(1)}s (restart #${restarts}). Server will re-queue orphaned work; pending jobs resume automatically.`
366070
+ );
366071
+ await new Promise((r) => setTimeout(r, backoffMs));
366072
+ backoffMs = Math.min(maxBackoffMs, Math.round(backoffMs * 1.5));
366073
+ }
366074
+ }
366075
+ function shouldSupervise(opts) {
366076
+ if (opts.supervise === false) return false;
366077
+ if (opts.supervise === true) return true;
366078
+ if (process.env.VALIDATE_RUNNER_SUPERVISED_CHILD === "1") return false;
366079
+ if (process.env.RUNNER_CLOUD === "true") return false;
366080
+ const env = (process.env.VALIDATE_RUNNER_SUPERVISE ?? "1").trim().toLowerCase();
366081
+ return env !== "0" && env !== "false" && env !== "off" && env !== "no";
366082
+ }
366083
+ program.command("start").description("Start the local test runner").option("--token <token>", "Runner token (overrides saved credentials)").option("--server <url>", "Server URL (overrides saved credentials)").option("--project <id>", "Project ID (overrides saved credentials)").option("--mode <mode>", "Runner mode: playwright-native (default)", "playwright-native").option("--poll-interval <ms>", "Poll interval in milliseconds", "2000").option("--mobile", "Enable mobile testing (Appium + mobile bridge server)").option("--tunnel", "Enable ngrok tunnel for mobile bridge (allows HTTPS dashboard to connect)").option("--supervise", "Auto-restart the runner if it crashes or self-recycles (default for local)").option("--no-supervise", "Disable auto-restart supervisor").action(async (opts) => {
366084
+ if (shouldSupervise(opts)) {
366085
+ const argv = [...process.argv.slice(1)];
366086
+ if (!argv.includes("--no-supervise")) argv.push("--no-supervise");
366087
+ await superviseRunnerStart(argv);
366088
+ return;
366089
+ }
365850
366090
  await requireLatestRunner();
365851
366091
  const saved = loadCredentials();
365852
366092
  const authToken = opts.token || process.env.AUTH_TOKEN || saved?.token;
package/dist/cli.mjs CHANGED
@@ -83739,6 +83739,9 @@ ${statePacket}`, redactionCredentials) },
83739
83739
  if (isContentActionScenario(scenario)) {
83740
83740
  return false;
83741
83741
  }
83742
+ if (isFormValidationScenario(scenario)) {
83743
+ return false;
83744
+ }
83742
83745
  const text = scenarioText(scenario);
83743
83746
  const visibilityOnlyGoal = (/\b(entry\s+points?|providers?|provider\s+buttons?|sign[- ]?in\s+options|auth(?:entication)?\s+options|available\s+options)\b/.test(text) || /\b(show|display)\s+(all\s+)?(the\s+)?(providers?|options|buttons?|links?|entry\s+points?)\b/.test(text) || /\b(all\s+(three\s+)?providers?)\b/.test(text)) && !/\b(change|update|create|save|send|register|edit|with\s+valid|successful\s+(sign|log)|complete\s+(the\s+)?(login|sign[- ]?in)|submit\s+(the\s+)?(form|credentials)|enter\s+(an?\s+)?(email|password))\b/.test(text);
83744
83747
  if (visibilityOnlyGoal) {
@@ -83904,8 +83907,8 @@ ${statePacket}`, redactionCredentials) },
83904
83907
  if (isNavChromeThrashTrace(actions)) {
83905
83908
  return "content-action scenario only recorded tab thrash / swipes \u2014 no product action";
83906
83909
  }
83907
- const asserts = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
83908
- if (asserts.length > 0 && asserts.every((a) => VOLATILE_BADGE_ASSERT_RE.test(actionText(a)) || NAV_CHROME_TAP_RE.test(actionText(a)) && !CONTENT_ACTION_VERB_RE.test(actionText(a)))) {
83910
+ const asserts2 = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
83911
+ if (asserts2.length > 0 && asserts2.every((a) => VOLATILE_BADGE_ASSERT_RE.test(actionText(a)) || NAV_CHROME_TAP_RE.test(actionText(a)) && !CONTENT_ACTION_VERB_RE.test(actionText(a)))) {
83909
83912
  return "content-action scenario only asserted tab chrome or a volatile badge count \u2014 assert the action outcome instead";
83910
83913
  }
83911
83914
  return null;
@@ -83942,12 +83945,15 @@ ${statePacket}`, redactionCredentials) },
83942
83945
  const hasValidationAssert = actions.some((a) => {
83943
83946
  if (a.type !== "ASSERT_VISIBLE" && a.type !== "ASSERT_TEXT")
83944
83947
  return false;
83945
- return /\b(error|required|invalid|empty|missing|incorrect|wrong|try again|mismatch|does not match|must match)\b/.test(actionText(a));
83948
+ return /\b(error|required|invalid|empty|missing|incorrect|wrong|try again|mismatch|does not match|must match|cannot be empty|please enter|fill out|is required)\b/.test(actionText(a));
83946
83949
  });
83947
83950
  const emptyFieldIntent = /\b(empty|required|missing|blank)\b/.test(text);
83948
83951
  const invalidValueIntent = /\b(wrong|incorrect|invalid|mismatch|reject)\b/.test(text);
83952
+ if (!hasValidationAssert) {
83953
+ return "validation scenario did not assert a validation/error outcome (commit alone is not proof)";
83954
+ }
83949
83955
  if (emptyFieldIntent && !invalidValueIntent) {
83950
- if (!hasCommit && !hasValidationAssert) {
83956
+ if (!hasCommit) {
83951
83957
  return "validation scenario did not record a form commit or validation outcome";
83952
83958
  }
83953
83959
  return null;
@@ -83955,7 +83961,7 @@ ${statePacket}`, redactionCredentials) },
83955
83961
  if (typedFields && !hasCommit) {
83956
83962
  return "validation scenario filled fields but never committed the form";
83957
83963
  }
83958
- if (!hasCommit && !hasValidationAssert) {
83964
+ if (!hasCommit) {
83959
83965
  return "validation scenario did not record a form commit or validation outcome";
83960
83966
  }
83961
83967
  return null;
@@ -83969,6 +83975,10 @@ ${statePacket}`, redactionCredentials) },
83969
83975
  if (!hasCommit) {
83970
83976
  return "form-mutation happy path did not record a form commit";
83971
83977
  }
83978
+ const asserts2 = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
83979
+ if (asserts2.length > 0 && asserts2.every((a) => NAV_CHROME_TAP_RE.test(actionText(a)) || VOLATILE_BADGE_ASSERT_RE.test(actionText(a)) || isStructuralOnlyAssert(a) || isEphemeralContentAssertText(assertIdentityText(a)))) {
83980
+ return "form-mutation happy path only asserted tab chrome or ephemeral content \u2014 assert the saved/updated product outcome";
83981
+ }
83972
83982
  }
83973
83983
  const isCredentialSignIn = /\b(sign\s*in|log\s*in|login|authenticate|auth)\b/.test(text) && /\b(email|e-mail|password|credential|valid credentials?)\b/.test(text) && !/\b(reset|forgot|recover|logout|log\s*out|sign\s*out|change\s+password|update\s+password)\b/.test(text);
83974
83984
  const typedEmailIndexes = [];
@@ -84061,13 +84071,70 @@ ${statePacket}`, redactionCredentials) },
84061
84071
  if (!actions.some((a) => isProductiveScenarioAction(a, text))) {
84062
84072
  return "happy-path / user-journey scenario did not record a productive product action (only chrome, gestures, or empty work)";
84063
84073
  }
84064
- const asserts = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
84065
- if (asserts.length > 0 && asserts.every((a) => VOLATILE_BADGE_ASSERT_RE.test(actionText(a)) || isStructuralOnlyAssert(a))) {
84074
+ const asserts2 = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
84075
+ if (asserts2.length > 0 && asserts2.every((a) => VOLATILE_BADGE_ASSERT_RE.test(actionText(a)) || isStructuralOnlyAssert(a))) {
84066
84076
  return "happy-path / user-journey scenario only asserted volatile badges or structural roots \u2014 assert a durable feature outcome";
84067
84077
  }
84068
84078
  }
84079
+ const goalFidelity = scenarioGoalActionFidelityReason(text, actions);
84080
+ if (goalFidelity)
84081
+ return goalFidelity;
84082
+ if (actions.some((a) => isBareHierarchyFieldAction(a))) {
84083
+ return "typed or tapped a bare hierarchy/container target with no accessible label \u2014 use a labeled field/control";
84084
+ }
84085
+ const asserts = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT");
84086
+ if (asserts.length > 0 && asserts.every((a) => isEphemeralContentAssertText(assertIdentityText(a)))) {
84087
+ return "only asserted ephemeral feed/catalog content (session-specific titles/ids) \u2014 assert durable product chrome or stable outcome";
84088
+ }
84069
84089
  return null;
84070
84090
  }
84091
+ function scenarioGoalActionFidelityReason(scenarioTextLower, actions) {
84092
+ const actionBlob = actions.map((a) => actionText(a)).join(" ").toLowerCase();
84093
+ if (/\b(pick|choose|select)\b.*\b(library|gallery|photos?|camera\s*roll|device)\b|\bfrom\s+(the\s+)?(library|gallery|photos?)\b/.test(scenarioTextLower)) {
84094
+ if (!/\b(library|gallery|photos?|camera\s*roll|pick\s+a\s+video|choose\s+(a\s+)?(video|photo|file)|from\s+library|document\s*picker|media\s*picker)\b/i.test(actionBlob)) {
84095
+ return "scenario claims pick-from-library but recording never tapped a library/gallery/picker control";
84096
+ }
84097
+ }
84098
+ if (/\b(style|styles)\b/.test(scenarioTextLower) && /\b(format|aspect|ratio|quality|reels?|9:16|16:9)\b/.test(scenarioTextLower)) {
84099
+ const pickedStyle = /\b(style|cinematic|anime|realistic|no\s*style|cartoon|oil\s*paint)\b/i.test(actionBlob);
84100
+ const pickedFormat = /\b(9:16|16:9|1:1|reels?|aspect|quality|format|720|1080)\b/i.test(actionBlob);
84101
+ if (!pickedStyle || !pickedFormat) {
84102
+ return "scenario claims style+format selection but recording did not select both a style and a format/aspect control";
84103
+ }
84104
+ }
84105
+ if (/\b(open|launch|start)\b.*\b(camera|recording)\b|\brecord\s+a\s+video\b/.test(scenarioTextLower)) {
84106
+ if (!/\b(camera|record|recording|flash|flip\s+camera|capture|shutter)\b/i.test(actionBlob)) {
84107
+ return "scenario claims open/record camera but recording never reached camera controls";
84108
+ }
84109
+ }
84110
+ return null;
84111
+ }
84112
+ function isBareHierarchyFieldAction(action) {
84113
+ if (action.type !== "TYPE" && action.type !== "CLEAR")
84114
+ return false;
84115
+ const sel = (action.selector ?? "").trim();
84116
+ if (!/^\/hierarchy/i.test(sel))
84117
+ return false;
84118
+ const bundle = action.metadata.locatorBundle;
84119
+ const label = (action.metadata.targetLabel ?? "").trim();
84120
+ if (label && !/^element$/i.test(label))
84121
+ return false;
84122
+ const inner = nonEmpty3(action.innerText);
84123
+ if (inner && !/^element$/i.test(inner))
84124
+ return false;
84125
+ if (nonEmpty3(bundle?.text) || nonEmpty3(bundle?.contentDesc) || nonEmpty3(bundle?.accessibilityId)) {
84126
+ return false;
84127
+ }
84128
+ if (nonEmpty3(bundle?.resourceId) && !isStructuralAssertionIdentity(bundle?.resourceId)) {
84129
+ return false;
84130
+ }
84131
+ if (action.type === "TYPE") {
84132
+ const v = (action.value ?? "").replace(/\s+/g, "");
84133
+ if (v.length > 0)
84134
+ return false;
84135
+ }
84136
+ return true;
84137
+ }
84071
84138
  function actionCycleKey(action) {
84072
84139
  return `${action.type}|${action.selector}|${action.value ?? ""}`;
84073
84140
  }
@@ -84337,6 +84404,15 @@ ${statePacket}`, redactionCredentials) },
84337
84404
  if (t.length >= 40 && /\b(error\s+\d{3,}|generation failed|rate limit|quota exceeded)\b/i.test(t)) {
84338
84405
  return true;
84339
84406
  }
84407
+ if (/\b(harness|seed|fixture|demo\s*data|test\s*artist|qa\s*published)\b/i.test(t) && /\d{6,}/.test(t)) {
84408
+ return true;
84409
+ }
84410
+ if (/\bplay\b.+\bby\b.+/i.test(t) && (t.length >= 40 || /\d{6,}/.test(t))) {
84411
+ return true;
84412
+ }
84413
+ if (/^play\s+/i.test(t) && t.length >= 35 && !/\b(home|search|settings|profile|library|playlist|sign\s*in|log\s*in)\b/i.test(t)) {
84414
+ return true;
84415
+ }
84340
84416
  return false;
84341
84417
  }
84342
84418
  function assertIdentityText(action) {
@@ -87673,6 +87749,7 @@ var require_auth_recipe = __commonJS({
87673
87749
  var START_MARKER = `// >>> VALIDATE_QA_AUTH ${exports.AUTH_MARKER_VERSION} \u2014 generated, do not edit >>>`;
87674
87750
  var END_MARKER = `// <<< VALIDATE_QA_AUTH ${exports.AUTH_MARKER_VERSION} <<<`;
87675
87751
  var SHARED_AUTH_RUNTIME_ENABLED = `!/^false$/i.test((process.env.SHARED_AUTH_INJECT ?? '').trim())`;
87752
+ var WARM_PROOF_SETTLE_MS = 8e3;
87676
87753
  var AUTH_REGION_RE = /\/\/ >>> VALIDATE_QA_AUTH[^\n]*\r?\n[\s\S]*?\/\/ <<< VALIDATE_QA_AUTH[^\n]*\r?\n?/g;
87677
87754
  function computeRecipeHash(input) {
87678
87755
  const canonical = JSON.stringify({
@@ -87808,12 +87885,20 @@ ${tabs}`);
87808
87885
  const pred = urlProofPredicateExpr(recipe.authProof.route, recipe.authProof.exact);
87809
87886
  lines.push(` await page.waitForURL((u) => !(${pred})(u), { timeout: 2000 }).catch(() => {});`);
87810
87887
  lines.push(` if ((${pred})(new URL(page.url())) && !(await page.locator('input[type="password"]').first().isVisible().catch(() => false))) return;`);
87811
- } else if (loginRoute) {
87812
- lines.push(` if (await ${authProofLocator(recipe.authProof)}.isVisible().catch(() => false) && new URL(page.url()).pathname !== new URL(${JSON.stringify(loginRoute)}, page.url()).pathname) return;`);
87813
87888
  } else {
87814
- lines.push(` if (await ${authProofLocator(recipe.authProof)}.isVisible().catch(() => false)) return;`);
87889
+ const proofSettle = `await ${authProofLocator(recipe.authProof)}.waitFor({ state: 'visible', timeout: ${WARM_PROOF_SETTLE_MS} }).then(() => true).catch(() => false)`;
87890
+ if (loginRoute) {
87891
+ lines.push(` if (new URL(page.url()).pathname !== new URL(${JSON.stringify(loginRoute)}, page.url()).pathname && ${proofSettle}) return;`);
87892
+ } else {
87893
+ lines.push(` if (${proofSettle}) return;`);
87894
+ }
87815
87895
  }
87816
87896
  lines.push(` }`);
87897
+ lines.push(` if (${SHARED_AUTH_RUNTIME_ENABLED}) {`);
87898
+ lines.push(` await page.context().clearCookies().catch(() => {});`);
87899
+ lines.push(` await page.goto(${JSON.stringify(recipe.preNavRoute)}, { waitUntil: 'domcontentloaded' }).catch(() => {});`);
87900
+ lines.push(` await page.evaluate(() => { try { localStorage.clear(); sessionStorage.clear(); } catch {} }).catch(() => {});`);
87901
+ lines.push(` }`);
87817
87902
  }
87818
87903
  lines.push(` await page.goto(${JSON.stringify(recipe.preNavRoute)}, { waitUntil: 'domcontentloaded' });`);
87819
87904
  for (const cmd of recipe.preNavCmds)
@@ -95766,6 +95851,32 @@ var require_agentic_discovery_prompts = __commonJS({
95766
95851
  lines.push("For a pure navigation or page-load scenario (no in-page mutation), URL + one");
95767
95852
  lines.push("structural heading is sufficient.");
95768
95853
  lines.push("");
95854
+ lines.push("**Stop at the gate for expensive or irreversible actions.** Some controls");
95855
+ lines.push("start work that costs the account real money (an AI generation, a paid");
95856
+ lines.push("third-party call, a checkout), takes minutes to finish, or cannot be undone");
95857
+ lines.push("(delete, publish, cancel a subscription). Do NOT press them. The regression");
95858
+ lines.push("risk lives in the wiring, and the wiring is fully provable for free:");
95859
+ lines.push("- Assert the control is present and correctly ENABLED/DISABLED by its inputs.");
95860
+ lines.push("- If it opens a dialog, assert the dialog, its fields and its confirm button,");
95861
+ lines.push(" then press Cancel and assert nothing changed.");
95862
+ lines.push("- Assert the auth / quota wall fires for a caller who should be refused.");
95863
+ lines.push("A test that waits minutes for generated output cannot finish inside the run");
95864
+ lines.push("budget, bills the account on every run, and leaves permanent rows behind. If");
95865
+ lines.push("the ONLY way to prove a scenario is to trigger such an action, drop it as a");
95866
+ lines.push("gap rather than emitting a test that does.");
95867
+ lines.push("");
95868
+ lines.push("**Never sign up a new account.** Use the injected credentials. Registering a");
95869
+ lines.push("`testuser-<timestamp>` account to get past a login wall leaves a permanent,");
95870
+ lines.push("unowned user in the target database on every single run. Registration is in");
95871
+ lines.push("scope ONLY when the scenario under test IS registration, in which case use");
95872
+ lines.push("the provided inbox helper so the account is tracked and reaped.");
95873
+ lines.push("");
95874
+ lines.push("**Write the assertion so the BUG fails it.** When a scenario documents broken");
95875
+ lines.push("behaviour, still assert the CORRECT outcome. A test that passes because the");
95876
+ lines.push("error message / console error / not-found page is present inverts the suite:");
95877
+ lines.push("fixing the bug turns the test red and the fix is reported as a regression.");
95878
+ lines.push("State the intended behaviour, let it fail, and flag it as a bug.");
95879
+ lines.push("");
95769
95880
  lines.push("## Tools");
95770
95881
  lines.push("");
95771
95882
  lines.push("Browser MCP: `browser_navigate`, `browser_snapshot`, `browser_click`,");
@@ -96575,7 +96686,7 @@ var require_discover_generator = __commonJS({
96575
96686
  "../runner-core/dist/services/discover-generator.js"(exports) {
96576
96687
  "use strict";
96577
96688
  Object.defineProperty(exports, "__esModule", { value: true });
96578
- exports.MIN_BUG_DESCRIPTION_LENGTH = exports.VALID_PLAYWRIGHT_GETBY_METHODS = exports.BLOCKED_MCP_TOOLS_SCENARIO_GEN = void 0;
96689
+ exports.MIN_BUG_DESCRIPTION_LENGTH = exports.NAME_PROHIBITED_ROLES = exports.VALID_PLAYWRIGHT_GETBY_METHODS = exports.BLOCKED_MCP_TOOLS_SCENARIO_GEN = void 0;
96579
96690
  exports.scenarioLooksLikeRegistration = scenarioLooksLikeRegistration;
96580
96691
  exports.signupFeatureTestingAllowed = signupFeatureTestingAllowed;
96581
96692
  exports.scenarioTouchesSignupAuthSurface = scenarioTouchesSignupAuthSurface;
@@ -96605,6 +96716,7 @@ var require_discover_generator = __commonJS({
96605
96716
  exports.scanFragileStructuralLocators = scanFragileStructuralLocators;
96606
96717
  exports.bannedRoleTokensInSelector = bannedRoleTokensInSelector;
96607
96718
  exports.scanInvalidRoleTagLocators = scanInvalidRoleTagLocators;
96719
+ exports.scanNameProhibitedRoleLocators = scanNameProhibitedRoleLocators;
96608
96720
  exports.scanHealedRoleTagLocators = scanHealedRoleTagLocators;
96609
96721
  exports.validateFinalActionFidelity = validateFinalActionFidelity;
96610
96722
  exports.resolveSuspectedBugVerdict = resolveSuspectedBugVerdict;
@@ -99672,10 +99784,55 @@ ${testOpen}`;
99672
99784
  }
99673
99785
  return findings;
99674
99786
  }
99787
+ exports.NAME_PROHIBITED_ROLES = /* @__PURE__ */ new Set([
99788
+ "caption",
99789
+ "code",
99790
+ "deletion",
99791
+ "emphasis",
99792
+ "generic",
99793
+ "insertion",
99794
+ "mark",
99795
+ "none",
99796
+ "paragraph",
99797
+ "presentation",
99798
+ "strong",
99799
+ "subscript",
99800
+ "superscript",
99801
+ "term",
99802
+ "time"
99803
+ ]);
99804
+ function scanNameProhibitedRoleLocators(code) {
99805
+ const findings = [];
99806
+ const seen = /* @__PURE__ */ new Set();
99807
+ const scanCode = code.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
99808
+ const RE = /getByRole\(\s*(['"`])([a-zA-Z]+)\1\s*,\s*\{[^}]*\bname\s*:/g;
99809
+ for (const rawLine of scanCode.split("\n")) {
99810
+ const line = rawLine.replace(/\/\/.*$/, "");
99811
+ if (line.indexOf("getByRole(") === -1)
99812
+ continue;
99813
+ RE.lastIndex = 0;
99814
+ let m;
99815
+ while ((m = RE.exec(line)) !== null) {
99816
+ const role = m[2].toLowerCase();
99817
+ if (!exports.NAME_PROHIBITED_ROLES.has(role))
99818
+ continue;
99819
+ const key = `${rawLine.trim()} ${role}`;
99820
+ if (seen.has(key))
99821
+ continue;
99822
+ seen.add(key);
99823
+ findings.push({
99824
+ line: rawLine.trim().slice(0, 200),
99825
+ role,
99826
+ reason: `ARIA prohibits an accessible name on role "${role}", so no element ever exposes one \u2014 getByRole('${role}', { name: \u2026 }) matches ZERO nodes and the assertion hangs to its timeout. Match the text instead: page.getByText('\u2026', { exact: true }), optionally scoped (e.g. page.getByRole('main').getByText('\u2026')). If you need the element itself, select a nameable ancestor (region/listitem/link/button) and assert its text.`
99827
+ });
99828
+ }
99829
+ }
99830
+ return findings;
99831
+ }
99675
99832
  function scanHealedRoleTagLocators(code, opts) {
99676
99833
  const inboxStripped = (0, inbox_stamp_js_1.stripInboxRegions)(code);
99677
99834
  const scanned = opts?.stripAuth === false ? inboxStripped : (0, auth_recipe_js_1.stripAuthRegions)(inboxStripped);
99678
- return scanInvalidRoleTagLocators(scanned);
99835
+ return [...scanInvalidRoleTagLocators(scanned), ...scanNameProhibitedRoleLocators(scanned)];
99679
99836
  }
99680
99837
  function validateFinalActionFidelity(playwrightCode, actionLedger, credentials, opts) {
99681
99838
  const ledgerSigs = [];
@@ -100396,7 +100553,7 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
100396
100553
  continue;
100397
100554
  }
100398
100555
  const roleTagMode = (0, discovery_lint_flags_js_1.roleTagLocatorLintMode)();
100399
- const roleTagFindings = roleTagMode !== "off" ? scanInvalidRoleTagLocators((0, auth_recipe_js_1.stripAuthRegions)((0, inbox_stamp_js_1.stripInboxRegions)(validated[0].playwrightCode))) : [];
100556
+ const roleTagFindings = roleTagMode !== "off" ? scanHealedRoleTagLocators(validated[0].playwrightCode) : [];
100400
100557
  if (roleTagMode === "warn" && roleTagFindings.length > 0) {
100401
100558
  logs2.push(` Role-tag locator gate [warn]: ${roleTagFindings.length} role-as-CSS-tag locator(s) \u2014 ${roleTagFindings.map((f) => sanitizeLedgerText(f.line)).join(" | ").slice(0, 300)}`);
100402
100559
  }
@@ -100409,12 +100566,10 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
100409
100566
  valid: false,
100410
100567
  revisionRequired: true,
100411
100568
  reason: "invalid_role_tag_locator",
100412
- instruction: targeted ? `One or more locators below use an ARIA ROLE as a CSS tag selector, e.g. page.locator('textbox[name^="code"]'). There is no <textbox>/<checkbox>/<heading> HTML element, so page.locator() matches ZERO nodes and the action hangs until the test times out.
100413
- For EACH flagged locator, rewrite it as a real Playwright locator:
100414
- 1. Prefer page.getByRole('<role>', { name: '<accessible name>' }) \u2014 the role is the token you wrongly put in the CSS.
100415
- 2. Or page.getByLabel('<label>'), or a type-scoped CSS like page.locator('input[type="text"]') / input[type="email"].
100416
- 3. For a verification-code field, prefer the injected helper: await __vqaEnterVerificationCode(page, code).
100417
- Keep every other line exactly as-is. Submit the COMPLETE corrected test and call finish_generate again.` : `Your resubmission STILL contains a locator that uses an ARIA role as a CSS tag selector (page.locator('<role>[\u2026]')). This can only ever match nothing. Replace EVERY such locator with page.getByRole('<role>', { name: '\u2026' }) / getByLabel / input[type="\u2026"], or use await __vqaEnterVerificationCode(page, code) for the code field, and resubmit the COMPLETE test.`,
100569
+ instruction: targeted ? `Every locator listed below matches ZERO nodes, so the step hangs until the test times out. Read the \`reason\` on each one \u2014 there are two distinct mistakes:
100570
+ (a) An ARIA ROLE used as a CSS tag selector, e.g. page.locator('textbox[name^="code"]'). There is no <textbox>/<checkbox>/<heading> element. Rewrite as page.getByRole('<role>', { name: '<accessible name>' }), or page.getByLabel('<label>'), or a type-scoped CSS like page.locator('input[type="email"]'). For a verification-code field prefer the injected helper: await __vqaEnterVerificationCode(page, code).
100571
+ (b) A { name: \u2026 } filter on a role whose accessible name ARIA PROHIBITS (paragraph, generic, code, emphasis, strong, term, time, mark, caption, presentation/none, insertion, deletion, sub/superscript). No element with those roles ever exposes a name. Match the text instead: page.getByText('\u2026', { exact: true }), scoped if needed (page.getByRole('main').getByText('\u2026')), or target a nameable ancestor such as region/listitem/link/button.
100572
+ Fix EVERY flagged locator, keep every other line exactly as-is, then submit the COMPLETE corrected test and call finish_generate again.` : `Your resubmission STILL contains a locator that can only ever match nothing \u2014 either an ARIA role used as a CSS tag selector (page.locator('<role>[\u2026]')) or a { name: \u2026 } filter on a name-prohibited role (paragraph/generic/code/emphasis/strong/term/time/\u2026). Replace EVERY one: use getByRole('<role>', { name: '\u2026' }) / getByLabel / input[type="\u2026"] for the first, and getByText('\u2026', { exact: true }) for the second. Resubmit the COMPLETE test.`,
100418
100573
  offendingLocators: roleTagFindings.map((f) => ({
100419
100574
  line: sanitizeLedgerText(f.line),
100420
100575
  reason: f.reason
@@ -353826,7 +353981,7 @@ var require_package4 = __commonJS({
353826
353981
  "package.json"(exports, module) {
353827
353982
  module.exports = {
353828
353983
  name: "@validate.qa/runner",
353829
- version: "1.0.86",
353984
+ version: "1.0.88",
353830
353985
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
353831
353986
  bin: {
353832
353987
  "validate-runner": "dist/cli.js"
@@ -362172,8 +362327,13 @@ var RunnerUpgradeRequiredError = class extends Error {
362172
362327
  this.latestVersion = latestVersion;
362173
362328
  }
362174
362329
  };
362330
+ var PENDING_POLL_TIMEOUT_MS = Math.max(15e3, envInt("VALIDATE_RUNNER_PENDING_TIMEOUT_MS", 9e4));
362331
+ var POLL_STUCK_RECYCLE_MS = Math.max(6e4, envInt("VALIDATE_RUNNER_POLL_STUCK_MS", 5 * 6e4));
362175
362332
  async function fetchPendingRuns(serverUrl, headers) {
362176
- const res = await fetch(`${serverUrl}/api/runner/pending`, { headers, signal: AbortSignal.timeout(3e4) });
362333
+ const res = await fetch(`${serverUrl}/api/runner/pending`, {
362334
+ headers,
362335
+ signal: AbortSignal.timeout(PENDING_POLL_TIMEOUT_MS)
362336
+ });
362177
362337
  if (!res.ok) {
362178
362338
  if (res.status === 401) throw new Error("Runner token is invalid or expired. Run: validate.qa-runner login");
362179
362339
  if (res.status === 426) {
@@ -365586,10 +365746,12 @@ async function startRunner(opts) {
365586
365746
  log.dim(line);
365587
365747
  };
365588
365748
  let lastPendingSummary = "";
365749
+ let lastSuccessfulPollAt = Date.now();
365589
365750
  while (!shuttingDown) {
365590
365751
  try {
365591
365752
  if (activeRuns < MAX_CONCURRENT) {
365592
365753
  const pending = await fetchPendingRuns(serverUrl, headers);
365754
+ lastSuccessfulPollAt = Date.now();
365593
365755
  {
365594
365756
  const pendingIds = new Set(pending.map((r) => r.runId));
365595
365757
  for (const id of [...deferralLogged.keys()]) {
@@ -365794,6 +365956,8 @@ async function startRunner(opts) {
365794
365956
  }
365795
365957
  }
365796
365958
  consecutivePollErrors = 0;
365959
+ } else {
365960
+ lastSuccessfulPollAt = Date.now();
365797
365961
  }
365798
365962
  } catch (err) {
365799
365963
  if (err instanceof RunnerUpgradeRequiredError) {
@@ -365802,6 +365966,13 @@ async function startRunner(opts) {
365802
365966
  }
365803
365967
  const message = err instanceof Error ? err.message : String(err);
365804
365968
  consecutivePollErrors++;
365969
+ const stuckForMs = Date.now() - lastSuccessfulPollAt;
365970
+ if (stuckForMs >= POLL_STUCK_RECYCLE_MS) {
365971
+ log.warn(
365972
+ `Poll stuck for ${Math.round(stuckForMs / 1e3)}s (>= ${Math.round(POLL_STUCK_RECYCLE_MS / 1e3)}s) after error: ${message}. Recycling runner process so the supervisor can restart with a clean loop; orphaned work is re-queued server-side.`
365973
+ );
365974
+ process.exit(78);
365975
+ }
365805
365976
  const backoffMs = Math.min(pollIntervalMs * Math.pow(2, consecutivePollErrors - 1), MAX_BACKOFF_MS);
365806
365977
  if (consecutivePollErrors > 1) {
365807
365978
  log.warn(`Poll error #${consecutivePollErrors} (${message}) - backing off for ${(backoffMs / 1e3).toFixed(1)}s`);
@@ -365854,7 +366025,76 @@ program.command("login").description("Save runner credentials for future use").r
365854
366025
  console.log("");
365855
366026
  console.log("Run: validate-runner start");
365856
366027
  });
365857
- program.command("start").description("Start the local test runner").option("--token <token>", "Runner token (overrides saved credentials)").option("--server <url>", "Server URL (overrides saved credentials)").option("--project <id>", "Project ID (overrides saved credentials)").option("--mode <mode>", "Runner mode: playwright-native (default)", "playwright-native").option("--poll-interval <ms>", "Poll interval in milliseconds", "2000").option("--mobile", "Enable mobile testing (Appium + mobile bridge server)").option("--tunnel", "Enable ngrok tunnel for mobile bridge (allows HTTPS dashboard to connect)").action(async (opts) => {
366028
+ async function superviseRunnerStart(argv) {
366029
+ const { spawn: spawn2 } = await import("child_process");
366030
+ const minBackoffMs = 2e3;
366031
+ const maxBackoffMs = 6e4;
366032
+ let backoffMs = minBackoffMs;
366033
+ let restarts = 0;
366034
+ const windowStart = Date.now();
366035
+ const maxRestartsPerHour = Math.max(1, Number(process.env.VALIDATE_RUNNER_MAX_RESTARTS_PER_HOUR || 30));
366036
+ const childEnv = { ...process.env, VALIDATE_RUNNER_SUPERVISED_CHILD: "1" };
366037
+ const runOnce = () => new Promise((resolve2) => {
366038
+ const child = spawn2(process.execPath, argv, {
366039
+ stdio: "inherit",
366040
+ env: childEnv
366041
+ });
366042
+ const onSignal = (sig) => {
366043
+ try {
366044
+ child.kill(sig);
366045
+ } catch {
366046
+ }
366047
+ };
366048
+ process.once("SIGINT", onSignal);
366049
+ process.once("SIGTERM", onSignal);
366050
+ child.on("exit", (code, signal) => {
366051
+ process.removeListener("SIGINT", onSignal);
366052
+ process.removeListener("SIGTERM", onSignal);
366053
+ if (signal === "SIGINT" || signal === "SIGTERM") {
366054
+ resolve2(0);
366055
+ return;
366056
+ }
366057
+ resolve2(code ?? 1);
366058
+ });
366059
+ });
366060
+ const childArgv = argv.filter((a) => a !== "--supervise" && a !== "--no-supervise");
366061
+ console.log("\u{1F6E1} Runner supervisor enabled \u2014 will auto-restart on crash/stuck exit");
366062
+ console.log(" (disable with --no-supervise or VALIDATE_RUNNER_SUPERVISE=0)");
366063
+ for (; ; ) {
366064
+ const code = await runOnce();
366065
+ if (code === 0) {
366066
+ process.exit(0);
366067
+ }
366068
+ restarts += 1;
366069
+ const elapsedH = (Date.now() - windowStart) / 36e5;
366070
+ if (elapsedH < 1 && restarts > maxRestartsPerHour) {
366071
+ console.error(
366072
+ `\u2717 Supervisor: ${restarts} restarts in under 1h (cap ${maxRestartsPerHour}). Stopping to avoid a crash loop. Fix the underlying issue and re-run start.`
366073
+ );
366074
+ process.exit(1);
366075
+ }
366076
+ console.warn(
366077
+ `\u26A0 Runner exited with code ${code} \u2014 restarting in ${(backoffMs / 1e3).toFixed(1)}s (restart #${restarts}). Server will re-queue orphaned work; pending jobs resume automatically.`
366078
+ );
366079
+ await new Promise((r) => setTimeout(r, backoffMs));
366080
+ backoffMs = Math.min(maxBackoffMs, Math.round(backoffMs * 1.5));
366081
+ }
366082
+ }
366083
+ function shouldSupervise(opts) {
366084
+ if (opts.supervise === false) return false;
366085
+ if (opts.supervise === true) return true;
366086
+ if (process.env.VALIDATE_RUNNER_SUPERVISED_CHILD === "1") return false;
366087
+ if (process.env.RUNNER_CLOUD === "true") return false;
366088
+ const env = (process.env.VALIDATE_RUNNER_SUPERVISE ?? "1").trim().toLowerCase();
366089
+ return env !== "0" && env !== "false" && env !== "off" && env !== "no";
366090
+ }
366091
+ program.command("start").description("Start the local test runner").option("--token <token>", "Runner token (overrides saved credentials)").option("--server <url>", "Server URL (overrides saved credentials)").option("--project <id>", "Project ID (overrides saved credentials)").option("--mode <mode>", "Runner mode: playwright-native (default)", "playwright-native").option("--poll-interval <ms>", "Poll interval in milliseconds", "2000").option("--mobile", "Enable mobile testing (Appium + mobile bridge server)").option("--tunnel", "Enable ngrok tunnel for mobile bridge (allows HTTPS dashboard to connect)").option("--supervise", "Auto-restart the runner if it crashes or self-recycles (default for local)").option("--no-supervise", "Disable auto-restart supervisor").action(async (opts) => {
366092
+ if (shouldSupervise(opts)) {
366093
+ const argv = [...process.argv.slice(1)];
366094
+ if (!argv.includes("--no-supervise")) argv.push("--no-supervise");
366095
+ await superviseRunnerStart(argv);
366096
+ return;
366097
+ }
365858
366098
  await requireLatestRunner();
365859
366099
  const saved = loadCredentials();
365860
366100
  const authToken = opts.token || process.env.AUTH_TOKEN || saved?.token;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@validate.qa/runner",
3
- "version": "1.0.86",
3
+ "version": "1.0.88",
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"