@specwright/plugin 0.1.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/.claude_README.md +276 -0
- package/.claude_memory_MEMORY.md +11 -0
- package/.gitignore.snippet +21 -0
- package/PLUGIN.md +172 -0
- package/README-TESTING.md +227 -0
- package/cli.js +53 -0
- package/e2e-tests/.env.testing +29 -0
- package/e2e-tests/data/authenticationData.js +76 -0
- package/e2e-tests/data/testConfig.js +39 -0
- package/e2e-tests/features/playwright-bdd/@Modules/.gitkeep +0 -0
- package/e2e-tests/features/playwright-bdd/@Modules/@Authentication/authentication.feature +64 -0
- package/e2e-tests/features/playwright-bdd/@Modules/@Authentication/steps.js +27 -0
- package/e2e-tests/features/playwright-bdd/@Workflows/.gitkeep +0 -0
- package/e2e-tests/features/playwright-bdd/shared/auth.steps.js +74 -0
- package/e2e-tests/features/playwright-bdd/shared/common.steps.js +52 -0
- package/e2e-tests/features/playwright-bdd/shared/global-hooks.js +44 -0
- package/e2e-tests/features/playwright-bdd/shared/navigation.steps.js +47 -0
- package/e2e-tests/instructions.example.js +110 -0
- package/e2e-tests/instructions.js +9 -0
- package/e2e-tests/playwright/auth-storage/.auth/.gitkeep +0 -0
- package/e2e-tests/playwright/auth.setup.js +74 -0
- package/e2e-tests/playwright/fixtures.js +264 -0
- package/e2e-tests/playwright/generated/.gitkeep +0 -0
- package/e2e-tests/playwright/global.setup.js +49 -0
- package/e2e-tests/playwright/global.teardown.js +23 -0
- package/e2e-tests/playwright/test-data/.gitkeep +0 -0
- package/e2e-tests/utils/stepHelpers.js +404 -0
- package/e2e-tests/utils/testDataGenerator.js +38 -0
- package/install.sh +148 -0
- package/package.json +39 -0
- package/package.json.snippet +27 -0
- package/playwright.config.ts +143 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global BDD Hooks — auto-loaded via playwright config glob.
|
|
3
|
+
* DO NOT import this file manually — it causes duplicate hooks.
|
|
4
|
+
*/
|
|
5
|
+
import { Before, After } from '../../../playwright/fixtures.js';
|
|
6
|
+
import { extractModuleName, loadScopedTestData, deriveDataScope } from '../../../playwright/fixtures.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Before each scenario:
|
|
10
|
+
* - Reset page.testData to empty object (scenario-scoped)
|
|
11
|
+
* - Derive featureKey from directory path
|
|
12
|
+
* - Hydrate in-memory cache if needed
|
|
13
|
+
*/
|
|
14
|
+
Before(async function ({ page, testData, $tags, $testInfo }) {
|
|
15
|
+
// Reset scenario-scoped test data
|
|
16
|
+
Object.keys(testData).forEach((key) => delete testData[key]);
|
|
17
|
+
|
|
18
|
+
// Extract module name from feature URI for cache key
|
|
19
|
+
const featureUri = $testInfo?.titlePath?.[1] || '';
|
|
20
|
+
const featureKey = extractModuleName(featureUri);
|
|
21
|
+
|
|
22
|
+
if (featureKey) {
|
|
23
|
+
// Initialize in-memory feature data cache if not present
|
|
24
|
+
if (!globalThis.__rt_featureDataCache) {
|
|
25
|
+
globalThis.__rt_featureDataCache = {};
|
|
26
|
+
}
|
|
27
|
+
if (!globalThis.__rt_featureDataCache[featureKey]) {
|
|
28
|
+
// Try to load from file-backed storage
|
|
29
|
+
const scope = deriveDataScope(featureUri, $tags || []);
|
|
30
|
+
globalThis.__rt_featureDataCache[featureKey] = loadScopedTestData(scope);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* After each scenario:
|
|
37
|
+
* - Capture screenshot on failure (if enabled via env)
|
|
38
|
+
*/
|
|
39
|
+
After(async function ({ page, $testInfo }) {
|
|
40
|
+
if ($testInfo?.status === 'failed' && process.env.ENABLE_SCREENSHOTS === 'true') {
|
|
41
|
+
const screenshotName = `failure-${$testInfo.title.replace(/[^a-z0-9]/gi, '-')}`;
|
|
42
|
+
await page.screenshot({ path: `reports/screenshots/${screenshotName}.png` });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared navigation steps — reusable across all modules.
|
|
3
|
+
* Lives in shared/ (no @-prefix) so it is globally scoped.
|
|
4
|
+
*/
|
|
5
|
+
import { Given, When, Then, expect } from '../../../playwright/fixtures.js';
|
|
6
|
+
|
|
7
|
+
Given('I am on the {string} page', async ({ page, testConfig }, pageName) => {
|
|
8
|
+
const route = testConfig.routes[pageName];
|
|
9
|
+
if (!route) {
|
|
10
|
+
throw new Error(`Unknown page name: "${pageName}". Available: ${Object.keys(testConfig.routes).join(', ')}`);
|
|
11
|
+
}
|
|
12
|
+
await page.goto(route);
|
|
13
|
+
await page.waitForLoadState('networkidle', { timeout: testConfig.timeout.loadState });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
When('I navigate to {string}', async ({ page, testConfig }, urlPath) => {
|
|
17
|
+
await page.goto(urlPath);
|
|
18
|
+
await page.waitForLoadState('networkidle', { timeout: testConfig.timeout.loadState });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
When('I click the link {string}', async ({ page }, linkText) => {
|
|
22
|
+
await page.getByRole('link', { name: linkText }).click();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
When('I click the button {string}', async ({ page }, buttonText) => {
|
|
26
|
+
await page.getByRole('button', { name: buttonText }).click();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
Then('the URL should contain {string}', async ({ page }, urlPart) => {
|
|
30
|
+
await expect(page).toHaveURL(new RegExp(urlPart));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
Then('I should see the text {string}', async ({ page }, text) => {
|
|
34
|
+
await expect(page.getByText(text, { exact: false }).first()).toBeVisible();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
Then('the element with test ID {string} should be visible', async ({ page }, testId) => {
|
|
38
|
+
await expect(page.getByTestId(testId)).toBeVisible();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
Then('the element with test ID {string} should be disabled', async ({ page }, testId) => {
|
|
42
|
+
await expect(page.getByTestId(testId)).toBeDisabled();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
Then('the element with test ID {string} should be enabled', async ({ page }, testId) => {
|
|
46
|
+
await expect(page.getByTestId(testId)).toBeEnabled();
|
|
47
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* instructions.js — Usage Examples
|
|
3
|
+
*
|
|
4
|
+
* Copy any entry below into instructions.js to generate BDD tests.
|
|
5
|
+
* Run with: /e2e-automate (Claude Code skill)
|
|
6
|
+
*
|
|
7
|
+
* After generation, run:
|
|
8
|
+
* pnpm bddgen # regenerate .features-gen/
|
|
9
|
+
* pnpm test:bdd # run all BDD tests
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export default [
|
|
13
|
+
// ─────────────────────────────────────────────────────────────
|
|
14
|
+
// Example 1: Text instructions — describe what to test
|
|
15
|
+
// ─────────────────────────────────────────────────────────────
|
|
16
|
+
{
|
|
17
|
+
filePath: '',
|
|
18
|
+
moduleName: '@YourModule',
|
|
19
|
+
category: '@Modules',
|
|
20
|
+
subModuleName: [],
|
|
21
|
+
fileName: 'your_feature',
|
|
22
|
+
instructions: [
|
|
23
|
+
'Navigate to the target page (authenticated)',
|
|
24
|
+
'Verify the main content loads correctly',
|
|
25
|
+
'Fill out a form and submit',
|
|
26
|
+
'Verify the result appears',
|
|
27
|
+
'Test with invalid input and verify error messages',
|
|
28
|
+
],
|
|
29
|
+
pageURL: 'http://localhost:5173/your-page', // Update: your app's URL
|
|
30
|
+
inputs: {},
|
|
31
|
+
explore: true,
|
|
32
|
+
runExploredCases: false,
|
|
33
|
+
runGeneratedCases: false,
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
// ─────────────────────────────────────────────────────────────
|
|
37
|
+
// Example 2: Jira-driven test generation
|
|
38
|
+
// ─────────────────────────────────────────────────────────────
|
|
39
|
+
{
|
|
40
|
+
filePath: '',
|
|
41
|
+
moduleName: '@YourModule',
|
|
42
|
+
category: '@Modules',
|
|
43
|
+
subModuleName: [],
|
|
44
|
+
fileName: 'your_feature',
|
|
45
|
+
instructions: [],
|
|
46
|
+
pageURL: 'http://localhost:5173/your-page', // Update: your app's URL
|
|
47
|
+
inputs: {
|
|
48
|
+
jira: {
|
|
49
|
+
url: 'https://your-org.atlassian.net/browse/PROJ-123', // Update: your Jira URL
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
explore: true,
|
|
53
|
+
runExploredCases: false,
|
|
54
|
+
runGeneratedCases: false,
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
// ─────────────────────────────────────────────────────────────
|
|
58
|
+
// Example 3: CSV file-based test generation
|
|
59
|
+
// ─────────────────────────────────────────────────────────────
|
|
60
|
+
{
|
|
61
|
+
filePath: 'e2e-tests/files/test-cases.csv', // Update: your CSV path
|
|
62
|
+
moduleName: '@YourModule',
|
|
63
|
+
category: '@Modules',
|
|
64
|
+
subModuleName: [],
|
|
65
|
+
fileName: 'your_feature',
|
|
66
|
+
instructions: [],
|
|
67
|
+
pageURL: 'http://localhost:5173/your-page',
|
|
68
|
+
inputs: {},
|
|
69
|
+
explore: false,
|
|
70
|
+
runExploredCases: false,
|
|
71
|
+
runGeneratedCases: true,
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
// ─────────────────────────────────────────────────────────────
|
|
75
|
+
// Example 4: Cross-module workflow
|
|
76
|
+
// ─────────────────────────────────────────────────────────────
|
|
77
|
+
{
|
|
78
|
+
filePath: '',
|
|
79
|
+
moduleName: '@YourWorkflow',
|
|
80
|
+
category: '@Workflows',
|
|
81
|
+
subModuleName: ['@Step1', '@Step2'],
|
|
82
|
+
fileName: 'workflow_name',
|
|
83
|
+
instructions: [
|
|
84
|
+
'Precondition: Create required data',
|
|
85
|
+
'Step 1: Process the created data',
|
|
86
|
+
'Step 2: Verify the result',
|
|
87
|
+
],
|
|
88
|
+
pageURL: 'http://localhost:5173/your-page',
|
|
89
|
+
inputs: {},
|
|
90
|
+
explore: true,
|
|
91
|
+
runExploredCases: false,
|
|
92
|
+
runGeneratedCases: false,
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Field Reference:
|
|
98
|
+
*
|
|
99
|
+
* filePath — Source file (CSV, Excel, PDF, JSON). Leave "" for instruction/Jira-based.
|
|
100
|
+
* moduleName — Target module directory name (e.g., "@Dashboard", "@Settings").
|
|
101
|
+
* category — "@Modules" (default) or "@Workflows".
|
|
102
|
+
* subModuleName — Array of nested subdirectories.
|
|
103
|
+
* fileName — Output filename stem (e.g., "dashboard" → dashboard.feature + steps.js).
|
|
104
|
+
* instructions — Free-text test descriptions.
|
|
105
|
+
* pageURL — App URL for exploration. Required when explore: true.
|
|
106
|
+
* inputs.jira.url — Jira ticket URL for requirements extraction.
|
|
107
|
+
* explore — Enable live browser exploration for selector discovery.
|
|
108
|
+
* runExploredCases — Run explored seed tests before BDD generation (Phase 5).
|
|
109
|
+
* runGeneratedCases — Run generated BDD tests after creation (Phase 8).
|
|
110
|
+
*/
|
|
File without changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication Setup — react-template two-step localStorage login
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Navigate to /signin
|
|
6
|
+
* 2. Fill email → click email submit
|
|
7
|
+
* 3. Wait for password field → fill password → click login submit
|
|
8
|
+
* 4. Handle 2FA if twoFactorCodeInput appears
|
|
9
|
+
* 5. Wait for redirect to /home
|
|
10
|
+
* 6. Save storageState (captures localStorage: tfauth-token, tfcurrent-user)
|
|
11
|
+
*/
|
|
12
|
+
import { test as setup } from '@playwright/test';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
import { authenticationData } from '../data/authenticationData.js';
|
|
16
|
+
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
+
const __dirname = path.dirname(__filename);
|
|
19
|
+
|
|
20
|
+
const authFile = path.join(__dirname, './auth-storage/.auth/user.json');
|
|
21
|
+
|
|
22
|
+
setup('authenticate', async ({ page }) => {
|
|
23
|
+
console.log('Starting authentication setup...');
|
|
24
|
+
|
|
25
|
+
const { validCredentials, locators, timeouts, twoFactor } = authenticationData;
|
|
26
|
+
|
|
27
|
+
// Step 1: Navigate to sign-in page
|
|
28
|
+
await page.goto(`${authenticationData.baseUrl}/signin`);
|
|
29
|
+
await page.waitForLoadState('networkidle', { timeout: timeouts.loadState });
|
|
30
|
+
console.log('Navigated to /signin');
|
|
31
|
+
|
|
32
|
+
// Step 2: Fill email and submit
|
|
33
|
+
const emailInput = page.getByTestId(locators.emailInput.testId);
|
|
34
|
+
await emailInput.waitFor({ state: 'visible', timeout: timeouts.elementWait });
|
|
35
|
+
await emailInput.fill(validCredentials.email);
|
|
36
|
+
console.log(`Filled email: ${validCredentials.email}`);
|
|
37
|
+
|
|
38
|
+
const emailSubmit = page.getByTestId(locators.emailSubmitButton.testId);
|
|
39
|
+
await emailSubmit.click();
|
|
40
|
+
console.log('Clicked email submit');
|
|
41
|
+
|
|
42
|
+
// Step 3: Wait for password field, fill, and submit
|
|
43
|
+
const passwordInput = page.getByTestId(locators.passwordInput.testId);
|
|
44
|
+
await passwordInput.waitFor({ state: 'visible', timeout: timeouts.elementWait });
|
|
45
|
+
await passwordInput.fill(validCredentials.password);
|
|
46
|
+
console.log('Filled password');
|
|
47
|
+
|
|
48
|
+
const loginSubmit = page.getByTestId(locators.loginSubmitButton.testId);
|
|
49
|
+
await loginSubmit.click();
|
|
50
|
+
console.log('Clicked login submit');
|
|
51
|
+
|
|
52
|
+
// Step 4: Handle 2FA if it appears
|
|
53
|
+
try {
|
|
54
|
+
const twoFactorInput = page.getByTestId(twoFactor.locators.codeInput.testId);
|
|
55
|
+
await twoFactorInput.waitFor({ state: 'visible', timeout: 5000 });
|
|
56
|
+
console.log('2FA detected, entering code...');
|
|
57
|
+
await twoFactorInput.fill(twoFactor.code);
|
|
58
|
+
const proceedButton = page.getByTestId(twoFactor.locators.proceedButton.testId);
|
|
59
|
+
await proceedButton.click();
|
|
60
|
+
console.log('2FA code submitted');
|
|
61
|
+
} catch {
|
|
62
|
+
// No 2FA prompt — expected for most test accounts
|
|
63
|
+
console.log('No 2FA prompt detected, continuing...');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Step 5: Wait for redirect to /home
|
|
67
|
+
await page.waitForURL('**/home**', { timeout: timeouts.login });
|
|
68
|
+
await page.waitForLoadState('networkidle', { timeout: timeouts.loadState });
|
|
69
|
+
console.log('Login successful — redirected to /home');
|
|
70
|
+
|
|
71
|
+
// Step 6: Save authentication state (captures localStorage)
|
|
72
|
+
await page.context().storageState({ path: authFile });
|
|
73
|
+
console.log(`Authentication state saved to: ${authFile}`);
|
|
74
|
+
});
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { test as base, createBdd } from 'playwright-bdd';
|
|
2
|
+
import { authenticationData } from '../data/authenticationData.js';
|
|
3
|
+
import { testConfig as fullTestConfig } from '../data/testConfig.js';
|
|
4
|
+
import dotenv from 'dotenv';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
|
|
9
|
+
// Load environment variables
|
|
10
|
+
dotenv.config();
|
|
11
|
+
|
|
12
|
+
// Get current directory for resolving test data path
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
+
const __dirname = path.dirname(__filename);
|
|
15
|
+
|
|
16
|
+
// Test data directory
|
|
17
|
+
const testDataDir = path.join(__dirname, './test-data');
|
|
18
|
+
|
|
19
|
+
// ==================== SCOPED TEST DATA API ====================
|
|
20
|
+
// Replaces single globalTestData.json with one file per feature/scope.
|
|
21
|
+
// Shared scopes (@cross-feature-data) → flat file at root (e.g., test-data/eobs.json)
|
|
22
|
+
// Feature-specific scopes → hierarchical path (e.g., test-data/auth/login.json)
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract module name from feature URI based on directory structure.
|
|
26
|
+
* Single source of truth — imported by global-hooks.js and step files.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} featureUri - Feature file path
|
|
29
|
+
* @returns {string|null} Module name (lowercase)
|
|
30
|
+
*
|
|
31
|
+
* Examples:
|
|
32
|
+
* "@Workflows/@Authentication/file.feature" → "authentication"
|
|
33
|
+
* "@Modules/@HomePage/file.feature" → "homepage"
|
|
34
|
+
*/
|
|
35
|
+
export function extractModuleName(featureUri) {
|
|
36
|
+
if (!featureUri) return null;
|
|
37
|
+
|
|
38
|
+
const parts = featureUri.split('/');
|
|
39
|
+
const categoriesWithModules = ['@Workflows', '@Modules'];
|
|
40
|
+
|
|
41
|
+
for (const category of categoriesWithModules) {
|
|
42
|
+
const categoryIndex = parts.indexOf(category);
|
|
43
|
+
if (categoryIndex !== -1 && parts[categoryIndex + 1]) {
|
|
44
|
+
return parts[categoryIndex + 1].replace('@', '').toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Convert feature URI to a hierarchical scope path.
|
|
53
|
+
* Strips @-prefixes and .feature extension, joins with "/".
|
|
54
|
+
*
|
|
55
|
+
* @param {string} featureUri - Feature file path
|
|
56
|
+
* @returns {string} Hierarchical scope path
|
|
57
|
+
*/
|
|
58
|
+
export function featureUriToScopePath(featureUri) {
|
|
59
|
+
const parts = featureUri.split('/');
|
|
60
|
+
const catIdx = parts.findIndex((p) => p === '@Modules' || p === '@Workflows');
|
|
61
|
+
if (catIdx === -1) return parts[parts.length - 1].replace(/\.feature.*$/, '');
|
|
62
|
+
|
|
63
|
+
return parts
|
|
64
|
+
.slice(catIdx + 1)
|
|
65
|
+
.map((p) => p.replace(/^@/, '').replace(/\.feature.*$/, ''))
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.map((p) => p.toLowerCase().replace(/[^a-z0-9_]+/g, '-'))
|
|
68
|
+
.join('/');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Derive the data scope for a feature.
|
|
73
|
+
* - @cross-feature-data → flat module name (e.g., "auth")
|
|
74
|
+
* - Otherwise → hierarchical path (e.g., "homepage/navigation")
|
|
75
|
+
*
|
|
76
|
+
* @param {string} featureUri - Feature file path
|
|
77
|
+
* @param {string[]} tags - Test tags array
|
|
78
|
+
* @returns {string} Scope string (used as file path relative to test-data/)
|
|
79
|
+
*/
|
|
80
|
+
export function deriveDataScope(featureUri, tags = []) {
|
|
81
|
+
const isCrossFeature = tags.some((t) => t.includes('cross-feature-data'));
|
|
82
|
+
if (isCrossFeature) {
|
|
83
|
+
return extractModuleName(featureUri) || 'shared';
|
|
84
|
+
}
|
|
85
|
+
return featureUriToScopePath(featureUri);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve scope string to absolute file path.
|
|
90
|
+
* @param {string} scope - Scope string (flat or hierarchical with "/")
|
|
91
|
+
* @returns {string} Absolute path to JSON file
|
|
92
|
+
*/
|
|
93
|
+
function scopeToFilePath(scope) {
|
|
94
|
+
return path.join(testDataDir, `${scope}.json`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Load test data for a specific scope.
|
|
99
|
+
* @param {string} scope - Scope string (e.g., "auth" or "homepage/navigation")
|
|
100
|
+
* @returns {Object} Parsed data or empty object
|
|
101
|
+
*/
|
|
102
|
+
export function loadScopedTestData(scope) {
|
|
103
|
+
const filePath = scopeToFilePath(scope);
|
|
104
|
+
if (fs.existsSync(filePath)) {
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
107
|
+
} catch {
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Save test data to a scoped file. Creates directories recursively.
|
|
116
|
+
* @param {string} scope - Scope string
|
|
117
|
+
* @param {Object} data - Data to save
|
|
118
|
+
*/
|
|
119
|
+
export function saveScopedTestData(scope, data) {
|
|
120
|
+
const filePath = scopeToFilePath(scope);
|
|
121
|
+
const dir = path.dirname(filePath);
|
|
122
|
+
if (!fs.existsSync(dir)) {
|
|
123
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
124
|
+
}
|
|
125
|
+
data.updatedAt = new Date().toISOString();
|
|
126
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check if scoped test data file exists.
|
|
131
|
+
* @param {string} scope - Scope string
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
*/
|
|
134
|
+
export function scopedTestDataExists(scope) {
|
|
135
|
+
return fs.existsSync(scopeToFilePath(scope));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ==================== FEATURE-LEVEL BROWSER REUSE ====================
|
|
139
|
+
// For serial-execution projects, the browser page persists across scenarios
|
|
140
|
+
// within the same feature file. A new page is created only when the test
|
|
141
|
+
// file changes (i.e., next feature file starts).
|
|
142
|
+
//
|
|
143
|
+
// Benefits:
|
|
144
|
+
// - Page + context created once per feature file (not per scenario)
|
|
145
|
+
// - Client-side state (Zustand store, localStorage) persists across scenarios
|
|
146
|
+
// - Background steps can skip redundant navigation if already on target page
|
|
147
|
+
// - Authentication state persists via storageState on context creation
|
|
148
|
+
|
|
149
|
+
let _featurePage = null;
|
|
150
|
+
let _featureContext = null;
|
|
151
|
+
let _lastTestFile = null;
|
|
152
|
+
|
|
153
|
+
const REUSABLE_PROJECTS = ['serial-execution'];
|
|
154
|
+
|
|
155
|
+
// Extend base test with custom fixtures
|
|
156
|
+
export const test = base.extend({
|
|
157
|
+
authData: async ({}, use) => {
|
|
158
|
+
await use(authenticationData);
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
testConfig: async ({}, use) => {
|
|
162
|
+
await use({
|
|
163
|
+
...fullTestConfig,
|
|
164
|
+
baseUrl: process.env.BASE_URL || fullTestConfig.baseUrl,
|
|
165
|
+
timeout: {
|
|
166
|
+
loadState: 60000,
|
|
167
|
+
elementWait: 10000,
|
|
168
|
+
networkIdle: 15000,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
testData: async ({}, use) => {
|
|
174
|
+
await use({});
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
scenarioContext: async ({}, use) => {
|
|
178
|
+
await use({});
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
// Feature-level page reuse for serial-execution projects.
|
|
182
|
+
// For these projects, the browser page persists across scenarios within the
|
|
183
|
+
// same feature file. A fresh context + page is created only when testInfo.file changes.
|
|
184
|
+
// All other projects retain standard per-scenario isolation.
|
|
185
|
+
page: async ({ browser }, use, testInfo) => {
|
|
186
|
+
const { viewport, baseURL, storageState, locale, timezoneId, ignoreHTTPSErrors, video } =
|
|
187
|
+
testInfo.project.use || {};
|
|
188
|
+
const contextOptions = {
|
|
189
|
+
viewport,
|
|
190
|
+
baseURL,
|
|
191
|
+
storageState,
|
|
192
|
+
locale,
|
|
193
|
+
timezoneId,
|
|
194
|
+
ignoreHTTPSErrors,
|
|
195
|
+
...(video && video !== 'off' ? { recordVideo: { dir: testInfo.outputDir } } : {}),
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
if (REUSABLE_PROJECTS.includes(testInfo.project.name)) {
|
|
199
|
+
const currentFile = testInfo.file;
|
|
200
|
+
|
|
201
|
+
// New feature file → create fresh context + page
|
|
202
|
+
if (currentFile !== _lastTestFile || !_featurePage || _featurePage.isClosed()) {
|
|
203
|
+
if (_featureContext) {
|
|
204
|
+
await _featureContext.close().catch(() => {});
|
|
205
|
+
}
|
|
206
|
+
_featureContext = await browser.newContext(contextOptions);
|
|
207
|
+
_featurePage = await _featureContext.newPage();
|
|
208
|
+
_lastTestFile = currentFile;
|
|
209
|
+
console.log(`[BrowserReuse] New page for: ${currentFile.split('/').slice(-2).join('/')}`);
|
|
210
|
+
} else {
|
|
211
|
+
console.log(`[BrowserReuse] Reusing page for: ${currentFile.split('/').slice(-2).join('/')}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
await use(_featurePage);
|
|
215
|
+
|
|
216
|
+
// Video attachment for reusable projects: on failure, close the shared
|
|
217
|
+
// context to finalize the video, attach it, then null out refs.
|
|
218
|
+
const retainAll = process.env.RETAIN_VIDEO_ON_SUCCESS === 'true';
|
|
219
|
+
const isFailed = testInfo.status !== testInfo.expectedStatus;
|
|
220
|
+
if (video && video !== 'off' && (isFailed || retainAll)) {
|
|
221
|
+
try {
|
|
222
|
+
const videoObj = _featurePage?.video?.();
|
|
223
|
+
if (videoObj && _featureContext) {
|
|
224
|
+
await _featureContext.close();
|
|
225
|
+
const savedPath = testInfo.outputPath('video.webm');
|
|
226
|
+
await videoObj.saveAs(savedPath);
|
|
227
|
+
await testInfo.attach('video', { path: savedPath, contentType: 'video/webm' });
|
|
228
|
+
}
|
|
229
|
+
} catch (err) {
|
|
230
|
+
console.log(`[Video][BrowserReuse] Could not attach video: ${err.message}`);
|
|
231
|
+
}
|
|
232
|
+
_featurePage = null;
|
|
233
|
+
_featureContext = null;
|
|
234
|
+
_lastTestFile = null;
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
// Default: fresh context + page per scenario (standard Playwright behavior)
|
|
238
|
+
const context = await browser.newContext(contextOptions);
|
|
239
|
+
const page = await context.newPage();
|
|
240
|
+
await use(page);
|
|
241
|
+
|
|
242
|
+
const videoObj = video && video !== 'off' ? page.video() : null;
|
|
243
|
+
await context.close();
|
|
244
|
+
|
|
245
|
+
const retainAll = process.env.RETAIN_VIDEO_ON_SUCCESS === 'true';
|
|
246
|
+
const isFailed = testInfo.status !== testInfo.expectedStatus;
|
|
247
|
+
if (videoObj && (isFailed || retainAll)) {
|
|
248
|
+
try {
|
|
249
|
+
const savedPath = testInfo.outputPath('video.webm');
|
|
250
|
+
await videoObj.saveAs(savedPath);
|
|
251
|
+
await testInfo.attach('video', { path: savedPath, contentType: 'video/webm' });
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.log(`[Video] Could not attach video: ${err.message}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Create BDD functions with custom fixtures
|
|
261
|
+
export const { Given, When, Then, Before, After, BeforeAll, AfterAll } = createBdd(test);
|
|
262
|
+
|
|
263
|
+
// Export expect from Playwright
|
|
264
|
+
export { expect } from '@playwright/test';
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global Setup — runs once before all test projects.
|
|
3
|
+
* Uses a .cleanup-done marker file to detect fresh vs. in-progress runs.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
|
|
12
|
+
const markerFile = path.join(__dirname, '.cleanup-done');
|
|
13
|
+
const testDataDir = path.join(__dirname, 'test-data');
|
|
14
|
+
const reportsDir = path.join(__dirname, '../../reports');
|
|
15
|
+
|
|
16
|
+
export default async function globalSetup() {
|
|
17
|
+
console.log('[global.setup] Starting global setup...');
|
|
18
|
+
|
|
19
|
+
if (!fs.existsSync(markerFile)) {
|
|
20
|
+
// New run — clean previous data
|
|
21
|
+
console.log('[global.setup] Fresh run detected. Cleaning previous data...');
|
|
22
|
+
|
|
23
|
+
// Clean test-data directory (but keep the directory)
|
|
24
|
+
if (fs.existsSync(testDataDir)) {
|
|
25
|
+
const files = fs.readdirSync(testDataDir);
|
|
26
|
+
for (const file of files) {
|
|
27
|
+
const filePath = path.join(testDataDir, file);
|
|
28
|
+
fs.rmSync(filePath, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
fs.mkdirSync(testDataDir, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Ensure reports directories exist
|
|
35
|
+
const reportDirs = ['json', 'cucumber-bdd', 'playwright', 'screenshots'];
|
|
36
|
+
for (const dir of reportDirs) {
|
|
37
|
+
const dirPath = path.join(reportsDir, dir);
|
|
38
|
+
if (!fs.existsSync(dirPath)) {
|
|
39
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
console.log('[global.setup] Run in progress — preserving data.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Place marker
|
|
47
|
+
fs.writeFileSync(markerFile, new Date().toISOString());
|
|
48
|
+
console.log('[global.setup] Setup complete.');
|
|
49
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global Teardown — runs once after all test projects complete.
|
|
3
|
+
* Removes the .cleanup-done marker so the next run starts fresh.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
|
|
12
|
+
const markerFile = path.join(__dirname, '.cleanup-done');
|
|
13
|
+
|
|
14
|
+
export default async function globalTeardown() {
|
|
15
|
+
console.log('[global.teardown] Starting global teardown...');
|
|
16
|
+
|
|
17
|
+
if (fs.existsSync(markerFile)) {
|
|
18
|
+
fs.unlinkSync(markerFile);
|
|
19
|
+
console.log('[global.teardown] Marker file removed.');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
console.log('[global.teardown] Teardown complete.');
|
|
23
|
+
}
|
|
File without changes
|