@keber/qa-framework 1.10.0 → 1.12.0

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 (113) hide show
  1. package/CHANGELOG.md +63 -18
  2. package/README.md +2 -2
  3. package/docs/architecture.md +30 -26
  4. package/docs/decisions/lane-heartbeat-caller.md +86 -0
  5. package/docs/final-report.md +23 -23
  6. package/docs/folder-structure-guide.md +106 -80
  7. package/docs/installation.md +35 -7
  8. package/docs/skills-architecture.md +23 -23
  9. package/docs/spec-driven-philosophy.md +9 -9
  10. package/docs/usage-with-agent.md +83 -9
  11. package/docs/visuals/diag-01-ejecutivo-1.png +0 -0
  12. package/docs/visuals/diag-02-tactico-1.png +0 -0
  13. package/docs/visuals/diag-03-tecnico-1.png +0 -0
  14. package/docs/visuals/info-01-ejecutivo-1.png +0 -0
  15. package/docs/visuals/info-02-tactico-1.png +0 -0
  16. package/docs/visuals/info-03-tecnico-1.png +0 -0
  17. package/docs/visuals/pres-01-ejecutivo-1.png +0 -0
  18. package/docs/visuals/pres-02-tactico-1.png +0 -0
  19. package/docs/visuals/pres-03-tecnico-1.png +0 -0
  20. package/docs/visuals/qa-01-problema-1.png +0 -0
  21. package/docs/visuals/qa-02-que-es-1.png +0 -0
  22. package/docs/visuals/qa-03-pipeline-1.png +0 -0
  23. package/docs/visuals/qa-04-6-archivos-1.png +0 -0
  24. package/docs/visuals/qa-05-humano-1.png +0 -0
  25. package/docs/visuals/qa-06-agente-1.png +0 -0
  26. package/docs/visuals/qa-07-skills-arch-1.png +0 -0
  27. package/docs/visuals/qa-08-traceability-1.png +0 -0
  28. package/docs/visuals/qa-en-01-problem-1.png +0 -0
  29. package/docs/visuals/qa-en-02-what-is-1.png +0 -0
  30. package/docs/visuals/qa-en-03-pipeline-1.png +0 -0
  31. package/docs/visuals/qa-en-04-6files-1.png +0 -0
  32. package/docs/visuals/qa-en-05-human-1.png +0 -0
  33. package/docs/visuals/qa-en-06-agent-1.png +0 -0
  34. package/docs/visuals/qa-en-07-skills-arch-1.png +0 -0
  35. package/docs/visuals/qa-en-08-traceability-1.png +0 -0
  36. package/examples/module-example/suppliers/00-inventory.md +1 -1
  37. package/examples/module-example/suppliers/suppliers-create.spec.ts +8 -8
  38. package/integrations/ado-powershell/README.md +1 -1
  39. package/integrations/ado-powershell/pipelines/azure-pipeline-qa.yml +92 -25
  40. package/integrations/ado-powershell/scripts/sync-ado-titles.ps1 +2 -2
  41. package/integrations/playwright/README.md +128 -4
  42. package/package.json +9 -6
  43. package/qa-framework.config.json +24 -3
  44. package/scripts/check-forbidden-chars.js +187 -0
  45. package/scripts/check-spanish-accents.js +159 -0
  46. package/scripts/cli.js +1 -1
  47. package/scripts/generate.js +2 -2
  48. package/scripts/init.js +108 -45
  49. package/scripts/lib/claude-agents.js +78 -0
  50. package/scripts/lib/claude-commands.js +56 -0
  51. package/scripts/sync-version.js +36 -0
  52. package/scripts/upgrade.js +461 -30
  53. package/scripts/validate.js +17 -17
  54. package/skills/qa-ado-integration/SKILL.md +19 -19
  55. package/skills/qa-ado-integration/references/scripts-and-config.md +6 -6
  56. package/skills/qa-automation/SKILL.md +49 -27
  57. package/skills/qa-automation/references/config-checklist.md +4 -4
  58. package/skills/qa-automation/references/dom-inspection-template.js +7 -7
  59. package/skills/qa-automation/references/patterns.md +20 -20
  60. package/skills/qa-automation/references/pom-template.md +161 -0
  61. package/skills/qa-maintenance/SKILL.md +11 -11
  62. package/skills/qa-maintenance/references/update-rules.md +8 -8
  63. package/skills/qa-module-analysis/SKILL.md +20 -19
  64. package/skills/qa-module-analysis/references/exploration-checklist.md +4 -4
  65. package/skills/qa-module-analysis/references/output-verification.md +69 -0
  66. package/skills/qa-module-analysis/references/spec-file-formats.md +14 -14
  67. package/skills/qa-spec-generation/SKILL.md +13 -7
  68. package/skills/qa-test-cases/SKILL.md +11 -81
  69. package/skills/qa-test-cases/references/test-case-template.md +5 -5
  70. package/skills/qa-test-plan/SKILL.md +22 -86
  71. package/skills/qa-test-plan/references/plan-de-pruebas-template.md +24 -24
  72. package/skills/qa-test-stabilization/SKILL.md +59 -30
  73. package/skills/qa-test-stabilization/references/classification-protocol.md +35 -35
  74. package/templates/agent-next-steps.md +10 -10
  75. package/templates/agents/qa-analisis.md +142 -0
  76. package/templates/agents/qa-asesoria.md +48 -0
  77. package/templates/agents/qa-informe-resultados.md +234 -0
  78. package/templates/agents/qa-plan.md +131 -0
  79. package/templates/automation-scaffold/.env.example +52 -3
  80. package/templates/automation-scaffold/fixtures/auth.ts +27 -5
  81. package/templates/automation-scaffold/fixtures/base.ts +130 -0
  82. package/templates/automation-scaffold/fixtures/test-helpers.ts +1 -1
  83. package/templates/automation-scaffold/global-setup.ts +196 -37
  84. package/templates/automation-scaffold/playwright.config.ts +74 -10
  85. package/templates/automation-scaffold/scripts/global-setup-guards.js +184 -0
  86. package/templates/automation-scaffold/scripts/lane-config.js +269 -0
  87. package/templates/automation-scaffold/scripts/lane-lock.js +589 -0
  88. package/templates/defect-report.md +18 -4
  89. package/templates/execution-report.md +9 -9
  90. package/templates/integration-scaffold/.env.example +40 -0
  91. package/templates/integration-scaffold/README.md +77 -0
  92. package/templates/integration-scaffold/global-setup.ts +119 -0
  93. package/templates/integration-scaffold/package.json +15 -0
  94. package/templates/integration-scaffold/playwright.config.ts +92 -0
  95. package/templates/integration-scaffold/tests/example.spec.ts +43 -0
  96. package/templates/memory/ci-pipeline-findings.md +36 -0
  97. package/templates/memory/data-volatility-strategies.md +70 -0
  98. package/templates/memory/e2e-stabilization-patterns.md +54 -0
  99. package/templates/qa-framework.instructions.md +16 -7
  100. package/templates/qa-framework.rules.md +53 -0
  101. package/templates/qa-readme.md +26 -5
  102. package/templates/session-summary.md +2 -2
  103. package/templates/specification/00-inventory.md +3 -3
  104. package/templates/specification/01-business-rules.md +6 -6
  105. package/templates/specification/02-workflows.md +1 -1
  106. package/templates/specification/03-roles-permissions.md +1 -1
  107. package/templates/specification/04-test-data.md +8 -8
  108. package/templates/specification/05-test-scenarios.md +5 -5
  109. package/templates/test-case.md +3 -3
  110. package/templates/test-data-guidelines.md +7 -7
  111. package/templates/test-plan-sprint.md +13 -13
  112. package/templates/test-plan.md +3 -3
  113. package/skills/qa-test-plan/references/priority-and-feasibility.md +0 -159
