@yasserkhanorg/e2e-agents 1.2.2 → 1.3.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 +20 -0
- package/dist/agent/feedback.d.ts.map +1 -1
- package/dist/agent/feedback.js +4 -0
- package/dist/esm/agent/feedback.js +3 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/qa-agent/cli.js +205 -0
- package/dist/esm/qa-agent/orchestrator.js +120 -0
- package/dist/esm/qa-agent/phase1/runner.js +139 -0
- package/dist/esm/qa-agent/phase1/scope.js +126 -0
- package/dist/esm/qa-agent/phase2/agent_browser.js +95 -0
- package/dist/esm/qa-agent/phase2/agent_loop.js +315 -0
- package/dist/esm/qa-agent/phase2/exploration_state.js +76 -0
- package/dist/esm/qa-agent/phase2/tools.js +288 -0
- package/dist/esm/qa-agent/phase2/vision.js +75 -0
- package/dist/esm/qa-agent/phase3/feedback.js +34 -0
- package/dist/esm/qa-agent/phase3/reporter.js +118 -0
- package/dist/esm/qa-agent/phase3/spec_generator.js +62 -0
- package/dist/esm/qa-agent/phase3/verdict.js +66 -0
- package/dist/esm/qa-agent/safe_env.js +23 -0
- package/dist/esm/qa-agent/types.js +3 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/qa-agent/cli.d.ts +3 -0
- package/dist/qa-agent/cli.d.ts.map +1 -0
- package/dist/qa-agent/cli.js +207 -0
- package/dist/qa-agent/orchestrator.d.ts +3 -0
- package/dist/qa-agent/orchestrator.d.ts.map +1 -0
- package/dist/qa-agent/orchestrator.js +123 -0
- package/dist/qa-agent/phase1/runner.d.ts +3 -0
- package/dist/qa-agent/phase1/runner.d.ts.map +1 -0
- package/dist/qa-agent/phase1/runner.js +142 -0
- package/dist/qa-agent/phase1/scope.d.ts +6 -0
- package/dist/qa-agent/phase1/scope.d.ts.map +1 -0
- package/dist/qa-agent/phase1/scope.js +129 -0
- package/dist/qa-agent/phase2/agent_browser.d.ts +35 -0
- package/dist/qa-agent/phase2/agent_browser.d.ts.map +1 -0
- package/dist/qa-agent/phase2/agent_browser.js +99 -0
- package/dist/qa-agent/phase2/agent_loop.d.ts +3 -0
- package/dist/qa-agent/phase2/agent_loop.d.ts.map +1 -0
- package/dist/qa-agent/phase2/agent_loop.js +321 -0
- package/dist/qa-agent/phase2/exploration_state.d.ts +12 -0
- package/dist/qa-agent/phase2/exploration_state.d.ts.map +1 -0
- package/dist/qa-agent/phase2/exploration_state.js +88 -0
- package/dist/qa-agent/phase2/tools.d.ts +28 -0
- package/dist/qa-agent/phase2/tools.d.ts.map +1 -0
- package/dist/qa-agent/phase2/tools.js +292 -0
- package/dist/qa-agent/phase2/vision.d.ts +3 -0
- package/dist/qa-agent/phase2/vision.d.ts.map +1 -0
- package/dist/qa-agent/phase2/vision.js +78 -0
- package/dist/qa-agent/phase3/feedback.d.ts +3 -0
- package/dist/qa-agent/phase3/feedback.d.ts.map +1 -0
- package/dist/qa-agent/phase3/feedback.js +37 -0
- package/dist/qa-agent/phase3/reporter.d.ts +3 -0
- package/dist/qa-agent/phase3/reporter.d.ts.map +1 -0
- package/dist/qa-agent/phase3/reporter.js +121 -0
- package/dist/qa-agent/phase3/spec_generator.d.ts +3 -0
- package/dist/qa-agent/phase3/spec_generator.d.ts.map +1 -0
- package/dist/qa-agent/phase3/spec_generator.js +65 -0
- package/dist/qa-agent/phase3/verdict.d.ts +3 -0
- package/dist/qa-agent/phase3/verdict.d.ts.map +1 -0
- package/dist/qa-agent/phase3/verdict.js +69 -0
- package/dist/qa-agent/safe_env.d.ts +3 -0
- package/dist/qa-agent/safe_env.d.ts.map +1 -0
- package/dist/qa-agent/safe_env.js +26 -0
- package/dist/qa-agent/types.d.ts +122 -0
- package/dist/qa-agent/types.d.ts.map +1 -0
- package/dist/qa-agent/types.js +4 -0
- package/package.json +12 -3
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
|
+
// See LICENSE.txt for license information.
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Tool definitions (Anthropic tool_use schema)
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
export const TOOL_DEFINITIONS = [
|
|
7
|
+
{
|
|
8
|
+
name: 'navigate',
|
|
9
|
+
description: 'Navigate to a URL. Use absolute paths starting with / or full URLs.',
|
|
10
|
+
input_schema: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
url: { type: 'string', description: 'URL or path to navigate to' },
|
|
14
|
+
},
|
|
15
|
+
required: ['url'],
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'click',
|
|
20
|
+
description: 'Click an element by its accessibility ref (e.g. @e4).',
|
|
21
|
+
input_schema: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
ref: { type: 'string', description: 'Accessibility ref like @e4' },
|
|
25
|
+
},
|
|
26
|
+
required: ['ref'],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'fill',
|
|
31
|
+
description: 'Clear a field and type new text into it.',
|
|
32
|
+
input_schema: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
ref: { type: 'string', description: 'Accessibility ref of the input field' },
|
|
36
|
+
value: { type: 'string', description: 'Text to type' },
|
|
37
|
+
},
|
|
38
|
+
required: ['ref', 'value'],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'press_key',
|
|
43
|
+
description: 'Press a keyboard key (e.g. Enter, Escape, Tab).',
|
|
44
|
+
input_schema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
key: { type: 'string', description: 'Key name (Enter, Escape, Tab, etc.)' },
|
|
48
|
+
},
|
|
49
|
+
required: ['key'],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'scroll',
|
|
54
|
+
description: 'Scroll the page or a specific element up or down.',
|
|
55
|
+
input_schema: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
direction: { type: 'string', enum: ['up', 'down'] },
|
|
59
|
+
ref: { type: 'string', description: 'Optional element ref to scroll within' },
|
|
60
|
+
},
|
|
61
|
+
required: ['direction'],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'go_back',
|
|
66
|
+
description: 'Go back to the previous page.',
|
|
67
|
+
input_schema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {},
|
|
70
|
+
required: [],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'take_screenshot',
|
|
75
|
+
description: 'Take an annotated screenshot for evidence or vision analysis. Use sparingly (costs tokens).',
|
|
76
|
+
input_schema: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {
|
|
79
|
+
label: { type: 'string', description: 'Short label for the screenshot (used in filename)' },
|
|
80
|
+
},
|
|
81
|
+
required: ['label'],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'get_text',
|
|
86
|
+
description: 'Read the text content of a specific element.',
|
|
87
|
+
input_schema: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
ref: { type: 'string', description: 'Accessibility ref' },
|
|
91
|
+
},
|
|
92
|
+
required: ['ref'],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'report_finding',
|
|
97
|
+
description: 'Report a bug, visual issue, UX problem, or gap you discovered. Always include current URL and repro steps.',
|
|
98
|
+
input_schema: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
properties: {
|
|
101
|
+
type: { type: 'string', enum: ['bug', 'visual-regression', 'ux-issue', 'gap'] },
|
|
102
|
+
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'] },
|
|
103
|
+
summary: { type: 'string', description: 'What you found' },
|
|
104
|
+
repro_steps: {
|
|
105
|
+
type: 'array',
|
|
106
|
+
items: { type: 'string' },
|
|
107
|
+
description: 'Steps to reproduce',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
required: ['type', 'severity', 'summary', 'repro_steps'],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'mark_flow_done',
|
|
115
|
+
description: 'Mark the current flow as verified/explored. Call when you are done testing a flow.',
|
|
116
|
+
input_schema: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: {
|
|
119
|
+
flow_id: { type: 'string', description: 'ID of the flow being marked done' },
|
|
120
|
+
status: { type: 'string', enum: ['verified-ok', 'has-issues'] },
|
|
121
|
+
},
|
|
122
|
+
required: ['flow_id', 'status'],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: 'switch_user',
|
|
127
|
+
description: 'Log out and log in as a different user role.',
|
|
128
|
+
input_schema: {
|
|
129
|
+
type: 'object',
|
|
130
|
+
properties: {
|
|
131
|
+
role: { type: 'string', description: 'Role of the user to switch to (e.g. admin, regular, guest)' },
|
|
132
|
+
},
|
|
133
|
+
required: ['role'],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
export function executeTool(ctx, name, input) {
|
|
138
|
+
switch (name) {
|
|
139
|
+
case 'navigate': {
|
|
140
|
+
const url = String(input.url || '');
|
|
141
|
+
const fullUrl = url.startsWith('http') ? url : `${ctx.baseUrl}${url}`;
|
|
142
|
+
// Security: restrict navigation to the configured baseUrl domain
|
|
143
|
+
if (!isAllowedUrl(fullUrl, ctx.baseUrl)) {
|
|
144
|
+
return { output: `Blocked: navigation to "${fullUrl}" is outside the allowed domain (${ctx.baseUrl}).` };
|
|
145
|
+
}
|
|
146
|
+
const output = ctx.browser.open(fullUrl);
|
|
147
|
+
ctx.currentUrl = ctx.browser.getUrl();
|
|
148
|
+
return { output: output || `Navigated to ${ctx.currentUrl}`, navigated: true };
|
|
149
|
+
}
|
|
150
|
+
case 'click': {
|
|
151
|
+
const output = ctx.browser.click(String(input.ref));
|
|
152
|
+
return { output: output || `Clicked ${input.ref}` };
|
|
153
|
+
}
|
|
154
|
+
case 'fill': {
|
|
155
|
+
const ref = String(input.ref);
|
|
156
|
+
const value = String(input.value);
|
|
157
|
+
const output = ctx.browser.fill(ref, value);
|
|
158
|
+
// Redact value for password-like fields to avoid leaking credentials to LLM
|
|
159
|
+
const isSensitive = /password|passwd|pwd|secret|token/i.test(ref);
|
|
160
|
+
const displayValue = isSensitive ? '[REDACTED]' : `"${value}"`;
|
|
161
|
+
return { output: output || `Filled ${ref} with ${displayValue}` };
|
|
162
|
+
}
|
|
163
|
+
case 'press_key': {
|
|
164
|
+
const output = ctx.browser.press(String(input.key));
|
|
165
|
+
return { output: output || `Pressed ${input.key}` };
|
|
166
|
+
}
|
|
167
|
+
case 'scroll': {
|
|
168
|
+
const rawDir = String(input.direction);
|
|
169
|
+
if (rawDir !== 'up' && rawDir !== 'down') {
|
|
170
|
+
return { output: `Invalid scroll direction "${rawDir}". Must be "up" or "down".` };
|
|
171
|
+
}
|
|
172
|
+
const ref = input.ref ? String(input.ref) : undefined;
|
|
173
|
+
const output = ctx.browser.scroll(rawDir, ref);
|
|
174
|
+
return { output: output || `Scrolled ${rawDir}` };
|
|
175
|
+
}
|
|
176
|
+
case 'go_back': {
|
|
177
|
+
const output = ctx.browser.back();
|
|
178
|
+
ctx.currentUrl = ctx.browser.getUrl();
|
|
179
|
+
return { output: output || `Went back to ${ctx.currentUrl}` };
|
|
180
|
+
}
|
|
181
|
+
case 'take_screenshot': {
|
|
182
|
+
ctx.screenshotCounter++;
|
|
183
|
+
const label = String(input.label || 'evidence').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
184
|
+
const filename = `${String(ctx.screenshotCounter).padStart(3, '0')}-${label}.png`;
|
|
185
|
+
const path = `${ctx.screenshotDir}/${filename}`;
|
|
186
|
+
ctx.browser.screenshot(path);
|
|
187
|
+
return { output: `Screenshot saved: ${path}` };
|
|
188
|
+
}
|
|
189
|
+
case 'get_text': {
|
|
190
|
+
const text = ctx.browser.getText(String(input.ref));
|
|
191
|
+
return { output: text || '(empty)' };
|
|
192
|
+
}
|
|
193
|
+
case 'report_finding': {
|
|
194
|
+
const VALID_TYPES = new Set(['bug', 'visual-regression', 'ux-issue', 'gap']);
|
|
195
|
+
const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'info']);
|
|
196
|
+
const rawType = String(input.type);
|
|
197
|
+
const rawSeverity = String(input.severity);
|
|
198
|
+
if (!VALID_TYPES.has(rawType)) {
|
|
199
|
+
return { output: `Invalid finding type "${rawType}". Must be one of: ${[...VALID_TYPES].join(', ')}.` };
|
|
200
|
+
}
|
|
201
|
+
if (!VALID_SEVERITIES.has(rawSeverity)) {
|
|
202
|
+
return { output: `Invalid severity "${rawSeverity}". Must be one of: ${[...VALID_SEVERITIES].join(', ')}.` };
|
|
203
|
+
}
|
|
204
|
+
if (!Array.isArray(input.repro_steps)) {
|
|
205
|
+
return { output: `Invalid repro_steps: expected an array of strings.` };
|
|
206
|
+
}
|
|
207
|
+
const finding = {
|
|
208
|
+
id: `f-${crypto.randomUUID()}`,
|
|
209
|
+
type: rawType,
|
|
210
|
+
severity: rawSeverity,
|
|
211
|
+
summary: String(input.summary),
|
|
212
|
+
flow: ctx.currentFlow,
|
|
213
|
+
evidence: {
|
|
214
|
+
url: ctx.currentUrl,
|
|
215
|
+
reproSteps: input.repro_steps.map(String),
|
|
216
|
+
},
|
|
217
|
+
timestamp: Date.now(),
|
|
218
|
+
};
|
|
219
|
+
return { output: `Finding recorded: [${finding.severity}] ${finding.summary}`, finding };
|
|
220
|
+
}
|
|
221
|
+
case 'mark_flow_done': {
|
|
222
|
+
const flowId = String(input.flow_id);
|
|
223
|
+
const rawStatus = String(input.status);
|
|
224
|
+
if (rawStatus !== 'verified-ok' && rawStatus !== 'has-issues') {
|
|
225
|
+
return { output: `Invalid status "${rawStatus}". Must be "verified-ok" or "has-issues".` };
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
output: `Flow "${flowId}" marked as ${rawStatus}`,
|
|
229
|
+
flowDone: { flowId, status: rawStatus },
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
case 'switch_user': {
|
|
233
|
+
const role = String(input.role);
|
|
234
|
+
const user = ctx.users?.find((u) => u.role === role);
|
|
235
|
+
if (!user) {
|
|
236
|
+
return { output: `No user configured for role "${role}". Available: ${(ctx.users || []).map((u) => u.role).join(', ')}` };
|
|
237
|
+
}
|
|
238
|
+
// Log out first, then log in as new user
|
|
239
|
+
try {
|
|
240
|
+
ctx.browser.open(`${ctx.baseUrl}/logout`);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// May not be logged in
|
|
244
|
+
}
|
|
245
|
+
ctx.browser.open(`${ctx.baseUrl}/login`);
|
|
246
|
+
// Use snapshot to find login fields, then fill
|
|
247
|
+
const snap = ctx.browser.snapshot();
|
|
248
|
+
const emailRef = extractRef(snap, 'email') || extractRef(snap, 'username') || '@e1';
|
|
249
|
+
const passRef = extractRef(snap, 'password') || '@e2';
|
|
250
|
+
ctx.browser.fill(emailRef, user.username);
|
|
251
|
+
ctx.browser.fill(passRef, user.password);
|
|
252
|
+
ctx.browser.press('Enter');
|
|
253
|
+
ctx.currentUrl = ctx.browser.getUrl();
|
|
254
|
+
// Redact credentials from LLM context — only expose role
|
|
255
|
+
return { output: `Switched to user role: ${user.role}` };
|
|
256
|
+
}
|
|
257
|
+
default:
|
|
258
|
+
return { output: `Unknown tool: ${name}` };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function isAllowedUrl(url, baseUrl) {
|
|
262
|
+
// Block dangerous schemes
|
|
263
|
+
const scheme = url.split(':')[0]?.toLowerCase();
|
|
264
|
+
if (scheme && !['http', 'https'].includes(scheme))
|
|
265
|
+
return false;
|
|
266
|
+
// Parse both URLs and compare origins (hostname + port)
|
|
267
|
+
try {
|
|
268
|
+
const target = new URL(url);
|
|
269
|
+
const base = new URL(baseUrl);
|
|
270
|
+
return target.origin === base.origin;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
// If URL parsing fails, only allow relative paths (already prefixed with baseUrl)
|
|
274
|
+
return url.startsWith(baseUrl);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function extractRef(snapshot, fieldHint) {
|
|
278
|
+
// Look for lines containing the hint and extract the @eN ref
|
|
279
|
+
const lines = snapshot.split('\n');
|
|
280
|
+
for (const line of lines) {
|
|
281
|
+
if (line.toLowerCase().includes(fieldHint)) {
|
|
282
|
+
const match = line.match(/@e\d+/);
|
|
283
|
+
if (match)
|
|
284
|
+
return match[0];
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
|
+
// See LICENSE.txt for license information.
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { LLMProviderFactory } from '../../provider_factory.js';
|
|
5
|
+
const VALID_TYPES = new Set(['bug', 'visual-regression', 'ux-issue', 'gap']);
|
|
6
|
+
const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'info']);
|
|
7
|
+
const VISION_PROMPT = `You are a QA engineer analyzing a screenshot of a web application.
|
|
8
|
+
Look for these categories of issues:
|
|
9
|
+
|
|
10
|
+
1. **Layout issues**: overlapping elements, misaligned content, broken grid, elements outside viewport
|
|
11
|
+
2. **Visual issues**: truncated text, missing icons/images, broken styling, inconsistent spacing
|
|
12
|
+
3. **UX issues**: unclear button labels, confusing navigation, missing feedback states, poor contrast
|
|
13
|
+
4. **State issues**: loading spinners stuck, empty states without messaging, stale data indicators
|
|
14
|
+
5. **Error states**: visible error messages, 404/500 pages, broken components
|
|
15
|
+
|
|
16
|
+
For each issue found, respond with a JSON array of objects:
|
|
17
|
+
[
|
|
18
|
+
{
|
|
19
|
+
"type": "bug" | "visual-regression" | "ux-issue",
|
|
20
|
+
"severity": "critical" | "high" | "medium" | "low" | "info",
|
|
21
|
+
"summary": "description of the issue"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
If no issues are found, respond with an empty array: []
|
|
26
|
+
|
|
27
|
+
Only report clear, actionable issues. Do not speculate about functionality you cannot see.`;
|
|
28
|
+
export async function analyzeScreenshot(screenshotPath, url, flow) {
|
|
29
|
+
const provider = await LLMProviderFactory.createFromEnv();
|
|
30
|
+
if (!provider.capabilities.vision || !provider.analyzeImage) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
let imageData;
|
|
34
|
+
try {
|
|
35
|
+
imageData = readFileSync(screenshotPath).toString('base64');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const response = await provider.analyzeImage([{ base64: imageData, mediaType: 'image/png' }], VISION_PROMPT, { maxTokens: 2000, temperature: 0.1 });
|
|
41
|
+
return parseVisionResponse(response.text, url, flow, screenshotPath);
|
|
42
|
+
}
|
|
43
|
+
function parseVisionResponse(text, url, flow, screenshotPath) {
|
|
44
|
+
// Extract JSON array from response
|
|
45
|
+
const jsonMatch = text.match(/\[[\s\S]*\]/);
|
|
46
|
+
if (!jsonMatch)
|
|
47
|
+
return [];
|
|
48
|
+
try {
|
|
49
|
+
const items = JSON.parse(jsonMatch[0]);
|
|
50
|
+
if (!Array.isArray(items))
|
|
51
|
+
return [];
|
|
52
|
+
return items
|
|
53
|
+
.filter((item) => {
|
|
54
|
+
const t = String(item.type || '');
|
|
55
|
+
const s = String(item.severity || '');
|
|
56
|
+
return VALID_TYPES.has(t) && VALID_SEVERITIES.has(s);
|
|
57
|
+
})
|
|
58
|
+
.map((item) => ({
|
|
59
|
+
id: `v-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`,
|
|
60
|
+
type: String(item.type),
|
|
61
|
+
severity: String(item.severity),
|
|
62
|
+
summary: String(item.summary || 'Visual issue detected'),
|
|
63
|
+
flow,
|
|
64
|
+
evidence: {
|
|
65
|
+
url,
|
|
66
|
+
screenshotPath,
|
|
67
|
+
reproSteps: ['Captured via automated vision analysis'],
|
|
68
|
+
},
|
|
69
|
+
timestamp: Date.now(),
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
|
+
// See LICENSE.txt for license information.
|
|
3
|
+
import { spawnSync } from 'child_process';
|
|
4
|
+
import { logger } from '../../logger.js';
|
|
5
|
+
import { safeEnv } from '../safe_env.js';
|
|
6
|
+
export function submitFeedback(config) {
|
|
7
|
+
const args = ['e2e-ai-agents', 'feedback'];
|
|
8
|
+
if (config.testsRoot) {
|
|
9
|
+
args.push('--tests-root', config.testsRoot);
|
|
10
|
+
}
|
|
11
|
+
logger.info('Submitting feedback to calibration system');
|
|
12
|
+
const result = spawnSync('npx', args, {
|
|
13
|
+
cwd: config.testsRoot || process.cwd(),
|
|
14
|
+
encoding: 'utf-8',
|
|
15
|
+
timeout: 30000,
|
|
16
|
+
env: safeEnv(),
|
|
17
|
+
});
|
|
18
|
+
if (result.error) {
|
|
19
|
+
logger.warn('Feedback submission spawn failed', {
|
|
20
|
+
error: result.error.message,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
else if (result.signal) {
|
|
24
|
+
logger.warn('Feedback submission killed by signal', {
|
|
25
|
+
signal: result.signal,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
else if (result.status !== 0) {
|
|
29
|
+
logger.warn('Feedback submission failed', {
|
|
30
|
+
status: result.status,
|
|
31
|
+
stderr: (result.stderr || '').slice(0, 200),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
|
+
// See LICENSE.txt for license information.
|
|
3
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
export function generateReport(config, phase1, phase2, verdict, generatedSpecs) {
|
|
6
|
+
const outputDir = config.outputDir || '.e2e-ai-agents';
|
|
7
|
+
mkdirSync(outputDir, { recursive: true });
|
|
8
|
+
const reportPath = join(outputDir, 'qa-report.json');
|
|
9
|
+
const summaryPath = join(outputDir, 'qa-summary.md');
|
|
10
|
+
const report = {
|
|
11
|
+
schemaVersion: '1.0.0',
|
|
12
|
+
generatedAt: new Date().toISOString(),
|
|
13
|
+
mode: config.mode,
|
|
14
|
+
config: {
|
|
15
|
+
baseUrl: config.baseUrl,
|
|
16
|
+
timeLimitMinutes: config.timeLimitMinutes,
|
|
17
|
+
budgetUSD: config.budgetUSD,
|
|
18
|
+
},
|
|
19
|
+
phase1,
|
|
20
|
+
phase2,
|
|
21
|
+
phase3: {
|
|
22
|
+
reportPath,
|
|
23
|
+
summaryPath,
|
|
24
|
+
verdict,
|
|
25
|
+
generatedSpecs,
|
|
26
|
+
},
|
|
27
|
+
verdict,
|
|
28
|
+
};
|
|
29
|
+
try {
|
|
30
|
+
writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf-8');
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
throw new Error(`Failed to write report to ${reportPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
34
|
+
}
|
|
35
|
+
const markdown = renderMarkdown(report);
|
|
36
|
+
try {
|
|
37
|
+
writeFileSync(summaryPath, markdown, 'utf-8');
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
throw new Error(`Failed to write summary to ${summaryPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
reportPath,
|
|
44
|
+
summaryPath,
|
|
45
|
+
verdict,
|
|
46
|
+
generatedSpecs,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function renderMarkdown(report) {
|
|
50
|
+
const v = report.verdict;
|
|
51
|
+
const badge = v.decision === 'go' ? '✅ GO' : v.decision === 'no-go' ? '❌ NO-GO' : '⚠️ CONDITIONAL';
|
|
52
|
+
const lines = [
|
|
53
|
+
`# QA Agent Report — ${badge}`,
|
|
54
|
+
'',
|
|
55
|
+
`**Mode:** ${report.mode}`,
|
|
56
|
+
`**Base URL:** ${report.config.baseUrl}`,
|
|
57
|
+
`**Generated:** ${report.generatedAt}`,
|
|
58
|
+
'',
|
|
59
|
+
`## Verdict`,
|
|
60
|
+
'',
|
|
61
|
+
`**Decision:** ${badge}`,
|
|
62
|
+
`**Reason:** ${v.reason}`,
|
|
63
|
+
'',
|
|
64
|
+
`| Severity | Count |`,
|
|
65
|
+
`|----------|-------|`,
|
|
66
|
+
`| Critical | ${v.criticalFindings} |`,
|
|
67
|
+
`| High | ${v.highFindings} |`,
|
|
68
|
+
`| Medium | ${v.mediumFindings} |`,
|
|
69
|
+
`| Low | ${v.lowFindings} |`,
|
|
70
|
+
'',
|
|
71
|
+
];
|
|
72
|
+
// Phase 1 summary
|
|
73
|
+
const specTotal = report.phase1.specResults.length;
|
|
74
|
+
const specPassed = report.phase1.specResults.reduce((s, r) => s + r.passed, 0);
|
|
75
|
+
const specFailed = report.phase1.specResults.reduce((s, r) => s + r.failed, 0);
|
|
76
|
+
lines.push(`## Phase 1: Scripted Tests`, '', `- Flows identified: ${report.phase1.flows.length}`, `- Specs run: ${specTotal} (${specPassed} passed, ${specFailed} failed)`, '');
|
|
77
|
+
// Phase 2 summary
|
|
78
|
+
lines.push(`## Phase 2: Autonomous Exploration`, '', `- Flows explored: ${report.phase2.flowsExplored.length}`, `- Actions taken: ${report.phase2.actionsCount}`, `- Duration: ${Math.round(report.phase2.durationMs / 1000)}s`, `- Cost: $${report.phase2.costUSD.toFixed(4)}`, `- Tokens: ${report.phase2.tokensUsed}`, '');
|
|
79
|
+
// Findings
|
|
80
|
+
if (report.phase2.findings.length > 0) {
|
|
81
|
+
lines.push(`## Findings`, '');
|
|
82
|
+
for (const f of report.phase2.findings) {
|
|
83
|
+
lines.push(`### [${f.severity.toUpperCase()}] ${f.summary}`);
|
|
84
|
+
lines.push('');
|
|
85
|
+
lines.push(`- **Type:** ${f.type}`);
|
|
86
|
+
lines.push(`- **Flow:** ${f.flow}`);
|
|
87
|
+
lines.push(`- **URL:** ${f.evidence.url}`);
|
|
88
|
+
if (f.evidence.screenshotPath) {
|
|
89
|
+
lines.push(`- **Screenshot:** ${f.evidence.screenshotPath}`);
|
|
90
|
+
}
|
|
91
|
+
if (f.evidence.reproSteps.length > 0) {
|
|
92
|
+
lines.push('- **Repro steps:**');
|
|
93
|
+
for (const step of f.evidence.reproSteps) {
|
|
94
|
+
lines.push(` 1. ${step}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
lines.push('');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Flow sign-offs
|
|
101
|
+
lines.push(`## Flow Sign-offs`, '');
|
|
102
|
+
lines.push(`| Flow | Status | Findings |`);
|
|
103
|
+
lines.push(`|------|--------|----------|`);
|
|
104
|
+
for (const s of v.flowSignoffs) {
|
|
105
|
+
const statusIcon = s.status === 'passed' ? '✅' : s.status === 'failed' ? '❌' : '⏭️';
|
|
106
|
+
lines.push(`| ${s.flowName} | ${statusIcon} ${s.status} | ${s.findings.length} |`);
|
|
107
|
+
}
|
|
108
|
+
lines.push('');
|
|
109
|
+
// Generated specs
|
|
110
|
+
if (report.phase3.generatedSpecs.length > 0) {
|
|
111
|
+
lines.push(`## Generated Specs`, '');
|
|
112
|
+
for (const spec of report.phase3.generatedSpecs) {
|
|
113
|
+
lines.push(`- ${spec}`);
|
|
114
|
+
}
|
|
115
|
+
lines.push('');
|
|
116
|
+
}
|
|
117
|
+
return lines.join('\n');
|
|
118
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
|
+
// See LICENSE.txt for license information.
|
|
3
|
+
import { spawnSync } from 'child_process';
|
|
4
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { logger } from '../../logger.js';
|
|
7
|
+
import { safeEnv } from '../safe_env.js';
|
|
8
|
+
export function generateSpecsForFindings(findings, config) {
|
|
9
|
+
// Only generate specs for bugs and gaps (not visual/UX issues)
|
|
10
|
+
const actionable = findings.filter((f) => f.type === 'bug' || f.type === 'gap');
|
|
11
|
+
if (actionable.length === 0) {
|
|
12
|
+
logger.info('No actionable findings for spec generation');
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
const scenarios = actionable.map((f) => ({
|
|
16
|
+
title: `Verify: ${f.summary}`,
|
|
17
|
+
flow: f.flow,
|
|
18
|
+
steps: f.evidence.reproSteps,
|
|
19
|
+
expected: `The issue "${f.summary}" should not occur`,
|
|
20
|
+
priority: f.severity === 'critical' || f.severity === 'high' ? 'P0' : 'P1',
|
|
21
|
+
}));
|
|
22
|
+
// Write scenarios to a temp file
|
|
23
|
+
const outputDir = config.outputDir || '.e2e-ai-agents';
|
|
24
|
+
mkdirSync(outputDir, { recursive: true });
|
|
25
|
+
const scenariosPath = join(outputDir, 'qa-findings-scenarios.json');
|
|
26
|
+
writeFileSync(scenariosPath, JSON.stringify(scenarios, null, 2), 'utf-8');
|
|
27
|
+
// Call e2e-ai-agents generate with the scenarios
|
|
28
|
+
const args = [
|
|
29
|
+
'e2e-ai-agents', 'generate',
|
|
30
|
+
'--scenarios', scenariosPath,
|
|
31
|
+
];
|
|
32
|
+
if (config.testsRoot) {
|
|
33
|
+
args.push('--tests-root', config.testsRoot);
|
|
34
|
+
}
|
|
35
|
+
if (config.baseUrl) {
|
|
36
|
+
args.push('--pipeline-base-url', config.baseUrl);
|
|
37
|
+
}
|
|
38
|
+
logger.info('Generating specs for findings', { count: scenarios.length });
|
|
39
|
+
const result = spawnSync('npx', args, {
|
|
40
|
+
cwd: config.testsRoot || process.cwd(),
|
|
41
|
+
encoding: 'utf-8',
|
|
42
|
+
timeout: 300000,
|
|
43
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
44
|
+
env: safeEnv(),
|
|
45
|
+
});
|
|
46
|
+
if (result.status !== 0) {
|
|
47
|
+
logger.warn('Spec generation exited with non-zero', {
|
|
48
|
+
status: result.status,
|
|
49
|
+
stderr: (result.stderr || '').slice(0, 500),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
// Parse generated spec paths from output
|
|
53
|
+
const generatedPaths = [];
|
|
54
|
+
const lines = (result.stdout || '').split('\n');
|
|
55
|
+
for (const line of lines) {
|
|
56
|
+
const match = line.match(/generated.*?:\s*(.+\.spec\.ts)/i);
|
|
57
|
+
if (match) {
|
|
58
|
+
generatedPaths.push(match[1].trim());
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return generatedPaths;
|
|
62
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
|
+
// See LICENSE.txt for license information.
|
|
3
|
+
export function computeVerdict(phase1, phase2) {
|
|
4
|
+
const findings = phase2.findings;
|
|
5
|
+
const critical = findings.filter((f) => f.severity === 'critical').length;
|
|
6
|
+
const high = findings.filter((f) => f.severity === 'high').length;
|
|
7
|
+
const medium = findings.filter((f) => f.severity === 'medium').length;
|
|
8
|
+
const low = findings.filter((f) => f.severity === 'low' || f.severity === 'info').length;
|
|
9
|
+
// Flow sign-offs
|
|
10
|
+
const flowSignoffs = buildFlowSignoffs(phase1.flows, phase2);
|
|
11
|
+
// Decision logic
|
|
12
|
+
let decision;
|
|
13
|
+
let reason;
|
|
14
|
+
if (critical > 0) {
|
|
15
|
+
decision = 'no-go';
|
|
16
|
+
reason = `${critical} critical finding(s) — must fix before release.`;
|
|
17
|
+
}
|
|
18
|
+
else if (high > 0) {
|
|
19
|
+
decision = 'no-go';
|
|
20
|
+
reason = `${high} high-severity finding(s) — requires triage before release.`;
|
|
21
|
+
}
|
|
22
|
+
else if (medium > 0) {
|
|
23
|
+
decision = 'conditional';
|
|
24
|
+
reason = `${medium} medium-severity finding(s) — review and decide if acceptable.`;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
decision = 'go';
|
|
28
|
+
reason = findings.length === 0
|
|
29
|
+
? 'No issues found across all tested flows.'
|
|
30
|
+
: `Only low/info findings (${low}). Safe to proceed.`;
|
|
31
|
+
}
|
|
32
|
+
// Check for untested flows (P0/P1 not tested → downgrade to conditional)
|
|
33
|
+
const untestedP0P1 = flowSignoffs.filter((s) => s.status === 'not-tested' && phase1.flows.find((f) => f.id === s.flowId && (f.priority === 'P0' || f.priority === 'P1')));
|
|
34
|
+
if (untestedP0P1.length > 0 && decision === 'go') {
|
|
35
|
+
decision = 'conditional';
|
|
36
|
+
reason += ` ${untestedP0P1.length} P0/P1 flow(s) were not tested.`;
|
|
37
|
+
}
|
|
38
|
+
// Check Phase 1 spec failures
|
|
39
|
+
const specFailures = phase1.specResults.reduce((sum, r) => sum + r.failed, 0);
|
|
40
|
+
if (specFailures > 0 && decision === 'go') {
|
|
41
|
+
decision = 'conditional';
|
|
42
|
+
reason += ` ${specFailures} existing spec(s) failed in Phase 1.`;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
decision,
|
|
46
|
+
reason,
|
|
47
|
+
flowSignoffs,
|
|
48
|
+
criticalFindings: critical,
|
|
49
|
+
highFindings: high,
|
|
50
|
+
mediumFindings: medium,
|
|
51
|
+
lowFindings: low,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function buildFlowSignoffs(flows, phase2) {
|
|
55
|
+
return flows.map((flow) => {
|
|
56
|
+
const explored = phase2.flowsExplored.includes(flow.id);
|
|
57
|
+
const flowFindings = phase2.findings.filter((f) => f.flow === flow.id);
|
|
58
|
+
const hasIssues = flowFindings.some((f) => f.type === 'bug' || f.severity === 'critical' || f.severity === 'high');
|
|
59
|
+
return {
|
|
60
|
+
flowId: flow.id,
|
|
61
|
+
flowName: flow.name,
|
|
62
|
+
status: explored ? (hasIssues ? 'failed' : 'passed') : 'not-tested',
|
|
63
|
+
findings: flowFindings.map((f) => f.id),
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
|
+
// See LICENSE.txt for license information.
|
|
3
|
+
/** Build a minimal env for child processes — only forward what's needed. */
|
|
4
|
+
export function safeEnv(extra) {
|
|
5
|
+
const env = {
|
|
6
|
+
PATH: process.env.PATH,
|
|
7
|
+
HOME: process.env.HOME,
|
|
8
|
+
NODE_PATH: process.env.NODE_PATH,
|
|
9
|
+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
|
10
|
+
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
|
11
|
+
LLM_PROVIDER: process.env.LLM_PROVIDER,
|
|
12
|
+
LOG_LEVEL: process.env.LOG_LEVEL,
|
|
13
|
+
// Node needs LANG/LC_ALL for proper string handling
|
|
14
|
+
LANG: process.env.LANG,
|
|
15
|
+
// npm/npx need these
|
|
16
|
+
npm_config_prefix: process.env.npm_config_prefix,
|
|
17
|
+
NVM_DIR: process.env.NVM_DIR,
|
|
18
|
+
NVM_BIN: process.env.NVM_BIN,
|
|
19
|
+
};
|
|
20
|
+
if (extra)
|
|
21
|
+
Object.assign(env, extra);
|
|
22
|
+
return env;
|
|
23
|
+
}
|