@ash-mallick/browserstack-sync 1.1.2 → 1.1.3

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,133 @@ 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 detailed manual test steps from automated test code. Your goal is to write steps so clear and detailed that ANY tester can execute the test manually by just reading them.
18
+
19
+ IMPORTANT: Output ONLY a valid JSON array. No markdown, no explanation, no extra text.
20
+
21
+ Each step must have:
22
+ - "step": A detailed, numbered action description. Include:
23
+ - Exact UI element to interact with (button name, field label, section title)
24
+ - Exact text/values to enter or look for
25
+ - Location hints (e.g., "in the header", "in the Solutions section", "at the top of the page")
26
+ - "result": The specific expected outcome that can be visually verified. Include:
27
+ - Exact text that should be displayed
28
+ - UI state changes (visible, enabled, selected, etc.)
29
+ - URL changes if applicable
30
+
31
+ RULES FOR DETAILED STEPS:
32
+ 1. NAVIGATION: "Open the browser and navigate to [exact URL]. Wait for the page to fully load."
33
+ 2. VISIBILITY CHECK: "Locate the [section name] section on the page. Verify it is visible and contains the text '[exact text]'."
34
+ 3. ELEMENT VERIFICATION: "In the [section name], find the [element type] with text '[text]'. Verify it is displayed."
35
+ 4. LINK VALIDATION: "Find the '[link text]' link in the [location]. Verify it has href pointing to '[URL pattern]'."
36
+ 5. BUTTON CHECK: "Locate the '[button text]' button in the [section]. Verify it is visible and clickable."
37
+ 6. CARD/TILE VALIDATION: "In the [section], locate the first card/tile. Verify it displays: title, image, and description."
38
+ 7. SCROLL ACTION: "Scroll down to the [section name] section to bring it into view."
39
+
40
+ EXAMPLE OUTPUT:
34
41
  [
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
- ]`;
42
+ {"step": "1. Open the browser and navigate to the application homepage (base URL + '/'). Wait for the page to fully load.", "result": "The homepage loads successfully. The masthead banner is visible at the top of the page."},
43
+ {"step": "2. Locate the masthead section at the top of the page. Verify the main heading is displayed.", "result": "The heading 'Accelerate AI with an open ecosystem' is visible in the masthead."},
44
+ {"step": "3. In the masthead section, find the 'Explore our AI ecosystem' call-to-action link.", "result": "The 'Explore our AI ecosystem' link is visible and has an href containing '/ai'."},
45
+ {"step": "4. Scroll down to the 'Solutions' section. Verify the section headline is displayed.", "result": "The Solutions section is visible with headline 'Find the solution you're looking for, in less time'."},
46
+ {"step": "5. In the Solutions section, locate the product slider/carousel. Verify the first product card is displayed.", "result": "The first product card shows: product title, product image, and product description."}
47
+ ]
48
+
49
+ IMPORTANT:
50
+ - Number each step sequentially (1, 2, 3...)
51
+ - Be specific about WHAT to look for and WHERE
52
+ - Include exact text strings from the code in quotes
53
+ - Make expected results measurable and verifiable
54
+ - Output ONLY the JSON array, nothing else`;
40
55
 
41
56
  /**
42
57
  * Build the user prompt for AI analysis.
43
58
  */
