@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.
- package/dist/cli.js +262 -22
- package/dist/cli.mjs +262 -22
- 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
|
|
83903
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
84060
|
-
if (
|
|
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
|
-
|
|
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" ?
|
|
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 ? `
|
|
100408
|
-
|
|
100409
|
-
|
|
100410
|
-
|
|
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.
|
|
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`, {
|
|
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
|
-
|
|
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
|
|
83908
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
84065
|
-
if (
|
|
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
|
-
|
|
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" ?
|
|
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 ? `
|
|
100413
|
-
|
|
100414
|
-
|
|
100415
|
-
|
|
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.
|
|
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`, {
|
|
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
|
-
|
|
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