@yasserkhanorg/e2e-agents 1.4.0 → 1.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.
- package/dist/agent/feedback.d.ts +16 -0
- package/dist/agent/feedback.d.ts.map +1 -1
- package/dist/agent/feedback.js +62 -0
- package/dist/agent/process_runner.d.ts +1 -1
- package/dist/agent/process_runner.d.ts.map +1 -1
- package/dist/agent/process_runner.js +3 -3
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +5 -2
- package/dist/cli/commands/train.d.ts.map +1 -1
- package/dist/cli/commands/train.js +31 -4
- package/dist/cli/parse_args.d.ts.map +1 -1
- package/dist/cli/parse_args.js +1 -0
- package/dist/cli/types.d.ts +1 -0
- package/dist/cli/types.d.ts.map +1 -1
- package/dist/engine/plan_builder.d.ts +2 -1
- package/dist/engine/plan_builder.d.ts.map +1 -1
- package/dist/engine/plan_builder.js +22 -9
- package/dist/esm/agent/feedback.js +61 -0
- package/dist/esm/agent/process_runner.js +3 -3
- package/dist/esm/api.js +5 -2
- package/dist/esm/cli/commands/train.js +31 -4
- package/dist/esm/cli/parse_args.js +1 -0
- package/dist/esm/engine/plan_builder.js +22 -9
- package/dist/esm/index.js +1 -1
- package/dist/esm/pipeline/spec_verifier.js +75 -0
- package/dist/esm/pipeline/stage3_generation.js +122 -4
- package/dist/esm/pipeline/stage4_heal.js +146 -3
- package/dist/esm/prompts/heal.js +4 -0
- package/dist/esm/qa-agent/phase2/agent_loop.js +60 -24
- package/dist/esm/qa-agent/phase2/exploration_state.js +21 -0
- package/dist/esm/qa-agent/phase2/tools.js +99 -1
- package/dist/esm/qa-agent/phase3/reporter.js +31 -4
- package/dist/esm/training/enricher.js +71 -7
- package/dist/esm/training/merger.js +77 -10
- package/dist/esm/training/scanner.js +368 -2
- package/dist/esm/validation/guardrails.js +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/pipeline/orchestrator.d.ts.map +1 -1
- package/dist/pipeline/spec_verifier.d.ts +20 -0
- package/dist/pipeline/spec_verifier.d.ts.map +1 -0
- package/dist/pipeline/spec_verifier.js +79 -0
- package/dist/pipeline/stage3_generation.d.ts +10 -0
- package/dist/pipeline/stage3_generation.d.ts.map +1 -1
- package/dist/pipeline/stage3_generation.js +120 -2
- package/dist/pipeline/stage4_heal.d.ts +4 -0
- package/dist/pipeline/stage4_heal.d.ts.map +1 -1
- package/dist/pipeline/stage4_heal.js +145 -2
- package/dist/prompts/heal.d.ts +2 -0
- package/dist/prompts/heal.d.ts.map +1 -1
- package/dist/prompts/heal.js +4 -0
- package/dist/qa-agent/phase2/agent_loop.d.ts.map +1 -1
- package/dist/qa-agent/phase2/agent_loop.js +60 -24
- package/dist/qa-agent/phase2/exploration_state.d.ts.map +1 -1
- package/dist/qa-agent/phase2/exploration_state.js +21 -0
- package/dist/qa-agent/phase2/tools.d.ts.map +1 -1
- package/dist/qa-agent/phase2/tools.js +99 -1
- package/dist/qa-agent/phase3/reporter.js +31 -4
- package/dist/qa-agent/types.d.ts +9 -1
- package/dist/qa-agent/types.d.ts.map +1 -1
- package/dist/training/enricher.d.ts +3 -1
- package/dist/training/enricher.d.ts.map +1 -1
- package/dist/training/enricher.js +71 -7
- package/dist/training/merger.d.ts +11 -1
- package/dist/training/merger.d.ts.map +1 -1
- package/dist/training/merger.js +77 -10
- package/dist/training/scanner.d.ts +15 -2
- package/dist/training/scanner.d.ts.map +1 -1
- package/dist/training/scanner.js +370 -2
- package/dist/training/types.d.ts +4 -0
- package/dist/training/types.d.ts.map +1 -1
- package/dist/validation/guardrails.d.ts +2 -0
- package/dist/validation/guardrails.d.ts.map +1 -1
- package/dist/validation/guardrails.js +4 -1
- package/package.json +1 -1
|
@@ -94,7 +94,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
94
94
|
},
|
|
95
95
|
{
|
|
96
96
|
name: 'report_finding',
|
|
97
|
-
description: 'Report a bug, visual issue, UX problem, or gap you discovered.
|
|
97
|
+
description: 'Report a bug, visual issue, UX problem, or gap you discovered. Include expected/actual behavior and repro steps. Take before/after screenshots before calling this.',
|
|
98
98
|
input_schema: {
|
|
99
99
|
type: 'object',
|
|
100
100
|
properties: {
|
|
@@ -106,6 +106,13 @@ export const TOOL_DEFINITIONS = [
|
|
|
106
106
|
items: { type: 'string' },
|
|
107
107
|
description: 'Steps to reproduce',
|
|
108
108
|
},
|
|
109
|
+
screenshot_refs: {
|
|
110
|
+
type: 'array',
|
|
111
|
+
items: { type: 'string' },
|
|
112
|
+
description: 'Paths to before/after screenshots (from take_screenshot)',
|
|
113
|
+
},
|
|
114
|
+
expected_behavior: { type: 'string', description: 'What should have happened' },
|
|
115
|
+
actual_behavior: { type: 'string', description: 'What actually happened' },
|
|
109
116
|
},
|
|
110
117
|
required: ['type', 'severity', 'summary', 'repro_steps'],
|
|
111
118
|
},
|
|
@@ -133,6 +140,23 @@ export const TOOL_DEFINITIONS = [
|
|
|
133
140
|
required: ['role'],
|
|
134
141
|
},
|
|
135
142
|
},
|
|
143
|
+
{
|
|
144
|
+
name: 'wait_for',
|
|
145
|
+
description: 'Wait for an element condition or page state. Use after actions that trigger async changes (navigation, API calls, animations).',
|
|
146
|
+
input_schema: {
|
|
147
|
+
type: 'object',
|
|
148
|
+
properties: {
|
|
149
|
+
condition: {
|
|
150
|
+
type: 'string',
|
|
151
|
+
enum: ['visible', 'hidden', 'stable', 'networkidle'],
|
|
152
|
+
description: 'What to wait for: visible/hidden (element state), stable (no DOM changes for 1s), networkidle (no pending requests)',
|
|
153
|
+
},
|
|
154
|
+
ref: { type: 'string', description: 'Accessibility ref for element conditions (visible/hidden). Not needed for stable/networkidle.' },
|
|
155
|
+
timeout_ms: { type: 'number', description: 'Max wait time in ms (default 5000, max 15000)' },
|
|
156
|
+
},
|
|
157
|
+
required: ['condition'],
|
|
158
|
+
},
|
|
159
|
+
},
|
|
136
160
|
];
|
|
137
161
|
export function executeTool(ctx, name, input) {
|
|
138
162
|
switch (name) {
|
|
@@ -204,6 +228,36 @@ export function executeTool(ctx, name, input) {
|
|
|
204
228
|
if (!Array.isArray(input.repro_steps)) {
|
|
205
229
|
return { output: `Invalid repro_steps: expected an array of strings.` };
|
|
206
230
|
}
|
|
231
|
+
// Auto-capture console errors at time of finding
|
|
232
|
+
let autoConsoleErrors;
|
|
233
|
+
try {
|
|
234
|
+
const raw = ctx.browser.evaluateInternal('JSON.stringify(window.__consoleErrors || [])');
|
|
235
|
+
const parsed = JSON.parse(raw);
|
|
236
|
+
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
237
|
+
autoConsoleErrors = parsed.map(String).slice(-10);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// Console error capture not available
|
|
242
|
+
}
|
|
243
|
+
// Auto-take screenshot if none provided
|
|
244
|
+
let autoScreenshot;
|
|
245
|
+
const screenshotRefs = Array.isArray(input.screenshot_refs)
|
|
246
|
+
? input.screenshot_refs.map(String)
|
|
247
|
+
: undefined;
|
|
248
|
+
if (!screenshotRefs || screenshotRefs.length === 0) {
|
|
249
|
+
try {
|
|
250
|
+
const nextCount = ctx.screenshotCounter + 1;
|
|
251
|
+
const filename = `${String(nextCount).padStart(3, '0')}-finding-auto.png`;
|
|
252
|
+
const screenshotPath = `${ctx.screenshotDir}/${filename}`;
|
|
253
|
+
ctx.browser.screenshot(screenshotPath);
|
|
254
|
+
ctx.screenshotCounter = nextCount;
|
|
255
|
+
autoScreenshot = screenshotPath;
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
autoScreenshot = undefined;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
207
261
|
const finding = {
|
|
208
262
|
id: `f-${crypto.randomUUID()}`,
|
|
209
263
|
type: rawType,
|
|
@@ -213,6 +267,11 @@ export function executeTool(ctx, name, input) {
|
|
|
213
267
|
evidence: {
|
|
214
268
|
url: ctx.currentUrl,
|
|
215
269
|
reproSteps: input.repro_steps.map(String),
|
|
270
|
+
screenshotRefs: screenshotRefs || (autoScreenshot ? [autoScreenshot] : undefined),
|
|
271
|
+
screenshotPath: autoScreenshot || (screenshotRefs ? screenshotRefs[0] : undefined),
|
|
272
|
+
consoleErrors: autoConsoleErrors,
|
|
273
|
+
expectedBehavior: input.expected_behavior ? String(input.expected_behavior) : undefined,
|
|
274
|
+
actualBehavior: input.actual_behavior ? String(input.actual_behavior) : undefined,
|
|
216
275
|
},
|
|
217
276
|
timestamp: Date.now(),
|
|
218
277
|
};
|
|
@@ -229,6 +288,45 @@ export function executeTool(ctx, name, input) {
|
|
|
229
288
|
flowDone: { flowId, status: rawStatus },
|
|
230
289
|
};
|
|
231
290
|
}
|
|
291
|
+
case 'wait_for': {
|
|
292
|
+
const condition = String(input.condition || '');
|
|
293
|
+
const VALID_CONDITIONS = new Set(['visible', 'hidden', 'stable', 'networkidle']);
|
|
294
|
+
if (!VALID_CONDITIONS.has(condition)) {
|
|
295
|
+
return { output: `Invalid condition "${condition}". Must be one of: ${[...VALID_CONDITIONS].join(', ')}.` };
|
|
296
|
+
}
|
|
297
|
+
const timeoutMs = Math.min(Math.max(Number(input.timeout_ms) || 5000, 500), 15000);
|
|
298
|
+
try {
|
|
299
|
+
if (condition === 'stable' || condition === 'networkidle') {
|
|
300
|
+
const waitMs = condition === 'networkidle' ? Math.min(timeoutMs, 3000) : 1000;
|
|
301
|
+
ctx.browser.evaluateInternal(`new Promise(r => setTimeout(r, ${waitMs}))`);
|
|
302
|
+
return { output: `Waited ${waitMs}ms for ${condition} (heuristic delay)` };
|
|
303
|
+
}
|
|
304
|
+
// Element-level wait: poll snapshot for ref presence/absence
|
|
305
|
+
const ref = input.ref ? String(input.ref) : undefined;
|
|
306
|
+
if (!ref) {
|
|
307
|
+
return { output: `Element condition "${condition}" requires a ref parameter.` };
|
|
308
|
+
}
|
|
309
|
+
const start = Date.now();
|
|
310
|
+
const wantVisible = condition === 'visible';
|
|
311
|
+
// Use word-boundary regex to avoid false positives (@e1 matching @e10)
|
|
312
|
+
const refPattern = new RegExp(`(?<![\\w@])${ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w])`);
|
|
313
|
+
const pollIntervalMs = 300;
|
|
314
|
+
while (Date.now() - start < timeoutMs) {
|
|
315
|
+
const snap = ctx.browser.snapshot();
|
|
316
|
+
const found = refPattern.test(snap);
|
|
317
|
+
if ((wantVisible && found) || (!wantVisible && !found)) {
|
|
318
|
+
return { output: `Element ${ref} is now ${condition} (took ${Date.now() - start}ms)` };
|
|
319
|
+
}
|
|
320
|
+
// Synchronous in-process sleep via Atomics.wait (available in Node.js 8.10+)
|
|
321
|
+
const buf = new SharedArrayBuffer(4);
|
|
322
|
+
Atomics.wait(new Int32Array(buf), 0, 0, pollIntervalMs);
|
|
323
|
+
}
|
|
324
|
+
return { output: `Timeout: element ${ref} did not become ${condition} within ${timeoutMs}ms` };
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
return { output: `wait_for error: ${String(err)}` };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
232
330
|
case 'switch_user': {
|
|
233
331
|
const role = String(input.role);
|
|
234
332
|
const user = ctx.users?.find((u) => u.role === role);
|
|
@@ -80,16 +80,43 @@ function renderMarkdown(report) {
|
|
|
80
80
|
if (report.phase2.findings.length > 0) {
|
|
81
81
|
lines.push(`## Findings`, '');
|
|
82
82
|
for (const f of report.phase2.findings) {
|
|
83
|
-
|
|
83
|
+
const dupNote = f.duplicateCount && f.duplicateCount > 1
|
|
84
|
+
? ` (seen ${f.duplicateCount} times)`
|
|
85
|
+
: '';
|
|
86
|
+
lines.push(`### [${f.severity.toUpperCase()}] ${f.summary}${dupNote}`);
|
|
84
87
|
lines.push('');
|
|
85
88
|
lines.push(`- **Type:** ${f.type}`);
|
|
86
89
|
lines.push(`- **Flow:** ${f.flow}`);
|
|
87
90
|
lines.push(`- **URL:** ${f.evidence.url}`);
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
// Expected vs actual behavior
|
|
92
|
+
if (f.evidence.expectedBehavior || f.evidence.actualBehavior) {
|
|
93
|
+
const escapePipe = (s) => s.replace(/\|/g, '\\|');
|
|
94
|
+
lines.push('');
|
|
95
|
+
lines.push(`| Expected | Actual |`);
|
|
96
|
+
lines.push(`|----------|--------|`);
|
|
97
|
+
lines.push(`| ${escapePipe(f.evidence.expectedBehavior || '—')} | ${escapePipe(f.evidence.actualBehavior || '—')} |`);
|
|
98
|
+
lines.push('');
|
|
99
|
+
}
|
|
100
|
+
// Screenshot evidence (inline images)
|
|
101
|
+
if (f.evidence.screenshotRefs && f.evidence.screenshotRefs.length > 0) {
|
|
102
|
+
for (const ref of f.evidence.screenshotRefs) {
|
|
103
|
+
lines.push(``);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (f.evidence.screenshotPath) {
|
|
107
|
+
lines.push(``);
|
|
108
|
+
}
|
|
109
|
+
// Console errors
|
|
110
|
+
if (f.evidence.consoleErrors && f.evidence.consoleErrors.length > 0) {
|
|
111
|
+
lines.push('');
|
|
112
|
+
lines.push('**Console errors:**');
|
|
113
|
+
for (const err of f.evidence.consoleErrors.slice(0, 5)) {
|
|
114
|
+
lines.push(`- \`${err.replace(/`/g, '\\`')}\``);
|
|
115
|
+
}
|
|
90
116
|
}
|
|
91
117
|
if (f.evidence.reproSteps.length > 0) {
|
|
92
|
-
lines.push('
|
|
118
|
+
lines.push('');
|
|
119
|
+
lines.push('**Repro steps:**');
|
|
93
120
|
for (const step of f.evidence.reproSteps) {
|
|
94
121
|
lines.push(` 1. ${step}`);
|
|
95
122
|
}
|
|
@@ -59,9 +59,47 @@ function sampleFiles(dir, maxFiles) {
|
|
|
59
59
|
walk(dir);
|
|
60
60
|
return files;
|
|
61
61
|
}
|
|
62
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Build a shallow directory listing of the source tree (depth 2-3) so the LLM
|
|
64
|
+
* can suggest accurate webappPaths / serverPaths for test-derived families.
|
|
65
|
+
*/
|
|
66
|
+
function getSourceTreeListing(projectRoot, maxDepth = 3) {
|
|
67
|
+
const lines = [];
|
|
68
|
+
function walk(dir, depth, prefix) {
|
|
69
|
+
if (depth > maxDepth || lines.length > 200)
|
|
70
|
+
return;
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = readdirSync(dir).sort();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const dirs = entries.filter((e) => {
|
|
79
|
+
if (e.startsWith('.') || SKIP_DIRS.has(e))
|
|
80
|
+
return false;
|
|
81
|
+
try {
|
|
82
|
+
const stat = lstatSync(join(dir, e));
|
|
83
|
+
return !stat.isSymbolicLink() && stat.isDirectory();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
for (const d of dirs) {
|
|
90
|
+
lines.push(`${prefix}${d}/`);
|
|
91
|
+
walk(join(dir, d), depth + 1, prefix + ' ');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
walk(resolve(projectRoot), 0, '');
|
|
95
|
+
return lines.join('\n');
|
|
96
|
+
}
|
|
97
|
+
function buildEnrichPrompt(families, projectRoot, testsRoot) {
|
|
63
98
|
const sections = [];
|
|
99
|
+
const hasTestOnlyFamilies = families.some((f) => f.webappPaths.length === 0 && f.serverPaths.length === 0);
|
|
100
|
+
const resolvedTestsRoot = testsRoot ? resolve(testsRoot) : resolve(projectRoot);
|
|
64
101
|
for (const family of families) {
|
|
102
|
+
const isTestOnly = family.webappPaths.length === 0 && family.serverPaths.length === 0;
|
|
65
103
|
const allDirs = [
|
|
66
104
|
...family.webappPaths.map((p) => p.replace(/\/?\*.*$/, '')),
|
|
67
105
|
...family.serverPaths.map((p) => p.replace(/\/?\*.*$/, '')),
|
|
@@ -75,10 +113,19 @@ function buildEnrichPrompt(families, projectRoot) {
|
|
|
75
113
|
if (samples.length >= MAX_FILES_PER_FAMILY)
|
|
76
114
|
break;
|
|
77
115
|
}
|
|
116
|
+
// For test-only families, sample the test files themselves for richer context
|
|
117
|
+
if (isTestOnly) {
|
|
118
|
+
for (const specDir of family.specDirs) {
|
|
119
|
+
if (samples.length >= MAX_FILES_PER_FAMILY)
|
|
120
|
+
break;
|
|
121
|
+
const fullDir = join(resolvedTestsRoot, specDir);
|
|
122
|
+
samples.push(...sampleFiles(fullDir, MAX_FILES_PER_FAMILY - samples.length));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
78
125
|
// Sample spec descriptions
|
|
79
126
|
const specSamples = [];
|
|
80
127
|
for (const specDir of family.specDirs) {
|
|
81
|
-
const fullDir = join(
|
|
128
|
+
const fullDir = join(resolvedTestsRoot, specDir);
|
|
82
129
|
const specFiles = sampleFiles(fullDir, 5);
|
|
83
130
|
for (const sf of specFiles) {
|
|
84
131
|
const matches = sf.content.match(/(?:test|it|describe)\s*\(\s*['"`]([^'"`]+)/g);
|
|
@@ -87,7 +134,7 @@ function buildEnrichPrompt(families, projectRoot) {
|
|
|
87
134
|
}
|
|
88
135
|
}
|
|
89
136
|
}
|
|
90
|
-
sections.push(`## Family: ${family.id}
|
|
137
|
+
sections.push(`## Family: ${family.id}${isTestOnly ? ' [TEST-ONLY — needs webappPaths/serverPaths]' : ''}
|
|
91
138
|
Routes (guessed): ${JSON.stringify(family.routes)}
|
|
92
139
|
Webapp paths: ${JSON.stringify(family.webappPaths)}
|
|
93
140
|
Server paths: ${JSON.stringify(family.serverPaths)}
|
|
@@ -102,6 +149,10 @@ Test descriptions:
|
|
|
102
149
|
${specSamples.length > 0 ? specSamples.map((d) => `- ${d}`).join('\n') : '(none found)'}
|
|
103
150
|
`);
|
|
104
151
|
}
|
|
152
|
+
// Include source tree listing when we have test-only families
|
|
153
|
+
const sourceTreeSection = hasTestOnlyFamilies
|
|
154
|
+
? `\n## Source Directory Structure\nUse this to suggest accurate webappPaths and serverPaths for test-only families:\n\`\`\`\n${getSourceTreeListing(projectRoot)}\n\`\`\`\n`
|
|
155
|
+
: '';
|
|
105
156
|
return `You are analyzing a codebase to enrich route-family definitions for an E2E test impact analysis tool.
|
|
106
157
|
|
|
107
158
|
For each family below, provide:
|
|
@@ -110,6 +161,8 @@ For each family below, provide:
|
|
|
110
161
|
3. **routes**: Improved URL patterns (e.g., "/{team}/channels/{channel}" instead of "/channels")
|
|
111
162
|
4. **pageObjects**: Array of page object class names found in the code
|
|
112
163
|
5. **components**: Array of UI component names relevant to this family
|
|
164
|
+
6. **webappPaths**: Array of glob patterns for frontend source directories (e.g., "src/components/drafts/**"). REQUIRED for families marked [TEST-ONLY].
|
|
165
|
+
7. **serverPaths**: Array of glob patterns for backend source directories. REQUIRED for families marked [TEST-ONLY].
|
|
113
166
|
|
|
114
167
|
Respond in JSON format:
|
|
115
168
|
\`\`\`json
|
|
@@ -120,11 +173,13 @@ Respond in JSON format:
|
|
|
120
173
|
"userFlows": ["Flow name 1", "Flow name 2"],
|
|
121
174
|
"routes": ["/improved/route/{param}"],
|
|
122
175
|
"pageObjects": ["PageName"],
|
|
123
|
-
"components": ["ComponentName"]
|
|
176
|
+
"components": ["ComponentName"],
|
|
177
|
+
"webappPaths": ["src/components/feature_name/**"],
|
|
178
|
+
"serverPaths": ["server/channels/api4/feature.go"]
|
|
124
179
|
}
|
|
125
180
|
]
|
|
126
181
|
\`\`\`
|
|
127
|
-
|
|
182
|
+
${sourceTreeSection}
|
|
128
183
|
${sections.join('\n---\n')}`;
|
|
129
184
|
}
|
|
130
185
|
export function validateEntries(parsed) {
|
|
@@ -143,6 +198,8 @@ export function validateEntries(parsed) {
|
|
|
143
198
|
userFlows: filterStrings(entry.userFlows, 500),
|
|
144
199
|
pageObjects: filterStrings(entry.pageObjects, 200),
|
|
145
200
|
components: filterStrings(entry.components, 200),
|
|
201
|
+
webappPaths: filterStrings(entry.webappPaths, 300),
|
|
202
|
+
serverPaths: filterStrings(entry.serverPaths, 300),
|
|
146
203
|
}));
|
|
147
204
|
}
|
|
148
205
|
export function parseEnrichResponse(response) {
|
|
@@ -192,9 +249,16 @@ function applyEnrichment(family, enriched) {
|
|
|
192
249
|
if (enriched.components && (!family.components || family.components.length === 0)) {
|
|
193
250
|
result.components = enriched.components;
|
|
194
251
|
}
|
|
252
|
+
// Only fill source paths when the family has none (test-derived families)
|
|
253
|
+
if (enriched.webappPaths && (!family.webappPaths || family.webappPaths.length === 0)) {
|
|
254
|
+
result.webappPaths = enriched.webappPaths;
|
|
255
|
+
}
|
|
256
|
+
if (enriched.serverPaths && (!family.serverPaths || family.serverPaths.length === 0)) {
|
|
257
|
+
result.serverPaths = enriched.serverPaths;
|
|
258
|
+
}
|
|
195
259
|
return result;
|
|
196
260
|
}
|
|
197
|
-
export async function enrichFamilies(families, scanned, projectRoot, provider, budgetUSD) {
|
|
261
|
+
export async function enrichFamilies(families, scanned, projectRoot, provider, budgetUSD, testsRoot) {
|
|
198
262
|
const scannedMap = new Map(scanned.map((s) => [s.id, s]));
|
|
199
263
|
const enriched = [];
|
|
200
264
|
let totalTokens = 0;
|
|
@@ -218,7 +282,7 @@ export async function enrichFamilies(families, scanned, projectRoot, provider, b
|
|
|
218
282
|
enriched.push(...chunk);
|
|
219
283
|
continue;
|
|
220
284
|
}
|
|
221
|
-
let prompt = buildEnrichPrompt(scannedChunk, projectRoot);
|
|
285
|
+
let prompt = buildEnrichPrompt(scannedChunk, projectRoot, testsRoot);
|
|
222
286
|
if (prompt.length > MAX_PROMPT_CHARS) {
|
|
223
287
|
// Truncate at the last complete section boundary to avoid malformed input
|
|
224
288
|
const lastSectionEnd = prompt.lastIndexOf('\n---\n', MAX_PROMPT_CHARS);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
2
|
// See LICENSE.txt for license information.
|
|
3
|
+
import { execFileSync } from 'child_process';
|
|
3
4
|
import { existsSync } from 'fs';
|
|
4
5
|
import { join, resolve } from 'path';
|
|
5
6
|
import { isGuessedRoute } from './types.js';
|
|
@@ -67,6 +68,21 @@ function scannedToRouteFamily(scanned) {
|
|
|
67
68
|
}
|
|
68
69
|
return family;
|
|
69
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Try to find a matching family ID with singular/plural normalization.
|
|
73
|
+
* "team" matches "teams", "emoji" matches "emoji", etc.
|
|
74
|
+
*/
|
|
75
|
+
function findFuzzyMatch(id, idMap) {
|
|
76
|
+
if (idMap.has(id))
|
|
77
|
+
return id;
|
|
78
|
+
// Try adding 's'
|
|
79
|
+
if (!id.endsWith('s') && idMap.has(id + 's'))
|
|
80
|
+
return id + 's';
|
|
81
|
+
// Try removing 's'
|
|
82
|
+
if (id.endsWith('s') && idMap.has(id.slice(0, -1)))
|
|
83
|
+
return id.slice(0, -1);
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
70
86
|
export function mergeFamilies(existing, scanned) {
|
|
71
87
|
const existingFamilies = existing?.families || [];
|
|
72
88
|
const existingMap = new Map(existingFamilies.map((f) => [f.id, f]));
|
|
@@ -74,9 +90,15 @@ export function mergeFamilies(existing, scanned) {
|
|
|
74
90
|
const newFamilies = [];
|
|
75
91
|
const updatedFamilies = [];
|
|
76
92
|
const mergedFamilies = [];
|
|
77
|
-
// Process existing families
|
|
93
|
+
// Process existing families — match scanned by exact or fuzzy ID
|
|
78
94
|
for (const ef of existingFamilies) {
|
|
79
|
-
|
|
95
|
+
let sf = scannedMap.get(ef.id);
|
|
96
|
+
// Try singular/plural match if exact match failed
|
|
97
|
+
if (!sf) {
|
|
98
|
+
const fuzzyId = findFuzzyMatch(ef.id, scannedMap);
|
|
99
|
+
if (fuzzyId)
|
|
100
|
+
sf = scannedMap.get(fuzzyId);
|
|
101
|
+
}
|
|
80
102
|
if (sf) {
|
|
81
103
|
mergedFamilies.push(mergeFamily(ef, sf));
|
|
82
104
|
updatedFamilies.push(ef.id);
|
|
@@ -86,9 +108,10 @@ export function mergeFamilies(existing, scanned) {
|
|
|
86
108
|
mergedFamilies.push({ ...ef });
|
|
87
109
|
}
|
|
88
110
|
}
|
|
89
|
-
// Add new families from scanner
|
|
111
|
+
// Add new families from scanner (if no existing family matched)
|
|
90
112
|
for (const sf of scanned) {
|
|
91
|
-
|
|
113
|
+
const matchedExisting = findFuzzyMatch(sf.id, existingMap);
|
|
114
|
+
if (!matchedExisting) {
|
|
92
115
|
mergedFamilies.push(scannedToRouteFamily(sf));
|
|
93
116
|
newFamilies.push(sf.id);
|
|
94
117
|
}
|
|
@@ -108,8 +131,33 @@ export function mergeFamilies(existing, scanned) {
|
|
|
108
131
|
summary: parts.join(', '),
|
|
109
132
|
};
|
|
110
133
|
}
|
|
111
|
-
|
|
112
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Detect families whose paths no longer exist on disk.
|
|
136
|
+
*
|
|
137
|
+
* Paths in the manifest may be relative to different roots:
|
|
138
|
+
* - webappPaths / serverPaths are typically relative to the repo root
|
|
139
|
+
* - specDirs may be relative to the tests root
|
|
140
|
+
*
|
|
141
|
+
* We try each pattern against all provided roots (and the git repo root
|
|
142
|
+
* if discoverable) to avoid false positives from path-prefix mismatches.
|
|
143
|
+
*/
|
|
144
|
+
export function detectStaleFamilies(manifest, projectRoot, testsRoot) {
|
|
145
|
+
const roots = new Set([resolve(projectRoot)]);
|
|
146
|
+
if (testsRoot)
|
|
147
|
+
roots.add(resolve(testsRoot));
|
|
148
|
+
// Also try to discover the git repo root — manifest paths may be repo-relative
|
|
149
|
+
try {
|
|
150
|
+
const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
151
|
+
cwd: projectRoot,
|
|
152
|
+
encoding: 'utf-8',
|
|
153
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
154
|
+
}).trim();
|
|
155
|
+
if (gitRoot)
|
|
156
|
+
roots.add(resolve(gitRoot));
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Not a git repo or git not available — that's fine
|
|
160
|
+
}
|
|
113
161
|
const stale = [];
|
|
114
162
|
for (const family of manifest.families) {
|
|
115
163
|
const allPatterns = [
|
|
@@ -119,15 +167,34 @@ export function detectStaleFamilies(manifest, projectRoot) {
|
|
|
119
167
|
];
|
|
120
168
|
if (allPatterns.length === 0)
|
|
121
169
|
continue;
|
|
122
|
-
// Check if any pattern resolves to existing files/dirs
|
|
170
|
+
// Check if any pattern resolves to existing files/dirs in any root
|
|
123
171
|
let hasAny = false;
|
|
124
172
|
for (const pattern of allPatterns) {
|
|
125
173
|
// Strip trailing glob (* or **) to get the directory
|
|
126
174
|
const dirPart = pattern.replace(/\/?\*.*$/, '');
|
|
127
|
-
if (dirPart
|
|
128
|
-
|
|
129
|
-
|
|
175
|
+
if (!dirPart)
|
|
176
|
+
continue;
|
|
177
|
+
// For file-level patterns like "server/channels/api4/draft*.go",
|
|
178
|
+
// dirPart is "server/channels/api4/draft" — check the parent dir instead
|
|
179
|
+
const isFileGlob = /\.\w+$/.test(pattern);
|
|
180
|
+
const pathsToCheck = [dirPart];
|
|
181
|
+
if (isFileGlob) {
|
|
182
|
+
const parentDir = dirPart.split('/').slice(0, -1).join('/');
|
|
183
|
+
if (parentDir)
|
|
184
|
+
pathsToCheck.push(parentDir);
|
|
185
|
+
}
|
|
186
|
+
for (const checkPath of pathsToCheck) {
|
|
187
|
+
for (const root of roots) {
|
|
188
|
+
if (existsSync(join(root, checkPath))) {
|
|
189
|
+
hasAny = true;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (hasAny)
|
|
194
|
+
break;
|
|
130
195
|
}
|
|
196
|
+
if (hasAny)
|
|
197
|
+
break;
|
|
131
198
|
}
|
|
132
199
|
if (!hasAny) {
|
|
133
200
|
stale.push(family.id);
|