44
59
  function buildUserPrompt(testTitle, testCode) {
45
- return `Analyze this Playwright/Cypress test and extract the test steps:
60
+ return `Create detailed manual test steps from this automated test. A QA tester should be able to execute this test manually by reading your steps.
46
61
 
47
- Test Title: ${testTitle}
62
+ TEST NAME: "${testTitle}"
48
63
 
49
- Test Code:
50
- \`\`\`
64
+ AUTOMATED TEST CODE:
65
+ \`\`\`javascript
51
66
  ${testCode}
52
67
  \`\`\`
53
68
 
54
- Return a JSON array of steps with "step" and "result" fields.`;
69
+ ANALYZE THE CODE CAREFULLY:
70
+ 1. Look at page.goto() calls to identify which page/URL to navigate to
71
+ 2. Look at getByTestId(), locator(), getByRole(), getByLabel() to identify UI elements
72
+ 3. Look at expect() assertions to understand what needs to be verified
73
+ 4. Look at .toContainText(), .toHaveText(), .toBeVisible() for expected text/state
74
+ 5. Look at .toHaveAttribute('href', ...) for link URL validations
75
+ 6. Identify ALL assertions and convert them to expected results
76
+
77
+ CREATE COMPREHENSIVE STEPS:
78
+ - Include EVERY action from the test code
79
+ - Include EVERY assertion/verification from the test code
80
+ - Use exact text strings from .toContainText() and .toHaveText() in your expected results
81
+ - Number steps sequentially (1, 2, 3...)
82
+ - Make each step detailed enough for a manual tester
83
+
84
+ OUTPUT: Return ONLY a valid JSON array, no other text.`;
85
+ }
86
+
87
+ /**
88
+ * Attempt to repair truncated JSON array.
89
+ * Handles cases where the response was cut off mid-array.
90
+ */
91
+ function repairTruncatedJSON(jsonStr) {
92
+ // First, try parsing as-is
93
+ try {
94
+ return JSON.parse(jsonStr);
95
+ } catch (_) {
96
+ // Continue with repair attempts
97
+ }
98
+
99
+ // Try to find the last complete object in the array
100
+ // Pattern: find all complete {"step": "...", "result": "..."} objects
101
+ const objectPattern = /\{\s*"step"\s*:\s*"(?:[^"\\]|\\.)*"\s*,\s*"result"\s*:\s*"(?:[^"\\]|\\.)*"\s*\}/g;
102
+ const matches = jsonStr.match(objectPattern);
103
+
104
+ if (matches && matches.length > 0) {
105
+ // Reconstruct a valid JSON array from complete objects
106
+ const reconstructed = '[' + matches.join(',') + ']';
107
+ try {
108
+ return JSON.parse(reconstructed);
109
+ } catch (_) {
110
+ // Fall through
111
+ }
112
+ }
113
+
114
+ // Try removing the last incomplete object and close the array
115
+ let trimmed = jsonStr.trim();
116
+
117
+ // If it starts with [, try to close it properly
118
+ if (trimmed.startsWith('[')) {
119
+ // Find the last complete }, before truncation
120
+ const lastCompleteObj = trimmed.lastIndexOf('},');
121
+ if (lastCompleteObj > 0) {
122
+ const fixedJson = trimmed.slice(0, lastCompleteObj + 1) + ']';
123
+ try {
124
+ return JSON.parse(fixedJson);
125
+ } catch (_) {
126
+ // Fall through
127
+ }
128
+ }
129
+
130
+ // Try finding the last }
131
+ const lastBrace = trimmed.lastIndexOf('}');
132
+ if (lastBrace > 0) {
133
+ const fixedJson = trimmed.slice(0, lastBrace + 1) + ']';
134
+ try {
135
+ return JSON.parse(fixedJson);
136
+ } catch (_) {
137
+ // Fall through
138
+ }
139
+ }
140
+ }
141
+
142
+ // Last resort: throw the original error
143
+ return JSON.parse(jsonStr);
55
144
  }
56
145
 
57
146
  /**
@@ -71,15 +160,22 @@ function parseAIResponse(content) {
71
160
 
72
161
  // Also try to extract JSON array from anywhere in the response
73
162
  if (!jsonStr.startsWith('[')) {
74
- const arrayMatch = jsonStr.match(/\[[\s\S]*\]/);
163
+ const arrayMatch = jsonStr.match(/\[[\s\S]*?\](?=[^}\]]*$)?/);
75
164
  if (arrayMatch) {
76
165
  jsonStr = arrayMatch[0];
77
166
  }
78
167
  }
79
168
 
169
+ // Try to find just the array portion if there's extra text
170
+ const arrayStart = jsonStr.indexOf('[');
171
+ if (arrayStart > 0) {
172
+ jsonStr = jsonStr.slice(arrayStart);
173
+ }
174
+
80
175
  let steps;
81
176
  try {
82
- steps = JSON.parse(jsonStr);
177
+ // Use repair function to handle truncated responses
178
+ steps = repairTruncatedJSON(jsonStr);
83
179
  } catch (parseError) {
84
180
  throw new Error(`Failed to parse AI response as JSON: ${parseError.message}. Response: ${content.slice(0, 200)}`);
85
181
  }
@@ -88,10 +184,13 @@ function parseAIResponse(content) {
88
184
  throw new Error('AI response is not an array');
89
185
  }
90
186
 
91
- return steps.map((s) => ({
92
- step: String(s.step || ''),
93
- result: String(s.result || ''),
94
- }));
187
+ // Filter out any empty or invalid steps
188
+ return steps
189
+ .filter((s) => s && (s.step || s.result))
190
+ .map((s) => ({
191
+ step: String(s.step || '').trim(),
192
+ result: String(s.result || '').trim(),
193
+ }));
95
194
  }
96
195
 
97
196
  /**
@@ -136,7 +235,9 @@ async function analyzeWithOllama(testTitle, testCode, model) {
136
235
  { role: 'user', content: buildUserPrompt(testTitle, testCode) },
137
236
  ],
138
237
  options: {
139
- temperature: 0.2,
238
+ temperature: 0.1, // Lower temperature for more consistent, focused output
239
+ num_predict: 8192, // Increased max tokens for detailed step descriptions
240
+ num_ctx: 8192, // Context window for longer test code input
140
241
  },
141
242
  });
142
243
 
@@ -176,47 +277,131 @@ export async function analyzeTestWithAI(testTitle, testCode, apiKey, options = {
176
277
  */
177
278
  export function extractStepsWithRegex(testCode, isCypress = false) {
178
279
  const steps = [];
280
+ let stepNum = 1;
179
281
 
180
- // Common Playwright patterns
282
+ // Helper to clean up regex patterns from text
283
+ const cleanText = (text) => text?.replace(/\\?\//gi, '').replace(/\^|\$/g, '').trim() || '';
284
+
285
+ // Common Playwright patterns - order matters for sequential extraction
181
286
  const patterns = [
182
287
  // page.goto or cy.visit
183
288
  {
184
- regex: /(?:page\.goto|cy\.visit)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
185
- format: (match) => ({ step: `Navigate to ${match[1]}`, result: 'Page loads successfully' }),
289
+ regex: /(?:page\.goto|cy\.visit)\s*\(\s*(?:process\.env\.[A-Z_]+\s*\+\s*)?['"`]([^'"`]+)['"`]/gi,
290
+ format: (match) => ({
291
+ step: `${stepNum++}. Open the browser and navigate to the URL: ${match[1] || '/'}. Wait for the page to fully load.`,
292
+ result: 'The page loads successfully and all elements are visible.',
293
+ }),
186
294
  },
187
- // page.fill or cy.get().type() with getByLabel
295
+ // getByTestId - identify section
188
296
  {
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
- },
297
+ regex: /(?:page\.|await\s+)getByTestId\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/gi,
298
+ format: (match) => ({
299
+ step: `${stepNum++}. Locate the section/component with test ID "${match[1]}" on the page.`,
300
+ result: `The "${match[1]}" section is found and visible on the page.`,
301
+ }),
195
302
  },
