@yasserkhanorg/e2e-agents 0.5.16 → 0.6.0

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.
Files changed (105) hide show
  1. package/dist/agent/pipeline.d.ts +1 -1
  2. package/dist/agent/pipeline.d.ts.map +1 -1
  3. package/dist/agent/plan.d.ts +0 -12
  4. package/dist/agent/plan.d.ts.map +1 -1
  5. package/dist/agent/plan.js +0 -365
  6. package/dist/agent/types.d.ts +42 -0
  7. package/dist/agent/types.d.ts.map +1 -0
  8. package/dist/agent/types.js +4 -0
  9. package/dist/api.d.ts +10 -14
  10. package/dist/api.d.ts.map +1 -1
  11. package/dist/api.js +29 -59
  12. package/dist/cli.js +41 -174
  13. package/dist/engine/impact_engine.d.ts +36 -0
  14. package/dist/engine/impact_engine.d.ts.map +1 -0
  15. package/dist/engine/impact_engine.js +196 -0
  16. package/dist/engine/plan_builder.d.ts +9 -0
  17. package/dist/engine/plan_builder.d.ts.map +1 -0
  18. package/dist/engine/plan_builder.js +329 -0
  19. package/dist/esm/agent/plan.js +1 -360
  20. package/dist/esm/agent/types.js +3 -0
  21. package/dist/esm/api.js +27 -56
  22. package/dist/esm/cli.js +40 -173
  23. package/dist/esm/engine/impact_engine.js +191 -0
  24. package/dist/esm/engine/plan_builder.js +323 -0
  25. package/dist/esm/index.js +6 -3
  26. package/dist/esm/knowledge/route_families.js +57 -0
  27. package/dist/index.d.ts +9 -4
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +14 -5
  30. package/dist/knowledge/route_families.d.ts +19 -0
  31. package/dist/knowledge/route_families.d.ts.map +1 -1
  32. package/dist/knowledge/route_families.js +60 -0
  33. package/package.json +1 -1
  34. package/dist/agent/ai_flow_analysis.d.ts +0 -13
  35. package/dist/agent/ai_flow_analysis.d.ts.map +0 -1
  36. package/dist/agent/ai_flow_analysis.js +0 -334
  37. package/dist/agent/ai_mapping.d.ts +0 -14
  38. package/dist/agent/ai_mapping.d.ts.map +0 -1
  39. package/dist/agent/ai_mapping.js +0 -560
  40. package/dist/agent/analysis.d.ts +0 -64
  41. package/dist/agent/analysis.d.ts.map +0 -1
  42. package/dist/agent/analysis.js +0 -292
  43. package/dist/agent/blast_radius.d.ts +0 -4
  44. package/dist/agent/blast_radius.d.ts.map +0 -1
  45. package/dist/agent/blast_radius.js +0 -37
  46. package/dist/agent/dependency_graph.d.ts +0 -14
  47. package/dist/agent/dependency_graph.d.ts.map +0 -1
  48. package/dist/agent/dependency_graph.js +0 -227
  49. package/dist/agent/flags.d.ts +0 -23
  50. package/dist/agent/flags.d.ts.map +0 -1
  51. package/dist/agent/flags.js +0 -171
  52. package/dist/agent/flow_catalog.d.ts +0 -25
  53. package/dist/agent/flow_catalog.d.ts.map +0 -1
  54. package/dist/agent/flow_catalog.js +0 -115
  55. package/dist/agent/flow_mapping.d.ts +0 -10
  56. package/dist/agent/flow_mapping.d.ts.map +0 -1
  57. package/dist/agent/flow_mapping.js +0 -84
  58. package/dist/agent/framework.d.ts +0 -13
  59. package/dist/agent/framework.d.ts.map +0 -1
  60. package/dist/agent/framework.js +0 -149
  61. package/dist/agent/gap_suggestions.d.ts +0 -14
  62. package/dist/agent/gap_suggestions.d.ts.map +0 -1
  63. package/dist/agent/gap_suggestions.js +0 -101
  64. package/dist/agent/generator.d.ts +0 -10
  65. package/dist/agent/generator.d.ts.map +0 -1
  66. package/dist/agent/generator.js +0 -115
  67. package/dist/agent/operational_insights.d.ts +0 -41
  68. package/dist/agent/operational_insights.d.ts.map +0 -1
  69. package/dist/agent/operational_insights.js +0 -127
  70. package/dist/agent/report.d.ts +0 -97
  71. package/dist/agent/report.d.ts.map +0 -1
  72. package/dist/agent/report.js +0 -159
  73. package/dist/agent/runner.d.ts +0 -7
  74. package/dist/agent/runner.d.ts.map +0 -1
  75. package/dist/agent/runner.js +0 -898
  76. package/dist/agent/selectors.d.ts +0 -10
  77. package/dist/agent/selectors.d.ts.map +0 -1
  78. package/dist/agent/selectors.js +0 -75
  79. package/dist/agent/subsystem_risk.d.ts +0 -23
  80. package/dist/agent/subsystem_risk.d.ts.map +0 -1
  81. package/dist/agent/subsystem_risk.js +0 -207
  82. package/dist/agent/tests.d.ts +0 -19
  83. package/dist/agent/tests.d.ts.map +0 -1
  84. package/dist/agent/tests.js +0 -116
  85. package/dist/agent/traceability.d.ts +0 -22
  86. package/dist/agent/traceability.d.ts.map +0 -1
  87. package/dist/agent/traceability.js +0 -183
  88. package/dist/esm/agent/ai_flow_analysis.js +0 -331
  89. package/dist/esm/agent/ai_mapping.js +0 -557
  90. package/dist/esm/agent/analysis.js +0 -287
  91. package/dist/esm/agent/blast_radius.js +0 -34
  92. package/dist/esm/agent/dependency_graph.js +0 -224
  93. package/dist/esm/agent/flags.js +0 -160
  94. package/dist/esm/agent/flow_catalog.js +0 -112
  95. package/dist/esm/agent/flow_mapping.js +0 -81
  96. package/dist/esm/agent/framework.js +0 -145
  97. package/dist/esm/agent/gap_suggestions.js +0 -98
  98. package/dist/esm/agent/generator.js +0 -112
  99. package/dist/esm/agent/operational_insights.js +0 -124
  100. package/dist/esm/agent/report.js +0 -156
  101. package/dist/esm/agent/runner.js +0 -894
  102. package/dist/esm/agent/selectors.js +0 -71
  103. package/dist/esm/agent/subsystem_risk.js +0 -204
  104. package/dist/esm/agent/tests.js +0 -111
  105. package/dist/esm/agent/traceability.js +0 -180
