@yasserkhanorg/e2e-agents 1.3.2 → 1.5.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/README.md +40 -9
- 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 +3 -0
- package/dist/cli/commands/train.d.ts.map +1 -0
- package/dist/cli/commands/train.js +307 -0
- package/dist/cli/parse_args.d.ts.map +1 -1
- package/dist/cli/parse_args.js +7 -1
- package/dist/cli/types.d.ts +6 -1
- package/dist/cli/types.d.ts.map +1 -1
- package/dist/cli/usage.d.ts.map +1 -1
- package/dist/cli/usage.js +7 -1
- package/dist/cli.js +5 -0
- 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 +271 -0
- package/dist/esm/cli/parse_args.js +7 -1
- package/dist/esm/cli/usage.js +7 -1
- package/dist/esm/cli.js +5 -0
- package/dist/esm/engine/plan_builder.js +22 -9
- package/dist/esm/index.js +6 -1
- package/dist/esm/knowledge/route_families.js +2 -2
- 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 +273 -0
- package/dist/esm/training/merger.js +137 -0
- package/dist/esm/training/scanner.js +386 -0
- package/dist/esm/training/types.js +6 -0
- package/dist/esm/training/validator.js +153 -0
- package/dist/esm/validation/guardrails.js +1 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -1
- package/dist/knowledge/route_families.d.ts +2 -0
- package/dist/knowledge/route_families.d.ts.map +1 -1
- package/dist/knowledge/route_families.js +2 -0
- 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 +15 -0
- package/dist/training/enricher.d.ts.map +1 -0
- package/dist/training/enricher.js +278 -0
- package/dist/training/merger.d.ts +5 -0
- package/dist/training/merger.d.ts.map +1 -0
- package/dist/training/merger.js +141 -0
- package/dist/training/scanner.d.ts +5 -0
- package/dist/training/scanner.d.ts.map +1 -0
- package/dist/training/scanner.js +391 -0
- package/dist/training/types.d.ts +109 -0
- package/dist/training/types.d.ts.map +1 -0
- package/dist/training/types.js +9 -0
- package/dist/training/validator.d.ts +16 -0
- package/dist/training/validator.d.ts.map +1 -0
- package/dist/training/validator.js +160 -0
- 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
|
@@ -8,6 +8,7 @@ export function createExplorationState(flows, timeLimitMs, budgetUSD) {
|
|
|
8
8
|
flowsExplored: [],
|
|
9
9
|
currentFlow: null,
|
|
10
10
|
findings: [],
|
|
11
|
+
findingDedupIndex: {},
|
|
11
12
|
actionsLog: [],
|
|
12
13
|
recentActions: [],
|
|
13
14
|
tokensUsed: 0,
|
|
@@ -24,7 +25,27 @@ export function recordAction(state, action) {
|
|
|
24
25
|
state.recentActions.shift();
|
|
25
26
|
}
|
|
26
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Hash a finding on (type + severity + normalizedSummary + urlPattern) for dedup.
|
|
30
|
+
*/
|
|
31
|
+
function findingDedupKey(finding) {
|
|
32
|
+
// Normalize: lowercase, collapse whitespace, strip trailing punctuation
|
|
33
|
+
const normalizedSummary = finding.summary.toLowerCase().replace(/\s+/g, ' ').replace(/[.!?]+$/, '').trim();
|
|
34
|
+
// Extract URL pattern: strip query params and hash, replace path segments that look like IDs
|
|
35
|
+
const urlPattern = finding.evidence.url
|
|
36
|
+
.replace(/[?#].*$/, '')
|
|
37
|
+
.replace(/\/[a-z0-9]{20,}/gi, '/{id}')
|
|
38
|
+
.replace(/\/\d{2,}/g, '/{id}');
|
|
39
|
+
return `${finding.type}|${finding.severity}|${normalizedSummary}|${urlPattern}`;
|
|
40
|
+
}
|
|
27
41
|
export function recordFinding(state, finding) {
|
|
42
|
+
const key = findingDedupKey(finding);
|
|
43
|
+
const existingIdx = state.findingDedupIndex[key];
|
|
44
|
+
if (existingIdx !== undefined && existingIdx < state.findings.length) {
|
|
45
|
+
state.findings[existingIdx].duplicateCount = (state.findings[existingIdx].duplicateCount || 1) + 1;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
state.findingDedupIndex[key] = state.findings.length;
|
|
28
49
|
state.findings.push(finding);
|
|
29
50
|
}
|
|
30
51
|
export function markFlowExplored(state, flowId) {
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
|
+
// See LICENSE.txt for license information.
|
|
3
|
+
import { lstatSync, readdirSync, readFileSync } from 'fs';
|
|
4
|
+
import { join, relative, resolve } from 'path';
|
|
5
|
+
import { isGuessedRoute } from './types.js';
|
|
6
|
+
const MAX_FILES_PER_FAMILY = 20;
|
|
7
|
+
const MAX_LINES_PER_FILE = 50;
|
|
8
|
+
const LLM_TIMEOUT_MS = 60000;
|
|
9
|
+
const MAX_PROMPT_CHARS = 100000;
|
|
10
|
+
const SENSITIVE_PATTERNS = [
|
|
11
|
+
/[._]env/, /secret/i, /credential/i, /\.pem$/, /\.key$/, /password/i,
|
|
12
|
+
/config\/secrets/, /fixtures\/.*auth/i, /\.npmrc/, /\.netrc/,
|
|
13
|
+
/id_rsa/, /id_ed25519/, /\.p12$/, /\.pfx$/, /tokens?\.json/i,
|
|
14
|
+
];
|
|
15
|
+
const SKIP_DIRS = new Set([
|
|
16
|
+
'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', 'coverage',
|
|
17
|
+
]);
|
|
18
|
+
function sampleFiles(dir, maxFiles) {
|
|
19
|
+
const files = [];
|
|
20
|
+
function walk(d, depth = 0, maxDepth = 10) {
|
|
21
|
+
if (files.length >= maxFiles)
|
|
22
|
+
return;
|
|
23
|
+
if (depth > maxDepth)
|
|
24
|
+
return;
|
|
25
|
+
try {
|
|
26
|
+
for (const entry of readdirSync(d)) {
|
|
27
|
+
if (files.length >= maxFiles)
|
|
28
|
+
return;
|
|
29
|
+
// Skip dot-dirs and known heavy directories
|
|
30
|
+
if (entry.startsWith('.') || SKIP_DIRS.has(entry))
|
|
31
|
+
continue;
|
|
32
|
+
const full = join(d, entry);
|
|
33
|
+
try {
|
|
34
|
+
// Skip symlinks
|
|
35
|
+
const lstat = lstatSync(full);
|
|
36
|
+
if (lstat.isSymbolicLink())
|
|
37
|
+
continue;
|
|
38
|
+
// Skip sensitive files (test against relative path from scan root)
|
|
39
|
+
const relPath = relative(dir, full);
|
|
40
|
+
if (SENSITIVE_PATTERNS.some((p) => p.test(relPath) || p.test(entry)))
|
|
41
|
+
continue;
|
|
42
|
+
if (lstat.isDirectory()) {
|
|
43
|
+
walk(full, depth + 1, maxDepth);
|
|
44
|
+
}
|
|
45
|
+
else if (lstat.isFile() && lstat.size < 50000) {
|
|
46
|
+
const ext = entry.slice(entry.lastIndexOf('.'));
|
|
47
|
+
if (['.ts', '.tsx', '.js', '.jsx', '.go', '.py', '.rs'].includes(ext)) {
|
|
48
|
+
const content = readFileSync(full, 'utf-8');
|
|
49
|
+
const lines = content.split('\n').slice(0, MAX_LINES_PER_FILE).join('\n');
|
|
50
|
+
files.push({ path: full, content: lines });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch { /* skip */ }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch { /* skip */ }
|
|
58
|
+
}
|
|
59
|
+
walk(dir);
|
|
60
|
+
return files;
|
|
61
|
+
}
|
|
62
|
+
function buildEnrichPrompt(families, projectRoot) {
|
|
63
|
+
const sections = [];
|
|
64
|
+
for (const family of families) {
|
|
65
|
+
const allDirs = [
|
|
66
|
+
...family.webappPaths.map((p) => p.replace(/\/?\*.*$/, '')),
|
|
67
|
+
...family.serverPaths.map((p) => p.replace(/\/?\*.*$/, '')),
|
|
68
|
+
];
|
|
69
|
+
const samples = [];
|
|
70
|
+
for (const dir of allDirs) {
|
|
71
|
+
if (!dir)
|
|
72
|
+
continue;
|
|
73
|
+
const fullDir = join(resolve(projectRoot), dir);
|
|
74
|
+
samples.push(...sampleFiles(fullDir, MAX_FILES_PER_FAMILY - samples.length));
|
|
75
|
+
if (samples.length >= MAX_FILES_PER_FAMILY)
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
// Sample spec descriptions
|
|
79
|
+
const specSamples = [];
|
|
80
|
+
for (const specDir of family.specDirs) {
|
|
81
|
+
const fullDir = join(resolve(projectRoot), specDir);
|
|
82
|
+
const specFiles = sampleFiles(fullDir, 5);
|
|
83
|
+
for (const sf of specFiles) {
|
|
84
|
+
const matches = sf.content.match(/(?:test|it|describe)\s*\(\s*['"`]([^'"`]+)/g);
|
|
85
|
+
if (matches) {
|
|
86
|
+
specSamples.push(...matches.map((m) => m.replace(/(?:test|it|describe)\s*\(\s*['"`]/, '')));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
sections.push(`## Family: ${family.id}
|
|
91
|
+
Routes (guessed): ${JSON.stringify(family.routes)}
|
|
92
|
+
Webapp paths: ${JSON.stringify(family.webappPaths)}
|
|
93
|
+
Server paths: ${JSON.stringify(family.serverPaths)}
|
|
94
|
+
Spec dirs: ${JSON.stringify(family.specDirs)}
|
|
95
|
+
Tags: ${JSON.stringify(family.tags)}
|
|
96
|
+
Features: ${family.features.map((f) => f.id).join(', ') || 'none'}
|
|
97
|
+
|
|
98
|
+
Sample files (${samples.length}):
|
|
99
|
+
${samples.map((s) => `### ${relative(projectRoot, s.path)}\n\`\`\`\n${s.content}\n\`\`\``).join('\n')}
|
|
100
|
+
|
|
101
|
+
Test descriptions:
|
|
102
|
+
${specSamples.length > 0 ? specSamples.map((d) => `- ${d}`).join('\n') : '(none found)'}
|
|
103
|
+
`);
|
|
104
|
+
}
|
|
105
|
+
return `You are analyzing a codebase to enrich route-family definitions for an E2E test impact analysis tool.
|
|
106
|
+
|
|
107
|
+
For each family below, provide:
|
|
108
|
+
1. **priority**: P0 (critical user flow), P1 (important), or P2 (nice-to-have)
|
|
109
|
+
2. **userFlows**: Array of human-readable flow names (e.g., "Create channel", "Search messages")
|
|
110
|
+
3. **routes**: Improved URL patterns (e.g., "/{team}/channels/{channel}" instead of "/channels")
|
|
111
|
+
4. **pageObjects**: Array of page object class names found in the code
|
|
112
|
+
5. **components**: Array of UI component names relevant to this family
|
|
113
|
+
|
|
114
|
+
Respond in JSON format:
|
|
115
|
+
\`\`\`json
|
|
116
|
+
[
|
|
117
|
+
{
|
|
118
|
+
"id": "family_id",
|
|
119
|
+
"priority": "P0",
|
|
120
|
+
"userFlows": ["Flow name 1", "Flow name 2"],
|
|
121
|
+
"routes": ["/improved/route/{param}"],
|
|
122
|
+
"pageObjects": ["PageName"],
|
|
123
|
+
"components": ["ComponentName"]
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
\`\`\`
|
|
127
|
+
|
|
128
|
+
${sections.join('\n---\n')}`;
|
|
129
|
+
}
|
|
130
|
+
export function validateEntries(parsed) {
|
|
131
|
+
const filterStrings = (arr, maxLen) => {
|
|
132
|
+
if (!Array.isArray(arr))
|
|
133
|
+
return undefined;
|
|
134
|
+
const filtered = arr.filter((v) => typeof v === 'string' && v.length < maxLen);
|
|
135
|
+
return filtered.length > 0 ? filtered : undefined;
|
|
136
|
+
};
|
|
137
|
+
return parsed
|
|
138
|
+
.filter((e) => !!e && typeof e.id === 'string')
|
|
139
|
+
.map((entry) => ({
|
|
140
|
+
id: entry.id,
|
|
141
|
+
priority: ['P0', 'P1', 'P2'].includes(entry.priority) ? entry.priority : undefined,
|
|
142
|
+
routes: filterStrings(entry.routes, 200),
|
|
143
|
+
userFlows: filterStrings(entry.userFlows, 500),
|
|
144
|
+
pageObjects: filterStrings(entry.pageObjects, 200),
|
|
145
|
+
components: filterStrings(entry.components, 200),
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
export function parseEnrichResponse(response) {
|
|
149
|
+
// Extract JSON from response (may be wrapped in markdown code block)
|
|
150
|
+
const jsonMatch = response.match(/```(?:json)?\s*([\s\S]*?)```/) || [null, response];
|
|
151
|
+
const jsonStr = jsonMatch[1]?.trim() || response.trim();
|
|
152
|
+
try {
|
|
153
|
+
const parsed = JSON.parse(jsonStr);
|
|
154
|
+
if (Array.isArray(parsed)) {
|
|
155
|
+
return validateEntries(parsed);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Try to find any JSON array in the response
|
|
160
|
+
const arrayMatch = response.match(/\[[\s\S]*\]/);
|
|
161
|
+
if (arrayMatch) {
|
|
162
|
+
try {
|
|
163
|
+
const parsed = JSON.parse(arrayMatch[0]);
|
|
164
|
+
if (Array.isArray(parsed)) {
|
|
165
|
+
return validateEntries(parsed);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// give up
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
function applyEnrichment(family, enriched) {
|
|
176
|
+
const result = { ...family };
|
|
177
|
+
if (enriched.priority && !family.priority) {
|
|
178
|
+
result.priority = enriched.priority;
|
|
179
|
+
}
|
|
180
|
+
if (enriched.userFlows && (!family.userFlows || family.userFlows.length === 0)) {
|
|
181
|
+
result.userFlows = enriched.userFlows;
|
|
182
|
+
}
|
|
183
|
+
if (enriched.routes && enriched.routes.length > 0) {
|
|
184
|
+
// Only replace if current routes look like guesses
|
|
185
|
+
if (isGuessedRoute(family.routes)) {
|
|
186
|
+
result.routes = enriched.routes;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (enriched.pageObjects && (!family.pageObjects || family.pageObjects.length === 0)) {
|
|
190
|
+
result.pageObjects = enriched.pageObjects;
|
|
191
|
+
}
|
|
192
|
+
if (enriched.components && (!family.components || family.components.length === 0)) {
|
|
193
|
+
result.components = enriched.components;
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
export async function enrichFamilies(families, scanned, projectRoot, provider, budgetUSD) {
|
|
198
|
+
const scannedMap = new Map(scanned.map((s) => [s.id, s]));
|
|
199
|
+
const enriched = [];
|
|
200
|
+
let totalTokens = 0;
|
|
201
|
+
let totalCost = 0;
|
|
202
|
+
const skipped = [];
|
|
203
|
+
// Process in chunks of 4 families
|
|
204
|
+
const chunkSize = 4;
|
|
205
|
+
for (let i = 0; i < families.length; i += chunkSize) {
|
|
206
|
+
if (totalCost >= budgetUSD) {
|
|
207
|
+
for (let j = i; j < families.length; j++) {
|
|
208
|
+
skipped.push(families[j].id);
|
|
209
|
+
enriched.push(families[j]);
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
const chunk = families.slice(i, i + chunkSize);
|
|
214
|
+
const scannedChunk = chunk
|
|
215
|
+
.map((f) => scannedMap.get(f.id))
|
|
216
|
+
.filter((s) => s !== undefined);
|
|
217
|
+
if (scannedChunk.length === 0) {
|
|
218
|
+
enriched.push(...chunk);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
let prompt = buildEnrichPrompt(scannedChunk, projectRoot);
|
|
222
|
+
if (prompt.length > MAX_PROMPT_CHARS) {
|
|
223
|
+
// Truncate at the last complete section boundary to avoid malformed input
|
|
224
|
+
const lastSectionEnd = prompt.lastIndexOf('\n---\n', MAX_PROMPT_CHARS);
|
|
225
|
+
if (lastSectionEnd > 0) {
|
|
226
|
+
console.warn(`[train] Prompt truncated from ${prompt.length} chars at section boundary`);
|
|
227
|
+
prompt = prompt.slice(0, lastSectionEnd);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
console.warn(`[train] Prompt truncated from ${prompt.length} to ${MAX_PROMPT_CHARS} chars`);
|
|
231
|
+
prompt = prompt.slice(0, MAX_PROMPT_CHARS);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
let timer;
|
|
235
|
+
try {
|
|
236
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
237
|
+
timer = setTimeout(() => reject(new Error('LLM request timed out')), LLM_TIMEOUT_MS);
|
|
238
|
+
});
|
|
239
|
+
const response = await Promise.race([
|
|
240
|
+
provider.generateText(prompt, { maxTokens: 4096, temperature: 0.3 }),
|
|
241
|
+
timeoutPromise,
|
|
242
|
+
]);
|
|
243
|
+
totalTokens += (response.usage?.inputTokens ?? 0) + (response.usage?.outputTokens ?? 0);
|
|
244
|
+
totalCost += response.cost ?? 0;
|
|
245
|
+
const entries = parseEnrichResponse(response.text);
|
|
246
|
+
const entryMap = new Map(entries.map((e) => [e.id, e]));
|
|
247
|
+
for (const family of chunk) {
|
|
248
|
+
const entry = entryMap.get(family.id);
|
|
249
|
+
if (entry) {
|
|
250
|
+
enriched.push(applyEnrichment(family, entry));
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
enriched.push(family);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
// On LLM failure, keep families unchanged
|
|
259
|
+
console.warn(`[train] LLM enrichment failed for chunk: ${error instanceof Error ? error.message : String(error)}`);
|
|
260
|
+
enriched.push(...chunk);
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
if (timer)
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
enrichedFamilies: enriched,
|
|
269
|
+
tokensUsed: totalTokens,
|
|
270
|
+
costUSD: Math.round(totalCost * 100) / 100,
|
|
271
|
+
skippedFamilies: skipped,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
|
+
// See LICENSE.txt for license information.
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { join, resolve } from 'path';
|
|
5
|
+
import { isGuessedRoute } from './types.js';
|
|
6
|
+
function unionArrays(existing, incoming) {
|
|
7
|
+
const set = new Set(existing || []);
|
|
8
|
+
for (const item of incoming) {
|
|
9
|
+
set.add(item);
|
|
10
|
+
}
|
|
11
|
+
return Array.from(set);
|
|
12
|
+
}
|
|
13
|
+
function mergeFamily(existing, scanned) {
|
|
14
|
+
const merged = { ...existing };
|
|
15
|
+
// Structural fields: union arrays
|
|
16
|
+
merged.webappPaths = unionArrays(existing.webappPaths, scanned.webappPaths);
|
|
17
|
+
merged.serverPaths = unionArrays(existing.serverPaths, scanned.serverPaths);
|
|
18
|
+
merged.specDirs = unionArrays(existing.specDirs, scanned.specDirs);
|
|
19
|
+
merged.cypressSpecDirs = unionArrays(existing.cypressSpecDirs, scanned.cypressSpecDirs);
|
|
20
|
+
merged.tags = unionArrays(existing.tags, scanned.tags);
|
|
21
|
+
// Routes: only update if existing looks like a guess
|
|
22
|
+
if (isGuessedRoute(existing.routes) && !isGuessedRoute(scanned.routes)) {
|
|
23
|
+
merged.routes = scanned.routes;
|
|
24
|
+
}
|
|
25
|
+
// Human-curated fields: never overwrite (priority, userFlows, pageObjects, components)
|
|
26
|
+
// Merge features
|
|
27
|
+
if (scanned.features.length > 0) {
|
|
28
|
+
const existingFeatures = existing.features || [];
|
|
29
|
+
const existingIds = new Set(existingFeatures.map((f) => f.id));
|
|
30
|
+
const newFeatures = scanned.features.filter((f) => !existingIds.has(f.id));
|
|
31
|
+
if (newFeatures.length > 0) {
|
|
32
|
+
merged.features = [
|
|
33
|
+
...existingFeatures,
|
|
34
|
+
...newFeatures.map((f) => ({
|
|
35
|
+
id: f.id,
|
|
36
|
+
webappPaths: f.webappPaths,
|
|
37
|
+
serverPaths: f.serverPaths,
|
|
38
|
+
specDirs: f.specDirs,
|
|
39
|
+
})),
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return merged;
|
|
44
|
+
}
|
|
45
|
+
function scannedToRouteFamily(scanned) {
|
|
46
|
+
const family = {
|
|
47
|
+
id: scanned.id,
|
|
48
|
+
routes: scanned.routes,
|
|
49
|
+
};
|
|
50
|
+
if (scanned.webappPaths.length > 0)
|
|
51
|
+
family.webappPaths = scanned.webappPaths;
|
|
52
|
+
if (scanned.serverPaths.length > 0)
|
|
53
|
+
family.serverPaths = scanned.serverPaths;
|
|
54
|
+
if (scanned.specDirs.length > 0)
|
|
55
|
+
family.specDirs = scanned.specDirs;
|
|
56
|
+
if (scanned.cypressSpecDirs.length > 0)
|
|
57
|
+
family.cypressSpecDirs = scanned.cypressSpecDirs;
|
|
58
|
+
if (scanned.tags.length > 0)
|
|
59
|
+
family.tags = scanned.tags;
|
|
60
|
+
if (scanned.features.length > 0) {
|
|
61
|
+
family.features = scanned.features.map((f) => ({
|
|
62
|
+
id: f.id,
|
|
63
|
+
webappPaths: f.webappPaths.length > 0 ? f.webappPaths : undefined,
|
|
64
|
+
serverPaths: f.serverPaths.length > 0 ? f.serverPaths : undefined,
|
|
65
|
+
specDirs: f.specDirs.length > 0 ? f.specDirs : undefined,
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
return family;
|
|
69
|
+
}
|
|
70
|
+
export function mergeFamilies(existing, scanned) {
|
|
71
|
+
const existingFamilies = existing?.families || [];
|
|
72
|
+
const existingMap = new Map(existingFamilies.map((f) => [f.id, f]));
|
|
73
|
+
const scannedMap = new Map(scanned.map((f) => [f.id, f]));
|
|
74
|
+
const newFamilies = [];
|
|
75
|
+
const updatedFamilies = [];
|
|
76
|
+
const mergedFamilies = [];
|
|
77
|
+
// Process existing families
|
|
78
|
+
for (const ef of existingFamilies) {
|
|
79
|
+
const sf = scannedMap.get(ef.id);
|
|
80
|
+
if (sf) {
|
|
81
|
+
mergedFamilies.push(mergeFamily(ef, sf));
|
|
82
|
+
updatedFamilies.push(ef.id);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
// Keep untouched
|
|
86
|
+
mergedFamilies.push({ ...ef });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Add new families from scanner
|
|
90
|
+
for (const sf of scanned) {
|
|
91
|
+
if (!existingMap.has(sf.id)) {
|
|
92
|
+
mergedFamilies.push(scannedToRouteFamily(sf));
|
|
93
|
+
newFamilies.push(sf.id);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const parts = [];
|
|
97
|
+
if (updatedFamilies.length > 0)
|
|
98
|
+
parts.push(`${updatedFamilies.length} families updated`);
|
|
99
|
+
if (newFamilies.length > 0)
|
|
100
|
+
parts.push(`${newFamilies.length} new families added`);
|
|
101
|
+
if (parts.length === 0)
|
|
102
|
+
parts.push('no changes');
|
|
103
|
+
return {
|
|
104
|
+
manifest: { families: mergedFamilies, source: existing?.source || 'train-scan' },
|
|
105
|
+
newFamilies,
|
|
106
|
+
updatedFamilies,
|
|
107
|
+
staleFamilies: [],
|
|
108
|
+
summary: parts.join(', '),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
export function detectStaleFamilies(manifest, projectRoot) {
|
|
112
|
+
const resolved = resolve(projectRoot);
|
|
113
|
+
const stale = [];
|
|
114
|
+
for (const family of manifest.families) {
|
|
115
|
+
const allPatterns = [
|
|
116
|
+
...(family.webappPaths || []),
|
|
117
|
+
...(family.serverPaths || []),
|
|
118
|
+
...(family.specDirs || []),
|
|
119
|
+
];
|
|
120
|
+
if (allPatterns.length === 0)
|
|
121
|
+
continue;
|
|
122
|
+
// Check if any pattern resolves to existing files/dirs
|
|
123
|
+
let hasAny = false;
|
|
124
|
+
for (const pattern of allPatterns) {
|
|
125
|
+
// Strip trailing glob (* or **) to get the directory
|
|
126
|
+
const dirPart = pattern.replace(/\/?\*.*$/, '');
|
|
127
|
+
if (dirPart && existsSync(join(resolved, dirPart))) {
|
|
128
|
+
hasAny = true;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!hasAny) {
|
|
133
|
+
stale.push(family.id);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return stale;
|
|
137
|
+
}
|