@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.
Files changed (32) hide show
  1. package/.claude_README.md +276 -0
  2. package/.claude_memory_MEMORY.md +11 -0
  3. package/.gitignore.snippet +21 -0
  4. package/PLUGIN.md +172 -0
  5. package/README-TESTING.md +227 -0
  6. package/cli.js +53 -0
  7. package/e2e-tests/.env.testing +29 -0
  8. package/e2e-tests/data/authenticationData.js +76 -0
  9. package/e2e-tests/data/testConfig.js +39 -0
  10. package/e2e-tests/features/playwright-bdd/@Modules/.gitkeep +0 -0
  11. package/e2e-tests/features/playwright-bdd/@Modules/@Authentication/authentication.feature +64 -0
  12. package/e2e-tests/features/playwright-bdd/@Modules/@Authentication/steps.js +27 -0
  13. package/e2e-tests/features/playwright-bdd/@Workflows/.gitkeep +0 -0
  14. package/e2e-tests/features/playwright-bdd/shared/auth.steps.js +74 -0
  15. package/e2e-tests/features/playwright-bdd/shared/common.steps.js +52 -0
  16. package/e2e-tests/features/playwright-bdd/shared/global-hooks.js +44 -0
  17. package/e2e-tests/features/playwright-bdd/shared/navigation.steps.js +47 -0
  18. package/e2e-tests/instructions.example.js +110 -0
  19. package/e2e-tests/instructions.js +9 -0
  20. package/e2e-tests/playwright/auth-storage/.auth/.gitkeep +0 -0
  21. package/e2e-tests/playwright/auth.setup.js +74 -0
  22. package/e2e-tests/playwright/fixtures.js +264 -0
  23. package/e2e-tests/playwright/generated/.gitkeep +0 -0
  24. package/e2e-tests/playwright/global.setup.js +49 -0
  25. package/e2e-tests/playwright/global.teardown.js +23 -0
  26. package/e2e-tests/playwright/test-data/.gitkeep +0 -0
  27. package/e2e-tests/utils/stepHelpers.js +404 -0
  28. package/e2e-tests/utils/testDataGenerator.js +38 -0
  29. package/install.sh +148 -0
  30. package/package.json +39 -0
  31. package/package.json.snippet +27 -0
  32. 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
+ */
@@ -0,0 +1,9 @@
1
+ /**
2
+ * instructions.js — Test Automation Pipeline Configuration
3
+ *
4
+ * Add your test generation configs here.
5
+ * See instructions.example.js for examples.
6
+ * Run with: /e2e-automate (Claude Code skill)
7
+ */
8
+
9
+ export default [];
@@ -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