@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/CHANGELOG.md +63 -18
- package/README.md +1 -1
- package/docs/folder-structure-guide.md +6 -0
- package/integrations/ado-powershell/pipelines/azure-pipeline-qa.yml +92 -25
- package/integrations/playwright/README.md +124 -0
- package/package.json +3 -4
- package/qa-framework.config.json +1 -1
- package/scripts/init.js +53 -38
- package/scripts/upgrade.js +105 -10
- package/templates/automation-scaffold/.env.example +20 -1
- package/templates/automation-scaffold/fixtures/base.ts +108 -0
- package/templates/automation-scaffold/global-setup.ts +119 -29
- package/templates/automation-scaffold/playwright.config.ts +24 -0
- package/templates/defect-report.md +1 -1
- package/templates/integration-scaffold/.env.example +40 -0
- package/templates/integration-scaffold/README.md +77 -0
- package/templates/integration-scaffold/global-setup.ts +99 -0
- package/templates/integration-scaffold/package.json +15 -0
- package/templates/integration-scaffold/playwright.config.ts +78 -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 +9 -1
- package/templates/qa-readme.md +21 -0
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
|
-
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
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\`
|
|
339
|
-
-
|
|
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);
|
package/scripts/upgrade.js
CHANGED
|
@@ -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
|
|
215
|
-
const
|
|
216
|
-
const
|
|
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(
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
82
|
-
const password = process.env.QA_USER_PASSWORD!;
|
|
171
|
+
const authUsers = resolveAuthUsers(_config);
|
|
83
172
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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}} |
|