@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.
- package/CHANGELOG.md +63 -18
- package/README.md +2 -2
- package/docs/architecture.md +30 -26
- package/docs/decisions/lane-heartbeat-caller.md +86 -0
- package/docs/final-report.md +23 -23
- package/docs/folder-structure-guide.md +106 -80
- package/docs/installation.md +35 -7
- package/docs/skills-architecture.md +23 -23
- package/docs/spec-driven-philosophy.md +9 -9
- package/docs/usage-with-agent.md +83 -9
- package/docs/visuals/diag-01-ejecutivo-1.png +0 -0
- package/docs/visuals/diag-02-tactico-1.png +0 -0
- package/docs/visuals/diag-03-tecnico-1.png +0 -0
- package/docs/visuals/info-01-ejecutivo-1.png +0 -0
- package/docs/visuals/info-02-tactico-1.png +0 -0
- package/docs/visuals/info-03-tecnico-1.png +0 -0
- package/docs/visuals/pres-01-ejecutivo-1.png +0 -0
- package/docs/visuals/pres-02-tactico-1.png +0 -0
- package/docs/visuals/pres-03-tecnico-1.png +0 -0
- package/docs/visuals/qa-01-problema-1.png +0 -0
- package/docs/visuals/qa-02-que-es-1.png +0 -0
- package/docs/visuals/qa-03-pipeline-1.png +0 -0
- package/docs/visuals/qa-04-6-archivos-1.png +0 -0
- package/docs/visuals/qa-05-humano-1.png +0 -0
- package/docs/visuals/qa-06-agente-1.png +0 -0
- package/docs/visuals/qa-07-skills-arch-1.png +0 -0
- package/docs/visuals/qa-08-traceability-1.png +0 -0
- package/docs/visuals/qa-en-01-problem-1.png +0 -0
- package/docs/visuals/qa-en-02-what-is-1.png +0 -0
- package/docs/visuals/qa-en-03-pipeline-1.png +0 -0
- package/docs/visuals/qa-en-04-6files-1.png +0 -0
- package/docs/visuals/qa-en-05-human-1.png +0 -0
- package/docs/visuals/qa-en-06-agent-1.png +0 -0
- package/docs/visuals/qa-en-07-skills-arch-1.png +0 -0
- package/docs/visuals/qa-en-08-traceability-1.png +0 -0
- package/examples/module-example/suppliers/00-inventory.md +1 -1
- package/examples/module-example/suppliers/suppliers-create.spec.ts +8 -8
- package/integrations/ado-powershell/README.md +1 -1
- package/integrations/ado-powershell/pipelines/azure-pipeline-qa.yml +92 -25
- package/integrations/ado-powershell/scripts/sync-ado-titles.ps1 +2 -2
- package/integrations/playwright/README.md +128 -4
- package/package.json +9 -6
- package/qa-framework.config.json +24 -3
- package/scripts/check-forbidden-chars.js +187 -0
- package/scripts/check-spanish-accents.js +159 -0
- package/scripts/cli.js +1 -1
- package/scripts/generate.js +2 -2
- package/scripts/init.js +108 -45
- package/scripts/lib/claude-agents.js +78 -0
- package/scripts/lib/claude-commands.js +56 -0
- package/scripts/sync-version.js +36 -0
- package/scripts/upgrade.js +461 -30
- package/scripts/validate.js +17 -17
- package/skills/qa-ado-integration/SKILL.md +19 -19
- package/skills/qa-ado-integration/references/scripts-and-config.md +6 -6
- package/skills/qa-automation/SKILL.md +49 -27
- package/skills/qa-automation/references/config-checklist.md +4 -4
- package/skills/qa-automation/references/dom-inspection-template.js +7 -7
- package/skills/qa-automation/references/patterns.md +20 -20
- package/skills/qa-automation/references/pom-template.md +161 -0
- package/skills/qa-maintenance/SKILL.md +11 -11
- package/skills/qa-maintenance/references/update-rules.md +8 -8
- package/skills/qa-module-analysis/SKILL.md +20 -19
- package/skills/qa-module-analysis/references/exploration-checklist.md +4 -4
- package/skills/qa-module-analysis/references/output-verification.md +69 -0
- package/skills/qa-module-analysis/references/spec-file-formats.md +14 -14
- package/skills/qa-spec-generation/SKILL.md +13 -7
- package/skills/qa-test-cases/SKILL.md +11 -81
- package/skills/qa-test-cases/references/test-case-template.md +5 -5
- package/skills/qa-test-plan/SKILL.md +22 -86
- package/skills/qa-test-plan/references/plan-de-pruebas-template.md +24 -24
- package/skills/qa-test-stabilization/SKILL.md +59 -30
- package/skills/qa-test-stabilization/references/classification-protocol.md +35 -35
- package/templates/agent-next-steps.md +10 -10
- package/templates/agents/qa-analisis.md +142 -0
- package/templates/agents/qa-asesoria.md +48 -0
- package/templates/agents/qa-informe-resultados.md +234 -0
- package/templates/agents/qa-plan.md +131 -0
- package/templates/automation-scaffold/.env.example +52 -3
- package/templates/automation-scaffold/fixtures/auth.ts +27 -5
- package/templates/automation-scaffold/fixtures/base.ts +130 -0
- package/templates/automation-scaffold/fixtures/test-helpers.ts +1 -1
- package/templates/automation-scaffold/global-setup.ts +196 -37
- package/templates/automation-scaffold/playwright.config.ts +74 -10
- package/templates/automation-scaffold/scripts/global-setup-guards.js +184 -0
- package/templates/automation-scaffold/scripts/lane-config.js +269 -0
- package/templates/automation-scaffold/scripts/lane-lock.js +589 -0
- package/templates/defect-report.md +18 -4
- package/templates/execution-report.md +9 -9
- package/templates/integration-scaffold/.env.example +40 -0
- package/templates/integration-scaffold/README.md +77 -0
- package/templates/integration-scaffold/global-setup.ts +119 -0
- package/templates/integration-scaffold/package.json +15 -0
- package/templates/integration-scaffold/playwright.config.ts +92 -0
- package/templates/integration-scaffold/tests/example.spec.ts +43 -0
- package/templates/memory/ci-pipeline-findings.md +36 -0
- package/templates/memory/data-volatility-strategies.md +70 -0
- package/templates/memory/e2e-stabilization-patterns.md +54 -0
- package/templates/qa-framework.instructions.md +16 -7
- package/templates/qa-framework.rules.md +53 -0
- package/templates/qa-readme.md +26 -5
- package/templates/session-summary.md +2 -2
- package/templates/specification/00-inventory.md +3 -3
- package/templates/specification/01-business-rules.md +6 -6
- package/templates/specification/02-workflows.md +1 -1
- package/templates/specification/03-roles-permissions.md +1 -1
- package/templates/specification/04-test-data.md +8 -8
- package/templates/specification/05-test-scenarios.md +5 -5
- package/templates/test-case.md +3 -3
- package/templates/test-data-guidelines.md +7 -7
- package/templates/test-plan-sprint.md +13 -13
- package/templates/test-plan.md +3 -3
- 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
|
|
15
|
-
* QA_USER_EMAIL
|
|
16
|
-
* QA_USER_PASSWORD
|
|
17
|
-
* QA_LOGIN_PATH
|
|
18
|
-
* QA_LOGIN_EMAIL_SELECTOR
|
|
19
|
-
* QA_LOGIN_PASSWORD_SELECTOR
|
|
20
|
-
* QA_LOGIN_SUBMIT_SELECTOR
|
|
21
|
-
* QA_LOGIN_SUCCESS_SELECTOR
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
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
|
-
|
|
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}}
|
|
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
|
+
};
|