@keber/qa-framework 1.10.0 → 1.11.3

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/scripts/init.js CHANGED
@@ -84,6 +84,7 @@ const topLevelFolders = [
84
84
  '04-test-data',
85
85
  '05-test-execution',
86
86
  '06-defects/open',
87
+ '06-defects/disputed',
87
88
  '06-defects/resolved',
88
89
  '07-automation/e2e/tests',
89
90
  '07-automation/e2e/tests/helpers/debug',
@@ -98,6 +99,18 @@ for (const folder of topLevelFolders) {
98
99
  console.log(` [created] ${path.relative(cwd, fullPath)}/`);
99
100
  }
100
101
 
102
+ // --- 06-defects/disputed/README.md ---
103
+ writeIfMissing(path.join(qaRoot, '06-defects', 'disputed', 'README.md'),
104
+ `# 06-defects/disputed/
105
+
106
+ Use this folder for defects where QA has reproducible evidence but the business or
107
+ development team disputes whether the observed behavior is actually a defect.
108
+
109
+ A defect stays here while the functional decision is pending. Once the team reaches
110
+ a formal conclusion, move the file to open/ (confirmed defect) or resolved/ (accepted
111
+ behavior or won't fix).
112
+ `);
113
+
101
114
  // --- qa/README.md ---
102
115
  const qaReadmeTemplate = path.resolve(__dirname, '..', 'templates', 'qa-readme.md');
103
116
  if (fs.existsSync(qaReadmeTemplate)) {
@@ -120,10 +133,17 @@ writeIfMissing(path.join(memoryDir, 'INDEX.md'),
120
133
 
121
134
  | File | Topic | When to load |
122
135
  |---|---|---|
123
-
124
- _No memory files yet. Add files to this directory and register them above._
136
+ | \`ci-pipeline-findings.md\` | CI pipeline issues, root causes, applied fixes, failure triage categories | Diagnosing CI failures; reviewing pipeline changes |
137
+ | \`e2e-stabilization-patterns.md\` | Flakiness patterns, crash-recovery techniques, strict-mode conflicts, reference timeouts | Investigating intermittent failures; refactoring fragile specs |
138
+ | \`data-volatility-strategies.md\` | Strategies for handling volatile test data (filter values, row counts, search inputs) | Fixing hardcoded-data failures; choosing an assertion resilience approach |
125
139
  `);
126
140
 
141
+ // --- qa/memory template files ---
142
+ const memorySrc = path.resolve(__dirname, '..', 'templates', 'memory');
143
+ for (const file of ['ci-pipeline-findings.md', 'e2e-stabilization-patterns.md', 'data-volatility-strategies.md']) {
144
+ writeIfMissing(path.join(memoryDir, file), fs.readFileSync(path.join(memorySrc, file), 'utf8'));
145
+ }
146
+
127
147
  // --- 03-test-cases README (optional directory marker) ---
128
148
  writeIfMissing(path.join(qaRoot, '03-test-cases', 'README.md'),
129
149
  `# 03-test-cases/ — Optional Standalone Test Cases
@@ -227,7 +247,7 @@ for (const file of ['playwright.config.ts', 'global-setup.ts', '.env.example', '
227
247
  }
228
248
  const fixturesDir = path.join(e2eScaffoldDir, 'fixtures');
229
249
  fs.mkdirSync(fixturesDir, { recursive: true });
230
- for (const file of ['auth.ts', 'test-helpers.ts']) {
250
+ for (const file of ['auth.ts', 'test-helpers.ts', 'base.ts']) {
231
251
  const dest = path.join(fixturesDir, file);
232
252
  if (!fs.existsSync(dest)) {
233
253
  fs.copyFileSync(path.join(scaffoldSrc, 'fixtures', file), dest);
@@ -235,9 +255,26 @@ for (const file of ['auth.ts', 'test-helpers.ts']) {
235
255
  }
236
256
  }
237
257
 
238
- // --- Integration + Load test placeholders ---
239
- writeIfMissing(path.join(automationDir, 'integration', 'README.md'),
240
- `# Integration Tests\n\n> Placeholder for API/integration tests (k6, JMeter, Azure Load Testing, etc.).\n> Each tool gets its own subdirectory.\n`);
258
+ // --- Integration scaffold (lives in 07-automation/integration/) ---
259
+ const integrationDir = path.join(automationDir, 'integration');
260
+ const integrationScaffold = path.resolve(__dirname, '..', 'templates', 'integration-scaffold');
261
+ fs.mkdirSync(integrationDir, { recursive: true });
262
+ for (const file of ['package.json', 'playwright.config.ts', 'global-setup.ts', 'README.md', '.env.example']) {
263
+ const dest = path.join(integrationDir, file);
264
+ if (!fs.existsSync(dest)) {
265
+ fs.copyFileSync(path.join(integrationScaffold, file), dest);
266
+ console.log(` [created] ${path.relative(cwd, dest)}`);
267
+ }
268
+ }
269
+ const integrationTestsDir = path.join(integrationDir, 'tests');
270
+ fs.mkdirSync(integrationTestsDir, { recursive: true });
271
+ const exampleSpec = path.join(integrationTestsDir, 'example.spec.ts');
272
+ if (!fs.existsSync(exampleSpec)) {
273
+ fs.copyFileSync(path.join(integrationScaffold, 'tests', 'example.spec.ts'), exampleSpec);
274
+ console.log(` [created] ${path.relative(cwd, exampleSpec)}`);
275
+ }
276
+
277
+ // --- Load test placeholder ---
241
278
  writeIfMissing(path.join(automationDir, 'load', 'README.md'),
242
279
  `# Load Tests\n\n> Placeholder for load and performance tests.\n> Each tool gets its own subdirectory (e.g. k6/, jmeter/).\n`);
243
280
 
@@ -272,37 +309,9 @@ if (fs.existsSync(structureGuideSrc)) {
272
309
  const githubDir = path.join(cwd, '.github');
273
310
  const copilotInstrPath = path.join(githubDir, 'instructions', 'qa-framework.instructions.md');
274
311
 
275
- // Detect whether the ado-qa skill is available (installed or already copied)
276
- const adoQaInstalled = fs.existsSync(path.join(cwd, 'node_modules', '@keber', 'ado-qa'))
277
- || fs.existsSync(path.join(cwd, 'node_modules', 'ado-qa'));
278
- const adoSkillReady = fs.existsSync(path.join(cwd, '.github', 'skills', 'ado-qa'));
279
- const adoAvailable = adoQaInstalled || adoSkillReady;
280
-
281
- const adoSection = adoAvailable
282
- ? `## Azure DevOps integration (ado-qa)
283
-
284
- The \`ado-qa\` skill is installed. Load it in PowerShell before any ADO operation:
285
- \`\`\`powershell
286
- . .github/skills/ado-qa/load.ps1
287
- \`\`\`
288
- Refer to \`qa/00-guides/AGENT-INSTRUCTIONS-ADO-INTEGRATION.md\` for the full ADO workflow.
289
-
290
- `
291
- : `## Azure DevOps integration (not installed)
292
-
293
- > ⚠️ **Agent note:** \`ado-qa\` is not installed in this project.
294
- > To enable Azure DevOps integration (query Work Items, Test Plans, create bugs, etc.) run:
295
- > \`\`\`bash
296
- > npm install github:keber/ado-qa
297
- > \`\`\`
298
- > This will automatically copy the ADO skills to \`.github/skills/\` via postinstall.
299
-
300
- `;
301
-
302
312
  const instrTemplatePath = path.resolve(__dirname, '..', 'templates', 'qa-framework.instructions.md');
303
313
  const copilotInstrContent = fs.readFileSync(instrTemplatePath, 'utf8')
304
- .replace('{{VERSION}}', config.frameworkVersion ?? '1.0.0')
305
- .replace('{{ADO_SECTION}}', adoSection);
314
+ .replace('{{VERSION}}', config.frameworkVersion ?? '1.0.0');
306
315
  writeIfMissing(copilotInstrPath, copilotInstrContent);
307
316
 
308
317
  // --- AGENT-NEXT-STEPS.md — readable by the agent after install ---
@@ -328,15 +337,21 @@ Edit \`qa/qa-framework.config.json\` and add your project modules under the \`"m
328
337
  npm install @keber/ado-qa
329
338
  \`\`\`
330
339
 
331
- **Playwright automation:**
340
+ **Playwright E2E automation:**
332
341
  \`\`\`bash
333
342
  npm install @playwright/test
334
343
  cd qa/07-automation/e2e && npm install && npx playwright install chromium
335
344
  \`\`\`
336
345
 
346
+ **Playwright API/integration automation:**
347
+ \`\`\`bash
348
+ cd qa/07-automation/integration && npm install
349
+ \`\`\`
350
+
337
351
  ### 3. Credentials
338
- - Copy \`qa/07-automation/e2e/.env.example\` → \`qa/07-automation/e2e/.env\` and fill in credentials
339
- - Add \`qa/07-automation/e2e/.env\` and \`qa/07-automation/e2e/.auth/\` to \`.gitignore\`
352
+ - Copy \`qa/07-automation/e2e/.env.example\` -> \`qa/07-automation/e2e/.env\` and fill in credentials
353
+ - Copy \`qa/07-automation/integration/.env.example\` -> \`qa/07-automation/integration/.env\` and fill in credentials
354
+ - Add both \`.env\` files and both \`.auth/\` folders to \`.gitignore\`
340
355
  `;
341
356
  const nextStepsPath = path.join(qaRoot, 'AGENT-NEXT-STEPS.md');
342
357
  writeIfMissing(nextStepsPath, nextStepsContent);
@@ -93,8 +93,7 @@ if (!fs.existsSync(skillsSrc)) {
93
93
  const copilotInstrPath = path.join(githubDir, 'instructions', 'qa-framework.instructions.md');
94
94
  const instrTemplatePath = path.resolve(__dirname, '..', 'templates', 'qa-framework.instructions.md');
95
95
  const copilotContent = fs.readFileSync(instrTemplatePath, 'utf8')
96
- .replace('{{VERSION}}', config.frameworkVersion ?? '1.0.0')
97
- .replace('{{ADO_SECTION}}', '');
96
+ .replace('{{VERSION}}', config.frameworkVersion ?? '1.0.0');
98
97
  forceWrite(copilotInstrPath, copilotContent);
99
98
 
100
99
  // ---------------------------------------------------------------------------
@@ -175,6 +174,22 @@ for (const file of scaffoldFiles) {
175
174
  }
176
175
  }
177
176
 
177
+ // 5a.1. Ensure the refreshed fixtures scaffold exists.
178
+ const scaffoldFixturesDir = path.join(e2eDir, 'fixtures');
179
+ const fixtureFiles = ['auth.ts', 'test-helpers.ts', 'base.ts'];
180
+ for (const file of fixtureFiles) {
181
+ const oldPath = path.join(automationDir, 'fixtures', file);
182
+ const newPath = path.join(scaffoldFixturesDir, file);
183
+ if (fs.existsSync(oldPath) && !fs.existsSync(newPath)) {
184
+ if (!dryRun) {
185
+ fs.mkdirSync(scaffoldFixturesDir, { recursive: true });
186
+ fs.renameSync(oldPath, newPath);
187
+ }
188
+ updated.push(newPath);
189
+ console.log(` [migrated] fixtures/${file} -> e2e/fixtures/${file}`);
190
+ }
191
+ }
192
+
178
193
  // 5b. fixtures/: 07-automation/fixtures/ -> 07-automation/e2e/fixtures/
179
194
  const oldFixturesDir = path.join(automationDir, 'fixtures');
180
195
  const newFixturesDir = path.join(e2eDir, 'fixtures');
@@ -211,17 +226,34 @@ if (fs.existsSync(e2eDir)) {
211
226
  }
212
227
  }
213
228
 
214
- // 5d. Create integration/ and load/ placeholders if missing.
215
- const integrationReadme = path.join(automationDir, 'integration', 'README.md');
216
- const loadReadme = path.join(automationDir, 'load', 'README.md');
229
+ // 5d. Create integration scaffold if missing (uses template; existing files are never overwritten).
230
+ const integrationDir = path.join(automationDir, 'integration');
231
+ const integrationScaffold = path.resolve(__dirname, '..', 'templates', 'integration-scaffold');
232
+ const integrationReadme = path.join(integrationDir, 'README.md');
233
+ const loadReadme = path.join(automationDir, 'load', 'README.md');
217
234
  if (!fs.existsSync(integrationReadme)) {
218
235
  if (!dryRun) {
219
- fs.mkdirSync(path.dirname(integrationReadme), { recursive: true });
220
- fs.writeFileSync(integrationReadme,
221
- '# Integration Tests\n\n> Placeholder for API/integration tests (k6, JMeter, Azure Load Testing, etc.).\n> Each tool gets its own subdirectory.\n', 'utf8');
236
+ fs.mkdirSync(integrationDir, { recursive: true });
237
+ for (const file of ['package.json', 'playwright.config.ts', 'global-setup.ts', 'README.md', '.env.example']) {
238
+ const dest = path.join(integrationDir, file);
239
+ if (!fs.existsSync(dest)) {
240
+ fs.copyFileSync(path.join(integrationScaffold, file), dest);
241
+ updated.push(dest);
242
+ console.log(` [created] ${path.relative(cwd, dest)}`);
243
+ }
244
+ }
245
+ const integrationTestsDir = path.join(integrationDir, 'tests');
246
+ fs.mkdirSync(integrationTestsDir, { recursive: true });
247
+ const exampleSpec = path.join(integrationTestsDir, 'example.spec.ts');
248
+ if (!fs.existsSync(exampleSpec)) {
249
+ fs.copyFileSync(path.join(integrationScaffold, 'tests', 'example.spec.ts'), exampleSpec);
250
+ updated.push(exampleSpec);
251
+ console.log(` [created] ${path.relative(cwd, exampleSpec)}`);
252
+ }
253
+ } else {
254
+ updated.push(integrationReadme);
255
+ console.log(` [created] ${path.relative(cwd, integrationReadme)}`);
222
256
  }
223
- updated.push(integrationReadme);
224
- console.log(` [created] ${path.relative(cwd, integrationReadme)}`);
225
257
  }
226
258
  if (!fs.existsSync(loadReadme)) {
227
259
  if (!dryRun) {
@@ -346,6 +378,69 @@ if (!fs.existsSync(testCasesReadme)) {
346
378
  console.log(` [created] 03-test-cases/README.md`);
347
379
  }
348
380
 
381
+ // ---------------------------------------------------------------------------
382
+ // 7. Ensure 06-defects/disputed/ exists (added in v1.11.0)
383
+ // Safe: only creates if missing. Never touches existing files.
384
+ // ---------------------------------------------------------------------------
385
+ const defectsDir = path.join(qaRoot, '06-defects');
386
+ const disputedDir = path.join(defectsDir, 'disputed');
387
+ const disputedReadme = path.join(disputedDir, 'README.md');
388
+
389
+ if (fs.existsSync(defectsDir) && !fs.existsSync(disputedDir)) {
390
+ if (!dryRun) {
391
+ fs.mkdirSync(disputedDir, { recursive: true });
392
+ fs.writeFileSync(disputedReadme,
393
+ `# 06-defects/disputed/
394
+
395
+ Use this folder for defects where QA has reproducible evidence but the business or
396
+ development team disputes whether the observed behavior is actually a defect.
397
+
398
+ A defect stays here while the functional decision is pending. Once the team reaches
399
+ a formal conclusion, move the file to open/ (confirmed defect) or resolved/ (accepted
400
+ behavior or won't fix).
401
+ `, 'utf8');
402
+ }
403
+ updated.push(disputedDir);
404
+ console.log(` [created] 06-defects/disputed/`);
405
+ console.log(` [created] 06-defects/disputed/README.md`);
406
+ } else if (!fs.existsSync(disputedReadme) && fs.existsSync(disputedDir)) {
407
+ if (!dryRun) {
408
+ fs.writeFileSync(disputedReadme,
409
+ `# 06-defects/disputed/
410
+
411
+ Use this folder for defects where QA has reproducible evidence but the business or
412
+ development team disputes whether the observed behavior is actually a defect.
413
+
414
+ A defect stays here while the functional decision is pending. Once the team reaches
415
+ a formal conclusion, move the file to open/ (confirmed defect) or resolved/ (accepted
416
+ behavior or won't fix).
417
+ `, 'utf8');
418
+ }
419
+ updated.push(disputedReadme);
420
+ console.log(` [created] 06-defects/disputed/README.md`);
421
+ }
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // 8. Seed qa/memory/ template files if missing (added in v1.11.0)
425
+ // Safe: only creates files that do not exist. Never overwrites user content.
426
+ // ---------------------------------------------------------------------------
427
+ const memoryDir = path.join(qaRoot, 'memory');
428
+ if (fs.existsSync(memoryDir)) {
429
+ const memorySrc = path.resolve(__dirname, '..', 'templates', 'memory');
430
+ for (const file of ['ci-pipeline-findings.md', 'e2e-stabilization-patterns.md', 'data-volatility-strategies.md']) {
431
+ const dest = path.join(memoryDir, file);
432
+ if (!fs.existsSync(dest)) {
433
+ if (!dryRun) {
434
+ fs.copyFileSync(path.join(memorySrc, file), dest);
435
+ }
436
+ updated.push(dest);
437
+ console.log(` [created] memory/${file}`);
438
+ } else {
439
+ skipped.push(dest);
440
+ }
441
+ }
442
+ }
443
+
349
444
  // ---------------------------------------------------------------------------
350
445
  // Summary
351
446
  // ---------------------------------------------------------------------------
@@ -1,4 +1,4 @@
1
- # -----------------------------------------------------------------------
1
+ # -----------------------------------------------------------------------
2
2
  # keber/qa-framework — Environment Variables Reference
3
3
  # -----------------------------------------------------------------------
4
4
  # Copy this file to .env and fill in real values.
@@ -10,6 +10,20 @@
10
10
  # -----------------------------------------------------------------------
11
11
  QA_BASE_URL=https://your-app.qa.example.com
12
12
 
13
+ # -----------------------------------------------------------------------
14
+ # Session and login resilience
15
+ # -----------------------------------------------------------------------
16
+ QA_SESSION_TTL_MS=7200000
17
+ # Optional path to a server-side logout endpoint if the app exposes one.
18
+ # QA_LOGOUT_PATH=/logout
19
+
20
+ # When parallel user mode is enabled, limit auth to the matching project(s).
21
+ # QA_AUTH_USER=1
22
+
23
+ # Optional second test user for 2-project parallel execution.
24
+ # QA_USER2_EMAIL=qa-user2@example.com
25
+ # QA_USER2_PASSWORD=CHANGE_ME
26
+
13
27
  # -----------------------------------------------------------------------
14
28
  # Login Configuration
15
29
  # These selectors allow the framework to work with any login form.
@@ -54,3 +68,8 @@ QA_USER_PASSWORD=CHANGE_ME
54
68
  # -----------------------------------------------------------------------
55
69
  # CI=true # Set automatically by most CI systems
56
70
  # ADO_SYNC_DISABLED=false # Set to true to skip ADO reporter in local runs
71
+
72
+ # -----------------------------------------------------------------------
73
+ # Playwright Service (optional - only needed for cloud or self-hosted grid)
74
+ # -----------------------------------------------------------------------
75
+ # PLAYWRIGHT_SERVICE_URL=wss://eastus.api.playwright.microsoft.com/accounts/<your-id>/browsers
@@ -0,0 +1,108 @@
1
+ /**
2
+ * fixtures/base.ts
3
+ *
4
+ * Playwright test wrapper with TTL-aware session refresh.
5
+ * Import this file instead of `@playwright/test` when the app uses a
6
+ * server-side session that may expire during long runs.
7
+ */
8
+
9
+ import { test as base, devices } from '@playwright/test';
10
+ import { SESSION_TTL_MS } from '../playwright.config';
11
+ import * as dotenv from 'dotenv';
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+
15
+ dotenv.config();
16
+
17
+ const authTimestamps = new Map<string, number>();
18
+
19
+ function stateFileForProject(storageState: unknown): string {
20
+ if (typeof storageState === 'string' && storageState) {
21
+ return path.resolve(__dirname, '..', storageState);
22
+ }
23
+ return path.resolve(__dirname, '../.auth/user-default.json');
24
+ }
25
+
26
+ function credentialsForState(stateFile: string): { email: string; password: string } {
27
+ if (stateFile.includes('user-2')) {
28
+ return {
29
+ email: process.env.QA_USER2_EMAIL ?? '',
30
+ password: process.env.QA_USER2_PASSWORD ?? '',
31
+ };
32
+ }
33
+
34
+ return {
35
+ email: process.env.QA_USER_EMAIL ?? '',
36
+ password: process.env.QA_USER_PASSWORD ?? '',
37
+ };
38
+ }
39
+
40
+ function lastAuthTime(stateFile: string): number {
41
+ if (!authTimestamps.has(stateFile)) {
42
+ try {
43
+ authTimestamps.set(stateFile, fs.statSync(stateFile).mtimeMs);
44
+ } catch {
45
+ authTimestamps.set(stateFile, 0);
46
+ }
47
+ }
48
+ return authTimestamps.get(stateFile) ?? 0;
49
+ }
50
+
51
+ async function reauth(browser: import('@playwright/test').Browser, stateFile: string): Promise<void> {
52
+ const { email, password } = credentialsForState(stateFile);
53
+ const baseURL = process.env.QA_BASE_URL ?? '';
54
+ const loginPath = process.env.QA_LOGIN_PATH ?? '/login';
55
+ const emailSelector = process.env.QA_LOGIN_EMAIL_SELECTOR ?? 'input[type="email"]';
56
+ const passwordSelector = process.env.QA_LOGIN_PASSWORD_SELECTOR ?? 'input[type="password"]';
57
+ const submitSelector = process.env.QA_LOGIN_SUBMIT_SELECTOR ?? 'button[type="submit"]';
58
+ const successSelector = process.env.QA_LOGIN_SUCCESS_SELECTOR ?? '.dashboard, .main-content, [data-testid="app-shell"]';
59
+
60
+ const context = await browser.newContext({ ...devices['Desktop Chrome'], baseURL });
61
+ const page = await context.newPage();
62
+
63
+ try {
64
+ await page.goto(loginPath, { waitUntil: 'load', timeout: 60_000 });
65
+ await page.locator(emailSelector).fill(email);
66
+ await page.locator(passwordSelector).fill(password);
67
+ await page.locator(submitSelector).click();
68
+ await page.waitForSelector(successSelector, { timeout: 30_000 });
69
+
70
+ const dir = path.dirname(stateFile);
71
+ if (!fs.existsSync(dir)) {
72
+ fs.mkdirSync(dir, { recursive: true });
73
+ }
74
+
75
+ await context.storageState({ path: stateFile });
76
+ authTimestamps.set(stateFile, Date.now());
77
+ console.log(`[session-refresh] storageState renewed: ${stateFile}`);
78
+ } finally {
79
+ await context.close();
80
+ }
81
+ }
82
+
83
+ export const test = base.extend<{ authStatePath: string }>({
84
+ authStatePath: async ({}, use, testInfo) => {
85
+ await use(stateFileForProject(testInfo.project.use.storageState));
86
+ },
87
+
88
+ context: async ({ browser }, use, testInfo) => {
89
+ const stateFile = stateFileForProject(testInfo.project.use.storageState);
90
+ if (Date.now() - lastAuthTime(stateFile) > SESSION_TTL_MS) {
91
+ await reauth(browser, stateFile).catch((error) => {
92
+ console.error(`[session-refresh] reauth failed: ${(error as Error).message}`);
93
+ });
94
+ }
95
+
96
+ const context = await browser.newContext({
97
+ ...devices['Desktop Chrome'],
98
+ storageState: stateFile,
99
+ baseURL: process.env.QA_BASE_URL,
100
+ });
101
+
102
+ await use(context);
103
+ await context.close();
104
+ },
105
+ });
106
+
107
+ export { expect } from '@playwright/test';
108
+ export type { Page, Browser, BrowserContext } from '@playwright/test';
@@ -30,6 +30,74 @@ import * as fs from 'fs';
30
30
 
31
31
  dotenv.config();
32
32
 
33
+ function resolveVar(param: string | undefined, envKey: string, defaultValue: string): [string, string] {
34
+ if (param !== undefined) {
35
+ return [param, 'param'];
36
+ }
37
+ if (process.env[envKey] !== undefined) {
38
+ return [process.env[envKey]!, `env:${envKey}`];
39
+ }
40
+ return [defaultValue, 'default'];
41
+ }
42
+
43
+ function resolveAuthUsers(config: FullConfig): Set<'1' | '2'> {
44
+ const explicit = process.env.QA_AUTH_USER;
45
+ if (explicit) {
46
+ const resolved = new Set<'1' | '2'>();
47
+ for (const token of explicit.split(',').map((value) => value.trim())) {
48
+ if (token === '1' || token === '2') {
49
+ resolved.add(token);
50
+ }
51
+ }
52
+ if (resolved.size) {
53
+ return resolved;
54
+ }
55
+ }
56
+
57
+ const fromProjects = new Set<'1' | '2'>();
58
+ for (const project of config.projects) {
59
+ const storageState = (project.use as { storageState?: unknown } | undefined)?.storageState;
60
+ if (typeof storageState === 'string') {
61
+ if (storageState.includes('user-2')) {
62
+ fromProjects.add('2');
63
+ } else if (storageState.includes('user-1')) {
64
+ fromProjects.add('1');
65
+ }
66
+ }
67
+ }
68
+
69
+ if (fromProjects.size) {
70
+ return fromProjects;
71
+ }
72
+
73
+ return new Set(['1']);
74
+ }
75
+
76
+ async function dismissOnboardingFlow(page: import('@playwright/test').Page): Promise<void> {
77
+ // Override this with your app's onboarding dismissal logic.
78
+ // Use QA_DISMISS_ONBOARDING_LABELS (comma-separated button labels) to enable
79
+ // without modifying this file. Example: QA_DISMISS_ONBOARDING_LABELS=Skip,Got it
80
+ const raw = process.env.QA_DISMISS_ONBOARDING_LABELS ?? '';
81
+ const labels = raw.split(',').map((l: string) => l.trim()).filter(Boolean);
82
+ if (!labels.length) return;
83
+
84
+ for (let round = 0; round < 3; round++) {
85
+ let dismissed = false;
86
+ for (const label of labels) {
87
+ const button = page.getByRole('button', { name: label, exact: true }).first();
88
+ const visible = await button.isVisible({ timeout: 1_000 }).catch(() => false);
89
+ if (visible) {
90
+ await button.click({ timeout: 2_000 }).catch(() => {});
91
+ await page.waitForTimeout(250);
92
+ dismissed = true;
93
+ }
94
+ }
95
+ if (!dismissed) {
96
+ break;
97
+ }
98
+ }
99
+ }
100
+
33
101
  /** Perform login and persist storageState to disk. */
34
102
  async function loginAs(params: {
35
103
  email: string;
@@ -59,11 +127,33 @@ async function loginAs(params: {
59
127
  const page = await context.newPage();
60
128
 
61
129
  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 });
130
+ const loginUrl = `${baseURL}${loginPath.startsWith('/') ? loginPath : `/${loginPath}`}`;
131
+ let signedIn = false;
132
+
133
+ for (let attempt = 1; attempt <= 3 && !signedIn; attempt++) {
134
+ await page.goto(loginUrl, { waitUntil: 'load', timeout: 60_000 });
135
+ await page.locator(emailSelector).fill(email);
136
+ await page.locator(passwordSelector).fill(password);
137
+ await page.locator(submitSelector).click();
138
+
139
+ const result = await Promise.race([
140
+ page.waitForSelector(successSelector, { timeout: 30_000 }).then(() => 'ok' as const),
141
+ page.locator('text=An unhandled error has occurred.').waitFor({ state: 'visible', timeout: 30_000 }).then(() => 'crash' as const),
142
+ ]).catch(() => 'timeout' as const);
143
+
144
+ if (result === 'ok') {
145
+ signedIn = true;
146
+ } else if (result === 'crash') {
147
+ await page.getByText('Reload').click({ timeout: 5_000 }).catch(() => {});
148
+ await page.waitForTimeout(2_000);
149
+ }
150
+ }
151
+
152
+ if (!signedIn) {
153
+ throw new Error('[qa-framework] Login did not reach the success selector after 3 attempts.');
154
+ }
155
+
156
+ await dismissOnboardingFlow(page);
67
157
 
68
158
  // Ensure .auth/ directory exists
69
159
  const dir = path.dirname(stateFile);
@@ -78,29 +168,29 @@ async function loginAs(params: {
78
168
 
79
169
  export default async function globalSetup(_config: FullConfig): Promise<void> {
80
170
  const baseURL = process.env.QA_BASE_URL!;
81
- const email = process.env.QA_USER_EMAIL!;
82
- const password = process.env.QA_USER_PASSWORD!;
171
+ const authUsers = resolveAuthUsers(_config);
83
172
 
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
- // });
99
- //
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
- // });
173
+ if (authUsers.has('1')) {
174
+ const [email, emailSource] = resolveVar(process.env.QA_USER_EMAIL, 'QA_USER_EMAIL', 'qa-user@example.com');
175
+ const [password, passwordSource] = resolveVar(process.env.QA_USER_PASSWORD, 'QA_USER_PASSWORD', 'CHANGE_ME');
176
+ console.log(`[global-setup] user-1 credentials from ${emailSource} / ${passwordSource}`);
177
+ await loginAs({
178
+ email,
179
+ password,
180
+ baseURL,
181
+ stateFile: '.auth/user-default.json',
182
+ });
183
+ }
184
+
185
+ if (authUsers.has('2') && process.env.QA_USER2_EMAIL && process.env.QA_USER2_PASSWORD) {
186
+ const [email, emailSource] = resolveVar(process.env.QA_USER2_EMAIL, 'QA_USER2_EMAIL', 'qa-user2@example.com');
187
+ const [password, passwordSource] = resolveVar(process.env.QA_USER2_PASSWORD, 'QA_USER2_PASSWORD', 'CHANGE_ME');
188
+ console.log(`[global-setup] user-2 credentials from ${emailSource} / ${passwordSource}`);
189
+ await loginAs({
190
+ email,
191
+ password,
192
+ baseURL,
193
+ stateFile: '.auth/user-2.json',
194
+ });
195
+ }
106
196
  }
@@ -2,6 +2,8 @@ import { defineConfig, devices } from '@playwright/test';
2
2
  import * as dotenv from 'dotenv';
3
3
  dotenv.config();
4
4
 
5
+ export const SESSION_TTL_MS = Number(process.env.QA_SESSION_TTL_MS ?? 2 * 60 * 60 * 1000);
6
+
5
7
  // -------------------------------------------------------------------
6
8
  // Validate required environment variables at config load time
7
9
  // -------------------------------------------------------------------
@@ -92,6 +94,28 @@ export default defineConfig({
92
94
  },
93
95
  ],
94
96
 
97
+ // ------ Parallel 2-user variant ------
98
+ // Uncomment and replace the single chromium project above to run two parallel
99
+ // workers with separate credentials. Also set QA_USER2_EMAIL and
100
+ // QA_USER2_PASSWORD in .env.
101
+ //
102
+ // {
103
+ // name: 'chromium-user1',
104
+ // use: {
105
+ // ...devices['Desktop Chrome'],
106
+ // storageState: '.auth/user-1.json',
107
+ // },
108
+ // dependencies: ['setup'],
109
+ // },
110
+ // {
111
+ // name: 'chromium-user2',
112
+ // use: {
113
+ // ...devices['Desktop Chrome'],
114
+ // storageState: '.auth/user-2.json',
115
+ // },
116
+ // dependencies: ['setup'],
117
+ // },
118
+
95
119
  // ------ Output directories ------
96
120
  outputDir: 'test-results/',
97
121
  snapshotPathTemplate: '{testDir}/__snapshots__/{testFilePath}/{arg}{ext}',
@@ -6,7 +6,7 @@
6
6
  | Title | {{Short title (max 80 chars)}} |
7
7
  | Severity | Critical / High / Medium / Low |
8
8
  | Priority | P0 / P1 / P2 / P3 |
9
- | Status | Open / In Progress / Resolved / Closed |
9
+ | Status | Open / In Progress / Disputed / Resolved / Closed |
10
10
  | Assigned to | {{Developer or "Unassigned"}} |
11
11
  | Module | {{Module name}} |
12
12
  | Submodule | {{Submodule name}} |