ado-sync 0.1.30 → 0.1.32

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.
@@ -0,0 +1,789 @@
1
+ "use strict";
2
+ /**
3
+ * Auto-summary generator for test cases that have no doc comment.
4
+ *
5
+ * Providers:
6
+ *
7
+ * local — Node.js-native LLM via node-llama-cpp (bundled dependency).
8
+ * Runs GGUF models directly in-process; no external server.
9
+ * Requires a GGUF model file path supplied via `model`.
10
+ * Falls back to heuristic if no model path is provided.
11
+ * Recommended model: Qwen2.5-Coder-1.5B-Instruct-Q4_K_M.gguf
12
+ *
13
+ * heuristic — regex pattern matching against the test body; zero
14
+ * dependencies, works fully offline.
15
+ *
16
+ * ollama — local LLM via Ollama REST API (http://localhost:11434).
17
+ * Model suggestion: qwen2.5-coder:7b.
18
+ *
19
+ * openai — OpenAI Chat Completions API (requires API key).
20
+ *
21
+ * anthropic — Anthropic Messages API (requires API key).
22
+ *
23
+ * Usage (engine.ts / CLI):
24
+ * const { title, description, steps } = await summarizeTest(test, localType, {
25
+ * provider: 'local',
26
+ * model: '/path/to/qwen2.5-coder-1.5b-instruct-q4_k_m.gguf',
27
+ * });
28
+ * test.title = title;
29
+ * test.description = description;
30
+ * test.steps = steps;
31
+ */
32
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
33
+ if (k2 === undefined) k2 = k;
34
+ var desc = Object.getOwnPropertyDescriptor(m, k);
35
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
36
+ desc = { enumerable: true, get: function() { return m[k]; } };
37
+ }
38
+ Object.defineProperty(o, k2, desc);
39
+ }) : (function(o, m, k, k2) {
40
+ if (k2 === undefined) k2 = k;
41
+ o[k2] = m[k];
42
+ }));
43
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
44
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
45
+ }) : function(o, v) {
46
+ o["default"] = v;
47
+ });
48
+ var __importStar = (this && this.__importStar) || (function () {
49
+ var ownKeys = function(o) {
50
+ ownKeys = Object.getOwnPropertyNames || function (o) {
51
+ var ar = [];
52
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
53
+ return ar;
54
+ };
55
+ return ownKeys(o);
56
+ };
57
+ return function (mod) {
58
+ if (mod && mod.__esModule) return mod;
59
+ var result = {};
60
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
61
+ __setModuleDefault(result, mod);
62
+ return result;
63
+ };
64
+ })();
65
+ Object.defineProperty(exports, "__esModule", { value: true });
66
+ exports.extractFunctionBody = extractFunctionBody;
67
+ exports.heuristicSummary = heuristicSummary;
68
+ exports.summarizeTest = summarizeTest;
69
+ const fs = __importStar(require("fs"));
70
+ // ─── Function body extraction ─────────────────────────────────────────────────
71
+ /**
72
+ * Read the source file and return the raw text of the test function body,
73
+ * starting from the marker line (1-based).
74
+ *
75
+ * For Python: collects lines indented past the `def` line.
76
+ * For all others: collects until balanced braces close.
77
+ */
78
+ function extractFunctionBody(filePath, markerLine, localType) {
79
+ const source = fs.readFileSync(filePath, 'utf8');
80
+ const lines = source.split('\n');
81
+ const markerIdx = markerLine - 1; // 0-based
82
+ return localType === 'python'
83
+ ? extractPythonBody(lines, markerIdx)
84
+ : extractBraceBody(lines, markerIdx);
85
+ }
86
+ function extractPythonBody(lines, markerIdx) {
87
+ const defLine = lines[markerIdx] ?? '';
88
+ const defIndent = (defLine.match(/^(\s*)/) ?? ['', ''])[1].length;
89
+ const body = [defLine];
90
+ for (let i = markerIdx + 1; i < lines.length && i < markerIdx + 80; i++) {
91
+ const line = lines[i];
92
+ const trimmed = line.trim();
93
+ if (trimmed === '') {
94
+ body.push(line);
95
+ continue;
96
+ }
97
+ const indent = (line.match(/^(\s*)/) ?? ['', ''])[1].length;
98
+ if (indent <= defIndent)
99
+ break;
100
+ body.push(line);
101
+ }
102
+ return body.join('\n');
103
+ }
104
+ function extractBraceBody(lines, markerIdx) {
105
+ const body = [];
106
+ let braceDepth = 0;
107
+ let started = false;
108
+ for (let i = markerIdx; i < lines.length && i < markerIdx + 100; i++) {
109
+ const line = lines[i];
110
+ body.push(line);
111
+ for (const ch of line) {
112
+ if (ch === '{') {
113
+ braceDepth++;
114
+ started = true;
115
+ }
116
+ else if (ch === '}') {
117
+ braceDepth--;
118
+ }
119
+ }
120
+ if (started && braceDepth === 0)
121
+ break;
122
+ }
123
+ return body.join('\n');
124
+ }
125
+ // ─── Selector prettifier ──────────────────────────────────────────────────────
126
+ function prettifySelector(selector) {
127
+ return (selector
128
+ .replace(/\[data-test=['"]([^'"]+)['"]\]/, '$1')
129
+ .replace(/\[data-testid=['"]([^'"]+)['"]\]/, '$1')
130
+ .replace(/^[#.]/, '')
131
+ .replace(/[-_]/g, ' ')
132
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
133
+ .toLowerCase()
134
+ .trim() || selector);
135
+ }
136
+ const PATTERNS = [
137
+ // ── Navigate ──────────────────────────────────────────────────────────────
138
+ {
139
+ regex: /(?:page\.goto|browser\.url|driver\.get|d\.get|\.GoToUrl|Navigate\(\)\.GoToUrl)\(\s*(['"`])(https?:\/\/[^'"` ]+)\1/,
140
+ toStep: (m) => ({ isCheck: false, text: `Navigate to ${m[2]}` }),
141
+ },
142
+ {
143
+ regex: /(?:page\.goto|browser\.url|driver\.get)\(\s*BASE_URL\s*\+?\s*(['"`])?([^'"`)\s]*)\1?/,
144
+ toStep: (m) => ({ isCheck: false, text: `Navigate to ${m[2] ? 'BASE_URL' + m[2] : 'the application'}` }),
145
+ },
146
+ // ── Fill / type ───────────────────────────────────────────────────────────
147
+ {
148
+ // page.fill('#selector', 'value')
149
+ regex: /(?:page\.fill|\.fill)\(\s*(['"`])([^'"` ]+)\1\s*,\s*(['"`])([^'"` ]*)\3/,
150
+ toStep: (m) => ({ isCheck: false, text: `Enter "${m[4]}" into the ${prettifySelector(m[2])} field` }),
151
+ },
152
+ {
153
+ // $(...).setValue('value') — WebdriverIO
154
+ regex: /\$\(\s*(['"`])([^'"` ]+)\1\s*\)\.setValue\(\s*(['"`])([^'"` ]*)\3/,
155
+ toStep: (m) => ({ isCheck: false, text: `Enter "${m[4]}" into the ${prettifySelector(m[2])} field` }),
156
+ },
157
+ {
158
+ // .sendKeys('value') — Selenium Java/JS
159
+ regex: /\.sendKeys\(\s*(['"`])([^'"` ]+)\1/,
160
+ toStep: (m) => ({ isCheck: false, text: `Type "${m[2]}"` }),
161
+ },
162
+ {
163
+ // .SendKeys("value") — C# Selenium
164
+ regex: /\.SendKeys\(\s*(['"`])([^'"` ]+)\1/,
165
+ toStep: (m) => ({ isCheck: false, text: `Type "${m[2]}"` }),
166
+ },
167
+ {
168
+ // .send_keys("value") — Python Selenium
169
+ regex: /\.send_keys\(\s*(['"`])([^'"` ]+)\1/,
170
+ toStep: (m) => ({ isCheck: false, text: `Type "${m[2]}"` }),
171
+ },
172
+ // ── Select ────────────────────────────────────────────────────────────────
173
+ {
174
+ regex: /\.selectOption\(\s*(['"`])([^'"` ]+)\1\s*,\s*(['"`])([^'"` ]+)\3/,
175
+ toStep: (m) => ({ isCheck: false, text: `Select "${m[4]}" from the ${prettifySelector(m[2])} dropdown` }),
176
+ },
177
+ {
178
+ regex: /\.selectByAttribute\(['"`][^'"` ]+['"`]\s*,\s*(['"`])([^'"` ]+)\1/,
179
+ toStep: (m) => ({ isCheck: false, text: `Select option "${m[2]}"` }),
180
+ },
181
+ // ── Click ─────────────────────────────────────────────────────────────────
182
+ {
183
+ // page.click('#selector')
184
+ regex: /page\.click\(\s*(['"`])([^'"` ]+)\1/,
185
+ toStep: (m) => ({ isCheck: false, text: `Click the ${prettifySelector(m[2])}` }),
186
+ },
187
+ {
188
+ // $(...).click() — WebdriverIO
189
+ regex: /\$\(\s*(['"`])([^'"` ]+)\1\s*\)\.click\(\)/,
190
+ toStep: (m) => ({ isCheck: false, text: `Click the ${prettifySelector(m[2])}` }),
191
+ },
192
+ {
193
+ // page.locator('...').click()
194
+ regex: /\.locator\(\s*(['"`])([^'"` ]+)\1\s*\)\.click\(\)/,
195
+ toStep: (m) => ({ isCheck: false, text: `Click the ${prettifySelector(m[2])}` }),
196
+ },
197
+ {
198
+ // driver.findElement(By.id("x")).click() — Selenium Java/JS
199
+ regex: /findElement\(By\.\w+\(\s*(['"`])([^'"` ]+)\1\s*\)\)\.click\(\)/,
200
+ toStep: (m) => ({ isCheck: false, text: `Click the ${prettifySelector(m[2])}` }),
201
+ },
202
+ {
203
+ // .FindElement(By.Id("x")).Click() — C# Selenium
204
+ regex: /FindElement\(By\.\w+\(\s*(['"`])([^'"` ]+)\1\s*\)\)\.Click\(\)/,
205
+ toStep: (m) => ({ isCheck: false, text: `Click the ${prettifySelector(m[2])}` }),
206
+ },
207
+ {
208
+ // .find_element(By.ID, "x").click() — Python Selenium
209
+ regex: /find_element\(By\.\w+,\s*(['"`])([^'"` ]+)\1\s*\)\.click\(\)/,
210
+ toStep: (m) => ({ isCheck: false, text: `Click the ${prettifySelector(m[2])}` }),
211
+ },
212
+ {
213
+ // .click() on a locator line — Playwright generic
214
+ regex: /\.locator\(\s*(['"`])([^'"` ]+)\1\s*\)\s*\.\s*click/,
215
+ toStep: (m) => ({ isCheck: false, text: `Click the ${prettifySelector(m[2])}` }),
216
+ },
217
+ // ── Wait / navigation ─────────────────────────────────────────────────────
218
+ {
219
+ regex: /(?:waitForURL|waitUntilUrlContains|wait\(until\.urlContains)\(\s*\/([^/]+)\//,
220
+ toStep: (m) => ({ isCheck: false, text: `Wait for URL to contain "${m[1]}"` }),
221
+ },
222
+ // ── Cypress commands ──────────────────────────────────────────────────────
223
+ {
224
+ // cy.visit('url')
225
+ regex: /cy\.visit\(\s*(['"`])([^'"` ]+)\1/,
226
+ toStep: (m) => ({ isCheck: false, text: `Navigate to ${m[2]}` }),
227
+ },
228
+ {
229
+ // cy.get('#selector').click()
230
+ regex: /cy\.get\(\s*(['"`])([^'"` ]+)\1\s*\)(?:\.[^(]+\(\s*['"`][^'"` ]*['"`]\s*\))*\.click\(\)/,
231
+ toStep: (m) => ({ isCheck: false, text: `Click the ${prettifySelector(m[2])}` }),
232
+ },
233
+ {
234
+ // cy.get('#selector').type('value')
235
+ regex: /cy\.get\(\s*(['"`])([^'"` ]+)\1\s*\)\.type\(\s*(['"`])([^'"` ]*)\3/,
236
+ toStep: (m) => ({ isCheck: false, text: `Enter "${m[4]}" into the ${prettifySelector(m[2])} field` }),
237
+ },
238
+ {
239
+ // cy.get('#selector').select('option')
240
+ regex: /cy\.get\(\s*(['"`])([^'"` ]+)\1\s*\)\.select\(\s*(['"`])([^'"` ]+)\3/,
241
+ toStep: (m) => ({ isCheck: false, text: `Select "${m[4]}" from the ${prettifySelector(m[2])} dropdown` }),
242
+ },
243
+ {
244
+ // cy.get('#selector').should('have.text', 'value')
245
+ regex: /cy\.get\(\s*(['"`])([^'"` ]+)\1\s*\)\.should\(\s*['"`]have\.text['"`]\s*,\s*(['"`])([^'"` ]+)\3/,
246
+ toStep: (m) => ({ isCheck: true, text: `text of ${prettifySelector(m[2])} equals "${m[4]}"` }),
247
+ },
248
+ {
249
+ // cy.get('#selector').should('contain', 'value') / contain.text
250
+ regex: /cy\.get\(\s*(['"`])([^'"` ]+)\1\s*\)\.should\(\s*['"`](?:contain|contain\.text)['"`]\s*,\s*(['"`])([^'"` ]+)\3/,
251
+ toStep: (m) => ({ isCheck: true, text: `${prettifySelector(m[2])} contains "${m[4]}"` }),
252
+ },
253
+ {
254
+ // cy.get('#selector').should('be.visible')
255
+ regex: /cy\.get\(\s*(['"`])([^'"` ]+)\1\s*\)\.should\(\s*['"`]be\.visible['"`]/,
256
+ toStep: (m) => ({ isCheck: true, text: `${prettifySelector(m[2])} is visible` }),
257
+ },
258
+ {
259
+ // cy.url().should('include', 'path') or cy.url().should('contain', 'path')
260
+ regex: /cy\.url\(\s*\)\.should\(\s*['"`](?:include|contain)['"`]\s*,\s*(['"`])([^'"` ]+)\1/,
261
+ toStep: (m) => ({ isCheck: true, text: `URL contains "${m[2]}"` }),
262
+ },
263
+ {
264
+ // cy.url().should('equal', 'url')
265
+ regex: /cy\.url\(\s*\)\.should\(\s*['"`]equal['"`]\s*,\s*(['"`])([^'"` ]+)\1/,
266
+ toStep: (m) => ({ isCheck: true, text: `URL equals "${m[2]}"` }),
267
+ },
268
+ {
269
+ // cy.contains('text') — broad text assertion
270
+ regex: /cy\.contains\(\s*(['"`])([^'"` ]+)\1\s*\)\.should/,
271
+ toStep: (m) => ({ isCheck: true, text: `page contains "${m[2]}"` }),
272
+ },
273
+ // ── TestCafe commands ──────────────────────────────────────────────────────
274
+ {
275
+ // await t.navigateTo('url')
276
+ regex: /t\.navigateTo\(\s*(['"`])([^'"` ]+)\1/,
277
+ toStep: (m) => ({ isCheck: false, text: `Navigate to ${m[2]}` }),
278
+ },
279
+ {
280
+ // await t.click('#selector') or t.click(Selector('#selector'))
281
+ regex: /t\.click\(\s*(?:Selector\(\s*)?(['"`])([^'"` ]+)\1/,
282
+ toStep: (m) => ({ isCheck: false, text: `Click the ${prettifySelector(m[2])}` }),
283
+ },
284
+ {
285
+ // await t.typeText('#selector', 'value')
286
+ regex: /t\.typeText\(\s*(?:Selector\(\s*)?(['"`])([^'"` ]+)\1\s*\)?\s*,\s*(['"`])([^'"` ]*)\3/,
287
+ toStep: (m) => ({ isCheck: false, text: `Enter "${m[4]}" into the ${prettifySelector(m[2])} field` }),
288
+ },
289
+ {
290
+ // await t.selectText('#selector')
291
+ regex: /t\.selectText\(\s*(?:Selector\(\s*)?(['"`])([^'"` ]+)\1/,
292
+ toStep: (m) => ({ isCheck: false, text: `Select text in the ${prettifySelector(m[2])} field` }),
293
+ },
294
+ {
295
+ // await t.expect(x).eql('value')
296
+ regex: /t\.expect\([^)]{3,60}\)\.eql\(\s*(['"`])([^'"` ]+)\1/,
297
+ toStep: (m) => ({ isCheck: true, text: `value equals "${m[2]}"` }),
298
+ },
299
+ {
300
+ // await t.expect(x).contains('value')
301
+ regex: /t\.expect\([^)]{3,60}\)\.contains\(\s*(['"`])([^'"` ]+)\1/,
302
+ toStep: (m) => ({ isCheck: true, text: `result contains "${m[2]}"` }),
303
+ },
304
+ {
305
+ // await t.expect(x).ok()
306
+ regex: /t\.expect\(([^)]{3,60})\)\.ok\(\)/,
307
+ toStep: (m) => ({ isCheck: true, text: `${m[1].trim()} is true` }),
308
+ },
309
+ // ── Puppeteer-specific (supplements the Playwright patterns above) ─────────
310
+ {
311
+ // page.type('#selector', 'value') — Puppeteer (page.fill is Playwright)
312
+ regex: /page\.type\(\s*(['"`])([^'"` ]+)\1\s*,\s*(['"`])([^'"` ]*)\3/,
313
+ toStep: (m) => ({ isCheck: false, text: `Enter "${m[4]}" into the ${prettifySelector(m[2])} field` }),
314
+ },
315
+ {
316
+ // page.waitForNavigation() — Puppeteer
317
+ regex: /page\.waitForNavigation\(/,
318
+ toStep: (_m) => ({ isCheck: false, text: `Wait for page navigation` }),
319
+ },
320
+ // ── Detox (React Native) commands ─────────────────────────────────────────
321
+ {
322
+ // await device.launchApp() / device.relaunchApp()
323
+ regex: /device\.(?:launch|relaunch)App\(/,
324
+ toStep: (_m) => ({ isCheck: false, text: `Launch the app` }),
325
+ },
326
+ {
327
+ // await element(by.id('x')).tap() / by.text / by.label
328
+ regex: /element\(by\.\w+\(\s*(['"`])([^'"` ]+)\1\s*\)\)\.tap\(\)/,
329
+ toStep: (m) => ({ isCheck: false, text: `Tap the ${prettifySelector(m[2])}` }),
330
+ },
331
+ {
332
+ // await element(by.id('x')).typeText('value')
333
+ regex: /element\(by\.\w+\(\s*(['"`])([^'"` ]+)\1\s*\)\)\.typeText\(\s*(['"`])([^'"` ]*)\3/,
334
+ toStep: (m) => ({ isCheck: false, text: `Enter "${m[4]}" into the ${prettifySelector(m[2])} field` }),
335
+ },
336
+ {
337
+ // await element(by.id('x')).clearText()
338
+ regex: /element\(by\.\w+\(\s*(['"`])([^'"` ]+)\1\s*\)\)\.clearText\(\)/,
339
+ toStep: (m) => ({ isCheck: false, text: `Clear the ${prettifySelector(m[2])} field` }),
340
+ },
341
+ {
342
+ // await expect(element(by.id('x'))).toBeVisible()
343
+ regex: /expect\(element\(by\.\w+\(\s*(['"`])([^'"` ]+)\1\s*\)\)\)\.toBeVisible\(\)/,
344
+ toStep: (m) => ({ isCheck: true, text: `${prettifySelector(m[2])} is visible` }),
345
+ },
346
+ {
347
+ // await expect(element(by.id('x'))).toHaveText('value')
348
+ regex: /expect\(element\(by\.\w+\(\s*(['"`])([^'"` ]+)\1\s*\)\)\)\.toHaveText\(\s*(['"`])([^'"` ]+)\3/,
349
+ toStep: (m) => ({ isCheck: true, text: `text of ${prettifySelector(m[2])} equals "${m[4]}"` }),
350
+ },
351
+ {
352
+ // await expect(element(by.id('x'))).not.toBeVisible()
353
+ regex: /expect\(element\(by\.\w+\(\s*(['"`])([^'"` ]+)\1\s*\)\)\)\.not\.toBeVisible\(\)/,
354
+ toStep: (m) => ({ isCheck: true, text: `${prettifySelector(m[2])} is not visible` }),
355
+ },
356
+ // ── Espresso (Android) commands ───────────────────────────────────────────
357
+ {
358
+ // onView(withId(R.id.name)).perform(typeText("value"))
359
+ regex: /onView\(withId\(R\.id\.(\w+)\)\)\.perform\(typeText\(\s*(['"`])([^'"` ]*)\2/,
360
+ toStep: (m) => ({ isCheck: false, text: `Enter "${m[3]}" into the ${prettifySelector(m[1])} field` }),
361
+ },
362
+ {
363
+ // onView(withId(R.id.name)).perform(click())
364
+ regex: /onView\(withId\(R\.id\.(\w+)\)\)\.perform\(click\(\)\)/,
365
+ toStep: (m) => ({ isCheck: false, text: `Click the ${prettifySelector(m[1])}` }),
366
+ },
367
+ {
368
+ // onView(withId(R.id.name)).perform(scrollTo())
369
+ regex: /onView\(withId\(R\.id\.(\w+)\)\)\.perform\(scrollTo\(\)\)/,
370
+ toStep: (m) => ({ isCheck: false, text: `Scroll to the ${prettifySelector(m[1])}` }),
371
+ },
372
+ {
373
+ // onView(withId(R.id.name)).check(matches(isDisplayed()))
374
+ regex: /onView\(withId\(R\.id\.(\w+)\)\)\.check\(matches\(isDisplayed\(\)\)\)/,
375
+ toStep: (m) => ({ isCheck: true, text: `${prettifySelector(m[1])} is displayed` }),
376
+ },
377
+ {
378
+ // onView(withId(R.id.name)).check(matches(withText("value")))
379
+ regex: /onView\(withId\(R\.id\.(\w+)\)\)\.check\(matches\(withText\(\s*(['"`])([^'"` ]+)\2/,
380
+ toStep: (m) => ({ isCheck: true, text: `text of ${prettifySelector(m[1])} equals "${m[3]}"` }),
381
+ },
382
+ {
383
+ // Espresso intending(hasComponent(hasShortClassName(".ActivityName")))
384
+ regex: /Intents\.intended\(hasComponent\(hasShortClassName\(\s*(['"`])([^'"` .]+)\1/,
385
+ toStep: (m) => ({ isCheck: true, text: `screen "${m[2]}" is displayed` }),
386
+ },
387
+ // ── XCUITest (iOS/macOS) commands ─────────────────────────────────────────
388
+ {
389
+ // app.launch() / app.activate()
390
+ regex: /app\.(?:launch|activate)\(\)/,
391
+ toStep: (_m) => ({ isCheck: false, text: `Launch the app` }),
392
+ },
393
+ {
394
+ // app.buttons["Login"].tap() / app.staticTexts["Title"].tap()
395
+ regex: /app\.\w+\[\s*(['"`])([^'"` ]+)\1\s*\]\.tap\(\)/,
396
+ toStep: (m) => ({ isCheck: false, text: `Tap "${m[2]}"` }),
397
+ },
398
+ {
399
+ // app.textFields["email"].typeText("value")
400
+ regex: /app\.(?:textFields|secureTextFields|searchFields)\[\s*(['"`])([^'"` ]+)\1\s*\]\.typeText\(\s*(['"`])([^'"` ]*)\3/,
401
+ toStep: (m) => ({ isCheck: false, text: `Enter "${m[4]}" into the "${m[2]}" field` }),
402
+ },
403
+ {
404
+ // app.textFields["email"].clearAndEnterText("value")
405
+ regex: /app\.(?:textFields|secureTextFields)\[\s*(['"`])([^'"` ]+)\1\s*\]\.clearAndEnterText\(\s*(['"`])([^'"` ]*)\3/,
406
+ toStep: (m) => ({ isCheck: false, text: `Enter "${m[4]}" into the "${m[2]}" field` }),
407
+ },
408
+ {
409
+ // app.swipeUp() / app.swipeDown()
410
+ regex: /app\.swipe(Up|Down|Left|Right)\(\)/,
411
+ toStep: (m) => ({ isCheck: false, text: `Swipe ${m[1].toLowerCase()}` }),
412
+ },
413
+ {
414
+ // XCTAssertTrue(app.staticTexts["Welcome"].exists)
415
+ regex: /XCTAssertTrue\(\s*app\.\w+\[\s*(['"`])([^'"` ]+)\1\s*\]\.exists/,
416
+ toStep: (m) => ({ isCheck: true, text: `"${m[2]}" is visible` }),
417
+ },
418
+ {
419
+ // XCTAssertEqual(actual, "expected")
420
+ regex: /XCTAssertEqual\(\s*[^,]+,\s*(['"`])([^'"` ]+)\1/,
421
+ toStep: (m) => ({ isCheck: true, text: `value equals "${m[2]}"` }),
422
+ },
423
+ {
424
+ // XCTAssertFalse / XCTAssertNil
425
+ regex: /XCTAssert(?:False|Nil)\(\s*(.{3,60}?)\s*\)/,
426
+ toStep: (m) => ({ isCheck: true, text: `${m[1].trim()} is false/nil` }),
427
+ },
428
+ // ── Flutter / Dart widget test commands ───────────────────────────────────
429
+ {
430
+ // await tester.tap(find.byKey(Key('x'))) / find.byType(X) / find.text('x')
431
+ regex: /tester\.tap\(\s*find\.(?:byKey\(Key\()?(?:['"`])([^'"` ]+)['"`]/,
432
+ toStep: (m) => ({ isCheck: false, text: `Tap "${prettifySelector(m[1])}"` }),
433
+ },
434
+ {
435
+ // await tester.enterText(find.byType(TextField), 'value')
436
+ regex: /tester\.enterText\(\s*find\.[^,]+,\s*(['"`])([^'"` ]*)\1/,
437
+ toStep: (m) => ({ isCheck: false, text: `Enter "${m[2]}"` }),
438
+ },
439
+ {
440
+ // await tester.longPress(find.byKey(Key('x')))
441
+ regex: /tester\.longPress\(\s*find\.(?:byKey\(Key\()?(?:['"`])([^'"` ]+)['"`]/,
442
+ toStep: (m) => ({ isCheck: false, text: `Long press "${prettifySelector(m[1])}"` }),
443
+ },
444
+ {
445
+ // await tester.pumpAndSettle()
446
+ regex: /tester\.pumpAndSettle\(/,
447
+ toStep: (_m) => ({ isCheck: false, text: `Wait for UI to settle` }),
448
+ },
449
+ {
450
+ // expect(find.text('Hello'), findsOneWidget) / findsWidgets / findsNothing
451
+ regex: /expect\(\s*find\.text\(\s*(['"`])([^'"` ]+)\1\s*\),\s*(findsOneWidget|findsWidgets)/,
452
+ toStep: (m) => ({ isCheck: true, text: `"${m[2]}" is visible` }),
453
+ },
454
+ {
455
+ // expect(find.text('Hello'), findsNothing)
456
+ regex: /expect\(\s*find\.text\(\s*(['"`])([^'"` ]+)\1\s*\),\s*findsNothing/,
457
+ toStep: (m) => ({ isCheck: true, text: `"${m[2]}" is not visible` }),
458
+ },
459
+ {
460
+ // expect(find.byKey(Key('x')), findsOneWidget)
461
+ regex: /expect\(\s*find\.byKey\(Key\(\s*(['"`])([^'"` ]+)\1\s*\)\),\s*(findsOneWidget|findsWidgets)/,
462
+ toStep: (m) => ({ isCheck: true, text: `"${prettifySelector(m[2])}" widget is present` }),
463
+ },
464
+ // ── Assertions / Checks ───────────────────────────────────────────────────
465
+ {
466
+ // expect(page).toHaveURL(/pattern/) or toHaveURL('url')
467
+ regex: /\.toHaveURL\(\s*\/([^/]+)\//,
468
+ toStep: (m) => ({ isCheck: true, text: `URL matches /${m[1]}/` }),
469
+ },
470
+ {
471
+ regex: /\.toHaveURL\(\s*(['"`])([^'"` ]+)\1/,
472
+ toStep: (m) => ({ isCheck: true, text: `URL equals "${m[2]}"` }),
473
+ },
474
+ {
475
+ // toHaveUrlContaining — WebdriverIO
476
+ regex: /\.toHaveUrlContaining\(\s*(['"`])([^'"` ]+)\1/,
477
+ toStep: (m) => ({ isCheck: true, text: `URL contains "${m[2]}"` }),
478
+ },
479
+ {
480
+ regex: /\.toHaveText\(\s*(['"`])([^'"` ]+)\1/,
481
+ toStep: (m) => ({ isCheck: true, text: `text equals "${m[2]}"` }),
482
+ },
483
+ {
484
+ regex: /\.toContainText\(\s*(['"`])([^'"` ]+)\1/,
485
+ toStep: (m) => ({ isCheck: true, text: `text contains "${m[2]}"` }),
486
+ },
487
+ {
488
+ regex: /\.toContain\(\s*(['"`])([^'"` ]+)\1/,
489
+ toStep: (m) => ({ isCheck: true, text: `result contains "${m[2]}"` }),
490
+ },
491
+ {
492
+ regex: /\.toEqual\(\s*(['"`])([^'"` ]+)\1/,
493
+ toStep: (m) => ({ isCheck: true, text: `value equals "${m[2]}"` }),
494
+ },
495
+ {
496
+ regex: /\.toBe\(\s*(['"`])([^'"` ]+)\1/,
497
+ toStep: (m) => ({ isCheck: true, text: `value equals "${m[2]}"` }),
498
+ },
499
+ {
500
+ regex: /\.toBe\(\s*(\d+)\)/,
501
+ toStep: (m) => ({ isCheck: true, text: `value equals ${m[1]}` }),
502
+ },
503
+ {
504
+ regex: /\.toHaveCount\(\s*(\d+)/,
505
+ toStep: (m) => ({ isCheck: true, text: `count equals ${m[1]}` }),
506
+ },
507
+ {
508
+ regex: /\.toBeVisible\(\)/,
509
+ toStep: (_m) => ({ isCheck: true, text: `element is visible` }),
510
+ },
511
+ {
512
+ // assertEquals("expected", actual) — JUnit / TestNG
513
+ regex: /(?:assertEquals|Assert\.assertEquals)\(\s*(['"`])([^'"` ]+)\1\s*,/,
514
+ toStep: (m) => ({ isCheck: true, text: `value equals "${m[2]}"` }),
515
+ },
516
+ {
517
+ // assertEquals(number, actual) — JUnit
518
+ regex: /(?:assertEquals|Assert\.assertEquals)\(\s*(\d+)\s*,/,
519
+ toStep: (m) => ({ isCheck: true, text: `count equals ${m[1]}` }),
520
+ },
521
+ {
522
+ regex: /assertTrue\(([^,)]{3,60})\)/,
523
+ toStep: (m) => ({ isCheck: true, text: `${m[1].trim()} is true` }),
524
+ },
525
+ {
526
+ // Assert.AreEqual("expected", actual) — C# MSTest
527
+ regex: /Assert\.AreEqual\(\s*(['"`])([^'"` ]+)\1\s*,/,
528
+ toStep: (m) => ({ isCheck: true, text: `value equals "${m[2]}"` }),
529
+ },
530
+ {
531
+ // StringAssert.Contains("x", ...) — C#
532
+ regex: /StringAssert\.Contains\(\s*(['"`])([^'"` ]+)\1/,
533
+ toStep: (m) => ({ isCheck: true, text: `text contains "${m[2]}"` }),
534
+ },
535
+ {
536
+ // Assert.That(x, Is.EqualTo("y")) — NUnit
537
+ regex: /Is\.EqualTo\(\s*(['"`])([^'"` ]+)\1/,
538
+ toStep: (m) => ({ isCheck: true, text: `value equals "${m[2]}"` }),
539
+ },
540
+ {
541
+ // Does.Contain("y") — NUnit
542
+ regex: /Does\.Contain\(\s*(['"`])([^'"` ]+)\1/,
543
+ toStep: (m) => ({ isCheck: true, text: `text contains "${m[2]}"` }),
544
+ },
545
+ {
546
+ // assert x == "y" — Python
547
+ regex: /assert\s+\w+\s*==\s*(['"`])([^'"` ]+)\1/,
548
+ toStep: (m) => ({ isCheck: true, text: `value equals "${m[2]}"` }),
549
+ },
550
+ {
551
+ // assert "x" in y — Python
552
+ regex: /assert\s+(['"`])([^'"` ]+)\1\s+in\s+/,
553
+ toStep: (m) => ({ isCheck: true, text: `result contains "${m[2]}"` }),
554
+ },
555
+ {
556
+ // assert "x" in driver.current_url — Python URL check
557
+ regex: /assert\s+(['"`])([^'"` ]+)\1\s+in\s+\w+\.current_url/,
558
+ toStep: (m) => ({ isCheck: true, text: `URL contains "${m[2]}"` }),
559
+ },
560
+ ];
561
+ // ─── Title derivation from method name ────────────────────────────────────────
562
+ function methodNameToTitle(name) {
563
+ return name
564
+ .replace(/^(test_?|Test)/i, '')
565
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
566
+ .replace(/[_-]+/g, ' ')
567
+ .toLowerCase()
568
+ .trim();
569
+ }
570
+ // ─── Heuristic summary ────────────────────────────────────────────────────────
571
+ function heuristicSummary(body, fallbackTitle) {
572
+ const steps = [];
573
+ const seenKeys = new Set();
574
+ for (const line of body.split('\n')) {
575
+ const trimmed = line.trim();
576
+ if (!trimmed)
577
+ continue;
578
+ if (trimmed.startsWith('//') ||
579
+ trimmed.startsWith('#') ||
580
+ trimmed.startsWith('*') ||
581
+ trimmed.startsWith('"""') ||
582
+ trimmed.startsWith("'''"))
583
+ continue;
584
+ for (const rule of PATTERNS) {
585
+ const m = trimmed.match(rule.regex);
586
+ if (!m)
587
+ continue;
588
+ const result = rule.toStep(m);
589
+ if (!result)
590
+ continue;
591
+ const key = result.text.toLowerCase();
592
+ if (seenKeys.has(key))
593
+ break;
594
+ seenKeys.add(key);
595
+ steps.push({ keyword: result.isCheck ? 'Then' : 'Step', text: result.text });
596
+ break;
597
+ }
598
+ }
599
+ const title = methodNameToTitle(fallbackTitle) || fallbackTitle;
600
+ const description = buildHeuristicDescription(title, steps);
601
+ return { title, description, steps };
602
+ }
603
+ function buildHeuristicDescription(title, steps) {
604
+ const checks = steps.filter((s) => s.keyword === 'Then').map((s) => s.text);
605
+ if (checks.length === 0)
606
+ return `Verifies that ${title}.`;
607
+ if (checks.length === 1)
608
+ return `Verifies that ${checks[0]}.`;
609
+ return `Verifies that ${checks.slice(0, -1).join(', ')} and ${checks[checks.length - 1]}.`;
610
+ }
611
+ // ─── LLM prompt ───────────────────────────────────────────────────────────────
612
+ const PROMPT_TEMPLATE = `You are a test documentation assistant. Given the test function below, produce a title, a short description, and numbered steps suitable for an Azure DevOps Test Case.
613
+
614
+ Rules:
615
+ - Title: one concise line (not the function name, a human-readable description)
616
+ - Description: 1-2 sentences explaining what the test verifies and any important preconditions. Do not repeat the title verbatim.
617
+ - Steps: numbered list. Actions → "N. Do X". Assertions → "N. Check: Y"
618
+ - Output ONLY the title, description, and numbered steps — no preamble or explanation.
619
+
620
+ Expected output format:
621
+ Title: <title>
622
+ Description: <1-2 sentence description>
623
+ 1. <first action step>
624
+ 2. <second action step>
625
+ N. Check: <expected result>
626
+
627
+ Test function:
628
+ {CODE}`;
629
+ function buildPrompt(code) {
630
+ return PROMPT_TEMPLATE.replace('{CODE}', code);
631
+ }
632
+ function parseAiResponse(raw, fallbackTitle) {
633
+ const lines = raw.trim().split('\n').map((l) => l.trim()).filter(Boolean);
634
+ let title = fallbackTitle;
635
+ let description = '';
636
+ const steps = [];
637
+ for (const line of lines) {
638
+ const titleM = line.match(/^Title:\s*(.+)$/i);
639
+ if (titleM) {
640
+ title = titleM[1].trim();
641
+ continue;
642
+ }
643
+ const descM = line.match(/^Description:\s*(.+)$/i);
644
+ if (descM) {
645
+ description = descM[1].trim();
646
+ continue;
647
+ }
648
+ const stepM = line.match(/^(\d+)\.\s+(.+)$/);
649
+ if (stepM) {
650
+ const text = stepM[2].trim();
651
+ const checkM = text.match(/^[Cc]heck:\s+(.+)$/);
652
+ steps.push(checkM
653
+ ? { keyword: 'Then', text: checkM[1].trim() }
654
+ : { keyword: 'Step', text });
655
+ }
656
+ }
657
+ return { title, description, steps };
658
+ }
659
+ const llamaSessionCache = new Map();
660
+ async function getLlamaSession(modelPath) {
661
+ const cached = llamaSessionCache.get(modelPath);
662
+ if (cached)
663
+ return cached;
664
+ const promise = (async () => {
665
+ // new Function bypasses TypeScript's CJS transform so the import() call is
666
+ // emitted as a true ESM dynamic import at runtime, not as require().
667
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-explicit-any
668
+ const esmImport = new Function('m', 'return import(m)');
669
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
670
+ const llamaModule = await esmImport('node-llama-cpp');
671
+ const { getLlama, LlamaChatSession } = llamaModule;
672
+ const llama = await getLlama();
673
+ const model = await llama.loadModel({ modelPath });
674
+ const context = await model.createContext();
675
+ return { LlamaChatSession, context };
676
+ })();
677
+ llamaSessionCache.set(modelPath, promise);
678
+ return promise;
679
+ }
680
+ /**
681
+ * node-llama-cpp provider — runs a GGUF model directly in-process.
682
+ * The llama context is cached per model path so it is only loaded once
683
+ * for the whole push run, regardless of how many tests are summarized.
684
+ */
685
+ async function localLlamaSummary(code, fallbackTitle, modelPath) {
686
+ const { LlamaChatSession, context } = await getLlamaSession(modelPath);
687
+ const session = new LlamaChatSession({ contextSequence: context.getSequence() });
688
+ const response = await session.prompt(buildPrompt(code));
689
+ return parseAiResponse(response, fallbackTitle);
690
+ }
691
+ async function ollamaSummary(code, fallbackTitle, model, baseUrl) {
692
+ const res = await fetch(`${baseUrl}/api/generate`, {
693
+ method: 'POST',
694
+ headers: { 'Content-Type': 'application/json' },
695
+ body: JSON.stringify({ model, prompt: buildPrompt(code), stream: false }),
696
+ signal: AbortSignal.timeout(60_000),
697
+ });
698
+ if (!res.ok)
699
+ throw new Error(`Ollama ${res.status}: ${await res.text()}`);
700
+ const data = await res.json();
701
+ return parseAiResponse(data.response ?? '', fallbackTitle);
702
+ }
703
+ async function openaiSummary(code, fallbackTitle, model, apiKey, baseUrl) {
704
+ const res = await fetch(`${baseUrl}/chat/completions`, {
705
+ method: 'POST',
706
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
707
+ body: JSON.stringify({
708
+ model,
709
+ messages: [{ role: 'user', content: buildPrompt(code) }],
710
+ temperature: 0,
711
+ }),
712
+ signal: AbortSignal.timeout(30_000),
713
+ });
714
+ if (!res.ok)
715
+ throw new Error(`OpenAI ${res.status}: ${await res.text()}`);
716
+ const data = await res.json();
717
+ return parseAiResponse(data.choices?.[0]?.message?.content ?? '', fallbackTitle);
718
+ }
719
+ async function anthropicSummary(code, fallbackTitle, model, apiKey) {
720
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
721
+ method: 'POST',
722
+ headers: {
723
+ 'Content-Type': 'application/json',
724
+ 'x-api-key': apiKey,
725
+ 'anthropic-version': '2023-06-01',
726
+ },
727
+ body: JSON.stringify({
728
+ model,
729
+ max_tokens: 512,
730
+ messages: [{ role: 'user', content: buildPrompt(code) }],
731
+ }),
732
+ signal: AbortSignal.timeout(30_000),
733
+ });
734
+ if (!res.ok)
735
+ throw new Error(`Anthropic ${res.status}: ${await res.text()}`);
736
+ const data = await res.json();
737
+ return parseAiResponse(data.content?.[0]?.text ?? '', fallbackTitle);
738
+ }
739
+ function resolveEnvVar(value) {
740
+ if (value.startsWith('$'))
741
+ return process.env[value.slice(1)] ?? value;
742
+ return value;
743
+ }
744
+ // ─── Public entry point ───────────────────────────────────────────────────────
745
+ /**
746
+ * Generate a title, description, and steps for a test that has no doc comment.
747
+ * Mutates nothing — returns new values; callers assign them.
748
+ */
749
+ async function summarizeTest(test, localType, opts) {
750
+ // Extract the raw function body for pattern matching / LLM context
751
+ let body = '';
752
+ try {
753
+ body = extractFunctionBody(test.filePath, test.line, localType);
754
+ }
755
+ catch {
756
+ // Can't read body — heuristic will produce an empty step list
757
+ }
758
+ const fallbackTitle = test.title;
759
+ if (opts.provider === 'heuristic' || !body) {
760
+ return heuristicSummary(body, fallbackTitle);
761
+ }
762
+ // local provider requires a model path
763
+ if (opts.provider === 'local' && !opts.model) {
764
+ process.stderr.write(` [ai-summary] local provider requires --ai-model <path/to/model.gguf>, falling back to heuristic\n`);
765
+ return heuristicSummary(body, fallbackTitle);
766
+ }
767
+ const heuristicFallback = opts.heuristicFallback ?? true;
768
+ try {
769
+ switch (opts.provider) {
770
+ case 'local':
771
+ return await localLlamaSummary(body, fallbackTitle, opts.model);
772
+ case 'ollama':
773
+ return await ollamaSummary(body, fallbackTitle, opts.model ?? 'qwen2.5-coder:7b', opts.baseUrl ?? 'http://localhost:11434');
774
+ case 'openai':
775
+ return await openaiSummary(body, fallbackTitle, opts.model ?? 'gpt-4o-mini', resolveEnvVar(opts.apiKey ?? ''), opts.baseUrl ?? 'https://api.openai.com/v1');
776
+ case 'anthropic':
777
+ return await anthropicSummary(body, fallbackTitle, opts.model ?? 'claude-haiku-4-5-20251001', resolveEnvVar(opts.apiKey ?? ''));
778
+ }
779
+ }
780
+ catch (err) {
781
+ if (heuristicFallback) {
782
+ process.stderr.write(` [ai-summary] ${opts.provider} failed (${err.message}), falling back to heuristic\n`);
783
+ return heuristicSummary(body, fallbackTitle);
784
+ }
785
+ throw err;
786
+ }
787
+ return heuristicSummary(body, fallbackTitle);
788
+ }
789
+ //# sourceMappingURL=summarizer.js.map