@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,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step Definition Helper Functions
|
|
3
|
+
* Provides reusable utilities for BDD step implementations with data tables.
|
|
4
|
+
*
|
|
5
|
+
* Core exports:
|
|
6
|
+
* - FIELD_TYPES — declarative field type constants
|
|
7
|
+
* - processDataTable — fills forms from Gherkin 3-column data tables
|
|
8
|
+
* - validateExpectations — asserts displayed values from data tables
|
|
9
|
+
* - fillFieldByName — fill a single field using selector priority hierarchy
|
|
10
|
+
* - selectDropDownByTestId — select option from a react-select dropdown
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { expect } from '@playwright/test';
|
|
14
|
+
import _ from 'lodash';
|
|
15
|
+
import { generateValueForField } from './testDataGenerator.js';
|
|
16
|
+
|
|
17
|
+
// ─────────────────────────────────────────────────────────────
|
|
18
|
+
// FIELD_TYPES — declarative type constants
|
|
19
|
+
// ─────────────────────────────────────────────────────────────
|
|
20
|
+
export const FIELD_TYPES = {
|
|
21
|
+
// Interaction types (used in processDataTable fieldConfig)
|
|
22
|
+
FILL: 'FILL', // plain text input
|
|
23
|
+
FILL_AND_ENTER: 'FILL_AND_ENTER', // fill then press Enter (multi-select tags)
|
|
24
|
+
DROPDOWN: 'DROPDOWN', // react-select dropdown
|
|
25
|
+
COMBO_BOX: 'COMBO_BOX', // creatable select (creates new option)
|
|
26
|
+
CLICK: 'CLICK', // button / toggle via click
|
|
27
|
+
CHECKBOX_TOGGLE: 'CHECKBOX_TOGGLE', // checkbox by label text
|
|
28
|
+
TOGGLE: 'TOGGLE', // boolean toggle switch
|
|
29
|
+
CUSTOM: 'CUSTOM', // write a fieldHandler for truly unique interactions
|
|
30
|
+
|
|
31
|
+
// Validation types (used in validateExpectations validationConfig)
|
|
32
|
+
INPUT_VALUE: 'INPUT_VALUE', // assert text input .value (toHaveValue)
|
|
33
|
+
MULTI_SELECT_TAG: 'MULTI_SELECT_TAG', // react-select multi-value chip visible
|
|
34
|
+
DROPDOWN_SINGLE_VALUE: 'DROPDOWN_SINGLE_VALUE', // react-select single-value text
|
|
35
|
+
TEXT_VISIBLE: 'TEXT_VISIBLE', // assert text is visible by testID
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ─────────────────────────────────────────────────────────────
|
|
39
|
+
// processDataTable — fills forms from 3-column Gherkin data tables
|
|
40
|
+
// ─────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Process a 3-column Gherkin data table (Field Name | Value | Type)
|
|
44
|
+
* and interact with each field based on its FIELD_TYPE configuration.
|
|
45
|
+
*
|
|
46
|
+
* Handles:
|
|
47
|
+
* - <gen_test_data> → generates faker value, caches in featureDataCache
|
|
48
|
+
* - <from_test_data> → reads previously cached value
|
|
49
|
+
* - Static values → uses as-is
|
|
50
|
+
*
|
|
51
|
+
* @param {Page} page - Playwright page
|
|
52
|
+
* @param {DataTable} dataTable - Gherkin data table
|
|
53
|
+
* @param {Object} config
|
|
54
|
+
* @param {Object} config.mapping - Field name → page.testData property name
|
|
55
|
+
* @param {Object} config.fieldConfig - Field name → { type: FIELD_TYPES.*, testID?, selector?, ... }
|
|
56
|
+
* @param {Object} config.fieldHandlers - Field name → async (page, value) => {} for CUSTOM types
|
|
57
|
+
* @param {Locator|Page} config.container - Scope all locators to this element (default: page)
|
|
58
|
+
*/
|
|
59
|
+
export async function processDataTable(page, dataTable, config = {}) {
|
|
60
|
+
const { mapping = {}, fieldConfig = {}, fieldHandlers = {}, container = page } = config;
|
|
61
|
+
|
|
62
|
+
const rows = dataTable.hashes();
|
|
63
|
+
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
const fieldName = row['Field Name'] || row['Field'] || row['Name'];
|
|
66
|
+
const valueType = (row['Type'] || 'Static').toLowerCase();
|
|
67
|
+
let value = row['Value'] || row['Expected Value'] || '';
|
|
68
|
+
|
|
69
|
+
// ── Step 1: Resolve value from placeholder ──
|
|
70
|
+
if (value === '<gen_test_data>') {
|
|
71
|
+
// Generate: check if mapping points to existing testData, otherwise generate with faker
|
|
72
|
+
if (mapping[fieldName] && page.testData?.[mapping[fieldName]] !== undefined) {
|
|
73
|
+
value = page.testData[mapping[fieldName]];
|
|
74
|
+
} else {
|
|
75
|
+
value = generateValueForField(fieldName);
|
|
76
|
+
}
|
|
77
|
+
// Cache SharedGenerated values for later <from_test_data> reads
|
|
78
|
+
if (valueType === 'sharedgenerated') {
|
|
79
|
+
const cacheKey = mapping[fieldName] || _.snakeCase(fieldName);
|
|
80
|
+
if (!page.testData) page.testData = {};
|
|
81
|
+
page.testData[cacheKey] = value;
|
|
82
|
+
// Also write to featureDataCache for cross-scenario persistence
|
|
83
|
+
const featureKey = getFeatureKey(page);
|
|
84
|
+
if (featureKey) {
|
|
85
|
+
if (!globalThis.__rt_featureDataCache) globalThis.__rt_featureDataCache = {};
|
|
86
|
+
if (!globalThis.__rt_featureDataCache[featureKey]) globalThis.__rt_featureDataCache[featureKey] = {};
|
|
87
|
+
globalThis.__rt_featureDataCache[featureKey][cacheKey] = value;
|
|
88
|
+
}
|
|
89
|
+
console.log(`🎲 Generated "${fieldName}" → ${cacheKey}: ${value}`);
|
|
90
|
+
}
|
|
91
|
+
} else if (value === '<from_test_data>') {
|
|
92
|
+
// Read: look up from page.testData or featureDataCache
|
|
93
|
+
const cacheKey = mapping[fieldName] || _.snakeCase(fieldName);
|
|
94
|
+
if (page.testData?.[cacheKey] !== undefined) {
|
|
95
|
+
value = page.testData[cacheKey];
|
|
96
|
+
console.log(`📖 Read "${fieldName}" → ${cacheKey}: ${value}`);
|
|
97
|
+
} else {
|
|
98
|
+
// Try featureDataCache
|
|
99
|
+
const featureKey = getFeatureKey(page);
|
|
100
|
+
const cached = featureKey && globalThis.__rt_featureDataCache?.[featureKey]?.[cacheKey];
|
|
101
|
+
if (cached) {
|
|
102
|
+
value = cached;
|
|
103
|
+
console.log(`📖 Read from cache "${fieldName}" → ${cacheKey}: ${value}`);
|
|
104
|
+
} else {
|
|
105
|
+
console.warn(`⚠️ No cached value for "${fieldName}" (key: ${cacheKey})`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Static values: use as-is
|
|
110
|
+
|
|
111
|
+
// ── Step 2: Interact with the field ──
|
|
112
|
+
if (fieldHandlers[fieldName]) {
|
|
113
|
+
await fieldHandlers[fieldName](page, value);
|
|
114
|
+
} else if (fieldConfig[fieldName]) {
|
|
115
|
+
await executeFieldInteraction(page, fieldName, value, fieldConfig[fieldName], container);
|
|
116
|
+
} else {
|
|
117
|
+
// Fallback: try to fill by testID derived from field name
|
|
118
|
+
await fillFieldByName(container, fieldName, value);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─────────────────────────────────────────────────────────────
|
|
124
|
+
// validateExpectations — asserts displayed values from data tables
|
|
125
|
+
// ─────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Validate displayed values from a 3-column Gherkin data table.
|
|
129
|
+
* Reads <from_test_data> from page.testData or featureDataCache.
|
|
130
|
+
*
|
|
131
|
+
* @param {Page} page - Playwright page
|
|
132
|
+
* @param {DataTable} dataTable - Gherkin data table
|
|
133
|
+
* @param {Object} config
|
|
134
|
+
* @param {Object} config.mapping - Field name → page.testData property name
|
|
135
|
+
* @param {Object} config.validationConfig - Field name → { type: FIELD_TYPES.*, testID?, selector? }
|
|
136
|
+
* @param {Locator|Page} config.container - Scope assertions to this element (default: page)
|
|
137
|
+
*/
|
|
138
|
+
export async function validateExpectations(page, dataTable, config = {}) {
|
|
139
|
+
const { mapping = {}, validationConfig = {}, container = page } = config;
|
|
140
|
+
|
|
141
|
+
const rows = dataTable.hashes();
|
|
142
|
+
|
|
143
|
+
for (const row of rows) {
|
|
144
|
+
const fieldName = row['Field Name'] || row['Field'] || row['Element'];
|
|
145
|
+
let expectedValue = row['Expected Value'] || row['Value'] || '';
|
|
146
|
+
|
|
147
|
+
// ── Resolve <from_test_data> placeholder ──
|
|
148
|
+
if (expectedValue === '<from_test_data>') {
|
|
149
|
+
const cacheKey = mapping[fieldName] || _.snakeCase(fieldName);
|
|
150
|
+
if (page.testData?.[cacheKey] !== undefined) {
|
|
151
|
+
expectedValue = page.testData[cacheKey];
|
|
152
|
+
console.log(`✅ Validate "${fieldName}" → ${cacheKey}: ${expectedValue}`);
|
|
153
|
+
} else {
|
|
154
|
+
const featureKey = getFeatureKey(page);
|
|
155
|
+
const cached = featureKey && globalThis.__rt_featureDataCache?.[featureKey]?.[cacheKey];
|
|
156
|
+
if (cached) {
|
|
157
|
+
expectedValue = cached;
|
|
158
|
+
console.log(`✅ Validate from cache "${fieldName}" → ${cacheKey}: ${expectedValue}`);
|
|
159
|
+
} else {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`No cached value for "${fieldName}" (key: ${cacheKey}). Was <gen_test_data> used in a prior fill step?`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Execute validation ──
|
|
168
|
+
const vConfig = validationConfig[fieldName];
|
|
169
|
+
if (vConfig) {
|
|
170
|
+
await executeValidation(page, fieldName, expectedValue, vConfig, container);
|
|
171
|
+
} else {
|
|
172
|
+
// Fallback: find element by text
|
|
173
|
+
await expect(page.getByText(expectedValue, { exact: false }).first()).toBeVisible();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─────────────────────────────────────────────────────────────
|
|
179
|
+
// fillFieldByName — fill a single field using selector priority
|
|
180
|
+
// ─────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Fill a field using Playwright's selector priority hierarchy.
|
|
184
|
+
* Tries: testID → name → placeholder → label → CSS fallback
|
|
185
|
+
*/
|
|
186
|
+
export async function fillFieldByName(container, fieldName, value) {
|
|
187
|
+
const fieldKebab = _.kebabCase(fieldName);
|
|
188
|
+
|
|
189
|
+
const strategies = [
|
|
190
|
+
() => container.getByTestId(fieldKebab),
|
|
191
|
+
() => container.getByTestId(`input-${fieldKebab}`),
|
|
192
|
+
() => container.locator(`input[name="${fieldName}"]`),
|
|
193
|
+
() => container.locator(`input[name="${fieldKebab}"]`),
|
|
194
|
+
() => container.getByPlaceholder(new RegExp(fieldName, 'i')),
|
|
195
|
+
() => container.getByLabel(fieldName),
|
|
196
|
+
() => container.getByRole('textbox', { name: fieldName }),
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
for (const getLocator of strategies) {
|
|
200
|
+
try {
|
|
201
|
+
const element = getLocator();
|
|
202
|
+
if ((await element.count()) > 0) {
|
|
203
|
+
const target = (await element.count()) > 1 ? element.first() : element;
|
|
204
|
+
await target.fill(value);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
throw new Error(`Could not find field "${fieldName}" using any selector strategy.`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─────────────────────────────────────────────────────────────
|
|
216
|
+
// selectDropDownByTestId — select option from react-select
|
|
217
|
+
// ─────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Select an option from a React Select dropdown by testID.
|
|
221
|
+
* Opens the dropdown, finds the option by text, clicks it.
|
|
222
|
+
*/
|
|
223
|
+
export async function selectDropDownByTestId(page, fieldName, value, autoKebab = true) {
|
|
224
|
+
const testId = autoKebab ? _.kebabCase(fieldName) : fieldName;
|
|
225
|
+
|
|
226
|
+
const dropdownContainer = page.getByTestId(testId);
|
|
227
|
+
await dropdownContainer.waitFor({ state: 'visible', timeout: 5000 });
|
|
228
|
+
|
|
229
|
+
const control = dropdownContainer
|
|
230
|
+
.locator('.react-select__control, .react-select__dropdown-indicator, [class*="select__control"]')
|
|
231
|
+
.first();
|
|
232
|
+
await control.waitFor({ state: 'visible', timeout: 5000 });
|
|
233
|
+
await control.click();
|
|
234
|
+
await page.waitForTimeout(300);
|
|
235
|
+
|
|
236
|
+
// Portal renders at document.body — must use page root
|
|
237
|
+
const option = page.locator('.react-select__menu-list').locator('.react-select__option').filter({ hasText: value });
|
|
238
|
+
|
|
239
|
+
await option.waitFor({ state: 'visible', timeout: 5000 });
|
|
240
|
+
await option.click();
|
|
241
|
+
|
|
242
|
+
await page
|
|
243
|
+
.locator('.react-select__menu-list')
|
|
244
|
+
.waitFor({ state: 'detached', timeout: 3000 })
|
|
245
|
+
.catch(() => {});
|
|
246
|
+
await page.waitForTimeout(300);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ─────────────────────────────────────────────────────────────
|
|
250
|
+
// Internal: executeFieldInteraction
|
|
251
|
+
// ─────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
async function executeFieldInteraction(page, fieldName, value, config, container = page) {
|
|
254
|
+
switch (config.type) {
|
|
255
|
+
case FIELD_TYPES.FILL:
|
|
256
|
+
if (config.testID) {
|
|
257
|
+
const el = container.getByTestId(config.testID);
|
|
258
|
+
const count = await el.count();
|
|
259
|
+
await (count > 1 ? el.first() : el).fill(value);
|
|
260
|
+
} else if (config.selector) {
|
|
261
|
+
await container.locator(config.selector).fill(value);
|
|
262
|
+
} else if (config.placeholder) {
|
|
263
|
+
await container.getByPlaceholder(config.placeholder).fill(value);
|
|
264
|
+
} else {
|
|
265
|
+
await fillFieldByName(container, fieldName, value);
|
|
266
|
+
}
|
|
267
|
+
break;
|
|
268
|
+
|
|
269
|
+
case FIELD_TYPES.FILL_AND_ENTER: {
|
|
270
|
+
const input = container.getByRole(config.role || 'textbox', {
|
|
271
|
+
name: config.name || fieldName,
|
|
272
|
+
});
|
|
273
|
+
await input.fill(value);
|
|
274
|
+
await input.press('Enter');
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
case FIELD_TYPES.DROPDOWN:
|
|
279
|
+
if (config.testID) {
|
|
280
|
+
const dropEl = container.getByTestId(config.testID);
|
|
281
|
+
const ctrl = dropEl.locator('.react-select__control, .react-select__dropdown-indicator').first();
|
|
282
|
+
await ctrl.waitFor({ state: 'visible', timeout: 5000 });
|
|
283
|
+
await ctrl.click();
|
|
284
|
+
await page.waitForTimeout(300);
|
|
285
|
+
const menuList = page.locator('.react-select__menu-list');
|
|
286
|
+
await expect(menuList).toBeVisible({ timeout: 5000 });
|
|
287
|
+
const opt = menuList.locator('.react-select__option').filter({ hasText: value });
|
|
288
|
+
const matched = (await opt.count()) > 0 ? opt.first() : menuList.locator('.react-select__option').first();
|
|
289
|
+
await matched.click();
|
|
290
|
+
await page
|
|
291
|
+
.locator('.react-select__menu-list')
|
|
292
|
+
.waitFor({ state: 'detached', timeout: 3000 })
|
|
293
|
+
.catch(() => {});
|
|
294
|
+
await page.waitForTimeout(300);
|
|
295
|
+
} else {
|
|
296
|
+
await selectDropDownByTestId(page, fieldName, value);
|
|
297
|
+
}
|
|
298
|
+
break;
|
|
299
|
+
|
|
300
|
+
case FIELD_TYPES.COMBO_BOX:
|
|
301
|
+
if (config.testID) {
|
|
302
|
+
await container.getByTestId(config.testID).fill(value);
|
|
303
|
+
} else if (config.placeholder) {
|
|
304
|
+
await container.getByPlaceholder(config.placeholder).fill(value);
|
|
305
|
+
} else {
|
|
306
|
+
await fillFieldByName(container, fieldName, value);
|
|
307
|
+
}
|
|
308
|
+
await page.getByRole('link', { name: `Create: ${value}` }).click();
|
|
309
|
+
break;
|
|
310
|
+
|
|
311
|
+
case FIELD_TYPES.CLICK:
|
|
312
|
+
if (config.testID) {
|
|
313
|
+
await container.getByTestId(config.testID).click();
|
|
314
|
+
} else if (config.selector) {
|
|
315
|
+
await container.locator(config.selector).click();
|
|
316
|
+
} else if (config.role) {
|
|
317
|
+
await container.getByRole(config.role, { name: config.name || value }).click();
|
|
318
|
+
} else {
|
|
319
|
+
await container.getByText(value).click();
|
|
320
|
+
}
|
|
321
|
+
break;
|
|
322
|
+
|
|
323
|
+
case FIELD_TYPES.CHECKBOX_TOGGLE:
|
|
324
|
+
if (config.testID) {
|
|
325
|
+
await container.getByTestId(config.testID).filter({ hasText: fieldName }).click();
|
|
326
|
+
} else {
|
|
327
|
+
await container.getByTestId('checkbox').filter({ hasText: fieldName }).click();
|
|
328
|
+
}
|
|
329
|
+
break;
|
|
330
|
+
|
|
331
|
+
case FIELD_TYPES.TOGGLE:
|
|
332
|
+
if (config.testID) {
|
|
333
|
+
await container.getByTestId(config.testID).click();
|
|
334
|
+
} else if (config.selector) {
|
|
335
|
+
await container.locator(config.selector).click();
|
|
336
|
+
}
|
|
337
|
+
break;
|
|
338
|
+
|
|
339
|
+
case FIELD_TYPES.CUSTOM:
|
|
340
|
+
console.warn(`CUSTOM field "${fieldName}" requires a fieldHandler. Skipping.`);
|
|
341
|
+
break;
|
|
342
|
+
|
|
343
|
+
default:
|
|
344
|
+
if (config.testID) {
|
|
345
|
+
await container.getByTestId(config.testID).fill(value);
|
|
346
|
+
} else {
|
|
347
|
+
await fillFieldByName(container, fieldName, value);
|
|
348
|
+
}
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ─────────────────────────────────────────────────────────────
|
|
354
|
+
// Internal: executeValidation
|
|
355
|
+
// ─────────────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
async function executeValidation(page, fieldName, expectedValue, config, container = page) {
|
|
358
|
+
switch (config.type) {
|
|
359
|
+
case FIELD_TYPES.INPUT_VALUE:
|
|
360
|
+
if (config.testID) {
|
|
361
|
+
await expect(container.getByTestId(config.testID)).toHaveValue(expectedValue, { timeout: 5000 });
|
|
362
|
+
} else if (config.selector) {
|
|
363
|
+
await expect(container.locator(config.selector)).toHaveValue(expectedValue, { timeout: 5000 });
|
|
364
|
+
}
|
|
365
|
+
break;
|
|
366
|
+
|
|
367
|
+
case FIELD_TYPES.MULTI_SELECT_TAG:
|
|
368
|
+
await expect(
|
|
369
|
+
container.locator('.react-select__multi-value__label').filter({ hasText: expectedValue }),
|
|
370
|
+
).toBeVisible({ timeout: 5000 });
|
|
371
|
+
break;
|
|
372
|
+
|
|
373
|
+
case FIELD_TYPES.DROPDOWN_SINGLE_VALUE:
|
|
374
|
+
if (config.testID) {
|
|
375
|
+
await expect(container.locator(`[data-testid="${config.testID}"] .react-select__single-value`)).toContainText(
|
|
376
|
+
expectedValue,
|
|
377
|
+
{ timeout: 5000 },
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
break;
|
|
381
|
+
|
|
382
|
+
case FIELD_TYPES.TEXT_VISIBLE:
|
|
383
|
+
if (config.testID) {
|
|
384
|
+
const el = container.getByTestId(config.testID);
|
|
385
|
+
await expect(el).toBeVisible();
|
|
386
|
+
await expect(el).toHaveText(expectedValue);
|
|
387
|
+
} else {
|
|
388
|
+
await expect(page.getByText(expectedValue, { exact: false }).first()).toBeVisible();
|
|
389
|
+
}
|
|
390
|
+
break;
|
|
391
|
+
|
|
392
|
+
default:
|
|
393
|
+
await expect(page.getByText(expectedValue, { exact: false }).first()).toBeVisible();
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ─────────────────────────────────────────────────────────────
|
|
399
|
+
// Internal: getFeatureKey helper
|
|
400
|
+
// ─────────────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
function getFeatureKey(page) {
|
|
403
|
+
return page.featureKey || null;
|
|
404
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test Data Generator — faker-based data utilities
|
|
3
|
+
* Used by processDataTable for dynamic value generation
|
|
4
|
+
*/
|
|
5
|
+
import { faker } from '@faker-js/faker';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate a value based on the field name.
|
|
9
|
+
* Uses faker to produce realistic, type-appropriate data.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} fieldName - Human-readable field name (e.g., "Name", "Email", "Phone")
|
|
12
|
+
* @returns {string} Generated value
|
|
13
|
+
*/
|
|
14
|
+
export function generateValueForField(fieldName) {
|
|
15
|
+
const field = fieldName.toLowerCase();
|
|
16
|
+
|
|
17
|
+
if (field.includes('email')) return faker.internet.email().toLowerCase();
|
|
18
|
+
if (field.includes('phone')) return faker.phone.number({ style: 'national' });
|
|
19
|
+
if (field.includes('name') && field.includes('company')) return faker.company.name();
|
|
20
|
+
if (field.includes('name')) return faker.person.fullName();
|
|
21
|
+
if (field.includes('catchphrase') || field.includes('catch phrase')) return faker.company.catchPhrase();
|
|
22
|
+
if (field.includes('address') || field.includes('street')) return faker.location.streetAddress();
|
|
23
|
+
if (field.includes('city')) return faker.location.city();
|
|
24
|
+
if (field.includes('zip') || field.includes('postal')) return faker.location.zipCode();
|
|
25
|
+
if (field.includes('country')) return faker.location.country();
|
|
26
|
+
if (field.includes('state')) return faker.location.state();
|
|
27
|
+
if (field.includes('website') || field.includes('url')) return faker.internet.url();
|
|
28
|
+
if (field.includes('description') || field.includes('comment')) return faker.lorem.sentence();
|
|
29
|
+
if (field.includes('id') || field.includes('number') || field.includes('code'))
|
|
30
|
+
return faker.string.alphanumeric(8).toUpperCase();
|
|
31
|
+
if (field.includes('tag')) return faker.string.alphanumeric(6).toUpperCase();
|
|
32
|
+
if (field.includes('date')) return faker.date.future().toISOString().split('T')[0];
|
|
33
|
+
if (field.includes('amount') || field.includes('price') || field.includes('quantity'))
|
|
34
|
+
return faker.number.int({ min: 1, max: 1000 }).toString();
|
|
35
|
+
|
|
36
|
+
// Default: random word with timestamp suffix for uniqueness
|
|
37
|
+
return `${faker.lorem.word()}_${Date.now().toString().slice(-6)}`;
|
|
38
|
+
}
|
package/install.sh
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# E2E Automation Plugin — Installation Script
|
|
3
|
+
# Run from your project root: bash path/to/e2e-plugin/install.sh
|
|
4
|
+
# Options:
|
|
5
|
+
# --skip-auth Skip installing authentication test module
|
|
6
|
+
|
|
7
|
+
set -e
|
|
8
|
+
|
|
9
|
+
PLUGIN_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
10
|
+
TARGET_DIR=""
|
|
11
|
+
SKIP_AUTH=false
|
|
12
|
+
|
|
13
|
+
# Parse arguments — first non-flag argument is the target directory
|
|
14
|
+
for arg in "$@"; do
|
|
15
|
+
case $arg in
|
|
16
|
+
--skip-auth) SKIP_AUTH=true ;;
|
|
17
|
+
-*) ;; # skip unknown flags
|
|
18
|
+
*) [ -z "$TARGET_DIR" ] && TARGET_DIR="$arg" ;;
|
|
19
|
+
esac
|
|
20
|
+
done
|
|
21
|
+
|
|
22
|
+
# Default to current directory if no target specified
|
|
23
|
+
TARGET_DIR="${TARGET_DIR:-$(pwd)}"
|
|
24
|
+
|
|
25
|
+
echo "╔══════════════════════════════════════════════╗"
|
|
26
|
+
echo "║ E2E Automation Plugin — Installing ║"
|
|
27
|
+
echo "╚══════════════════════════════════════════════╝"
|
|
28
|
+
echo ""
|
|
29
|
+
echo "Source: $PLUGIN_DIR"
|
|
30
|
+
echo "Target: $TARGET_DIR"
|
|
31
|
+
if [ "$SKIP_AUTH" = true ]; then
|
|
32
|
+
echo "Option: --skip-auth (authentication module will NOT be installed)"
|
|
33
|
+
fi
|
|
34
|
+
echo ""
|
|
35
|
+
|
|
36
|
+
# ── Step 1: Copy .claude/ directory ──
|
|
37
|
+
echo "📦 Step 1: Installing .claude/ (agents, skills, rules)..."
|
|
38
|
+
mkdir -p "$TARGET_DIR/.claude/agents/playwright"
|
|
39
|
+
mkdir -p "$TARGET_DIR/.claude/skills"
|
|
40
|
+
mkdir -p "$TARGET_DIR/.claude/rules"
|
|
41
|
+
mkdir -p "$TARGET_DIR/.claude/memory"
|
|
42
|
+
mkdir -p "$TARGET_DIR/.claude/agent-memory/playwright-test-planner"
|
|
43
|
+
mkdir -p "$TARGET_DIR/.claude/agent-memory/playwright-test-healer"
|
|
44
|
+
mkdir -p "$TARGET_DIR/.claude/agent-memory/execution-manager"
|
|
45
|
+
|
|
46
|
+
cp -r "$PLUGIN_DIR/.claude_agents/"* "$TARGET_DIR/.claude/agents/"
|
|
47
|
+
cp -r "$PLUGIN_DIR/.claude_skills/"* "$TARGET_DIR/.claude/skills/"
|
|
48
|
+
cp -r "$PLUGIN_DIR/.claude_rules/"* "$TARGET_DIR/.claude/rules/"
|
|
49
|
+
cp "$PLUGIN_DIR/.claude_README.md" "$TARGET_DIR/.claude/README.md"
|
|
50
|
+
cp "$PLUGIN_DIR/.claude_memory_MEMORY.md" "$TARGET_DIR/.claude/memory/MEMORY.md"
|
|
51
|
+
cp "$PLUGIN_DIR/.claude_agent-memory/playwright-test-planner/MEMORY.md" "$TARGET_DIR/.claude/agent-memory/playwright-test-planner/"
|
|
52
|
+
cp "$PLUGIN_DIR/.claude_agent-memory/playwright-test-healer/MEMORY.md" "$TARGET_DIR/.claude/agent-memory/playwright-test-healer/"
|
|
53
|
+
cp "$PLUGIN_DIR/.claude_agent-memory/execution-manager/MEMORY.md" "$TARGET_DIR/.claude/agent-memory/execution-manager/"
|
|
54
|
+
echo " ✅ .claude/ installed"
|
|
55
|
+
|
|
56
|
+
# ── Step 2: Copy e2e-tests/ infrastructure ──
|
|
57
|
+
echo "📦 Step 2: Installing e2e-tests/ infrastructure..."
|
|
58
|
+
mkdir -p "$TARGET_DIR/e2e-tests/playwright/auth-storage/.auth"
|
|
59
|
+
mkdir -p "$TARGET_DIR/e2e-tests/playwright/generated"
|
|
60
|
+
mkdir -p "$TARGET_DIR/e2e-tests/playwright/test-data"
|
|
61
|
+
mkdir -p "$TARGET_DIR/e2e-tests/features/playwright-bdd/shared"
|
|
62
|
+
mkdir -p "$TARGET_DIR/e2e-tests/utils"
|
|
63
|
+
mkdir -p "$TARGET_DIR/e2e-tests/data"
|
|
64
|
+
|
|
65
|
+
cp "$PLUGIN_DIR/e2e-tests/playwright/fixtures.js" "$TARGET_DIR/e2e-tests/playwright/"
|
|
66
|
+
cp "$PLUGIN_DIR/e2e-tests/playwright/auth.setup.js" "$TARGET_DIR/e2e-tests/playwright/"
|
|
67
|
+
cp "$PLUGIN_DIR/e2e-tests/playwright/global.setup.js" "$TARGET_DIR/e2e-tests/playwright/"
|
|
68
|
+
cp "$PLUGIN_DIR/e2e-tests/playwright/global.teardown.js" "$TARGET_DIR/e2e-tests/playwright/"
|
|
69
|
+
cp "$PLUGIN_DIR/e2e-tests/utils/stepHelpers.js" "$TARGET_DIR/e2e-tests/utils/"
|
|
70
|
+
cp "$PLUGIN_DIR/e2e-tests/utils/testDataGenerator.js" "$TARGET_DIR/e2e-tests/utils/"
|
|
71
|
+
cp "$PLUGIN_DIR/e2e-tests/features/playwright-bdd/shared/"*.js "$TARGET_DIR/e2e-tests/features/playwright-bdd/shared/"
|
|
72
|
+
cp "$PLUGIN_DIR/e2e-tests/data/authenticationData.js" "$TARGET_DIR/e2e-tests/data/"
|
|
73
|
+
cp "$PLUGIN_DIR/e2e-tests/data/testConfig.js" "$TARGET_DIR/e2e-tests/data/"
|
|
74
|
+
cp "$PLUGIN_DIR/e2e-tests/instructions.js" "$TARGET_DIR/e2e-tests/"
|
|
75
|
+
cp "$PLUGIN_DIR/e2e-tests/instructions.example.js" "$TARGET_DIR/e2e-tests/"
|
|
76
|
+
cp "$PLUGIN_DIR/e2e-tests/.env.testing" "$TARGET_DIR/e2e-tests/"
|
|
77
|
+
|
|
78
|
+
# Gitkeep files + directory stubs
|
|
79
|
+
touch "$TARGET_DIR/e2e-tests/playwright/auth-storage/.auth/.gitkeep"
|
|
80
|
+
touch "$TARGET_DIR/e2e-tests/playwright/generated/.gitkeep"
|
|
81
|
+
touch "$TARGET_DIR/e2e-tests/playwright/test-data/.gitkeep"
|
|
82
|
+
mkdir -p "$TARGET_DIR/e2e-tests/features/playwright-bdd/@Modules"
|
|
83
|
+
mkdir -p "$TARGET_DIR/e2e-tests/features/playwright-bdd/@Workflows"
|
|
84
|
+
touch "$TARGET_DIR/e2e-tests/features/playwright-bdd/@Modules/.gitkeep"
|
|
85
|
+
touch "$TARGET_DIR/e2e-tests/features/playwright-bdd/@Workflows/.gitkeep"
|
|
86
|
+
echo " ✅ e2e-tests/ infrastructure installed"
|
|
87
|
+
|
|
88
|
+
# ── Step 3: Install authentication module (unless --skip-auth) ──
|
|
89
|
+
if [ "$SKIP_AUTH" = true ]; then
|
|
90
|
+
echo "⏭️ Step 3: Authentication module — SKIPPED (--skip-auth)"
|
|
91
|
+
else
|
|
92
|
+
echo "📦 Step 3: Installing authentication test module..."
|
|
93
|
+
mkdir -p "$TARGET_DIR/e2e-tests/features/playwright-bdd/@Modules/@Authentication"
|
|
94
|
+
cp "$PLUGIN_DIR/e2e-tests/features/playwright-bdd/@Modules/@Authentication/authentication.feature" \
|
|
95
|
+
"$TARGET_DIR/e2e-tests/features/playwright-bdd/@Modules/@Authentication/"
|
|
96
|
+
cp "$PLUGIN_DIR/e2e-tests/features/playwright-bdd/@Modules/@Authentication/steps.js" \
|
|
97
|
+
"$TARGET_DIR/e2e-tests/features/playwright-bdd/@Modules/@Authentication/"
|
|
98
|
+
echo " ✅ @Authentication module installed (7 scenarios)"
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
# ── Step 4: Copy playwright config ──
|
|
102
|
+
echo "📦 Step 4: Installing playwright.config.ts..."
|
|
103
|
+
if [ -f "$TARGET_DIR/playwright.config.ts" ]; then
|
|
104
|
+
echo " ⚠️ playwright.config.ts already exists — backed up to playwright.config.ts.bak"
|
|
105
|
+
cp "$TARGET_DIR/playwright.config.ts" "$TARGET_DIR/playwright.config.ts.bak"
|
|
106
|
+
fi
|
|
107
|
+
cp "$PLUGIN_DIR/playwright.config.ts" "$TARGET_DIR/"
|
|
108
|
+
echo " ✅ playwright.config.ts installed"
|
|
109
|
+
|
|
110
|
+
# ── Step 5: Copy documentation ──
|
|
111
|
+
echo "📦 Step 5: Installing documentation..."
|
|
112
|
+
cp "$PLUGIN_DIR/README-TESTING.md" "$TARGET_DIR/"
|
|
113
|
+
echo " ✅ README-TESTING.md installed"
|
|
114
|
+
|
|
115
|
+
# ── Done ──
|
|
116
|
+
echo ""
|
|
117
|
+
echo "╔══════════════════════════════════════════════╗"
|
|
118
|
+
echo "║ Installation Complete ║"
|
|
119
|
+
echo "╚══════════════════════════════════════════════╝"
|
|
120
|
+
echo ""
|
|
121
|
+
echo "📋 Next steps:"
|
|
122
|
+
echo ""
|
|
123
|
+
echo " 1. Merge devDependencies + scripts from e2e-plugin/package.json.snippet"
|
|
124
|
+
echo " into your package.json, then run: pnpm install"
|
|
125
|
+
echo ""
|
|
126
|
+
echo " 2. Install Playwright browsers: pnpx playwright install"
|
|
127
|
+
echo ""
|
|
128
|
+
echo " 3. Append lines from e2e-plugin/.gitignore.snippet"
|
|
129
|
+
echo " to your .gitignore"
|
|
130
|
+
echo ""
|
|
131
|
+
echo " 4. Update e2e-tests/data/authenticationData.js"
|
|
132
|
+
echo " → Set your app's login form testIDs"
|
|
133
|
+
echo ""
|
|
134
|
+
echo " 5. Update e2e-tests/data/testConfig.js"
|
|
135
|
+
echo " → Set your app's routes"
|
|
136
|
+
echo ""
|
|
137
|
+
echo " 6. Set credentials in .env:"
|
|
138
|
+
echo " TEST_USER_EMAIL=your-email@example.com"
|
|
139
|
+
echo " TEST_USER_PASSWORD=your-password"
|
|
140
|
+
echo " BASE_URL=http://localhost:5173"
|
|
141
|
+
echo ""
|
|
142
|
+
echo " 7. Start dev server and run tests:"
|
|
143
|
+
echo " pnpm test:bdd:auth # Run authentication tests"
|
|
144
|
+
echo " pnpm test:bdd # Run all tests (except auth)"
|
|
145
|
+
echo " pnpm test:bdd:all # Run everything including auth"
|
|
146
|
+
echo ""
|
|
147
|
+
echo " 8. Generate more tests: /e2e-automate (Claude Code CLI)"
|
|
148
|
+
echo ""
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@specwright/plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Drop-in E2E test automation framework plugin — Playwright BDD + Claude Code agents",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"specwright-plugin": "./cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"cli.js",
|
|
11
|
+
"install.sh",
|
|
12
|
+
"PLUGIN.md",
|
|
13
|
+
"README-TESTING.md",
|
|
14
|
+
"playwright.config.ts",
|
|
15
|
+
"package.json.snippet",
|
|
16
|
+
".gitignore.snippet",
|
|
17
|
+
".claude_*",
|
|
18
|
+
"e2e-tests/"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"playwright",
|
|
25
|
+
"bdd",
|
|
26
|
+
"e2e",
|
|
27
|
+
"testing",
|
|
28
|
+
"automation",
|
|
29
|
+
"claude",
|
|
30
|
+
"ai",
|
|
31
|
+
"specwright"
|
|
32
|
+
],
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/SanthoshDhandapani/specwright.git",
|
|
36
|
+
"directory": "packages/plugin"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/SanthoshDhandapani/specwright"
|
|
39
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"// MERGE THESE into your project's package.json": "",
|
|
3
|
+
"// Scripts: add all entries under 'scripts'": "",
|
|
4
|
+
"// DevDependencies: add all entries under 'devDependencies'": "",
|
|
5
|
+
|
|
6
|
+
"scripts": {
|
|
7
|
+
"bddgen": "npx bddgen",
|
|
8
|
+
"test:bdd": "pnpm bddgen && playwright test --project setup --project serial-execution --project main-e2e",
|
|
9
|
+
"test:bdd:all": "pnpm bddgen && playwright test",
|
|
10
|
+
"test:bdd:auth": "pnpm bddgen && playwright test --project setup --project auth-tests",
|
|
11
|
+
"test:bdd:serial": "pnpm bddgen && playwright test --project setup --project serial-execution",
|
|
12
|
+
"test:bdd:debug": "pnpm bddgen && PWDEBUG=console playwright test --debug",
|
|
13
|
+
"test:playwright": "playwright test",
|
|
14
|
+
"test:playwright:headed": "playwright test --headed",
|
|
15
|
+
"test:playwright:debug": "playwright test --debug",
|
|
16
|
+
"report:playwright": "playwright show-report",
|
|
17
|
+
"test:clean": "rm -rf reports .features-gen test-results e2e-tests/playwright/test-data/*.json"
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@faker-js/faker": "^10.3.0",
|
|
22
|
+
"@playwright/test": "^1.54.1",
|
|
23
|
+
"dotenv": "^17.2.0",
|
|
24
|
+
"lodash": "^4.17.23",
|
|
25
|
+
"playwright-bdd": "^8.5.0"
|
|
26
|
+
}
|
|
27
|
+
}
|