@nhonh/qabot 1.2.0 → 2.0.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/package.json +1 -1
- package/src/cli/commands/test.js +140 -29
- package/src/core/constants.js +1 -1
- package/src/e2e/e2e-prompts.js +81 -74
- package/src/e2e/playwright-setup.js +2 -1
- package/src/e2e/preflight.js +217 -0
package/package.json
CHANGED
package/src/cli/commands/test.js
CHANGED
|
@@ -9,6 +9,7 @@ import { loadConfig } from "../../core/config.js";
|
|
|
9
9
|
import { analyzeProject } from "../../analyzers/project-analyzer.js";
|
|
10
10
|
import { AIEngine } from "../../ai/ai-engine.js";
|
|
11
11
|
import { UseCaseParser } from "../../ai/usecase-parser.js";
|
|
12
|
+
import { runPreflight } from "../../e2e/preflight.js";
|
|
12
13
|
import {
|
|
13
14
|
ensurePlaywright,
|
|
14
15
|
ensureE2EStructure,
|
|
@@ -70,6 +71,39 @@ async function runTest(feature, options) {
|
|
|
70
71
|
console.log(DIM(" Browser: ") + W(options.headed ? "Headed" : "Headless"));
|
|
71
72
|
logger.blank();
|
|
72
73
|
|
|
74
|
+
const preflightSpinner = ora({
|
|
75
|
+
text: V(" Analyzing project routes & auth..."),
|
|
76
|
+
spinner: "dots",
|
|
77
|
+
}).start();
|
|
78
|
+
let preflight;
|
|
79
|
+
try {
|
|
80
|
+
preflight = await runPreflight(projectDir, feature, config);
|
|
81
|
+
const authStatus = preflight.featureRequiresAuth
|
|
82
|
+
? Y("requires auth")
|
|
83
|
+
: G("public");
|
|
84
|
+
preflightSpinner.succeed(
|
|
85
|
+
G(` Route analysis complete — ${feature}: ${authStatus}`),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (preflight.featureRequiresAuth) {
|
|
89
|
+
logger.dim(` Sign-in URL: ${preflight.routes.authUrl}`);
|
|
90
|
+
logger.dim(
|
|
91
|
+
` Credentials: ${preflight.credentials.email ? G("ready") : Y("will prompt")}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (preflight.permissions.geolocation)
|
|
95
|
+
logger.dim(` Geolocation: ${G("mock granted (SF, CA)")}`);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
preflightSpinner.succeed(G(" Route analysis done (partial)"));
|
|
98
|
+
preflight = {
|
|
99
|
+
routes: { private: [], public: [] },
|
|
100
|
+
credentials: {},
|
|
101
|
+
permissions: {},
|
|
102
|
+
featureRequiresAuth: false,
|
|
103
|
+
routeIntelligence: "",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
73
107
|
const spinner = ora({
|
|
74
108
|
text: V(" Setting up Playwright..."),
|
|
75
109
|
spinner: "dots",
|
|
@@ -77,7 +111,12 @@ async function runTest(feature, options) {
|
|
|
77
111
|
try {
|
|
78
112
|
await ensurePlaywright(projectDir);
|
|
79
113
|
await ensureE2EStructure(projectDir);
|
|
80
|
-
await writePlaywrightConfig(
|
|
114
|
+
await writePlaywrightConfig(
|
|
115
|
+
projectDir,
|
|
116
|
+
config,
|
|
117
|
+
baseUrl,
|
|
118
|
+
preflight.permissions,
|
|
119
|
+
);
|
|
81
120
|
await writeAuthHelper(projectDir, config);
|
|
82
121
|
spinner.succeed(G(" Playwright ready"));
|
|
83
122
|
} catch (err) {
|
|
@@ -168,8 +207,12 @@ async function runTest(feature, options) {
|
|
|
168
207
|
baseUrl,
|
|
169
208
|
sourceCode,
|
|
170
209
|
route,
|
|
171
|
-
|
|
210
|
+
routeIntelligence: preflight.routeIntelligence,
|
|
172
211
|
authProvider: config.auth?.provider || "none",
|
|
212
|
+
authUrl: preflight.routes.authUrl,
|
|
213
|
+
signupUrl: preflight.routes.signupUrl,
|
|
214
|
+
featureRequiresAuth: preflight.featureRequiresAuth,
|
|
215
|
+
permissions: preflight.permissions,
|
|
173
216
|
useCases,
|
|
174
217
|
});
|
|
175
218
|
specFile = path.join(specDir, `${feature}.spec.js`);
|
|
@@ -421,29 +464,27 @@ async function generateE2EReport(
|
|
|
421
464
|
result,
|
|
422
465
|
) {
|
|
423
466
|
try {
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
const
|
|
467
|
+
const tests = await parsePlaywrightJsonReport(projectDir);
|
|
468
|
+
|
|
469
|
+
const passed = tests.filter((t) => t.status === "passed").length;
|
|
470
|
+
const failed = tests.filter((t) => t.status === "failed").length;
|
|
471
|
+
const skipped = tests.filter((t) => t.status === "skipped").length;
|
|
472
|
+
const total = tests.length;
|
|
473
|
+
|
|
474
|
+
const reportData = {
|
|
427
475
|
summary: {
|
|
428
|
-
totalTests: result.passed + result.failed + result.skipped,
|
|
429
|
-
totalPassed: result.passed,
|
|
430
|
-
totalFailed: result.failed,
|
|
431
|
-
totalSkipped: result.skipped,
|
|
432
|
-
overallPassRate:
|
|
433
|
-
result.passed + result.failed + result.skipped > 0
|
|
434
|
-
? Math.round(
|
|
435
|
-
(result.passed /
|
|
436
|
-
(result.passed + result.failed + result.skipped)) *
|
|
437
|
-
100,
|
|
438
|
-
)
|
|
439
|
-
: 0,
|
|
476
|
+
totalTests: total || result.passed + result.failed + result.skipped,
|
|
477
|
+
totalPassed: total ? passed : result.passed,
|
|
478
|
+
totalFailed: total ? failed : result.failed,
|
|
479
|
+
totalSkipped: total ? skipped : result.skipped,
|
|
480
|
+
overallPassRate: total > 0 ? Math.round((passed / total) * 100) : 0,
|
|
440
481
|
totalDuration: result.duration,
|
|
441
482
|
byLayer: {
|
|
442
483
|
e2e: {
|
|
443
|
-
total: result.passed + result.failed + result.skipped,
|
|
444
|
-
passed: result.passed,
|
|
445
|
-
failed: result.failed,
|
|
446
|
-
skipped: result.skipped,
|
|
484
|
+
total: total || result.passed + result.failed + result.skipped,
|
|
485
|
+
passed: total ? passed : result.passed,
|
|
486
|
+
failed: total ? failed : result.failed,
|
|
487
|
+
skipped: total ? skipped : result.skipped,
|
|
447
488
|
},
|
|
448
489
|
},
|
|
449
490
|
},
|
|
@@ -453,17 +494,13 @@ async function generateE2EReport(
|
|
|
453
494
|
layer: "e2e",
|
|
454
495
|
feature,
|
|
455
496
|
tests,
|
|
456
|
-
summary: {
|
|
457
|
-
total: result.passed + result.failed + result.skipped,
|
|
458
|
-
passed: result.passed,
|
|
459
|
-
failed: result.failed,
|
|
460
|
-
skipped: result.skipped,
|
|
461
|
-
},
|
|
497
|
+
summary: { total, passed, failed, skipped },
|
|
462
498
|
},
|
|
463
499
|
],
|
|
464
500
|
};
|
|
465
501
|
|
|
466
|
-
const
|
|
502
|
+
const reporter = new ReportGenerator(config);
|
|
503
|
+
const reportPaths = await reporter.generate(reportData, {
|
|
467
504
|
feature,
|
|
468
505
|
environment: env,
|
|
469
506
|
projectName: config.project?.name || "unknown",
|
|
@@ -472,7 +509,81 @@ async function generateE2EReport(
|
|
|
472
509
|
});
|
|
473
510
|
|
|
474
511
|
logger.info(`Report: ${chalk.underline(reportPaths.htmlPath)}`);
|
|
512
|
+
} catch (err) {
|
|
513
|
+
logger.dim(`Report generation failed: ${err.message}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function parsePlaywrightJsonReport(projectDir) {
|
|
518
|
+
const jsonPath = path.join(
|
|
519
|
+
projectDir,
|
|
520
|
+
"qabot-reports",
|
|
521
|
+
"playwright",
|
|
522
|
+
"results.json",
|
|
523
|
+
);
|
|
524
|
+
if (!(await fileExists(jsonPath))) return [];
|
|
525
|
+
|
|
526
|
+
try {
|
|
527
|
+
const raw = await readFile(jsonPath, "utf-8");
|
|
528
|
+
const data = JSON.parse(raw);
|
|
529
|
+
const tests = [];
|
|
530
|
+
flattenPwSuites(data.suites || [], tests);
|
|
531
|
+
return tests;
|
|
475
532
|
} catch {
|
|
476
|
-
|
|
533
|
+
return [];
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function flattenPwSuites(suites, tests) {
|
|
538
|
+
for (const suite of suites) {
|
|
539
|
+
for (const spec of suite.specs || []) {
|
|
540
|
+
for (const t of spec.tests || []) {
|
|
541
|
+
const lastResult = t.results?.[t.results.length - 1];
|
|
542
|
+
const annotations = t.annotations || [];
|
|
543
|
+
const skipAnnotation = annotations.find(
|
|
544
|
+
(a) => a.type === "skip" || a.type === "fixme",
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
let status;
|
|
548
|
+
if (
|
|
549
|
+
t.expectedStatus === "skipped" ||
|
|
550
|
+
lastResult?.status === "skipped"
|
|
551
|
+
) {
|
|
552
|
+
status = "skipped";
|
|
553
|
+
} else if (lastResult?.status === "passed") {
|
|
554
|
+
status = "passed";
|
|
555
|
+
} else {
|
|
556
|
+
status = "failed";
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const screenshots = (lastResult?.attachments || [])
|
|
560
|
+
.filter((a) => a.contentType?.startsWith("image/"))
|
|
561
|
+
.map((a) => a.path);
|
|
562
|
+
|
|
563
|
+
tests.push({
|
|
564
|
+
name: spec.title,
|
|
565
|
+
suite: suite.title,
|
|
566
|
+
file: suite.file || spec.file || "",
|
|
567
|
+
status,
|
|
568
|
+
duration: lastResult?.duration || 0,
|
|
569
|
+
error: lastResult?.error
|
|
570
|
+
? {
|
|
571
|
+
message: lastResult.error.message || "",
|
|
572
|
+
stack: lastResult.error.stack || "",
|
|
573
|
+
expected: lastResult.error.expected,
|
|
574
|
+
actual: lastResult.error.actual,
|
|
575
|
+
}
|
|
576
|
+
: null,
|
|
577
|
+
skipReason:
|
|
578
|
+
skipAnnotation?.description ||
|
|
579
|
+
(status === "skipped"
|
|
580
|
+
? "Test was skipped (conditional skip or missing prerequisite)"
|
|
581
|
+
: null),
|
|
582
|
+
screenshots,
|
|
583
|
+
retries: (t.results?.length || 1) - 1,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (suite.suites) flattenPwSuites(suite.suites, tests);
|
|
477
588
|
}
|
|
478
589
|
}
|
package/src/core/constants.js
CHANGED
package/src/e2e/e2e-prompts.js
CHANGED
|
@@ -3,11 +3,7 @@ export function buildE2EPrompt(featureName, context) {
|
|
|
3
3
|
? `\n## Source Code (analyze navigation, hooks, API calls, user actions, conditions)\n\`\`\`\n${context.sourceCode.slice(0, 10000)}\n\`\`\`\n`
|
|
4
4
|
: "";
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
? `\n## All Application Routes\n${context.routes}\n`
|
|
8
|
-
: context.route
|
|
9
|
-
? `\n## Feature Route: ${context.route}\n`
|
|
10
|
-
: "";
|
|
6
|
+
const routeIntelligence = context.routeIntelligence || "";
|
|
11
7
|
|
|
12
8
|
const useCaseSection = context.useCases?.length
|
|
13
9
|
? `\n## QA Use Cases\n${context.useCases.map((uc) => `### ${uc.scenario}\n${uc.steps.map((s, i) => `${i + 1}. ${s}`).join("\n")}`).join("\n\n")}\n`
|
|
@@ -15,29 +11,40 @@ export function buildE2EPrompt(featureName, context) {
|
|
|
15
11
|
|
|
16
12
|
const baseUrl = context.baseUrl || "http://localhost:3000";
|
|
17
13
|
const route = context.route || "/" + featureName;
|
|
14
|
+
const requiresAuth = context.featureRequiresAuth !== false;
|
|
15
|
+
const authUrl = context.authUrl || "/signin";
|
|
16
|
+
const signupUrl = context.signupUrl || "/signup";
|
|
17
|
+
const hasGeolocation = context.permissions?.geolocation || false;
|
|
18
18
|
|
|
19
|
-
return `You are an expert QA automation engineer
|
|
19
|
+
return `You are an expert QA automation engineer. You deeply understand web app testing.
|
|
20
20
|
|
|
21
|
-
## Feature: ${featureName}
|
|
21
|
+
## Feature Under Test: ${featureName}
|
|
22
22
|
## Base URL: ${baseUrl}
|
|
23
|
-
## Auth: ${context.authProvider || "none"}
|
|
24
|
-
${
|
|
23
|
+
## Auth Provider: ${context.authProvider || "none"}
|
|
24
|
+
## Feature Route: ${route}
|
|
25
|
+
## Feature Requires Auth: ${requiresAuth ? "YES" : "NO"}
|
|
26
|
+
## Sign In URL: ${baseUrl}${authUrl}
|
|
27
|
+
## Sign Up URL: ${baseUrl}${signupUrl}
|
|
28
|
+
## Geolocation Required: ${hasGeolocation ? "YES (already granted via Playwright config)" : "NO"}
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
${routeIntelligence}
|
|
31
|
+
${sourceSection}${useCaseSection}
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
1. Land on the app → see landing/home page
|
|
30
|
-
2. Sign in or are already signed in
|
|
31
|
-
3. Navigate to the feature via menu/link/URL
|
|
32
|
-
4. Interact with the feature (click buttons, fill forms, read data)
|
|
33
|
-
5. Feature may redirect them to other pages (FAQ, account, modals)
|
|
34
|
-
6. Come back, verify state changed
|
|
33
|
+
## YOUR JOB: Write a REAL user journey test — not a page screenshot test.
|
|
35
34
|
|
|
36
|
-
|
|
35
|
+
A real QA engineer:
|
|
36
|
+
1. Opens the app at ${baseUrl}/
|
|
37
|
+
2. ${requiresAuth ? `Clicks "Sign In", enters email/password from env vars (process.env.E2E_TEST_EMAIL, process.env.E2E_TEST_PASSWORD), submits, waits for redirect` : "Directly navigates to the feature page"}
|
|
38
|
+
3. Navigates to the feature: ${baseUrl}${route}
|
|
39
|
+
4. Tests EVERY interaction: clicks buttons, reads data, verifies states
|
|
40
|
+
5. Tests cross-page navigation (feature links to FAQ, account, etc.)
|
|
41
|
+
6. Verifies error handling, loading states, empty states
|
|
42
|
+
|
|
43
|
+
## ARCHITECTURE
|
|
37
44
|
|
|
38
45
|
\`\`\`javascript
|
|
39
46
|
const { test, expect } = require("@playwright/test");
|
|
40
|
-
const { login } = require("../helpers/auth.js")
|
|
47
|
+
${requiresAuth ? `const { login } = require("../helpers/auth.js");` : ""}
|
|
41
48
|
|
|
42
49
|
test.describe.serial("${featureName} - User Journey", () => {
|
|
43
50
|
let page;
|
|
@@ -50,68 +57,68 @@ test.describe.serial("${featureName} - User Journey", () => {
|
|
|
50
57
|
if (page) await page.close();
|
|
51
58
|
});
|
|
52
59
|
|
|
53
|
-
//
|
|
54
|
-
// Each test() does NOT take { page } argument
|
|
60
|
+
// ALL tests share this 'page' — single browser session
|
|
61
|
+
// Each test() does NOT take { page } argument — uses shared variable
|
|
55
62
|
});
|
|
56
63
|
\`\`\`
|
|
57
64
|
|
|
58
|
-
##
|
|
59
|
-
|
|
60
|
-
### Phase 1: Entry & Authentication (TC-01 to TC-03)
|
|
61
|
-
- TC-01: Open app landing page → verify it loads → screenshot
|
|
62
|
-
- TC-02: Sign in flow → click sign in → fill credentials → submit → verify redirect to lobby/home
|
|
63
|
-
- TC-03: Verify authenticated state → user menu visible, correct username/avatar
|
|
64
|
-
|
|
65
|
-
### Phase 2: Navigation to Feature (TC-04 to TC-05)
|
|
66
|
-
- TC-04: Navigate to "${featureName}" → via menu, link, or direct URL "${route}" → verify page loads
|
|
67
|
-
- TC-05: Verify feature page layout → key sections, headers, data loaded (not empty/loading)
|
|
68
|
-
|
|
69
|
-
### Phase 3: Core Feature Interactions (TC-06 to TC-10)
|
|
70
|
-
READ THE SOURCE CODE and generate tests for the ACTUAL user interactions:
|
|
71
|
-
- What buttons does the page have? Test clicking them.
|
|
72
|
-
- What data does the page display? Verify it shows correctly.
|
|
73
|
-
- What actions can the user take? (share, copy, claim, submit, toggle, select)
|
|
74
|
-
- What modals/popups appear? Verify they open and close.
|
|
75
|
-
- What state changes happen? (loading → loaded, button text changes, counters update)
|
|
65
|
+
## TEST CASES — Generate 10-15 sequential tests:
|
|
76
66
|
|
|
77
|
-
### Phase
|
|
78
|
-
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
### Phase 5: Edge Cases & Cleanup (TC-13 to TC-15)
|
|
83
|
-
- Test with different viewport (mobile: 375x667)
|
|
84
|
-
- Test error states if possible (disconnect, slow network)
|
|
85
|
-
- Verify page after refresh (F5) — does state persist?
|
|
86
|
-
|
|
87
|
-
## PLAYWRIGHT RULES:
|
|
88
|
-
1. Use \`test.describe.serial\` — all tests share ONE page
|
|
89
|
-
2. \`let page\` at describe scope — shared across ALL tests
|
|
90
|
-
3. \`test.beforeAll\` → \`browser.newPage()\` ONLY (login happens in TC-02)
|
|
91
|
-
4. Each \`test()\` does NOT take \`{ page }\` argument
|
|
92
|
-
5. Use \`page.goto("${baseUrl}${route}")\` with FULL URL for navigation
|
|
93
|
-
6. After navigation: \`await page.waitForLoadState("networkidle")\`
|
|
94
|
-
7. Screenshot EVERY test: \`await page.screenshot({ path: "e2e/screenshots/${featureName}-TC{XX}.png", fullPage: true })\`
|
|
95
|
-
8. Selectors priority: \`getByRole()\` > \`getByText()\` > \`getByTestId()\` > \`locator()\`
|
|
96
|
-
9. For slow elements: \`{ timeout: 15000 }\`
|
|
97
|
-
10. For navigation between pages, always verify URL changed: \`expect(page).toHaveURL()\`
|
|
98
|
-
11. After clicking links/buttons that navigate: \`await page.waitForLoadState("networkidle")\`
|
|
99
|
-
12. For modals: \`await expect(page.getByRole("dialog")).toBeVisible()\`
|
|
100
|
-
13. Use \`try/catch\` for optional elements that may not exist (feature flags, A/B tests)
|
|
101
|
-
14. If auth is "${context.authProvider}":
|
|
67
|
+
### Phase 1: App Entry${requiresAuth ? " & Login" : ""} (TC-01 to TC-0${requiresAuth ? "3" : "2"})
|
|
68
|
+
- TC-01: Navigate to ${baseUrl}/ → verify landing page loads → screenshot
|
|
69
|
+
${
|
|
70
|
+
requiresAuth
|
|
71
|
+
? `- TC-02: Login flow:
|
|
102
72
|
\`\`\`
|
|
103
|
-
const { login } = require("../helpers/auth.js");
|
|
104
|
-
// In TC-02:
|
|
105
73
|
await login(page, "${baseUrl}");
|
|
74
|
+
// login() navigates to ${baseUrl}/, clicks sign in, fills email/password from env, submits
|
|
75
|
+
// After login, verify user is redirected (URL no longer contains "signin")
|
|
76
|
+
await expect(page).not.toHaveURL(/signin/);
|
|
106
77
|
\`\`\`
|
|
78
|
+
Take screenshot after login.
|
|
79
|
+
- TC-03: Verify authenticated state → look for user avatar, account menu, or logged-in indicator`
|
|
80
|
+
: `- TC-02: Verify page is accessible without login`
|
|
81
|
+
}
|
|
107
82
|
|
|
108
|
-
|
|
109
|
-
-
|
|
110
|
-
-
|
|
111
|
-
-
|
|
112
|
-
-
|
|
113
|
-
-
|
|
83
|
+
### Phase 2: Navigate to Feature (TC-0${requiresAuth ? "4" : "3"} to TC-0${requiresAuth ? "5" : "4"})
|
|
84
|
+
- Navigate to ${baseUrl}${route} via page.goto()
|
|
85
|
+
- Wait for networkidle
|
|
86
|
+
- Verify URL contains "${route}"
|
|
87
|
+
- Verify feature-specific content is visible (NOT loading spinner)
|
|
88
|
+
- Screenshot
|
|
89
|
+
|
|
90
|
+
### Phase 3: Feature Interactions (TC-06 to TC-10)
|
|
91
|
+
READ THE SOURCE CODE CAREFULLY. Generate tests for ACTUAL elements:
|
|
92
|
+
- Buttons: what do they do when clicked? Test each one.
|
|
93
|
+
- Data display: verify numbers, text, images are correct
|
|
94
|
+
- Actions: share link, copy, claim rewards, submit form — test the real action
|
|
95
|
+
- Modals: do any actions open popups? Verify they appear and can be closed
|
|
96
|
+
- State changes: loading→loaded, button disabled→enabled, counter changes
|
|
114
97
|
|
|
115
|
-
|
|
116
|
-
|
|
98
|
+
### Phase 4: Cross-Page Navigation (TC-11 to TC-12)
|
|
99
|
+
- If feature has links to other pages, CLICK THEM and verify navigation
|
|
100
|
+
- Test going BACK to the feature page after navigating away
|
|
101
|
+
- Verify the feature state after returning
|
|
102
|
+
|
|
103
|
+
### Phase 5: Responsive & Edge Cases (TC-13 to TC-15)
|
|
104
|
+
- Resize viewport to mobile (375x667) → verify layout adapts
|
|
105
|
+
- Refresh the page → verify data reloads
|
|
106
|
+
- Verify error states if applicable
|
|
107
|
+
|
|
108
|
+
## RULES:
|
|
109
|
+
1. \`test.describe.serial\` — tests run IN ORDER, sharing state
|
|
110
|
+
2. \`let page\` at describe scope — NEVER use \`{ page }\` in test arguments
|
|
111
|
+
3. \`test.beforeAll\` = \`browser.newPage()\` ONLY — login is a test case, not setup
|
|
112
|
+
4. Use FULL URLs: \`page.goto("${baseUrl}${route}")\` — not relative paths
|
|
113
|
+
5. After navigation: \`await page.waitForLoadState("networkidle")\`
|
|
114
|
+
6. Screenshot every test: \`await page.screenshot({ path: "e2e/screenshots/${featureName}-TC{XX}.png", fullPage: true })\`
|
|
115
|
+
7. Selectors: getByRole > getByText > getByTestId > locator (CSS last resort)
|
|
116
|
+
8. Slow elements: \`{ timeout: 15000 }\`
|
|
117
|
+
9. After clicks that navigate: \`await page.waitForLoadState("networkidle")\`
|
|
118
|
+
10. Verify URLs after navigation: \`await expect(page).toHaveURL()\`
|
|
119
|
+
11. Use \`try/catch\` for elements that might not exist (feature flags, A/B tests)
|
|
120
|
+
${requiresAuth ? `12. Login uses helper: \`await login(page, "${baseUrl}");\` — credentials come from process.env` : ""}
|
|
121
|
+
${hasGeolocation ? `13. Geolocation is pre-granted in Playwright config — no need to handle permission popup` : ""}
|
|
122
|
+
|
|
123
|
+
Return ONLY JavaScript code. No markdown fences. No explanation.`;
|
|
117
124
|
}
|
|
@@ -47,6 +47,7 @@ export async function writePlaywrightConfig(
|
|
|
47
47
|
projectDir,
|
|
48
48
|
config,
|
|
49
49
|
overrideBaseUrl,
|
|
50
|
+
permissions = {},
|
|
50
51
|
) {
|
|
51
52
|
const configPath = path.join(projectDir, "e2e", "playwright.config.js");
|
|
52
53
|
|
|
@@ -83,7 +84,7 @@ module.exports = defineConfig({
|
|
|
83
84
|
video: "retain-on-failure",
|
|
84
85
|
ignoreHTTPSErrors: true,
|
|
85
86
|
actionTimeout: 15000,
|
|
86
|
-
navigationTimeout: 30000,
|
|
87
|
+
navigationTimeout: 30000,${permissions.geolocation ? `\n geolocation: { latitude: 37.7749, longitude: -122.4194 },\n permissions: ["geolocation"],` : ""}${permissions.notifications ? `\n permissions: [...(${permissions.geolocation ? '["geolocation"]' : "[]"}), "notifications"],` : ""}
|
|
87
88
|
launchOptions: {
|
|
88
89
|
slowMo: 100,
|
|
89
90
|
},
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import Enquirer from "enquirer";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { findFiles, safeReadFile } from "../utils/file-utils.js";
|
|
5
|
+
import { logger } from "../core/logger.js";
|
|
6
|
+
|
|
7
|
+
const V = chalk.hex("#A78BFA");
|
|
8
|
+
const DIM = chalk.hex("#6B7280");
|
|
9
|
+
const W = chalk.hex("#F3F4F6");
|
|
10
|
+
const Y = chalk.hex("#FBBF24");
|
|
11
|
+
const G = chalk.hex("#34D399");
|
|
12
|
+
|
|
13
|
+
export async function runPreflight(projectDir, feature, config) {
|
|
14
|
+
const result = {
|
|
15
|
+
routes: { private: [], public: [], authUrl: null, signupUrl: null },
|
|
16
|
+
credentials: { email: "", password: "" },
|
|
17
|
+
permissions: { geolocation: false, notifications: false },
|
|
18
|
+
featureRequiresAuth: false,
|
|
19
|
+
routeIntelligence: "",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
result.routes = await analyzeRoutes(projectDir);
|
|
23
|
+
|
|
24
|
+
const featureRoute = guessFeatureRoute(feature);
|
|
25
|
+
result.featureRequiresAuth = result.routes.private.some((r) =>
|
|
26
|
+
matchRoute(r, featureRoute),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
result.routeIntelligence = buildRouteIntelligence(
|
|
30
|
+
result.routes,
|
|
31
|
+
feature,
|
|
32
|
+
featureRoute,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
result.permissions = await detectPermissions(projectDir);
|
|
36
|
+
|
|
37
|
+
if (result.featureRequiresAuth) {
|
|
38
|
+
result.credentials = await resolveCredentials(config);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function analyzeRoutes(projectDir) {
|
|
45
|
+
const routes = {
|
|
46
|
+
private: [],
|
|
47
|
+
public: [],
|
|
48
|
+
authUrl: "/signin",
|
|
49
|
+
signupUrl: "/signup",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const routeFiles = await findFiles(
|
|
53
|
+
projectDir,
|
|
54
|
+
"src/routes/*.{js,jsx,ts,tsx}",
|
|
55
|
+
);
|
|
56
|
+
for (const file of routeFiles) {
|
|
57
|
+
const content = await safeReadFile(file);
|
|
58
|
+
if (!content) continue;
|
|
59
|
+
|
|
60
|
+
const pathMatches = content.matchAll(/path:\s*["'`]([^"'`]+)["'`]/g);
|
|
61
|
+
const privateMatches = content.matchAll(
|
|
62
|
+
/path:\s*["'`]([^"'`]+)["'`][^}]*isPrivate:\s*true/gs,
|
|
63
|
+
);
|
|
64
|
+
const publicMatches = content.matchAll(
|
|
65
|
+
/path:\s*["'`]([^"'`]+)["'`][^}]*isPrivate:\s*false/gs,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
for (const m of privateMatches) routes.private.push(m[1]);
|
|
69
|
+
for (const m of publicMatches) routes.public.push(m[1]);
|
|
70
|
+
|
|
71
|
+
const signInMatch = content.match(/pageSignIn:\s*["'`]([^"'`]+)["'`]/);
|
|
72
|
+
if (signInMatch) routes.authUrl = signInMatch[1];
|
|
73
|
+
|
|
74
|
+
const signUpMatch = content.match(/pageSignUp:\s*["'`]([^"'`]+)["'`]/);
|
|
75
|
+
if (signUpMatch) routes.signupUrl = signUpMatch[1];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (routes.private.length === 0 && routes.public.length === 0) {
|
|
79
|
+
const allRouteFiles = await findFiles(
|
|
80
|
+
projectDir,
|
|
81
|
+
"src/**/{routes,router,routing}*.{js,jsx,ts,tsx}",
|
|
82
|
+
);
|
|
83
|
+
for (const file of allRouteFiles) {
|
|
84
|
+
const content = await safeReadFile(file);
|
|
85
|
+
if (!content) continue;
|
|
86
|
+
const matches = content.matchAll(/path:\s*["'`]([^"'`]+)["'`]/g);
|
|
87
|
+
for (const m of matches) routes.public.push(m[1]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return routes;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function detectPermissions(projectDir) {
|
|
95
|
+
const permissions = { geolocation: false, notifications: false };
|
|
96
|
+
|
|
97
|
+
const geoFiles = await findFiles(
|
|
98
|
+
projectDir,
|
|
99
|
+
"src/**/*{geo,location,radar}*.{js,jsx,ts,tsx}",
|
|
100
|
+
);
|
|
101
|
+
if (geoFiles.length > 0) permissions.geolocation = true;
|
|
102
|
+
|
|
103
|
+
const notifFiles = await findFiles(
|
|
104
|
+
projectDir,
|
|
105
|
+
"src/**/*{notification,push}*.{js,jsx,ts,tsx}",
|
|
106
|
+
);
|
|
107
|
+
if (notifFiles.length > 0) permissions.notifications = true;
|
|
108
|
+
|
|
109
|
+
return permissions;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function resolveCredentials(config) {
|
|
113
|
+
const email =
|
|
114
|
+
process.env.E2E_TEST_EMAIL || config.auth?.credentials?.email || "";
|
|
115
|
+
const password =
|
|
116
|
+
process.env.E2E_TEST_PASSWORD || config.auth?.credentials?.password || "";
|
|
117
|
+
|
|
118
|
+
if (
|
|
119
|
+
email &&
|
|
120
|
+
password &&
|
|
121
|
+
!email.includes("ENV_") &&
|
|
122
|
+
!password.includes("ENV_")
|
|
123
|
+
) {
|
|
124
|
+
return { email, password };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (process.env.E2E_TEST_EMAIL && process.env.E2E_TEST_PASSWORD) {
|
|
128
|
+
return {
|
|
129
|
+
email: process.env.E2E_TEST_EMAIL,
|
|
130
|
+
password: process.env.E2E_TEST_PASSWORD,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
logger.blank();
|
|
135
|
+
logger.warn("This feature requires authentication.");
|
|
136
|
+
logger.dim(
|
|
137
|
+
"Credentials not found in env vars (E2E_TEST_EMAIL, E2E_TEST_PASSWORD)",
|
|
138
|
+
);
|
|
139
|
+
logger.blank();
|
|
140
|
+
|
|
141
|
+
const enquirer = new Enquirer();
|
|
142
|
+
const answers = await enquirer.prompt([
|
|
143
|
+
{
|
|
144
|
+
type: "input",
|
|
145
|
+
name: "email",
|
|
146
|
+
message: V("\u25C6") + W(" Test account email"),
|
|
147
|
+
validate: (v) => (v.includes("@") ? true : "Enter a valid email"),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
type: "password",
|
|
151
|
+
name: "password",
|
|
152
|
+
message: V("\u25C6") + W(" Test account password"),
|
|
153
|
+
validate: (v) => (v.length >= 1 ? true : "Password required"),
|
|
154
|
+
},
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
process.env.E2E_TEST_EMAIL = answers.email;
|
|
158
|
+
process.env.E2E_TEST_PASSWORD = answers.password;
|
|
159
|
+
|
|
160
|
+
logger.success("Credentials set for this session");
|
|
161
|
+
return { email: answers.email, password: answers.password };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function buildRouteIntelligence(routes, feature, featureRoute) {
|
|
165
|
+
const lines = [];
|
|
166
|
+
|
|
167
|
+
lines.push("## Route Intelligence (analyzed from source code)");
|
|
168
|
+
lines.push("");
|
|
169
|
+
|
|
170
|
+
if (routes.private.length > 0) {
|
|
171
|
+
lines.push("### Routes requiring authentication (isPrivate: true):");
|
|
172
|
+
routes.private.forEach((r) => lines.push(`- ${r}`));
|
|
173
|
+
lines.push("");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (routes.public.length > 0) {
|
|
177
|
+
lines.push("### Public routes (no auth required):");
|
|
178
|
+
routes.public.slice(0, 15).forEach((r) => lines.push(`- ${r}`));
|
|
179
|
+
lines.push("");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
lines.push(`### Auth URLs:`);
|
|
183
|
+
lines.push(`- Sign In: ${routes.authUrl || "/signin"}`);
|
|
184
|
+
lines.push(`- Sign Up: ${routes.signupUrl || "/signup"}`);
|
|
185
|
+
lines.push("");
|
|
186
|
+
|
|
187
|
+
const requiresAuth = routes.private.some((r) => matchRoute(r, featureRoute));
|
|
188
|
+
lines.push(`### Feature "${feature}" (${featureRoute}):`);
|
|
189
|
+
lines.push(
|
|
190
|
+
`- Requires auth: ${requiresAuth ? "YES — must login first" : "NO — public page"}`,
|
|
191
|
+
);
|
|
192
|
+
lines.push(
|
|
193
|
+
`- Test strategy: ${requiresAuth ? "Login → Navigate → Test" : "Direct access → Test"}`,
|
|
194
|
+
);
|
|
195
|
+
lines.push("");
|
|
196
|
+
|
|
197
|
+
return lines.join("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function guessFeatureRoute(feature) {
|
|
201
|
+
const map = {
|
|
202
|
+
home: "/",
|
|
203
|
+
lobby: "/lobby",
|
|
204
|
+
auth: "/signin",
|
|
205
|
+
account: "/account",
|
|
206
|
+
redemption: "/redemption",
|
|
207
|
+
"refer-a-friend": "/refer-a-friend",
|
|
208
|
+
faq: "/faq",
|
|
209
|
+
promotions: "/promotions",
|
|
210
|
+
};
|
|
211
|
+
return map[feature] || `/${feature}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function matchRoute(routePattern, target) {
|
|
215
|
+
const normalized = routePattern.replace(/:[^/]+/g, "[^/]+");
|
|
216
|
+
return new RegExp(`^${normalized}$`).test(target) || routePattern === target;
|
|
217
|
+
}
|