@ash-mallick/browserstack-sync 1.1.3 → 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 +61 -57
- package/lib/browserstack.js +65 -6
- package/lib/enrich.js +51 -5
- package/package.json +2 -1
package/lib/ai-analyzer.js
CHANGED
|
@@ -14,74 +14,78 @@ 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 QA expert creating
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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:
|
|
41
48
|
[
|
|
42
|
-
{"step": "1.
|
|
43
|
-
{"step": "2.
|
|
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."}
|
|
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'."}
|
|
47
51
|
]
|
|
48
52
|
|
|
49
|
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
-
|
|
53
|
-
-
|
|
54
|
-
- Output ONLY the JSON array, nothing else`;
|
|
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`;
|
|
55
58
|
|
|
56
59
|
/**
|
|
57
60
|
* Build the user prompt for AI analysis.
|
|
58
61
|
*/
|
|
59
62
|
function buildUserPrompt(testTitle, testCode) {
|
|
60
|
-
return `
|
|
63
|
+
return `Convert this automated test into manual test steps.
|
|
61
64
|
|
|
62
|
-
TEST
|
|
65
|
+
TEST: "${testTitle}"
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
\`\`\`javascript
|
|
67
|
+
CODE:
|
|
66
68
|
${testCode}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
69
|
+
|
|
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.`;
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
/**
|
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/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
|
},
|