@ash-mallick/browserstack-sync 1.1.2 → 1.1.4
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/lib/ai-analyzer.js +246 -57
- package/lib/browserstack.js +65 -6
- package/lib/enrich.js +51 -5
- package/lib/index.js +36 -24
- package/package.json +2 -1
package/lib/ai-analyzer.js
CHANGED
|
@@ -14,44 +14,137 @@ const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://localhost:11434';
|
|
|
14
14
|
/**
|
|
15
15
|
* System prompt for the AI to understand what we want.
|
|
16
16
|
*/
|
|
17
|
-
const SYSTEM_PROMPT = `You are a
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
17
|
+
const SYSTEM_PROMPT = `You are a QA expert creating manual test steps from automated test code.
|
|
18
|
+
|
|
19
|
+
CRITICAL RULES:
|
|
20
|
+
1. Generate steps ONLY for what is explicitly in the test code provided
|
|
21
|
+
2. DO NOT add steps for things not in the code
|
|
22
|
+
3. DO NOT hallucinate or assume content - use ONLY text/values from the code
|
|
23
|
+
4. Each expect() or assertion = one verification step
|
|
24
|
+
5. Output ONLY valid JSON array, nothing else
|
|
25
|
+
|
|
26
|
+
STEP FORMAT:
|
|
27
|
+
{
|
|
28
|
+
"step": "Numbered action description with exact element and location",
|
|
29
|
+
"result": "Expected outcome using exact text from the code"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
MAPPING CODE TO STEPS:
|
|
33
|
+
|
|
34
|
+
1. getByTestId('section-name') → "Locate the section with test ID 'section-name'"
|
|
35
|
+
2. locator('.class-name') → "Find the element with class 'class-name'"
|
|
36
|
+
3. getByRole('link', { name: /text/i }) → "Find the link labeled 'text'"
|
|
37
|
+
4. expect(element).toBeVisible() → Result: "The element is visible"
|
|
38
|
+
5. expect(element).toContainText('exact text') → Result: "The element displays 'exact text'"
|
|
39
|
+
6. expect(element).toHaveText('exact text') → Result: "The element shows exactly 'exact text'"
|
|
40
|
+
7. expect(link).toHaveAttribute('href', '/path') → Result: "The link href is '/path'"
|
|
41
|
+
8. scrollIntoViewIfNeeded() → "Scroll to bring the element into view"
|
|
42
|
+
|
|
43
|
+
EXAMPLE - For this code:
|
|
44
|
+
const scope = page.getByTestId("success-stories");
|
|
45
|
+
await expect(scope.locator(".band__headline")).toHaveText("Success stories");
|
|
46
|
+
|
|
47
|
+
OUTPUT:
|
|
34
48
|
[
|
|
35
|
-
{"step": "
|
|
36
|
-
{"step": "
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
49
|
+
{"step": "1. Locate the section with test ID 'success-stories' on the page.", "result": "The success-stories section is found."},
|
|
50
|
+
{"step": "2. In the success-stories section, find the element with class 'band__headline'. Verify its text content.", "result": "The headline displays exactly 'Success stories'."}
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
REMEMBER:
|
|
54
|
+
- ONLY use text/values that appear in the test code
|
|
55
|
+
- DO NOT make up text or values
|
|
56
|
+
- Number steps 1, 2, 3...
|
|
57
|
+
- Be precise and specific`;
|
|
40
58
|
|
|
41
59
|
/**
|
|
42
60
|
* Build the user prompt for AI analysis.
|
|
43
61
|
*/
|
|
44
62
|
function buildUserPrompt(testTitle, testCode) {
|
|
45
|
-
return `
|
|
63
|
+
return `Convert this automated test into manual test steps.
|
|
46
64
|
|
|
47
|
-
|
|
65
|
+
TEST: "${testTitle}"
|
|
48
66
|
|
|
49
|
-
|
|
50
|
-
\`\`\`
|
|
67
|
+
CODE:
|
|
51
68
|
${testCode}
|
|
52
|
-
\`\`\`
|
|
53
69
|
|
|
54
|
-
|
|
70
|
+
INSTRUCTIONS:
|
|
71
|
+
1. Create one step for EACH expect() assertion in the code
|
|
72
|
+
2. Use ONLY the exact text strings that appear in the code (in quotes or regex)
|
|
73
|
+
3. For each getByTestId(), locator(), or getByRole() - describe THAT element
|
|
74
|
+
4. For each .toContainText('X') or .toHaveText('X') - the expected result is "displays 'X'"
|
|
75
|
+
5. For each .toHaveAttribute('href', 'Y') - the expected result is "href is 'Y'"
|
|
76
|
+
6. DO NOT add extra text or values that are not in the code
|
|
77
|
+
|
|
78
|
+
Example mapping:
|
|
79
|
+
- expect(page.getByTestId("masthead")).toBeVisible()
|
|
80
|
+
→ Step: "Locate the element with test ID 'masthead'." Result: "The masthead element is visible."
|
|
81
|
+
|
|
82
|
+
- expect(el).toContainText("Hello World")
|
|
83
|
+
→ Step: "Verify the element's text content." Result: "The element contains 'Hello World'."
|
|
84
|
+
|
|
85
|
+
- expect(link).toHaveAttribute("href", "/contact")
|
|
86
|
+
→ Step: "Check the link's href attribute." Result: "The link href is '/contact'."
|
|
87
|
+
|
|
88
|
+
OUTPUT: Return ONLY a JSON array of {step, result} objects. No other text.`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Attempt to repair truncated JSON array.
|
|
93
|
+
* Handles cases where the response was cut off mid-array.
|
|
94
|
+
*/
|
|
95
|
+
function repairTruncatedJSON(jsonStr) {
|
|
96
|
+
// First, try parsing as-is
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(jsonStr);
|
|
99
|
+
} catch (_) {
|
|
100
|
+
// Continue with repair attempts
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Try to find the last complete object in the array
|
|
104
|
+
// Pattern: find all complete {"step": "...", "result": "..."} objects
|
|
105
|
+
const objectPattern = /\{\s*"step"\s*:\s*"(?:[^"\\]|\\.)*"\s*,\s*"result"\s*:\s*"(?:[^"\\]|\\.)*"\s*\}/g;
|
|
106
|
+
const matches = jsonStr.match(objectPattern);
|
|
107
|
+
|
|
108
|
+
if (matches && matches.length > 0) {
|
|
109
|
+
// Reconstruct a valid JSON array from complete objects
|
|
110
|
+
const reconstructed = '[' + matches.join(',') + ']';
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(reconstructed);
|
|
113
|
+
} catch (_) {
|
|
114
|
+
// Fall through
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Try removing the last incomplete object and close the array
|
|
119
|
+
let trimmed = jsonStr.trim();
|
|
120
|
+
|
|
121
|
+
// If it starts with [, try to close it properly
|
|
122
|
+
if (trimmed.startsWith('[')) {
|
|
123
|
+
// Find the last complete }, before truncation
|
|
124
|
+
const lastCompleteObj = trimmed.lastIndexOf('},');
|
|
125
|
+
if (lastCompleteObj > 0) {
|
|
126
|
+
const fixedJson = trimmed.slice(0, lastCompleteObj + 1) + ']';
|
|
127
|
+
try {
|
|
128
|
+
return JSON.parse(fixedJson);
|
|
129
|
+
} catch (_) {
|
|
130
|
+
// Fall through
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Try finding the last }
|
|
135
|
+
const lastBrace = trimmed.lastIndexOf('}');
|
|
136
|
+
if (lastBrace > 0) {
|
|
137
|
+
const fixedJson = trimmed.slice(0, lastBrace + 1) + ']';
|
|
138
|
+
try {
|
|
139
|
+
return JSON.parse(fixedJson);
|
|
140
|
+
} catch (_) {
|
|
141
|
+
// Fall through
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Last resort: throw the original error
|
|
147
|
+
return JSON.parse(jsonStr);
|
|
55
148
|
}
|
|
56
149
|
|
|
57
150
|
/**
|
|
@@ -71,15 +164,22 @@ function parseAIResponse(content) {
|
|
|
71
164
|
|
|
72
165
|
// Also try to extract JSON array from anywhere in the response
|
|
73
166
|
if (!jsonStr.startsWith('[')) {
|
|
74
|
-
const arrayMatch = jsonStr.match(/\[[\s\S]
|
|
167
|
+
const arrayMatch = jsonStr.match(/\[[\s\S]*?\](?=[^}\]]*$)?/);
|
|
75
168
|
if (arrayMatch) {
|
|
76
169
|
jsonStr = arrayMatch[0];
|
|
77
170
|
}
|
|
78
171
|
}
|
|
79
172
|
|
|
173
|
+
// Try to find just the array portion if there's extra text
|
|
174
|
+
const arrayStart = jsonStr.indexOf('[');
|
|
175
|
+
if (arrayStart > 0) {
|
|
176
|
+
jsonStr = jsonStr.slice(arrayStart);
|
|
177
|
+
}
|
|
178
|
+
|
|
80
179
|
let steps;
|
|
81
180
|
try {
|
|
82
|
-
|
|
181
|
+
// Use repair function to handle truncated responses
|
|
182
|
+
steps = repairTruncatedJSON(jsonStr);
|
|
83
183
|
} catch (parseError) {
|
|
84
184
|
throw new Error(`Failed to parse AI response as JSON: ${parseError.message}. Response: ${content.slice(0, 200)}`);
|
|
85
185
|
}
|
|
@@ -88,10 +188,13 @@ function parseAIResponse(content) {
|
|
|
88
188
|
throw new Error('AI response is not an array');
|
|
89
189
|
}
|
|
90
190
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
191
|
+
// Filter out any empty or invalid steps
|
|
192
|
+
return steps
|
|
193
|
+
.filter((s) => s && (s.step || s.result))
|
|
194
|
+
.map((s) => ({
|
|
195
|
+
step: String(s.step || '').trim(),
|
|
196
|
+
result: String(s.result || '').trim(),
|
|
197
|
+
}));
|
|
95
198
|
}
|
|
96
199
|
|
|
97
200
|
/**
|
|
@@ -136,7 +239,9 @@ async function analyzeWithOllama(testTitle, testCode, model) {
|
|
|
136
239
|
{ role: 'user', content: buildUserPrompt(testTitle, testCode) },
|
|
137
240
|
],
|
|
138
241
|
options: {
|
|
139
|
-
temperature: 0.
|
|
242
|
+
temperature: 0.1, // Lower temperature for more consistent, focused output
|
|
243
|
+
num_predict: 8192, // Increased max tokens for detailed step descriptions
|
|
244
|
+
num_ctx: 8192, // Context window for longer test code input
|
|
140
245
|
},
|
|
141
246
|
});
|
|
142
247
|
|
|
@@ -176,47 +281,131 @@ export async function analyzeTestWithAI(testTitle, testCode, apiKey, options = {
|
|
|
176
281
|
*/
|
|
177
282
|
export function extractStepsWithRegex(testCode, isCypress = false) {
|
|
178
283
|
const steps = [];
|
|
284
|
+
let stepNum = 1;
|
|
179
285
|
|
|
180
|
-
//
|
|
286
|
+
// Helper to clean up regex patterns from text
|
|
287
|
+
const cleanText = (text) => text?.replace(/\\?\//gi, '').replace(/\^|\$/g, '').trim() || '';
|
|
288
|
+
|
|
289
|
+
// Common Playwright patterns - order matters for sequential extraction
|
|
181
290
|
const patterns = [
|
|
182
291
|
// page.goto or cy.visit
|
|
183
292
|
{
|
|
184
|
-
regex: /(?:page\.goto|cy\.visit)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
|
|
185
|
-
format: (match) => ({
|
|
293
|
+
regex: /(?:page\.goto|cy\.visit)\s*\(\s*(?:process\.env\.[A-Z_]+\s*\+\s*)?['"`]([^'"`]+)['"`]/gi,
|
|
294
|
+
format: (match) => ({
|
|
295
|
+
step: `${stepNum++}. Open the browser and navigate to the URL: ${match[1] || '/'}. Wait for the page to fully load.`,
|
|
296
|
+
result: 'The page loads successfully and all elements are visible.',
|
|
297
|
+
}),
|
|
186
298
|
},
|
|
187
|
-
//
|
|
299
|
+
// getByTestId - identify section
|
|
188
300
|
{
|
|
189
|
-
regex: /(?:
|
|
190
|
-
format: (match) => {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
301
|
+
regex: /(?:page\.|await\s+)getByTestId\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/gi,
|
|
302
|
+
format: (match) => ({
|
|
303
|
+
step: `${stepNum++}. Locate the section/component with test ID "${match[1]}" on the page.`,
|
|
304
|
+
result: `The "${match[1]}" section is found and visible on the page.`,
|
|
305
|
+
}),
|
|
306
|
+
},
|
|
307
|
+
// locator with class or selector
|
|
308
|
+
{
|
|
309
|
+
regex: /\.locator\s*\(\s*['"`]\.([^'"`]+)['"`]\s*\)(?:\.first\(\))?/gi,
|
|
310
|
+
format: (match) => ({
|
|
311
|
+
step: `${stepNum++}. Within the current section, find the element with class "${match[1]}".`,
|
|
312
|
+
result: `Element with class "${match[1]}" is located.`,
|
|
313
|
+
}),
|
|
314
|
+
},
|
|
315
|
+
// getByRole with name - link
|
|
316
|
+
{
|
|
317
|
+
regex: /getByRole\s*\(\s*['"`]link['"`]\s*,\s*\{[^}]*name:\s*['"`/]([^'"`/]+)/gi,
|
|
318
|
+
format: (match) => ({
|
|
319
|
+
step: `${stepNum++}. Find the link labeled "${cleanText(match[1])}" on the page.`,
|
|
320
|
+
result: `The "${cleanText(match[1])}" link is visible and clickable.`,
|
|
321
|
+
}),
|
|
322
|
+
},
|
|
323
|
+
// getByRole with name - button
|
|
324
|
+
{
|
|
325
|
+
regex: /getByRole\s*\(\s*['"`]button['"`]\s*,\s*\{[^}]*name:\s*['"`/]([^'"`/]+)/gi,
|
|
326
|
+
format: (match) => ({
|
|
327
|
+
step: `${stepNum++}. Locate the button labeled "${cleanText(match[1])}" on the page.`,
|
|
328
|
+
result: `The "${cleanText(match[1])}" button is displayed and enabled.`,
|
|
329
|
+
}),
|
|
195
330
|
},
|
|
196
|
-
//
|
|
331
|
+
// scrollIntoViewIfNeeded
|
|
197
332
|
{
|
|
198
|
-
regex: /
|
|
199
|
-
format: (
|
|
333
|
+
regex: /scrollIntoViewIfNeeded\s*\(\s*\)/gi,
|
|
334
|
+
format: () => ({
|
|
335
|
+
step: `${stepNum++}. Scroll the page until the target element is visible in the viewport.`,
|
|
336
|
+
result: 'The element scrolls into view successfully.',
|
|
337
|
+
}),
|
|
200
338
|
},
|
|
201
339
|
// expect toBeVisible
|
|
202
340
|
{
|
|
203
|
-
regex: /expect\s*\([^)]+\)\.toBeVisible\s*\(
|
|
204
|
-
format: () => ({
|
|
341
|
+
regex: /expect\s*\([^)]+\)\.toBeVisible\s*\(/gi,
|
|
342
|
+
format: () => ({
|
|
343
|
+
step: `${stepNum++}. Verify that the element is visible on the page.`,
|
|
344
|
+
result: 'The element is confirmed to be visible and displayed.',
|
|
345
|
+
}),
|
|
346
|
+
},
|
|
347
|
+
// expect toContainText with text
|
|
348
|
+
{
|
|
349
|
+
regex: /expect\s*\([^)]+\)\.toContainText\s*\(\s*['"`]([^'"`]+)['"`]/gi,
|
|
350
|
+
format: (match) => ({
|
|
351
|
+
step: `${stepNum++}. Verify the element contains the text "${match[1]}".`,
|
|
352
|
+
result: `The text "${match[1]}" is displayed within the element.`,
|
|
353
|
+
}),
|
|
354
|
+
},
|
|
355
|
+
// expect toHaveText with text
|
|
356
|
+
{
|
|
357
|
+
regex: /expect\s*\([^)]+\)\.toHaveText\s*\(\s*['"`]([^'"`]+)['"`]/gi,
|
|
358
|
+
format: (match) => ({
|
|
359
|
+
step: `${stepNum++}. Verify the element has the exact text "${match[1]}".`,
|
|
360
|
+
result: `The element displays exactly: "${match[1]}".`,
|
|
361
|
+
}),
|
|
362
|
+
},
|
|
363
|
+
// expect toHaveAttribute href - capture full URL including slashes
|
|
364
|
+
{
|
|
365
|
+
regex: /expect\s*\([^)]+\)\.toHaveAttribute\s*\(\s*['"`]href['"`]\s*,\s*(?:\/)?['"`]?([^'"`\)]+?)['"`]?\s*\)/gi,
|
|
366
|
+
format: (match) => {
|
|
367
|
+
const url = match[1]?.trim().replace(/^\/|\/$/g, '') || '';
|
|
368
|
+
return {
|
|
369
|
+
step: `${stepNum++}. Verify the link has an href attribute pointing to "${url}".`,
|
|
370
|
+
result: `The link's href contains or matches "${url}".`,
|
|
371
|
+
};
|
|
372
|
+
},
|
|
205
373
|
},
|
|
206
374
|
// expect toHaveURL
|
|
207
375
|
{
|
|
208
376
|
regex: /expect\s*\([^)]+\)\.toHaveURL\s*\(\s*['"`/]([^'"`/]+)/gi,
|
|
209
|
-
format: (match) => ({
|
|
377
|
+
format: (match) => ({
|
|
378
|
+
step: `${stepNum++}. Verify the browser URL matches "${cleanText(match[1])}".`,
|
|
379
|
+
result: `The URL is confirmed to contain/match "${cleanText(match[1])}".`,
|
|
380
|
+
}),
|
|
381
|
+
},
|
|
382
|
+
// page.fill or getByLabel().fill()
|
|
383
|
+
{
|
|
384
|
+
regex: /(?:getByLabel|getByPlaceholder)\s*\([^)]+\)\.fill\s*\(\s*['"`]([^'"`]+)['"`]/gi,
|
|
385
|
+
format: (match) => {
|
|
386
|
+
const labelMatch = match.input.match(/(?:getByLabel|getByPlaceholder)\s*\(\s*['"`/]([^'"`/]+)/i);
|
|
387
|
+
const label = cleanText(labelMatch?.[1]) || 'input field';
|
|
388
|
+
return {
|
|
389
|
+
step: `${stepNum++}. Locate the "${label}" input field and enter the value "${match[1]}".`,
|
|
390
|
+
result: `The value "${match[1]}" is entered in the "${label}" field.`,
|
|
391
|
+
};
|
|
392
|
+
},
|
|
210
393
|
},
|
|
211
|
-
//
|
|
394
|
+
// .click()
|
|
212
395
|
{
|
|
213
|
-
regex:
|
|
214
|
-
format: (
|
|
396
|
+
regex: /\.click\s*\(\s*\)/gi,
|
|
397
|
+
format: () => ({
|
|
398
|
+
step: `${stepNum++}. Click on the identified element.`,
|
|
399
|
+
result: 'The click action is performed successfully.',
|
|
400
|
+
}),
|
|
215
401
|
},
|
|
216
402
|
// cy.get().should()
|
|
217
403
|
{
|
|
218
404
|
regex: /cy\.get\s*\([^)]+\)\.should\s*\(\s*['"`]([^'"`]+)['"`]/gi,
|
|
219
|
-
format: (match) => ({
|
|
405
|
+
format: (match) => ({
|
|
406
|
+
step: `${stepNum++}. Verify the element state using Cypress assertion.`,
|
|
407
|
+
result: `Element assertion "${match[1]}" passes.`,
|
|
408
|
+
}),
|
|
220
409
|
},
|
|
221
410
|
];
|
|
222
411
|
|
|
@@ -239,8 +428,8 @@ export function extractStepsWithRegex(testCode, isCypress = false) {
|
|
|
239
428
|
if (steps.length === 0) {
|
|
240
429
|
const fw = isCypress ? 'Cypress' : 'Playwright';
|
|
241
430
|
steps.push({
|
|
242
|
-
step: `Execute ${
|
|
243
|
-
result: 'Test
|
|
431
|
+
step: `1. Execute the "${testTitle}" automated test using ${fw}.`,
|
|
432
|
+
result: 'Test completes successfully with all assertions verified.',
|
|
244
433
|
});
|
|
245
434
|
}
|
|
246
435
|
|
package/lib/browserstack.js
CHANGED
|
@@ -112,32 +112,91 @@ export async function updateTestCase(projectId, testCaseId, testCase, auth) {
|
|
|
112
112
|
|
|
113
113
|
/** Match existing BS test case to our local case: by title or by our TC-xxx tag */
|
|
114
114
|
function findExisting(existingList, localCase) {
|
|
115
|
-
const byTitle = existingList.find((t) => (t.title || '').trim() === (localCase.title || '').trim());
|
|
115
|
+
const byTitle = existingList.find((t) => (t.name || t.title || '').trim() === (localCase.title || '').trim());
|
|
116
116
|
if (byTitle) return byTitle;
|
|
117
117
|
const byTag = existingList.find((t) => (t.tags || []).includes(localCase.id));
|
|
118
118
|
return byTag;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Compare local test case with existing BrowserStack test case.
|
|
123
|
+
* Returns true if content has changed and update is needed.
|
|
124
|
+
*/
|
|
125
|
+
function hasContentChanged(existing, localCase) {
|
|
126
|
+
// Compare steps - this is the main content that changes
|
|
127
|
+
const existingSteps = existing.test_case_steps || [];
|
|
128
|
+
const localSteps = Array.isArray(localCase.steps) ? localCase.steps : [];
|
|
129
|
+
|
|
130
|
+
// Different number of steps = changed
|
|
131
|
+
if (existingSteps.length !== localSteps.length) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Compare each step
|
|
136
|
+
for (let i = 0; i < localSteps.length; i++) {
|
|
137
|
+
const existingStep = existingSteps[i] || {};
|
|
138
|
+
const localStep = localSteps[i] || {};
|
|
139
|
+
|
|
140
|
+
const existingStepText = (existingStep.step || '').trim();
|
|
141
|
+
const localStepText = (localStep.step || '').trim();
|
|
142
|
+
const existingResult = (existingStep.result || '').trim();
|
|
143
|
+
const localResult = (localStep.result || '').trim();
|
|
144
|
+
|
|
145
|
+
if (existingStepText !== localStepText || existingResult !== localResult) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Compare title
|
|
151
|
+
const existingTitle = (existing.name || existing.title || '').trim();
|
|
152
|
+
const localTitle = (localCase.title || '').trim();
|
|
153
|
+
if (existingTitle !== localTitle) {
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// No changes detected
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
121
161
|
export async function syncToBrowserStack(specsMap, projectId, auth) {
|
|
122
162
|
console.log('\nSyncing to BrowserStack project', projectId);
|
|
163
|
+
|
|
164
|
+
let totalCreated = 0;
|
|
165
|
+
let totalUpdated = 0;
|
|
166
|
+
let totalSkipped = 0;
|
|
167
|
+
|
|
123
168
|
for (const [baseName, { specFile, cases }] of specsMap) {
|
|
124
169
|
const folderName = baseName;
|
|
125
170
|
const folderId = await ensureFolder(projectId, folderName, null, auth);
|
|
126
171
|
const existing = await getTestCasesInFolder(projectId, folderId, auth);
|
|
127
|
-
console.log('Folder:', folderName, '(id:', folderId, ')');
|
|
172
|
+
console.log('Folder:', folderName, '(id:', folderId, ') -', cases.length, 'test(s)');
|
|
173
|
+
|
|
128
174
|
for (const tc of cases) {
|
|
129
175
|
try {
|
|
130
176
|
const found = findExisting(existing, tc);
|
|
131
177
|
if (found) {
|
|
132
|
-
|
|
133
|
-
|
|
178
|
+
// Check if content has actually changed before updating
|
|
179
|
+
if (hasContentChanged(found, tc)) {
|
|
180
|
+
await updateTestCase(projectId, found.identifier, tc, auth);
|
|
181
|
+
console.log(' ✓ Updated', tc.id, tc.title, '->', found.identifier);
|
|
182
|
+
totalUpdated++;
|
|
183
|
+
} else {
|
|
184
|
+
console.log(' ○ Skipped', tc.id, tc.title, '(no changes)');
|
|
185
|
+
totalSkipped++;
|
|
186
|
+
}
|
|
134
187
|
} else {
|
|
135
188
|
const id = await createTestCase(projectId, folderId, tc, auth);
|
|
136
|
-
console.log(' Created', tc.id, tc.title, '->', id);
|
|
189
|
+
console.log(' + Created', tc.id, tc.title, '->', id);
|
|
190
|
+
totalCreated++;
|
|
137
191
|
}
|
|
138
192
|
} catch (e) {
|
|
139
|
-
console.error(' Failed', tc.id, tc.title, e.message);
|
|
193
|
+
console.error(' ✗ Failed', tc.id, tc.title, e.message);
|
|
140
194
|
}
|
|
141
195
|
}
|
|
142
196
|
}
|
|
197
|
+
|
|
198
|
+
console.log('\nSync summary:');
|
|
199
|
+
console.log(` Created: ${totalCreated} test case(s)`);
|
|
200
|
+
console.log(` Updated: ${totalUpdated} test case(s)`);
|
|
201
|
+
console.log(` Skipped: ${totalSkipped} test case(s) (no changes)`);
|
|
143
202
|
}
|
package/lib/enrich.js
CHANGED
|
@@ -40,6 +40,46 @@ function inferTags(specBaseName, title, isCypress) {
|
|
|
40
40
|
return tags;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Count the number of expect() assertions in test code.
|
|
45
|
+
*/
|
|
46
|
+
function countAssertions(code) {
|
|
47
|
+
const expectMatches = code.match(/expect\s*\(/g) || [];
|
|
48
|
+
return expectMatches.length;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Merge AI steps with regex steps to ensure completeness.
|
|
53
|
+
* If AI generated fewer steps than assertions, supplement with unique regex steps.
|
|
54
|
+
*/
|
|
55
|
+
function mergeStepsForCompleteness(aiSteps, regexSteps, assertionCount) {
|
|
56
|
+
if (!aiSteps || aiSteps.length === 0) {
|
|
57
|
+
return regexSteps;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// If AI generated enough steps, use AI steps
|
|
61
|
+
if (aiSteps.length >= assertionCount * 0.7) { // 70% coverage threshold
|
|
62
|
+
return aiSteps;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// If AI generated too few steps, supplement with unique regex steps
|
|
66
|
+
const aiStepTexts = new Set(aiSteps.map(s => s.step.toLowerCase()));
|
|
67
|
+
const supplementalSteps = regexSteps.filter(rs => {
|
|
68
|
+
// Only add regex steps that aren't similar to existing AI steps
|
|
69
|
+
const rsLower = rs.step.toLowerCase();
|
|
70
|
+
return !Array.from(aiStepTexts).some(aiText =>
|
|
71
|
+
aiText.includes(rsLower.slice(0, 30)) || rsLower.includes(aiText.slice(0, 30))
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Combine and renumber
|
|
76
|
+
const combined = [...aiSteps, ...supplementalSteps];
|
|
77
|
+
return combined.map((step, idx) => ({
|
|
78
|
+
step: step.step.replace(/^\d+\.\s*/, `${idx + 1}. `),
|
|
79
|
+
result: step.result,
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
43
83
|
/**
|
|
44
84
|
* Enrich a single case with state, case_type, steps, expected_results, jira_issues, automation_status, tags, description.
|
|
45
85
|
* @param {Object} case_ - Test case with id, title, code
|
|
@@ -54,13 +94,19 @@ export function enrichCase(case_, specBaseName, isCypress = false, aiSteps = nul
|
|
|
54
94
|
const fw = isCypress ? FRAMEWORK.cypress : FRAMEWORK.playwright;
|
|
55
95
|
const tags = inferTags(specBaseName, title, isCypress);
|
|
56
96
|
|
|
57
|
-
//
|
|
97
|
+
// Count assertions in code for completeness check
|
|
98
|
+
const assertionCount = code ? countAssertions(code) : 0;
|
|
99
|
+
|
|
100
|
+
// Get regex-based steps as fallback/supplement
|
|
101
|
+
const regexSteps = code ? extractStepsWithRegex(code, isCypress) : [];
|
|
102
|
+
|
|
103
|
+
// Determine steps: merge AI + regex for completeness, or use regex only
|
|
58
104
|
let steps;
|
|
59
105
|
if (aiSteps && aiSteps.length > 0) {
|
|
60
|
-
steps
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
steps =
|
|
106
|
+
// Merge AI steps with regex steps to ensure we don't miss assertions
|
|
107
|
+
steps = mergeStepsForCompleteness(aiSteps, regexSteps, assertionCount);
|
|
108
|
+
} else if (regexSteps.length > 0) {
|
|
109
|
+
steps = regexSteps;
|
|
64
110
|
} else {
|
|
65
111
|
// Fallback to generic step
|
|
66
112
|
steps = [{ step: fw.step, result: DEFAULT_EXPECTED_RESULT }];
|
package/lib/index.js
CHANGED
|
@@ -38,35 +38,47 @@ export async function runSync(options = {}) {
|
|
|
38
38
|
return;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
//
|
|
41
|
+
// Filter specs BEFORE AI analysis (to avoid analyzing specs we don't need)
|
|
42
|
+
if (options.spec && options.spec.length > 0) {
|
|
43
|
+
const set = new Set(options.spec);
|
|
44
|
+
// Match by exact name, base name, or partial match
|
|
45
|
+
specsMap = new Map([...specsMap].filter(([name]) => {
|
|
46
|
+
// Check exact match
|
|
47
|
+
if (set.has(name)) return true;
|
|
48
|
+
// Check if any spec pattern matches this name
|
|
49
|
+
for (const pattern of options.spec) {
|
|
50
|
+
if (name.includes(pattern) || pattern.includes(name)) return true;
|
|
51
|
+
// Also try without extension
|
|
52
|
+
const patternBase = pattern.replace(/\.(spec|test|cy)\.(js|ts)$/i, '');
|
|
53
|
+
const nameBase = name.replace(/\.(spec|test|cy)$/i, '');
|
|
54
|
+
if (nameBase.includes(patternBase) || patternBase.includes(nameBase)) return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}));
|
|
58
|
+
if (specsMap.size === 0) {
|
|
59
|
+
console.log('No matching spec files for:', options.spec.join(', '));
|
|
60
|
+
console.log('Available specs:', [...analyzeSpecs(e2eDir).keys()].join(', '));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
} else if (!options.all && !csvOnly && options.interactive !== false && process.stdin.isTTY) {
|
|
64
|
+
// Interactive selection BEFORE AI analysis
|
|
65
|
+
const selected = await promptSpecSelection(specsMap);
|
|
66
|
+
if (selected === null) {
|
|
67
|
+
// user chose "all"
|
|
68
|
+
} else if (selected.size === 0) {
|
|
69
|
+
console.log('No spec files selected. Exiting.');
|
|
70
|
+
return;
|
|
71
|
+
} else {
|
|
72
|
+
specsMap = new Map([...specsMap].filter(([name]) => selected.has(name)));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Enrich with AI-powered step analysis (only for filtered specs)
|
|
42
77
|
await enrichSpecsMapWithAI(specsMap, {
|
|
43
78
|
useAI,
|
|
44
79
|
model: options.model,
|
|
45
80
|
});
|
|
46
81
|
|
|
47
|
-
if (!csvOnly) {
|
|
48
|
-
if (options.all) {
|
|
49
|
-
// sync all, no prompt
|
|
50
|
-
} else if (options.spec && options.spec.length > 0) {
|
|
51
|
-
const set = new Set(options.spec);
|
|
52
|
-
specsMap = new Map([...specsMap].filter(([name]) => set.has(name)));
|
|
53
|
-
if (specsMap.size === 0) {
|
|
54
|
-
console.log('No matching spec files for:', options.spec.join(', '));
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
} else if (options.interactive !== false && process.stdin.isTTY) {
|
|
58
|
-
const selected = await promptSpecSelection(specsMap);
|
|
59
|
-
if (selected === null) {
|
|
60
|
-
// user chose "all"
|
|
61
|
-
} else if (selected.size === 0) {
|
|
62
|
-
console.log('No spec files selected. Exiting.');
|
|
63
|
-
return;
|
|
64
|
-
} else {
|
|
65
|
-
specsMap = new Map([...specsMap].filter(([name]) => selected.has(name)));
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
82
|
writeCsvFiles(specsMap, csvOutputDir);
|
|
71
83
|
|
|
72
84
|
if (csvOnly) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ash-mallick/browserstack-sync",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "Sync Playwright & Cypress e2e specs to CSV and BrowserStack Test Management with FREE AI-powered test step extraction using Ollama (local)",
|
|
5
5
|
"author": "Ashutosh Mallick",
|
|
6
6
|
"type": "module",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"url": ""
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
+
"@ash-mallick/browserstack-sync": "^1.1.3",
|
|
45
46
|
"dotenv": "^16.4.5",
|
|
46
47
|
"ollama": "^0.6.3"
|
|
47
48
|
},
|