@@ -1,557 +0,0 @@
1
- // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
2
- // See LICENSE.txt for license information.
3
- import { existsSync, readFileSync } from 'fs';
4
- import { isAbsolute, join } from 'path';
5
- import { LLMProviderFactory } from '../provider_factory.js';
6
- import { normalizePath, tokenize, uniqueTokens } from './utils.js';
7
- const PRIORITY_RANK = {
8
- P0: 0,
9
- P1: 1,
10
- P2: 2,
11
- };
12
- const MIN_SINGLE_KEYWORD_LENGTH = 6;
13
- const LOW_SIGNAL_FLOW_KEYWORDS = new Set([
14
- 'app',
15
- 'apps',
16
- 'channel',
17
- 'channels',
18
- 'client',
19
- 'common',
20
- 'component',
21
- 'components',
22
- 'detail',
23
- 'details',
24
- 'dialog',
25
- 'feature',
26
- 'files',
27
- 'flow',
28
- 'group',
29
- 'groups',
30
- 'hooks',
31
- 'message',
32
- 'messages',
33
- 'modal',
34
- 'new',
35
- 'page',
36
- 'pages',
37
- 'panel',
38
- 'post',
39
- 'posts',
40
- 'query',
41
- 'result',
42
- 'results',
43
- 'screen',
44
- 'screens',
45
- 'section',
46
- 'src',
47
- 'tsx',
48
- 'ts',
49
- 'jsx',
50
- 'js',
51
- 'ui',
52
- 'use',
53
- 'user',
54
- 'users',
55
- 'view',
56
- 'webapp',
57
- ]);
58
- function extractJson(text) {
59
- const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
60
- const candidates = fenced ? [fenced[1], text] : [text];
61
- for (const candidate of candidates) {
62
- const start = candidate.indexOf('{');
63
- const end = candidate.lastIndexOf('}');
64
- if (start < 0 || end <= start) {
65
- continue;
66
- }
67
- const raw = candidate.slice(start, end + 1);
68
- try {
69
- const parsed = JSON.parse(raw);
70
- if (parsed && Array.isArray(parsed.mappings)) {
71
- return parsed;
72
- }
73
- }
74
- catch {
75
- // Continue trying other candidates.
76
- }
77
- }
78
- return null;
79
- }
80
- function resolveContextFiles(appRoot, testsRoot, files) {
81
- const resolved = [];
82
- const seen = new Set();
83
- const maxCharsPerFile = 12000;
84
- const maxTotalChars = 30000;
85
- let totalChars = 0;
86
- for (const file of files) {
87
- const candidates = isAbsolute(file)
88
- ? [file]
89
- : [join(testsRoot, file), join(appRoot, file)];
90
- for (const candidate of candidates) {
91
- const normalized = normalizePath(candidate);
92
- if (seen.has(normalized) || !existsSync(candidate)) {
93
- continue;
94
- }
95
- const content = readFileSync(candidate, 'utf-8');
96
- const trimmed = content.trim();
97
- if (!trimmed) {
98
- seen.add(normalized);
99
- continue;
100
- }
101
- const remaining = Math.max(0, maxTotalChars - totalChars);
102
- if (remaining <= 0) {
103
- return resolved;
104
- }
105
- const clipped = trimmed.slice(0, Math.min(maxCharsPerFile, remaining));
106
- resolved.push({ path: normalized, content: clipped });
107
- seen.add(normalized);
108
- totalChars += clipped.length;
109
- break;
110
- }
111
- }
112
- return resolved;
113
- }
114
- function flowKeywords(flow) {
115
- return uniqueTokens([
116
- ...tokenize(flow.id || ''),
117
- ...tokenize(flow.name || ''),
118
- ...(flow.keywords || []),
119
- ]).filter((keyword) => (keyword.length >= 3 &&
120
- !LOW_SIGNAL_FLOW_KEYWORDS.has(keyword))).slice(0, 18);
121
- }
122
- function matchedFlowKeywords(flow, testPath) {
123
- const haystack = testPath.toLowerCase();
124
- return flowKeywords(flow).filter((keyword) => keyword && haystack.includes(keyword.toLowerCase()));
125
- }
126
- function scoreTestPath(flow, testPath) {
127
- return matchedFlowKeywords(flow, testPath).length;
128
- }
129
- function isStrongCandidateMatch(flow, matchedKeywords) {
130
- if (matchedKeywords.length >= 2) {
131
- return true;
132
- }
133
- if (matchedKeywords.length !== 1) {
134
- return false;
135
- }
136
- const keywords = flowKeywords(flow);
137
- return keywords.length === 1 && matchedKeywords[0].length >= MIN_SINGLE_KEYWORD_LENGTH;
138
- }
139
- // Stop-words excluded from content-fallback keyword matching.
140
- const CONTENT_FALLBACK_STOP_WORDS = new Set(['and', 'for', 'the', 'to', 'of', 'on', 'at', 'with', 'in', 'a', 'an']);
141
- // Returns raw (unfiltered) tokens for flows where flowKeywords() returns nothing.
142
- // Used exclusively for content-title matching when all standard keywords are low-signal.
143
- // Empty array is returned when the flow already has effective path keywords.
144
- function contentFallbackKeywords(flow) {
145
- if (flowKeywords(flow).length > 0) {
146
- return [];
147
- }
148
- return uniqueTokens([
149
- ...tokenize(flow.id || ''),
150
- ...tokenize(flow.name || ''),
151
- ...(flow.keywords || []),
152
- ]).filter((k) => k.length >= 3 && !CONTENT_FALLBACK_STOP_WORDS.has(k));
153
- }
154
- // Extract test/describe/it title strings from file content for semantic matching.
155
- function extractTestTitles(content) {
156
- const titles = [];
157
- const pattern = /(?:^|\s)(?:test|it|describe)\s*\(\s*(?:'([^']*)'|"([^"]*)"|`([^`]*)`)/gm;
158
- let match;
159
- while ((match = pattern.exec(content)) !== null) {
160
- const title = match[1] ?? match[2] ?? match[3];
161
- if (title) {
162
- titles.push(title);
163
- }
164
- }
165
- return titles.join(' ');
166
- }
167
- function matchedFlowKeywordsInTitles(flow, testContent) {
168
- const haystack = extractTestTitles(testContent).toLowerCase();
169
- if (!haystack) {
170
- return [];
171
- }
172
- return flowKeywords(flow).filter((keyword) => keyword && haystack.includes(keyword.toLowerCase()));
173
- }
174
- function selectCandidateTests(flows, tests, maxCandidateTests) {
175
- const selected = new Set();
176
- const byFlow = new Map();
177
- const evidence = [];
178
- const warnings = [];
179
- const normalizedTests = tests.map((test) => normalizePath(test.path)).filter(Boolean);
180
- const testByNormalizedPath = new Map(tests.map((t) => [normalizePath(t.path), t]));
181
- const perFlowLimit = Math.max(2, Math.min(6, Math.floor(maxCandidateTests / Math.max(1, flows.length))));
182
- for (const flow of flows) {
183
- // Pass 1: path-keyword matching
184
- const scored = [];
185
- for (const testPath of normalizedTests) {
186
- const matchedKeywords = matchedFlowKeywords(flow, testPath);
187
- if (matchedKeywords.length === 0) {
188
- continue;
189
- }
190
- scored.push({
191
- path: testPath,
192
- score: matchedKeywords.length,
193
- matchedKeywords,
194
- });
195
- }
196
- const strongCandidates = scored
197
- .filter((candidate) => isStrongCandidateMatch(flow, candidate.matchedKeywords))
198
- .sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
199
- .slice(0, perFlowLimit);
200
- // Pass 2: content-title matching
201
- // For flows without enough path-matched candidates, also search test/describe/it
202
- // title strings for flow keywords. This surfaces semantically related tests even
203
- // when the file name does not match the flow (e.g. a search.spec.ts with a test
204
- // titled "search for message in channel" covers search_messages).
205
- const contentCandidates = [];
206
- if (strongCandidates.length < perFlowLimit) {
207
- const alreadyByPath = new Set(strongCandidates.map((c) => c.path));
208
- for (const testPath of normalizedTests) {
209
- if (alreadyByPath.has(testPath)) {
210
- continue;
211
- }
212
- const testFile = testByNormalizedPath.get(testPath);
213
- if (!testFile?.content) {
214
- continue;
215
- }
216
- const titleKeywords = matchedFlowKeywordsInTitles(flow, testFile.content);
217
- if (!isStrongCandidateMatch(flow, titleKeywords)) {
218
- continue;
219
- }
220
- // Score content matches lower than path matches so path candidates rank higher.
221
- contentCandidates.push({
222
- path: testPath,
223
- score: titleKeywords.length,
224
- matchedKeywords: titleKeywords,
225
- });
226
- }
227
- contentCandidates.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
228
- }
229
- // Pass 2b: comprehensive fallback for all-low-signal flows.
230
- // When flowKeywords() is empty (all tokens are low-signal), flowKeywords-based
231
- // matching in both Pass 1 and Pass 2 yields nothing. As a last resort, search
232
- // test titles using the raw unfiltered tokens from the flow ID/name, but require
233
- // ALL tokens to match simultaneously — they are individually weak signals so the
234
- // full conjunction is needed to establish behavioral coverage evidence.
235
- const fallbackCandidates = [];
236
- if (strongCandidates.length === 0 && contentCandidates.length === 0) {
237
- const fallbackKws = contentFallbackKeywords(flow);
238
- if (fallbackKws.length > 0) {
239
- for (const testPath of normalizedTests) {
240
- const testFile = testByNormalizedPath.get(testPath);
241
- if (!testFile?.content) {
242
- continue;
243
- }
244
- const haystack = extractTestTitles(testFile.content).toLowerCase();
245
- if (!haystack) {
246
- continue;
247
- }
248
- const matched = fallbackKws.filter((k) => haystack.includes(k));
249
- // For 3+ tokens, n-1 must match (allows one absent word like "view");
250
- // for 1-2 tokens all must match.
251
- const required = fallbackKws.length >= 3 ? fallbackKws.length - 1 : fallbackKws.length;
252
- if (matched.length < required) {
253
- continue;
254
- }
255
- fallbackCandidates.push({ path: testPath, score: matched.length, matchedKeywords: matched });
256
- }
257
- fallbackCandidates.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
258
- }
259
- }
260
- const allCandidates = [
261
- ...strongCandidates,
262
- ...contentCandidates.slice(0, perFlowLimit),
263
- ...fallbackCandidates.slice(0, perFlowLimit),
264
- ];
265
- if (allCandidates.length === 0) {
266
- // Exact-name fallback: if the flow ID has no effective keywords (all tokens are
267
- // low-signal, e.g. view_user_group_modal), look for a test whose path contains
268
- // the exact flow ID as a directory name or filename without extension.
269
- const exactMatchPath = normalizedTests.find((testPath) => {
270
- const segments = testPath.split('/');
271
- return segments.some((seg) => seg === flow.id || seg.replace(/\.spec\.[tj]sx?$/, '') === flow.id);
272
- });
273
- if (exactMatchPath) {
274
- byFlow.set(flow.id, new Set([exactMatchPath]));
275
- evidence.push({ flowId: flow.id, candidates: [{ path: exactMatchPath, score: 999, matchedKeywords: [flow.id] }] });
276
- selected.add(exactMatchPath);
277
- }
278
- else if (scored.length > 0) {
279
- warnings.push(`AI mapping withheld weak path-only candidates for ${flow.id}; traceability evidence is required to reuse existing tests.`);
280
- }
281
- continue;
282
- }
283
- byFlow.set(flow.id, new Set(allCandidates.map((candidate) => candidate.path)));
284
- evidence.push({ flowId: flow.id, candidates: allCandidates });
285
- for (const candidate of allCandidates) {
286
- selected.add(candidate.path);
287
- }
288
- }
289
- return {
290
- tests: Array.from(selected).sort((a, b) => a.localeCompare(b)).slice(0, maxCandidateTests),
291
- byFlow,
292
- evidence,
293
- warnings,
294
- };
295
- }
296
- function readCandidateTestContents(testsRoot, testPaths) {
297
- const result = [];
298
- const maxCharsPerFile = 6000;
299
- const maxTotalChars = 24000;
300
- let totalChars = 0;
301
- for (const testPath of testPaths.slice(0, 6)) {
302
- if (totalChars >= maxTotalChars) {
303
- break;
304
- }
305
- const candidates = isAbsolute(testPath) ? [testPath] : [join(testsRoot, testPath)];
306
- for (const fullPath of candidates) {
307
- if (!existsSync(fullPath)) {
308
- continue;
309
- }
310
- const content = readFileSync(fullPath, 'utf-8').trim();
311
- if (!content) {
312
- continue;
313
- }
314
- const remaining = Math.max(0, maxTotalChars - totalChars);
315
- const clipped = content.slice(0, Math.min(maxCharsPerFile, remaining));
316
- result.push({ path: testPath, content: clipped });
317
- totalChars += clipped.length;
318
- break;
319
- }
320
- }
321
- return result;
322
- }
323
- function buildCoverage(flows, mapped, scenarioGaps) {
324
- return flows.map((flow) => ({
325
- flowId: flow.id,
326
- flowName: flow.name,
327
- priority: flow.priority,
328
- coveredBy: mapped.get(flow.id) || [],
329
- score: (mapped.get(flow.id) || []).length,
330
- source: 'ai',
331
- missingScenarios: scenarioGaps.get(flow.id) || [],
332
- }));
333
- }
334
- function providerFor(config) {
335
- if (config.provider === 'auto') {
336
- return 'auto';
337
- }
338
- return config.provider;
339
- }
340
- export async function mapAITestsToFlows(appRoot, testsRoot, config, flows, tests) {
341
- const warnings = [];
342
- const providerName = providerFor(config);
343
- if (!config.enabled) {
344
- return {
345
- enabled: false,
346
- used: false,
347
- provider: providerName,
348
- mappedFlows: 0,
349
- matchedTests: 0,
350
- coverage: [],
351
- warnings,
352
- };
353
- }
354
- const prioritizedFlows = [...flows]
355
- .sort((a, b) => {
356
- const prioDiff = (PRIORITY_RANK[a.priority] ?? 3) - (PRIORITY_RANK[b.priority] ?? 3);
357
- if (prioDiff !== 0) {
358
- return prioDiff;
359
- }
360
- return (b.score || 0) - (a.score || 0);
361
- })
362
- .slice(0, Math.max(1, config.maxFlowsPerRequest));
363
- const candidateSelection = selectCandidateTests(prioritizedFlows, tests, Math.max(20, config.maxCandidateTests));
364
- warnings.push(...candidateSelection.warnings);
365
- const candidateTests = candidateSelection.tests;
366
- if (prioritizedFlows.length === 0 || candidateTests.length === 0) {
367
- warnings.push('AI mapping skipped: no prioritized flows or path-aligned candidate tests were available.');
368
- return {
369
- enabled: true,
370
- used: false,
371
- provider: providerName,
372
- mappedFlows: 0,
373
- matchedTests: 0,
374
- coverage: [],
375
- warnings,
376
- };
377
- }
378
- const contextFiles = resolveContextFiles(appRoot, testsRoot, config.contextFiles || []);
379
- const contextBlock = contextFiles.length > 0
380
- ? contextFiles.map((entry) => `### Context: ${entry.path}\n${entry.content}`).join('\n\n')
381
- : 'No optional markdown context files were found.';
382
- if (contextFiles.length === 0) {
383
- warnings.push('AI mapping context files were not found; continuing without optional markdown context.');
384
- }
385
- // Read candidate test file contents so the AI can reason about what scenarios
386
- // are already covered and which ones are still missing.
387
- const candidateTestContents = readCandidateTestContents(testsRoot, candidateTests);
388
- const testContentBlock = candidateTestContents.length > 0
389
- ? candidateTestContents.map((entry) => `### Test: ${entry.path}\n\`\`\`typescript\n${entry.content}\n\`\`\``).join('\n\n')
390
- : 'No candidate test file contents could be read.';
391
- let provider;
392
- try {
393
- provider = config.provider === 'auto'
394
- ? await LLMProviderFactory.createFromEnv()
395
- : LLMProviderFactory.createFromString(config.provider);
396
- }
397
- catch (error) {
398
- const message = error instanceof Error ? error.message : String(error);
399
- warnings.push(`AI mapping unavailable (${providerName}): ${message}`);
400
- return {
401
- enabled: true,
402
- used: false,
403
- provider: providerName,
404
- mappedFlows: 0,
405
- matchedTests: 0,
406
- coverage: [],
407
- warnings,
408
- };
409
- }
410
- const prompt = [
411
- 'You are an expert Mattermost E2E test impact analyst.',
412
- 'Map impacted flows to existing Playwright test file paths.',
413
- 'Only use tests from CANDIDATE_TESTS. Never invent paths.',
414
- 'Prefer no mapping over a broad or generic mapping.',
415
- 'Return strict JSON only with this shape:',
416
- '{"mappings":[{"flowId":"<flow id>","tests":["specs/..."],"reason":"short reason","confidence":0.0,"missingScenarios":["scenario description"]}]}',
417
- '',
418
- 'Rules:',
419
- '- Keep at most 5 tests per flow.',
420
- '- Use exact flowId values from FLOWS.',
421
- '- A flow may only map to tests listed under FLOW_CANDIDATE_SIGNALS for that flow.',
422
- '- Map a test when its file path structure OR test content titles specifically indicate it covers the flow scenario. Behavioral specificity is required — "search_user_post_spec.js" in a /search/ directory covers search_messages because it specifically tests searching for messages. A file named "find_channels.spec.ts" does NOT cover search_messages even if it is in a search-related path because it tests channel navigation, not message searching.',
423
- '- Map every candidate that has specific behavioral evidence. Multiple files each covering a different aspect of the same flow should all be mapped.',
424
- '- For candidates whose content you have not read, judge by path structure alone: map only when the path clearly names the specific behavior (not just a general subsystem keyword).',
425
- '- Only return tests: [] when no candidate has specific behavioral connection to the flow.',
426
- '- missingScenarios decision tree based on tests.length AFTER you have determined your test mappings:',
427
- ' * tests.length >= 3: return missingScenarios: [] — three or more specific tests covering different scenarios = comprehensive coverage.',
428
- ' * tests.length 1-2: list only scenarios that are genuinely absent from ALL mapped tests combined.',
429
- ' * tests.length 0: list 3-5 core user-facing scenarios that must be covered.',
430
- ' Write each scenario as a short imperative starting with a verb.',
431
- '',
432
- `FLOWS (${prioritizedFlows.length}):`,
433
- JSON.stringify(prioritizedFlows.map((flow) => ({
434
- flowId: flow.id,
435
- name: flow.name,
436
- priority: flow.priority,
437
- score: flow.score,
438
- files: (flow.files || []).slice(0, 5),
439
- keywords: flowKeywords(flow),
440
- })), null, 2),
441
- '',
442
- `CANDIDATE_TESTS (${candidateTests.length}):`,
443
- JSON.stringify(candidateTests, null, 2),
444
- '',
445
- `FLOW_CANDIDATE_SIGNALS (${candidateSelection.evidence.length}):`,
446
- JSON.stringify(candidateSelection.evidence, null, 2),
447
- '',
448
- `CANDIDATE_TEST_CONTENT (${candidateTestContents.length} file(s)):`,
449
- testContentBlock,
450
- '',
451
- contextBlock,
452
- ].join('\n');
453
- let parsed = null;
454
- try {
455
- const response = await provider.generateText(prompt, {
456
- maxTokens: Math.max(500, config.maxTokens),
457
- temperature: Math.max(0, Math.min(1, config.temperature)),
458
- timeout: 45000,
459
- systemPrompt: 'Return only valid JSON. Do not include markdown fences unless necessary.',
460
- });
461
- parsed = extractJson(response.text);
462
- }
463
- catch (error) {
464
- const message = error instanceof Error ? error.message : String(error);
465
- warnings.push(`AI mapping request failed (${provider.name}): ${message}`);
466
- return {
467
- enabled: true,
468
- used: false,
469
- provider: provider.name,
470
- mappedFlows: 0,
471
- matchedTests: 0,
472
- coverage: [],
473
- warnings,
474
- };
475
- }
476
- if (!parsed) {
477
- warnings.push(`AI mapping returned invalid JSON (${provider.name}).`);
478
- return {
479
- enabled: true,
480
- used: false,
481
- provider: provider.name,
482
- mappedFlows: 0,
483
- matchedTests: 0,
484
- coverage: [],
485
- warnings,
486
- };
487
- }
488
- const allowedFlowIds = new Set(prioritizedFlows.map((flow) => flow.id));
489
- const prioritizedFlowsById = new Map(prioritizedFlows.map((flow) => [flow.id, flow]));
490
- const mapped = new Map();
491
- const scenarioGaps = new Map();
492
- const matchedTests = new Set();
493
- for (const entry of parsed.mappings) {
494
- if (!entry || !allowedFlowIds.has(entry.flowId) || !Array.isArray(entry.tests)) {
495
- continue;
496
- }
497
- // Capture scenario suggestions for ALL flows up-front — before any early returns —
498
- // so unmapped flows (tests: []) still get their suggested scenarios in the gap report.
499
- if (Array.isArray(entry.missingScenarios) && entry.missingScenarios.length > 0) {
500
- const scenarios = entry.missingScenarios
501
- .filter((s) => typeof s === 'string' && s.trim().length > 0)
502
- .slice(0, 5);
503
- if (scenarios.length > 0) {
504
- scenarioGaps.set(entry.flowId, scenarios);
505
- }
506
- }
507
- const flow = prioritizedFlowsById.get(entry.flowId);
508
- const confidence = typeof entry.confidence === 'number' ? entry.confidence : undefined;
509
- const allowedTestsForFlow = candidateSelection.byFlow.get(entry.flowId);
510
- const valid = Array.from(new Set(entry.tests
511
- .map((testPath) => normalizePath(testPath))
512
- .filter((testPath) => allowedTestsForFlow?.has(testPath))
513
- .filter((testPath) => (flow ? scoreTestPath(flow, testPath) > 0 : true)))).slice(0, 5);
514
- if (confidence !== undefined && confidence < 0.5) {
515
- warnings.push(`AI mapping rejected low-confidence result for ${entry.flowId} (${confidence}).`);
516
- continue;
517
- }
518
- if (valid.length === 0) {
519
- continue;
520
- }
521
- mapped.set(entry.flowId, valid);
522
- for (const testPath of valid) {
523
- matchedTests.add(testPath);
524
- }
525
- }
526
- // Post-AI exact-name fallback: for any flow still uncovered, search all test paths
527
- // for a file or directory whose name exactly matches the flow ID. This handles flows
528
- // whose keywords are all low-signal (e.g. view_user_group_modal) but whose test file
529
- // is named after the flow and is therefore unambiguous coverage evidence.
530
- const allNormalizedTests = tests.map((t) => normalizePath(t.path)).filter(Boolean);
531
- for (const flow of prioritizedFlows) {
532
- if (mapped.has(flow.id)) {
533
- continue;
534
- }
535
- const exactMatch = allNormalizedTests.find((testPath) => {
536
- const segments = testPath.split('/');
537
- return segments.some((seg) => seg === flow.id || seg.replace(/\.spec\.[tj]sx?$/, '') === flow.id);
538
- });
539
- if (exactMatch) {
540
- mapped.set(flow.id, [exactMatch]);
541
- matchedTests.add(exactMatch);
542
- }
543
- }
544
- const coverage = buildCoverage(flows, mapped, scenarioGaps);
545
- if (mapped.size === 0) {
546
- warnings.push(`AI mapping returned no valid test mappings (${provider.name}).`);
547
- }
548
- return {
549
- enabled: true,
550
- used: mapped.size > 0,
551
- provider: provider.name,
552
- mappedFlows: mapped.size,
553
- matchedTests: matchedTests.size,
554
- coverage,
555
- warnings,
556
- };
557
- }