@@ -11,14 +11,14 @@
11
11
  * - Reference the matching storageState in playwright.config.ts projects[].
12
12
  *
13
13
  * Environment variables required:
14
- * QA_BASE_URL — Base URL of the application under test
15
- * QA_USER_EMAIL — Default QA user email (or username/RUT)
16
- * QA_USER_PASSWORD — Default QA user password
17
- * QA_LOGIN_PATH — Relative path to the login page (default: /login)
18
- * QA_LOGIN_EMAIL_SELECTOR — CSS selector for the username/email input
19
- * QA_LOGIN_PASSWORD_SELECTOR — CSS selector for the password input
20
- * QA_LOGIN_SUBMIT_SELECTOR — CSS selector for the submit button
21
- * QA_LOGIN_SUCCESS_SELECTOR — CSS selector that confirms successful login
14
+ * QA_BASE_URL - Base URL of the application under test
15
+ * QA_USER_EMAIL - Default QA user email (or username/RUT)
16
+ * QA_USER_PASSWORD - Default QA user password
17
+ * QA_LOGIN_PATH - Relative path to the login page (default: /login)
18
+ * QA_LOGIN_EMAIL_SELECTOR - CSS selector for the username/email input
19
+ * QA_LOGIN_PASSWORD_SELECTOR - CSS selector for the password input
20
+ * QA_LOGIN_SUBMIT_SELECTOR - CSS selector for the submit button
21
+ * QA_LOGIN_SUCCESS_SELECTOR - CSS selector that confirms successful login
22
22
  *
