@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.
@@ -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 test automation expert. Your job is to analyze Playwright or Cypress test code and extract human-readable test steps.
18
-
19
- For each test, output a JSON array of steps. Each step should have:
20
- - "step": A clear, human-readable action (e.g., "Navigate to /login page", "Enter 'test@example.com' in the email field")
21
- - "result": The expected result or assertion for this step (e.g., "Login form is displayed", "Error message 'Invalid credentials' appears")
22
-
23
- Rules:
24
- 1. Convert code actions to plain English that a manual tester could follow
25
- 2. For page.goto(url) -> "Navigate to <url>"
26
- 3. For page.fill(selector, value) or getByLabel().fill() -> "Enter '<value>' in the <field name> field"
27
- 4. For page.click() or getByRole().click() -> "Click the '<button/link name>' button/link"
28
- 5. For expect() assertions -> This becomes the "result" of the previous step
29
- 6. If there's no explicit assertion, the result should describe the expected state
30
- 7. Be concise but clear
31
- 8. Group related actions if they form one logical step
32
-
33
- Output ONLY valid JSON array, no markdown, no explanation. Example:
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": "Navigate to /login page", "result": "Login page loads successfully"},
36
- {"step": "Enter 'user@test.com' in the Email field", "result": "Email is entered"},
37
- {"step": "Enter 'password123' in the Password field", "result": "Password is masked and entered"},
38
- {"step": "Click the 'Sign In' button", "result": "User is redirected to /dashboard"}
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 `Analyze this Playwright/Cypress test and extract the test steps:
63
+ return `Convert this automated test into manual test steps.
46
64
 
47
- Test Title: ${testTitle}
65
+ TEST: "${testTitle}"
48
66
 
49
- Test Code:
50
- \`\`\`
67
+ CODE:
51
68
  ${testCode}
52
- \`\`\`
53
69
 
54
- Return a JSON array of steps with "step" and "result" fields.`;
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
- steps = JSON.parse(jsonStr);
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
- return steps.map((s) => ({
92
- step: String(s.step || ''),
93
- result: String(s.result || ''),
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.2,
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
- // Common Playwright patterns
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) => ({ step: `Navigate to ${match[1]}`, result: 'Page loads successfully' }),
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
- // page.fill or cy.get().type() with getByLabel
299
+ // getByTestId - identify section
188
300
  {
189
- regex: /(?:getByLabel|getByPlaceholder)\s*\([^)]+\)\.fill\s*\(\s*['"`]([^'"`]+)['"`]/gi,
190
- format: (match) => {
191
- const labelMatch = match.input.match(/(?:getByLabel|getByPlaceholder)\s*\(\s*['"`/]([^'"`/]+)/i);
192
- const label = labelMatch ? labelMatch[1].replace(/\\?\/i$/, '') : 'field';
193
- return { step: `Enter '${match[1]}' in the ${label} field`, result: 'Value is entered' };
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
- // page.click or getByRole().click()
331
+ // scrollIntoViewIfNeeded
197
332
  {
198
- regex: /getByRole\s*\(\s*['"`]([^'"`]+)['"`]\s*,\s*\{[^}]*name:\s*['"`/]([^'"`/]+)/gi,
199
- format: (match) => ({ step: `Click the '${match[2].replace(/\\?\/i$/, '')}' ${match[1]}`, result: `${match[1]} is clicked` }),
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*\(\s*\)/gi,
204
- format: () => ({ step: 'Verify element is visible', result: 'Element is visible on the page' }),
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) => ({ step: 'Verify URL', result: `URL matches ${match[1]}` }),
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
- // expect toHaveText or toContainText
394
+ // .click()
212
395
  {
213
- regex: /expect\s*\([^)]+\)\.(?:toHaveText|toContainText)\s*\(\s*['"`/]([^'"`/]+)/gi,
214
- format: (match) => ({ step: 'Verify text content', result: `Text '${match[1]}' is displayed` }),
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) => ({ step: 'Verify element state', result: `Element should ${match[1]}` }),
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 ${fw} automated test`,
243
- result: 'Test passes with all assertions verified',
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
 
@@ -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
- await updateTestCase(projectId, found.identifier, tc, auth);
133
- console.log(' Updated', tc.id, tc.title, '->', found.identifier);
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
- // Determine steps: AI steps > regex-extracted steps > default
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 = aiSteps;
61
- } else if (code) {
62
- // Try regex-based extraction from code
63
- steps = extractStepsWithRegex(code, isCypress);
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
- // Enrich with AI-powered step analysis (falls back to regex if no API key)
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.2",
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
  },