196
- // page.click or getByRole().click()
303
+ // locator with class or selector
197
304
  {
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` }),
305
+ regex: /\.locator\s*\(\s*['"`]\.([^'"`]+)['"`]\s*\)(?:\.first\(\))?/gi,
306
+ format: (match) => ({
307
+ step: `${stepNum++}. Within the current section, find the element with class "${match[1]}".`,
308
+ result: `Element with class "${match[1]}" is located.`,
309
+ }),
310
+ },
311
+ // getByRole with name - link
312
+ {
313
+ regex: /getByRole\s*\(\s*['"`]link['"`]\s*,\s*\{[^}]*name:\s*['"`/]([^'"`/]+)/gi,
314
+ format: (match) => ({
315
+ step: `${stepNum++}. Find the link labeled "${cleanText(match[1])}" on the page.`,
316
+ result: `The "${cleanText(match[1])}" link is visible and clickable.`,
317
+ }),
318
+ },
319
+ // getByRole with name - button
320
+ {
321
+ regex: /getByRole\s*\(\s*['"`]button['"`]\s*,\s*\{[^}]*name:\s*['"`/]([^'"`/]+)/gi,
322
+ format: (match) => ({
323
+ step: `${stepNum++}. Locate the button labeled "${cleanText(match[1])}" on the page.`,
324
+ result: `The "${cleanText(match[1])}" button is displayed and enabled.`,
325
+ }),
326
+ },
327
+ // scrollIntoViewIfNeeded
328
+ {
329
+ regex: /scrollIntoViewIfNeeded\s*\(\s*\)/gi,
330
+ format: () => ({
331
+ step: `${stepNum++}. Scroll the page until the target element is visible in the viewport.`,
332
+ result: 'The element scrolls into view successfully.',
333
+ }),
200
334
  },
201
335
  // expect toBeVisible
202
336
  {
203
- regex: /expect\s*\([^)]+\)\.toBeVisible\s*\(\s*\)/gi,
204
- format: () => ({ step: 'Verify element is visible', result: 'Element is visible on the page' }),
337
+ regex: /expect\s*\([^)]+\)\.toBeVisible\s*\(/gi,
338
+ format: () => ({
339
+ step: `${stepNum++}. Verify that the element is visible on the page.`,
340
+ result: 'The element is confirmed to be visible and displayed.',
341
+ }),
342
+ },
343
+ // expect toContainText with text
344
+ {
345
+ regex: /expect\s*\([^)]+\)\.toContainText\s*\(\s*['"`]([^'"`]+)['"`]/gi,
346
+ format: (match) => ({
347
+ step: `${stepNum++}. Verify the element contains the text "${match[1]}".`,
348
+ result: `The text "${match[1]}" is displayed within the element.`,
349
+ }),
350
+ },
351
+ // expect toHaveText with text
352
+ {
353
+ regex: /expect\s*\([^)]+\)\.toHaveText\s*\(\s*['"`]([^'"`]+)['"`]/gi,
354
+ format: (match) => ({
355
+ step: `${stepNum++}. Verify the element has the exact text "${match[1]}".`,
356
+ result: `The element displays exactly: "${match[1]}".`,
357
+ }),
358
+ },
359
+ // expect toHaveAttribute href - capture full URL including slashes
360
+ {
361
+ regex: /expect\s*\([^)]+\)\.toHaveAttribute\s*\(\s*['"`]href['"`]\s*,\s*(?:\/)?['"`]?([^'"`\)]+?)['"`]?\s*\)/gi,
362
+ format: (match) => {
363
+ const url = match[1]?.trim().replace(/^\/|\/$/g, '') || '';
364
+ return {
365
+ step: `${stepNum++}. Verify the link has an href attribute pointing to "${url}".`,
366
+ result: `The link's href contains or matches "${url}".`,
367
+ };
368
+ },
205
369
  },
206
370
  // expect toHaveURL
207
371
  {
208
372
  regex: /expect\s*\([^)]+\)\.toHaveURL\s*\(\s*['"`/]([^'"`/]+)/gi,
209
- format: (match) => ({ step: 'Verify URL', result: `URL matches ${match[1]}` }),
373
+ format: (match) => ({
374
+ step: `${stepNum++}. Verify the browser URL matches "${cleanText(match[1])}".`,
375
+ result: `The URL is confirmed to contain/match "${cleanText(match[1])}".`,
376
+ }),
377
+ },
378
+ // page.fill or getByLabel().fill()
379
+ {
380
+ regex: /(?:getByLabel|getByPlaceholder)\s*\([^)]+\)\.fill\s*\(\s*['"`]([^'"`]+)['"`]/gi,
381
+ format: (match) => {
382
+ const labelMatch = match.input.match(/(?:getByLabel|getByPlaceholder)\s*\(\s*['"`/]([^'"`/]+)/i);
383
+ const label = cleanText(labelMatch?.[1]) || 'input field';
384
+ return {
385
+ step: `${stepNum++}. Locate the "${label}" input field and enter the value "${match[1]}".`,
386
+ result: `The value "${match[1]}" is entered in the "${label}" field.`,
387
+ };
388
+ },
210
389
  },
211
- // expect toHaveText or toContainText
390
+ // .click()
212
391
  {
213
- regex: /expect\s*\([^)]+\)\.(?:toHaveText|toContainText)\s*\(\s*['"`/]([^'"`/]+)/gi,
214
- format: (match) => ({ step: 'Verify text content', result: `Text '${match[1]}' is displayed` }),
392
+ regex: /\.click\s*\(\s*\)/gi,
393
+ format: () => ({
394
+ step: `${stepNum++}. Click on the identified element.`,
395
+ result: 'The click action is performed successfully.',
396
+ }),
215
397
  },
216
398
  // cy.get().should()
217
399
  {
218
400
  regex: /cy\.get\s*\([^)]+\)\.should\s*\(\s*['"`]([^'"`]+)['"`]/gi,
219
- format: (match) => ({ step: 'Verify element state', result: `Element should ${match[1]}` }),
401
+ format: (match) => ({
402
+ step: `${stepNum++}. Verify the element state using Cypress assertion.`,
403
+ result: `Element assertion "${match[1]}" passes.`,
404
+ }),
220
405
  },
221
406
  ];
222
407
 
@@ -239,8 +424,8 @@ export function extractStepsWithRegex(testCode, isCypress = false) {
239
424
  if (steps.length === 0) {
240
425
  const fw = isCypress ? 'Cypress' : 'Playwright';
241
426
  steps.push({
242
- step: `Execute ${fw} automated test`,
243
- result: 'Test passes with all assertions verified',
427
+ step: `1. Execute the "${testTitle}" automated test using ${fw}.`,
428
+ result: 'Test completes successfully with all assertions verified.',
244
429
  });
245
430
  }
246
431
 
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.3",
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",