23
23
  * See .env.example for all supported variables.
24
24
  */
@@ -30,6 +30,102 @@ import * as fs from 'fs';
30
30
 
31
31
  dotenv.config();
32
32
 
33
+ // Lane table and guards. Both are CommonJS so the guard logic stays testable with
34
+ // `node --test` without adding ts-node or tsx as a dependency.
35
+ const laneLock = require('./scripts/lane-lock.js');
36
+ const guards = require('./scripts/global-setup-guards.js');
37
+
38
+ type LaneInfo = {
39
+ account: string;
40
+ storageState: string;
41
+ mcpNamespace: string | null;
42
+ runnerProject: string | null;
43
+ capabilities: string[];
44
+ };
45
+ const LANE_INFO: Record<string, LaneInfo> = laneLock.LANE_INFO;
46
+
47
+ function resolveVar(param: string | undefined, envKey: string, defaultValue: string): [string, string] {
48
+ if (param !== undefined) {
49
+ return [param, 'param'];
50
+ }
51
+ if (process.env[envKey] !== undefined) {
52
+ return [process.env[envKey]!, `env:${envKey}`];
53
+ }
54
+ return [defaultValue, 'default'];
55
+ }
56
+
57
+ /**
58
+ * Decides which lanes to log in.
59
+ *
60
+ * Resolution order:
61
+ * 1. QA_LANE_ONLY - pins the run to exactly one lane. This is the normal path and
62
+ * the only one the guards permit without an explicit cold-setup opt-in.
63
+ * 2. QA_AUTH_USER - comma-separated lane ids, for an intentional multi-lane setup.
64
+ * 3. the projects' storageState, matched against the lane table.
65
+ * 4. every configured lane (the cold-setup path, gated by QA_COLD_SETUP=1).
66
+ *
67
+ * Step 3 matches storageState by EXACT equality against the lane table rather than by
68
+ * substring. The previous scaffold used storageState.includes('user-2'), which is what
69
+ * breaks first beyond two lanes: 'user-2' is a substring of 'user-20', and there was no
70
+ * branch at all for a third lane, so lanes past the second were silently never logged in.
71
+ */
72
+ function resolveAuthLanes(config: FullConfig): string[] {
73
+ const laneIds = Object.keys(LANE_INFO);
74
+
75
+ const laneOnly = process.env.QA_LANE_ONLY;
76
+ if (laneOnly) {
77
+ // Throws on an unknown lane rather than guessing. Guessing is how a run logs into
78
+ // an account it does not hold.
79
+ return [laneLock.laneIdForQaLaneOnly(laneOnly)];
80
+ }
81
+
82
+ const explicit = process.env.QA_AUTH_USER;
83
+ if (explicit) {
84
+ const resolved = explicit
85
+ .split(',')
86
+ .map((value) => value.trim())
87
+ .filter((value) => laneIds.includes(value));
88
+ if (resolved.length) return resolved;
89
+ }
90
+
91
+ const fromProjects = new Set<string>();
92
+ for (const project of config.projects) {
93
+ const storageState = (project.use as { storageState?: unknown } | undefined)?.storageState;
94
+ if (typeof storageState !== 'string') continue;
95
+ for (const laneId of laneIds) {
96
+ if (LANE_INFO[laneId].storageState === storageState) fromProjects.add(laneId);
97
+ }
98
+ }
99
+ if (fromProjects.size) return [...fromProjects];
100
+
101
+ return laneIds;
102
+ }
103
+
104
+ async function dismissOnboardingFlow(page: import('@playwright/test').Page): Promise<void> {
105
+ // Override this with your app's onboarding dismissal logic.
106
+ // Use QA_DISMISS_ONBOARDING_LABELS (comma-separated button labels) to enable
107
+ // without modifying this file. Example: QA_DISMISS_ONBOARDING_LABELS=Skip,Got it
108
+ const raw = process.env.QA_DISMISS_ONBOARDING_LABELS ?? '';
109
+ const labels = raw.split(',').map((l: string) => l.trim()).filter(Boolean);
110
+ if (!labels.length) return;
111
+
112
+ for (let round = 0; round < 3; round++) {
113
+ let dismissed = false;
114
+ for (const label of labels) {
115
+ const button = page.getByRole('button', { name: label, exact: true }).first();
116
+ const visible = await button.isVisible({ timeout: 1_000 }).catch(() => false);
117
+ if (visible) {
118
+ await button.click({ timeout: 2_000 }).catch(() => {});
119
+ await page.waitForTimeout(250);
120
+ dismissed = true;
121
+ }
122
+ }
123
+ if (!dismissed) {
124
+ break;
125
+ }
126
+ }
127
+ }
128
+
33
129
  /** Perform login and persist storageState to disk. */
34
130
  async function loginAs(params: {
35
131
  email: string;
@@ -59,11 +155,53 @@ async function loginAs(params: {
59
155
  const page = await context.newPage();
60
156
 
61
157
  try {
62
- await page.goto(`${baseURL}${loginPath}`, { waitUntil: 'domcontentloaded' });
63
- await page.locator(emailSelector).fill(email);
64
- await page.locator(passwordSelector).fill(password);
65
- await page.locator(submitSelector).click();
66
- await page.waitForSelector(successSelector, { timeout: 15_000 });
158
+ const loginUrl = `${baseURL}${loginPath.startsWith('/') ? loginPath : `/${loginPath}`}`;
159
+ let signedIn = false;
160
+
161
+ for (let attempt = 1; attempt <= 3 && !signedIn; attempt++) {
162
+ await page.goto(loginUrl, { waitUntil: 'load', timeout: 60_000 });
163
+ await page.locator(emailSelector).fill(email);
164
+ // Trace safety: Playwright records fill() argument values in traces, and this
165
+ // project runs with trace/video 'retain-on-failure' - a failed CI run would
166
+ // persist the plaintext password in the artifact. Setting the value through
167
+ // evaluate() keeps it out of the trace. Do not "simplify" this back to fill().
168
+ // The input/change events are required because assigning .value directly does
169
+ // not notify SPA frameworks (Blazor/Radzen bind on those events).
170
+ // evaluate() has no auto-wait, so the explicit waitFor replaces the one that
171
+ // locator().fill() performed implicitly.
172
+ await page.locator(passwordSelector).waitFor({ state: 'visible', timeout: 30_000 });
173
+ await page.evaluate(
174
+ ([selector, pwd]) => {
175
+ const input = document.querySelector(selector) as HTMLInputElement | null;
176
+ if (!input) {
177
+ throw new Error(`[qa-framework] Password input not found for selector: ${selector}`);
178
+ }
179
+ input.value = pwd;
180
+ input.dispatchEvent(new Event('input', { bubbles: true }));
181
+ input.dispatchEvent(new Event('change', { bubbles: true }));
182
+ },
183
+ [passwordSelector, password] as const
184
+ );
185
+ await page.locator(submitSelector).click();
186
+
187
+ const result = await Promise.race([
188
+ page.waitForSelector(successSelector, { timeout: 30_000 }).then(() => 'ok' as const),
189
+ page.locator('text=An unhandled error has occurred.').waitFor({ state: 'visible', timeout: 30_000 }).then(() => 'crash' as const),
190
+ ]).catch(() => 'timeout' as const);
191
+
192
+ if (result === 'ok') {
193
+ signedIn = true;
194
+ } else if (result === 'crash') {
195
+ await page.getByText('Reload').click({ timeout: 5_000 }).catch(() => {});
196
+ await page.waitForTimeout(2_000);
197
+ }
198
+ }
199
+
200
+ if (!signedIn) {
201
+ throw new Error('[qa-framework] Login did not reach the success selector after 3 attempts.');
202
+ }
203
+
204
+ await dismissOnboardingFlow(page);
67
205
 
68
206
  // Ensure .auth/ directory exists
69
207
  const dir = path.dirname(stateFile);
@@ -77,30 +215,51 @@ async function loginAs(params: {
77
215
  }
78
216
 
79
217
  export default async function globalSetup(_config: FullConfig): Promise<void> {
80
- const baseURL = process.env.QA_BASE_URL!;
81
- const email = process.env.QA_USER_EMAIL!;
82
- const password = process.env.QA_USER_PASSWORD!;
218
+ const baseURL = process.env.QA_BASE_URL!;
83
219
 
84
- // --- Default role login ---
85
- await loginAs({
86
- email,
87
- password,
88
- baseURL,
89
- stateFile: '.auth/user-default.json',
90
- });
91
-
92
- // --- Additional roles (uncomment and adapt as needed) ---
93
- // await loginAs({
94
- // email: process.env.QA_ADMIN_EMAIL!,
95
- // password: process.env.QA_ADMIN_PASSWORD!,
96
- // baseURL,
97
- // stateFile: '.auth/user-admin.json',
98
- // });
220
+ // Guards run BEFORE any login. A lock nothing consults is decoration, and this is
221
+ // the last point at which a run about to drive an account it does not hold can
222
+ // still be stopped.
99
223
  //
100
- // await loginAs({
101
- // email: process.env.QA_READONLY_EMAIL!,
102
- // password: process.env.QA_READONLY_PASSWORD!,
103
- // baseURL,
104
- // stateFile: '.auth/user-readonly.json',
105
- // });
224
+ // global-setup NEVER acquires or reserves a lane itself. It only READS the lock
225
+ // state and verifies the lane is already held. Reserving is the caller's job - the
226
+ // operator, the orchestrator, or the CI job. A global-setup that reserved its own
227
+ // lane would make every accidental run look legitimate.
228
+ guards.assertColdSetupIsIntentional(process.env);
229
+
230
+ const laneOnly = process.env.QA_LANE_ONLY;
231
+ if (laneOnly) {
232
+ guards.assertLaneIsLocked(laneOnly, process.env, laneLock);
233
+ guards.assertProjectsMatchLane(laneOnly, _config.projects, process.argv, laneLock);
234
+ }
235
+
236
+ for (const laneId of resolveAuthLanes(_config)) {
237
+ const lane = LANE_INFO[laneId];
238
+
239
+ // Credentials come from the lane's declared account prefix, e.g. account
240
+ // 'QA_USER4' resolves QA_USER4_EMAIL / QA_USER4_PASSWORD. This is what replaces
241
+ // the old hardcoded QA_USER / QA_USER2 pair and is what lets a lane be added by
242
+ // config alone.
243
+ const emailKey = `${lane.account}_EMAIL`;
244
+ const passwordKey = `${lane.account}_PASSWORD`;
245
+
246
+ if (!process.env[emailKey] || !process.env[passwordKey]) {
247
+ throw new Error(
248
+ `[global-setup] Lane ${laneId} (account ${lane.account}) is missing ${emailKey} or ` +
249
+ `${passwordKey}. Every configured lane needs its own credentials, otherwise two lanes ` +
250
+ 'end up sharing an account, which is exactly what lanes exist to prevent.'
251
+ );
252
+ }
253
+
254
+ const [email, emailSource] = resolveVar(process.env[emailKey], emailKey, 'qa-user@example.com');
255
+ const [password, passwordSource] = resolveVar(process.env[passwordKey], passwordKey, 'CHANGE_ME');
256
+ console.log(`[global-setup] lane ${laneId} (${lane.account}) credentials from ${emailSource} / ${passwordSource}`);
257
+
258
+ await loginAs({
259
+ email,
260
+ password,
261
+ baseURL,
262
+ stateFile: lane.storageState,
263
+ });
264
+ }
106
265
  }
@@ -1,6 +1,23 @@
1
1
  import { defineConfig, devices } from '@playwright/test';
2
2
  import * as dotenv from 'dotenv';
3
- dotenv.config();
3
+ import * as fs from 'fs';
4
+
5
+ // Least privilege: this config only needs a handful of non-secret values, so it reads
6
+ // them with dotenv.parse(), which returns a plain object and never touches process.env.
7
+ // dotenv.config() would inject the WHOLE .env - every QA account password and ADO token
8
+ // living in it - into a process that has no use for them, where any dependency, crash
9
+ // dump or child process inherits them. Do not "simplify" this back to config().
10
+ const ENV_KEYS = ['QA_SESSION_TTL_MS', 'QA_BASE_URL'] as const;
11
+ if (fs.existsSync('.env')) {
12
+ const parsed = dotenv.parse(fs.readFileSync('.env'));
13
+ for (const key of ENV_KEYS) {
14
+ if (process.env[key] === undefined && parsed[key] !== undefined) {
15
+ process.env[key] = parsed[key];
16
+ }
17
+ }
18
+ }
19
+
20
+ export const SESSION_TTL_MS = Number(process.env.QA_SESSION_TTL_MS ?? 2 * 60 * 60 * 1000);
4
21
 
5
22
  // -------------------------------------------------------------------
6
23
  // Validate required environment variables at config load time
@@ -24,7 +41,7 @@ for (const key of required) {
24
41
  // token: process.env.SYSTEM_ACCESSTOKEN ?? process.env.ADO_PAT,
25
42
  // planId: Number(process.env.ADO_PLAN_ID),
26
43
  // projectName: process.env.ADO_PROJECT!,
27
- // testRunTitle: `[Auto] Sprint {{NNN}} — ${new Date().toISOString().slice(0, 10)}`,
44
+ // testRunTitle: `[Auto] Sprint {{NNN}} - ${new Date().toISOString().slice(0, 10)}`,
28
45
  // publishTestResultsMode: 'testRun' as const,
29
46
  // uploadAttachments: true,
30
47
  // attachmentsType: ['screenshot', 'video', 'trace'] as const,
@@ -36,6 +53,57 @@ for (const key of required) {
36
53
  // },
37
54
  // };
38
55
 
56
+ // -------------------------------------------------------------------
57
+ // Parallel QA lanes
58
+ // -------------------------------------------------------------------
59
+ // A lane is a distinct QA account. Lanes exist because the applications under test
60
+ // invalidate a session when the same account logs in again, so two concurrent workers
61
+ // sharing one account silently destroy each other's session. Speed is not the reason.
62
+ //
63
+ // The lane table lives in qa-framework.config.json (parallelLanes.lanes) and is read
64
+ // here through lane-lock.js, which is the SINGLE SOURCE OF TRUTH for the
65
+ // lane -> storageState mapping. Never hardcode a storageState on a project below. A
66
+ // parallel hardcoded mapping caused real evidence corruption once already: a project
67
+ // pinned to one lane's storageState while QA_LANE_ONLY named another, so a run was
68
+ // recorded under the wrong account and nothing failed at the time.
69
+ //
70
+ // A project with no parallelLanes block gets one implicit default lane, producing
71
+ // exactly the single 'chromium' project this scaffold shipped before lanes existed.
72
+ const laneLock = require('./scripts/lane-lock.js');
73
+
74
+ type LaneInfo = {
75
+ account: string;
76
+ storageState: string;
77
+ mcpNamespace: string | null;
78
+ runnerProject: string | null;
79
+ capabilities: string[];
80
+ };
81
+ const LANE_INFO: Record<string, LaneInfo> = laneLock.LANE_INFO;
82
+
83
+ // QA_LANE_ONLY pins the whole run to a single lane. When it is set, every lane project
84
+ // resolves its storageState through that lane, so a --project bound to a different lane
85
+ // cannot quietly drive an account this run does not hold. global-setup-guards.js
86
+ // additionally rejects a --project whose storageState does not match the reserved lane.
87
+ const QA_LANE_ONLY = process.env.QA_LANE_ONLY;
88
+
89
+ function resolveStorageState(laneId: string): string {
90
+ return QA_LANE_ONLY
91
+ ? laneLock.storageStateForLaneOnly(QA_LANE_ONLY)
92
+ : LANE_INFO[laneId].storageState;
93
+ }
94
+
95
+ // One Playwright project per configured lane. A lane that declares a runnerProject uses
96
+ // that name; otherwise the project is named chromium-lane-<id>. With the single implicit
97
+ // default lane this yields one project named 'chromium'.
98
+ const laneProjects = Object.entries(LANE_INFO).map(([laneId, lane]) => ({
99
+ name: lane.runnerProject || `chromium-lane-${laneId}`,
100
+ use: {
101
+ ...devices['Desktop Chrome'],
102
+ storageState: resolveStorageState(laneId),
103
+ },
104
+ dependencies: ['setup'],
105
+ }));
106
+
39
107
  export default defineConfig({
40
108
  // ------ Test discovery ------
41
109
  testDir: './tests',
@@ -76,20 +144,16 @@ export default defineConfig({
76
144
  globalSetup: './global-setup.ts',
77
145
 
78
146
  // ------ Projects ------
147
+ // To add a lane, add an entry to parallelLanes.lanes in qa-framework.config.json.
148
+ // There is nothing to uncomment or copy-paste here: the lane projects are generated
149
+ // from that table, so the config stays the only place a lane is defined.
79
150
  projects: [
80
151
  {
81
152
  name: 'setup',
82
153
  use: { ...devices['Desktop Chrome'] },
83
154
  testMatch: /global-setup\.ts/,
84
155
  },
85
- {
86
- name: 'chromium',
87
- use: {
88
- ...devices['Desktop Chrome'],
89
- storageState: '.auth/user-default.json',
90
- },
91
- dependencies: ['setup'],
92
- },
156
+ ...laneProjects,
93
157
  ],
94
158
 
95
159
  // ------ Output directories ------
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Guard functions for global-setup.ts.
6
+ *
7
+ * These live in a plain CommonJS module rather than as private functions inside
8
+ * global-setup.ts so they are directly testable with `node --test`, without adding a
9
+ * TypeScript test runner (ts-node/tsx) as a dependency. global-setup.ts requires this
10
+ * module the same way it requires ./lane-lock.js.
11
+ *
12
+ * WHY GUARDS AT ALL: a lock nothing consults is decoration. global-setup is the one
13
+ * place that actually performs a login, so it is the last point at which a run about
14
+ * to drive an account it does not hold can still be stopped.
15
+ *
16
+ * global-setup NEVER calls acquire or reserve itself. It only READS lane state and
17
+ * verifies the lane is already held. Owning the reserve/release lifecycle is the
18
+ * caller's job - the CLI operator, the orchestrator, or the CI job. A global-setup
19
+ * that reserved its own lane would make every accidental run look legitimate.
20
+ *
21
+ * Each guard takes an explicit `env` (defaulting to process.env) and an injectable
22
+ * lane-lock module, so tests can pass a synthetic matrix without mutating globals.
23
+ */
24
+
25
+ /**
26
+ * Fail-closed guard. Runs BEFORE any login or browser work.
27
+ *
28
+ * Without QA_LANE_ONLY this is a "cold setup" that re-logs in EVERY lane's account,
29
+ * which silently invalidates any other agent's or CI job's concurrently running
30
+ * session on the shared QA server. That is occasionally what you actually want, so
31
+ * QA_COLD_SETUP=1 is the single explicit escape hatch - explicit so it can never be
32
+ * an accident. If neither is set, throw before touching anything.
33
+ */
34
+ function assertColdSetupIsIntentional(env) {
35
+ env = env || process.env;
36
+ if (env.QA_LANE_ONLY) return;
37
+ if (env.QA_COLD_SETUP === '1') return;
38
+ throw new Error(
39
+ '[global-setup] Refusing to run a cold setup (no QA_LANE_ONLY set). A cold setup re-logs in ' +
40
+ "EVERY lane account and can silently invalidate another agent or CI job's concurrently " +
41
+ 'running session on the shared QA server. Set QA_LANE_ONLY=<lane id> to log in a single lane ' +
42
+ 'only, or set QA_COLD_SETUP=1 explicitly if you really intend to relogin ALL lanes.'
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Lane-lock awareness guard. Only meaningful when QA_LANE_ONLY is set; the cold-setup
48
+ * guard above already rejects the "neither set" case.
49
+ *
50
+ * Skipped entirely in CI (env.CI === 'true'): the locks directory is local, ephemeral
51
+ * and per-machine, and does not persist between CI agents, so a CI job's lane will
52
+ * never appear busy in a fresh agent's lanes.json and enforcing this there would fail
53
+ * every time. The CI-side equivalent belongs at the pipeline level - fail the job in
54
+ * seconds if QA_LANE_ONLY is empty, before npm ci and browser installation.
55
+ */
56
+ function assertLaneIsLocked(laneOnly, env, laneLockModule) {
57
+ env = env || process.env;
58
+ if (env.CI === 'true') return;
59
+
60
+ const laneLock = laneLockModule || require('./lane-lock.js');
61
+
62
+ const laneId = laneLock.laneIdForQaLaneOnly(laneOnly);
63
+ const state = laneLock.loadState();
64
+ const laneState = state.lanes[laneId];
65
+
66
+ if (!laneState || laneState.status !== 'busy') {
67
+ throw new Error(
68
+ `[global-setup] QA_LANE_ONLY=${laneOnly} (lane ${laneId}) is not reserved in the lane lock state. ` +
69
+ 'Run `node scripts/lane-lock.js reserve <laneId> "<label>"` before running Playwright locally, ' +
70
+ 'so global-setup knows this lane is intentionally in use.'
71
+ );
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Parses --project=<name> and --project <name> out of an argv-shaped array. Returns
77
+ * every match in order, because Playwright accepts --project more than once.
78
+ */
79
+ function selectedProjectNamesFromArgv(argv) {
80
+ const names = [];
81
+ for (let i = 0; i < argv.length; i++) {
82
+ const arg = argv[i];
83
+ if (arg.startsWith('--project=')) {
84
+ names.push(arg.slice('--project='.length));
85
+ continue;
86
+ }
87
+ if (arg === '--project' && i + 1 < argv.length) {
88
+ names.push(argv[i + 1]);
89
+ i++;
90
+ }
91
+ }
92
+ return names;
93
+ }
94
+
95
+ /**
96
+ * Project/lane correspondence guard.
97
+ *
98
+ * The guards above verify the LANE named by QA_LANE_ONLY is held. They never check
99
+ * that the --project actually being run points its storageState at that same lane.
100
+ * This closes that gap by comparing the storageState of the requested project(s)
101
+ * against the storageState the lane table says QA_LANE_ONLY should be using.
102
+ *
103
+ * With playwright.config.ts resolving storageState through the lane table, this
104
+ * should never fire in the happy path. It is kept as cheap defense in depth for two
105
+ * cases that stay real: a regression in the resolver itself, and a future project
106
+ * added with a hand-hardcoded storageState (an easy mistake to make by copy-pasting
107
+ * an older project block). That second case is exactly what caused evidence
108
+ * corruption before the resolver existed, and this guard is the only thing that
109
+ * catches it at runtime.
110
+ *
111
+ * `projects` is the COMPLETE unfiltered list Playwright passes to globalSetup, not
112
+ * just the ones selected by --project, so process.argv is the only reliable signal
113
+ * for what was actually requested.
114
+ */
115
+ function assertProjectsMatchLane(qaLaneOnly, projects, argv, laneLockModule) {
116
+ if (!qaLaneOnly) return; // cold-setup path, already gated above
117
+
118
+ argv = argv || process.argv;
119
+ const laneLock = laneLockModule || require('./lane-lock.js');
120
+
121
+ const selectedNames = selectedProjectNamesFromArgv(argv);
122
+ if (selectedNames.length === 0) {
123
+ throw new Error(
124
+ `[global-setup] QA_LANE_ONLY=${qaLaneOnly} is set but no --project was passed on the CLI. ` +
125
+ 'Running without --project executes EVERY project declared in playwright.config.ts, most of ' +
126
+ "which point at other lanes' storageState - this would silently drive another lane's session " +
127
+ 'instead of the one you reserved. Pass --project=<name> explicitly.'
128
+ );
129
+ }
130
+
131
+ const selectedProjects = selectedNames.map((name) => {
132
+ const found = (projects || []).find((p) => p.name === name);
133
+ if (!found) {
134
+ throw new Error(
135
+ `[global-setup] --project=${name} does not match any project declared in playwright.config.ts. ` +
136
+ 'Check for a typo in the --project value.'
137
+ );
138
+ }
139
+ return found;
140
+ });
141
+
142
+ const laneId = laneLock.laneIdForQaLaneOnly(qaLaneOnly);
143
+ const expectedStorageState = laneLock.LANE_INFO[laneId].storageState;
144
+
145
+ // Projects without a string use.storageState (the 'setup' project, for instance)
146
+ // do not authenticate against any lane, so there is nothing to compare. Filtering
147
+ // by "has a string storageState" rather than by a name allowlist means any future
148
+ // no-auth project is excluded automatically.
149
+ const mismatches = selectedProjects.filter((p) => {
150
+ const storageState = p.use && p.use.storageState;
151
+ return typeof storageState === 'string' && storageState !== expectedStorageState;
152
+ });
153
+
154
+ if (mismatches.length > 0) {
155
+ const reverseLookup = (storageState) => {
156
+ for (const [id, info] of Object.entries(laneLock.LANE_INFO)) {
157
+ if (info.storageState === storageState) return id;
158
+ }
159
+ return 'unknown';
160
+ };
161
+
162
+ const details = mismatches
163
+ .map((p) => {
164
+ const actual = p.use.storageState;
165
+ return ` - project "${p.name}" uses storageState "${actual}" (that storageState belongs to ` +
166
+ `lane ${reverseLookup(actual)}, not lane ${laneId})`;
167
+ })
168
+ .join('\n');
169
+
170
+ throw new Error(
171
+ `[global-setup] QA_LANE_ONLY=${qaLaneOnly} (lane ${laneId}, expected storageState ` +
172
+ `"${expectedStorageState}") does not match the storageState of the --project(s) requested:\n${details}\n` +
173
+ 'Either fix QA_LANE_ONLY to match the --project you are running, or pass a --project whose ' +
174
+ 'storageState matches the reserved lane.'
175
+ );
176
+ }
177
+ }
178
+
179
+ module.exports = {
180
+ assertColdSetupIsIntentional,
181
+ assertLaneIsLocked,
182
+ selectedProjectNamesFromArgv,
183
+ assertProjectsMatchLane,
184
+ };