@openqa/cli 1.3.4 → 2.0.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 +1 -1
- package/dist/agent/brain/diff-analyzer.js +140 -0
- package/dist/agent/brain/diff-analyzer.js.map +1 -0
- package/dist/agent/brain/llm-cache.js +47 -0
- package/dist/agent/brain/llm-cache.js.map +1 -0
- package/dist/agent/brain/llm-resilience.js +252 -0
- package/dist/agent/brain/llm-resilience.js.map +1 -0
- package/dist/agent/config/index.js +588 -0
- package/dist/agent/config/index.js.map +1 -0
- package/dist/agent/coverage/index.js +74 -0
- package/dist/agent/coverage/index.js.map +1 -0
- package/dist/agent/export/index.js +158 -0
- package/dist/agent/export/index.js.map +1 -0
- package/dist/agent/index-v2.js +2795 -0
- package/dist/agent/index-v2.js.map +1 -0
- package/dist/agent/index.js +369 -105
- package/dist/agent/index.js.map +1 -1
- package/dist/agent/logger.js +41 -0
- package/dist/agent/logger.js.map +1 -0
- package/dist/agent/metrics.js +39 -0
- package/dist/agent/metrics.js.map +1 -0
- package/dist/agent/notifications/index.js +106 -0
- package/dist/agent/notifications/index.js.map +1 -0
- package/dist/agent/openapi/spec.js +338 -0
- package/dist/agent/openapi/spec.js.map +1 -0
- package/dist/agent/tools/project-runner.js +481 -0
- package/dist/agent/tools/project-runner.js.map +1 -0
- package/dist/cli/config.html.js +454 -0
- package/dist/cli/daemon.js +7572 -0
- package/dist/cli/dashboard.html.js +1619 -0
- package/dist/cli/index.js +3492 -1622
- package/dist/cli/kanban.html.js +577 -0
- package/dist/cli/routes.js +895 -0
- package/dist/cli/routes.js.map +1 -0
- package/dist/cli/server.js +3469 -1630
- package/dist/database/index.js +485 -60
- package/dist/database/index.js.map +1 -1
- package/dist/database/sqlite.js +281 -0
- package/dist/database/sqlite.js.map +1 -0
- package/package.json +18 -5
|
@@ -0,0 +1,2795 @@
|
|
|
1
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
2
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
|
+
}) : x)(function(x) {
|
|
5
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
var __esm = (fn, res) => function __init() {
|
|
9
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// node_modules/tsup/assets/esm_shims.js
|
|
13
|
+
import path from "path";
|
|
14
|
+
import { fileURLToPath } from "url";
|
|
15
|
+
var init_esm_shims = __esm({
|
|
16
|
+
"node_modules/tsup/assets/esm_shims.js"() {
|
|
17
|
+
"use strict";
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// database/sqlite.ts
|
|
22
|
+
import Database from "better-sqlite3";
|
|
23
|
+
var init_sqlite = __esm({
|
|
24
|
+
"database/sqlite.ts"() {
|
|
25
|
+
"use strict";
|
|
26
|
+
init_esm_shims();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// agent/index-v2.ts
|
|
31
|
+
init_esm_shims();
|
|
32
|
+
import { EventEmitter as EventEmitter5 } from "events";
|
|
33
|
+
|
|
34
|
+
// database/index.ts
|
|
35
|
+
init_esm_shims();
|
|
36
|
+
init_sqlite();
|
|
37
|
+
import { Low } from "lowdb";
|
|
38
|
+
import { JSONFile } from "lowdb/node";
|
|
39
|
+
import { dirname } from "path";
|
|
40
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
41
|
+
import { mkdirSync } from "fs";
|
|
42
|
+
var __filename2 = fileURLToPath2(import.meta.url);
|
|
43
|
+
var __dirname2 = dirname(__filename2);
|
|
44
|
+
var OpenQADatabase = class {
|
|
45
|
+
constructor(dbPath = "./data/openqa.json") {
|
|
46
|
+
this.dbPath = dbPath;
|
|
47
|
+
this.initialize();
|
|
48
|
+
}
|
|
49
|
+
db = null;
|
|
50
|
+
initialize() {
|
|
51
|
+
const dir = dirname(this.dbPath);
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
const adapter = new JSONFile(this.dbPath);
|
|
54
|
+
this.db = new Low(adapter, {
|
|
55
|
+
config: {},
|
|
56
|
+
test_sessions: [],
|
|
57
|
+
actions: [],
|
|
58
|
+
bugs: [],
|
|
59
|
+
kanban_tickets: [],
|
|
60
|
+
users: []
|
|
61
|
+
});
|
|
62
|
+
this.db.read();
|
|
63
|
+
if (!this.db.data) {
|
|
64
|
+
this.db.data = {
|
|
65
|
+
config: {},
|
|
66
|
+
test_sessions: [],
|
|
67
|
+
actions: [],
|
|
68
|
+
bugs: [],
|
|
69
|
+
kanban_tickets: [],
|
|
70
|
+
users: []
|
|
71
|
+
};
|
|
72
|
+
this.db.write();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async ensureInitialized() {
|
|
76
|
+
if (!this.db) {
|
|
77
|
+
this.initialize();
|
|
78
|
+
}
|
|
79
|
+
await this.db.read();
|
|
80
|
+
let migrated = false;
|
|
81
|
+
if (!this.db.data.users) {
|
|
82
|
+
this.db.data.users = [];
|
|
83
|
+
migrated = true;
|
|
84
|
+
}
|
|
85
|
+
if (migrated) await this.db.write();
|
|
86
|
+
}
|
|
87
|
+
async getConfig(key) {
|
|
88
|
+
await this.ensureInitialized();
|
|
89
|
+
return this.db.data.config[key] || null;
|
|
90
|
+
}
|
|
91
|
+
async setConfig(key, value) {
|
|
92
|
+
await this.ensureInitialized();
|
|
93
|
+
this.db.data.config[key] = value;
|
|
94
|
+
await this.db.write();
|
|
95
|
+
}
|
|
96
|
+
async getAllConfig() {
|
|
97
|
+
await this.ensureInitialized();
|
|
98
|
+
return this.db.data.config;
|
|
99
|
+
}
|
|
100
|
+
async createSession(id, metadata) {
|
|
101
|
+
await this.ensureInitialized();
|
|
102
|
+
const session = {
|
|
103
|
+
id,
|
|
104
|
+
started_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
105
|
+
status: "running",
|
|
106
|
+
total_actions: 0,
|
|
107
|
+
bugs_found: 0,
|
|
108
|
+
metadata: metadata ? JSON.stringify(metadata) : void 0
|
|
109
|
+
};
|
|
110
|
+
this.db.data.test_sessions.push(session);
|
|
111
|
+
await this.db.write();
|
|
112
|
+
return session;
|
|
113
|
+
}
|
|
114
|
+
async getSession(id) {
|
|
115
|
+
await this.ensureInitialized();
|
|
116
|
+
return this.db.data.test_sessions.find((s) => s.id === id) || null;
|
|
117
|
+
}
|
|
118
|
+
async updateSession(id, updates) {
|
|
119
|
+
await this.ensureInitialized();
|
|
120
|
+
const index = this.db.data.test_sessions.findIndex((s) => s.id === id);
|
|
121
|
+
if (index !== -1) {
|
|
122
|
+
this.db.data.test_sessions[index] = { ...this.db.data.test_sessions[index], ...updates };
|
|
123
|
+
await this.db.write();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async getRecentSessions(limit = 10) {
|
|
127
|
+
await this.ensureInitialized();
|
|
128
|
+
return this.db.data.test_sessions.sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime()).slice(0, limit);
|
|
129
|
+
}
|
|
130
|
+
async createAction(action) {
|
|
131
|
+
await this.ensureInitialized();
|
|
132
|
+
const newAction = {
|
|
133
|
+
id: `action_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
134
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
135
|
+
...action
|
|
136
|
+
};
|
|
137
|
+
this.db.data.actions.push(newAction);
|
|
138
|
+
await this.db.write();
|
|
139
|
+
return newAction;
|
|
140
|
+
}
|
|
141
|
+
async getSessionActions(sessionId) {
|
|
142
|
+
await this.ensureInitialized();
|
|
143
|
+
return this.db.data.actions.filter((a) => a.session_id === sessionId).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
144
|
+
}
|
|
145
|
+
async createBug(bug) {
|
|
146
|
+
await this.ensureInitialized();
|
|
147
|
+
const newBug = {
|
|
148
|
+
id: `bug_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
149
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
150
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
151
|
+
...bug
|
|
152
|
+
};
|
|
153
|
+
this.db.data.bugs.push(newBug);
|
|
154
|
+
await this.db.write();
|
|
155
|
+
return newBug;
|
|
156
|
+
}
|
|
157
|
+
async updateBug(id, updates) {
|
|
158
|
+
await this.ensureInitialized();
|
|
159
|
+
const index = this.db.data.bugs.findIndex((b) => b.id === id);
|
|
160
|
+
if (index !== -1) {
|
|
161
|
+
this.db.data.bugs[index] = {
|
|
162
|
+
...this.db.data.bugs[index],
|
|
163
|
+
...updates,
|
|
164
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
165
|
+
};
|
|
166
|
+
await this.db.write();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async getAllBugs() {
|
|
170
|
+
await this.ensureInitialized();
|
|
171
|
+
return this.db.data.bugs.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
|
172
|
+
}
|
|
173
|
+
async getBugsByStatus(status) {
|
|
174
|
+
await this.ensureInitialized();
|
|
175
|
+
return this.db.data.bugs.filter((b) => b.status === status).sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
|
176
|
+
}
|
|
177
|
+
async createKanbanTicket(ticket) {
|
|
178
|
+
await this.ensureInitialized();
|
|
179
|
+
const newTicket = {
|
|
180
|
+
id: `ticket_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
181
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
182
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
183
|
+
...ticket
|
|
184
|
+
};
|
|
185
|
+
this.db.data.kanban_tickets.push(newTicket);
|
|
186
|
+
await this.db.write();
|
|
187
|
+
return newTicket;
|
|
188
|
+
}
|
|
189
|
+
async updateKanbanTicket(id, updates) {
|
|
190
|
+
await this.ensureInitialized();
|
|
191
|
+
const index = this.db.data.kanban_tickets.findIndex((t) => t.id === id);
|
|
192
|
+
if (index !== -1) {
|
|
193
|
+
this.db.data.kanban_tickets[index] = {
|
|
194
|
+
...this.db.data.kanban_tickets[index],
|
|
195
|
+
...updates,
|
|
196
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
197
|
+
};
|
|
198
|
+
await this.db.write();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async getKanbanTickets() {
|
|
202
|
+
await this.ensureInitialized();
|
|
203
|
+
return this.db.data.kanban_tickets.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
|
204
|
+
}
|
|
205
|
+
async getKanbanTicketsByColumn(column) {
|
|
206
|
+
await this.ensureInitialized();
|
|
207
|
+
return this.db.data.kanban_tickets.filter((t) => t.column === column).sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
|
208
|
+
}
|
|
209
|
+
async deleteKanbanTicket(id) {
|
|
210
|
+
await this.ensureInitialized();
|
|
211
|
+
const index = this.db.data.kanban_tickets.findIndex((t) => t.id === id);
|
|
212
|
+
if (index !== -1) {
|
|
213
|
+
this.db.data.kanban_tickets.splice(index, 1);
|
|
214
|
+
await this.db.write();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async clearAllConfig() {
|
|
218
|
+
await this.ensureInitialized();
|
|
219
|
+
this.db.data.config = {};
|
|
220
|
+
await this.db.write();
|
|
221
|
+
}
|
|
222
|
+
// Get real data methods - connected to actual database records
|
|
223
|
+
async getActiveAgents() {
|
|
224
|
+
await this.ensureInitialized();
|
|
225
|
+
const sessions = await this.getRecentSessions(1);
|
|
226
|
+
const currentSession = sessions[0];
|
|
227
|
+
const isRunning = currentSession?.status === "running";
|
|
228
|
+
const totalActions = currentSession?.total_actions || 0;
|
|
229
|
+
const agents = [
|
|
230
|
+
{
|
|
231
|
+
name: "Main Agent",
|
|
232
|
+
status: isRunning ? "running" : "idle",
|
|
233
|
+
purpose: "Autonomous QA orchestration",
|
|
234
|
+
performance: totalActions > 0 ? Math.min(100, Math.round(totalActions / 100 * 100)) : 0,
|
|
235
|
+
tasks: totalActions
|
|
236
|
+
}
|
|
237
|
+
];
|
|
238
|
+
if (currentSession && totalActions > 0) {
|
|
239
|
+
const actions = await this.getSessionActions(currentSession.id);
|
|
240
|
+
const actionTypes = actions.reduce((acc, action) => {
|
|
241
|
+
const type = action.type || "unknown";
|
|
242
|
+
acc[type] = (acc[type] || 0) + 1;
|
|
243
|
+
return acc;
|
|
244
|
+
}, {});
|
|
245
|
+
if (actionTypes["navigate"] || actionTypes["click"] || actionTypes["screenshot"]) {
|
|
246
|
+
agents.push({
|
|
247
|
+
name: "Browser Specialist",
|
|
248
|
+
status: isRunning ? "running" : "idle",
|
|
249
|
+
purpose: "UI navigation and interaction",
|
|
250
|
+
performance: Math.round(((actionTypes["navigate"] || 0) + (actionTypes["click"] || 0)) / totalActions * 100),
|
|
251
|
+
tasks: (actionTypes["navigate"] || 0) + (actionTypes["click"] || 0)
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (actionTypes["api_call"] || actionTypes["request"]) {
|
|
255
|
+
agents.push({
|
|
256
|
+
name: "API Tester",
|
|
257
|
+
status: isRunning ? "running" : "idle",
|
|
258
|
+
purpose: "API endpoint testing",
|
|
259
|
+
performance: Math.round((actionTypes["api_call"] || actionTypes["request"] || 0) / totalActions * 100),
|
|
260
|
+
tasks: actionTypes["api_call"] || actionTypes["request"] || 0
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
if (actionTypes["auth"] || actionTypes["login"]) {
|
|
264
|
+
agents.push({
|
|
265
|
+
name: "Auth Specialist",
|
|
266
|
+
status: isRunning ? "running" : "idle",
|
|
267
|
+
purpose: "Authentication testing",
|
|
268
|
+
performance: Math.round((actionTypes["auth"] || actionTypes["login"] || 0) / totalActions * 100),
|
|
269
|
+
tasks: actionTypes["auth"] || actionTypes["login"] || 0
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return agents;
|
|
274
|
+
}
|
|
275
|
+
async getCurrentTasks() {
|
|
276
|
+
await this.ensureInitialized();
|
|
277
|
+
const sessions = await this.getRecentSessions(1);
|
|
278
|
+
const currentSession = sessions[0];
|
|
279
|
+
if (!currentSession) {
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
const actions = await this.getSessionActions(currentSession.id);
|
|
283
|
+
const recentActions = actions.slice(-10).reverse();
|
|
284
|
+
return recentActions.map((action, index) => ({
|
|
285
|
+
id: action.id,
|
|
286
|
+
name: action.type || "Unknown Action",
|
|
287
|
+
status: index === 0 && currentSession.status === "running" ? "running" : "completed",
|
|
288
|
+
progress: index === 0 && currentSession.status === "running" ? "65%" : "100%",
|
|
289
|
+
agent: "Main Agent",
|
|
290
|
+
started_at: action.timestamp,
|
|
291
|
+
result: action.output || action.description || "Completed"
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
async getCurrentIssues() {
|
|
295
|
+
await this.ensureInitialized();
|
|
296
|
+
const bugs = await this.getAllBugs();
|
|
297
|
+
return bugs.slice(0, 10).map((bug) => ({
|
|
298
|
+
id: bug.id,
|
|
299
|
+
title: bug.title,
|
|
300
|
+
description: bug.description,
|
|
301
|
+
severity: bug.severity || "medium",
|
|
302
|
+
status: bug.status || "open",
|
|
303
|
+
discovered_at: bug.created_at,
|
|
304
|
+
agent: "Main Agent"
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
async pruneOldSessions(maxAgeDays) {
|
|
308
|
+
await this.ensureInitialized();
|
|
309
|
+
const cutoff = new Date(Date.now() - maxAgeDays * 864e5).toISOString();
|
|
310
|
+
const oldSessions = this.db.data.test_sessions.filter((s) => s.started_at < cutoff);
|
|
311
|
+
const oldSessionIds = new Set(oldSessions.map((s) => s.id));
|
|
312
|
+
const actionsBefore = this.db.data.actions.length;
|
|
313
|
+
this.db.data.actions = this.db.data.actions.filter((a) => !oldSessionIds.has(a.session_id));
|
|
314
|
+
const actionsRemoved = actionsBefore - this.db.data.actions.length;
|
|
315
|
+
this.db.data.test_sessions = this.db.data.test_sessions.filter((s) => s.started_at >= cutoff);
|
|
316
|
+
await this.db.write();
|
|
317
|
+
return { sessionsRemoved: oldSessions.length, actionsRemoved };
|
|
318
|
+
}
|
|
319
|
+
async getStorageStats() {
|
|
320
|
+
await this.ensureInitialized();
|
|
321
|
+
return {
|
|
322
|
+
sessions: this.db.data.test_sessions.length,
|
|
323
|
+
actions: this.db.data.actions.length,
|
|
324
|
+
bugs: this.db.data.bugs.length,
|
|
325
|
+
tickets: this.db.data.kanban_tickets.length
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
// ── User management ──────────────────────────────────────────────────────────
|
|
329
|
+
async countUsers() {
|
|
330
|
+
await this.ensureInitialized();
|
|
331
|
+
return this.db.data.users.length;
|
|
332
|
+
}
|
|
333
|
+
async findUserByUsername(username) {
|
|
334
|
+
await this.ensureInitialized();
|
|
335
|
+
return this.db.data.users.find((u) => u.username === username) ?? null;
|
|
336
|
+
}
|
|
337
|
+
async getUserById(id) {
|
|
338
|
+
await this.ensureInitialized();
|
|
339
|
+
return this.db.data.users.find((u) => u.id === id) ?? null;
|
|
340
|
+
}
|
|
341
|
+
async getAllUsers() {
|
|
342
|
+
await this.ensureInitialized();
|
|
343
|
+
return [...this.db.data.users];
|
|
344
|
+
}
|
|
345
|
+
async createUser(data) {
|
|
346
|
+
await this.ensureInitialized();
|
|
347
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
348
|
+
const user = {
|
|
349
|
+
id: `user_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
|
350
|
+
username: data.username,
|
|
351
|
+
passwordHash: data.passwordHash,
|
|
352
|
+
role: data.role,
|
|
353
|
+
createdAt: now,
|
|
354
|
+
updatedAt: now
|
|
355
|
+
};
|
|
356
|
+
this.db.data.users.push(user);
|
|
357
|
+
await this.db.write();
|
|
358
|
+
return user;
|
|
359
|
+
}
|
|
360
|
+
async updateUser(id, updates) {
|
|
361
|
+
await this.ensureInitialized();
|
|
362
|
+
const idx = this.db.data.users.findIndex((u) => u.id === id);
|
|
363
|
+
if (idx !== -1) {
|
|
364
|
+
this.db.data.users[idx] = {
|
|
365
|
+
...this.db.data.users[idx],
|
|
366
|
+
...updates,
|
|
367
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
368
|
+
};
|
|
369
|
+
await this.db.write();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async deleteUser(id) {
|
|
373
|
+
await this.ensureInitialized();
|
|
374
|
+
const idx = this.db.data.users.findIndex((u) => u.id === id);
|
|
375
|
+
if (idx !== -1) {
|
|
376
|
+
this.db.data.users.splice(idx, 1);
|
|
377
|
+
await this.db.write();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async close() {
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// agent/logger.ts
|
|
385
|
+
init_esm_shims();
|
|
386
|
+
var LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
387
|
+
var MIN_LEVEL = process.env.LOG_LEVEL || "info";
|
|
388
|
+
function shouldLog(level) {
|
|
389
|
+
return LEVELS[level] >= LEVELS[MIN_LEVEL];
|
|
390
|
+
}
|
|
391
|
+
function format(level, message, context) {
|
|
392
|
+
const entry = {
|
|
393
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
394
|
+
level,
|
|
395
|
+
msg: message,
|
|
396
|
+
...context
|
|
397
|
+
};
|
|
398
|
+
return JSON.stringify(entry);
|
|
399
|
+
}
|
|
400
|
+
var logger = {
|
|
401
|
+
debug(message, context) {
|
|
402
|
+
if (shouldLog("debug")) process.stdout.write(format("debug", message, context) + "\n");
|
|
403
|
+
},
|
|
404
|
+
info(message, context) {
|
|
405
|
+
if (shouldLog("info")) process.stdout.write(format("info", message, context) + "\n");
|
|
406
|
+
},
|
|
407
|
+
warn(message, context) {
|
|
408
|
+
if (shouldLog("warn")) process.stderr.write(format("warn", message, context) + "\n");
|
|
409
|
+
},
|
|
410
|
+
error(message, context) {
|
|
411
|
+
if (shouldLog("error")) process.stderr.write(format("error", message, context) + "\n");
|
|
412
|
+
},
|
|
413
|
+
child(defaults) {
|
|
414
|
+
return {
|
|
415
|
+
debug: (msg, ctx) => logger.debug(msg, { ...defaults, ...ctx }),
|
|
416
|
+
info: (msg, ctx) => logger.info(msg, { ...defaults, ...ctx }),
|
|
417
|
+
warn: (msg, ctx) => logger.warn(msg, { ...defaults, ...ctx }),
|
|
418
|
+
error: (msg, ctx) => logger.error(msg, { ...defaults, ...ctx })
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// agent/config/index.ts
|
|
424
|
+
init_esm_shims();
|
|
425
|
+
import { config as dotenvConfig } from "dotenv";
|
|
426
|
+
|
|
427
|
+
// agent/config/schema.ts
|
|
428
|
+
init_esm_shims();
|
|
429
|
+
import { z } from "zod";
|
|
430
|
+
var llmConfigSchema = z.object({
|
|
431
|
+
provider: z.enum(["openai", "anthropic", "ollama"]).default("openai"),
|
|
432
|
+
apiKey: z.string().optional(),
|
|
433
|
+
model: z.string().optional(),
|
|
434
|
+
baseUrl: z.string().url().optional()
|
|
435
|
+
});
|
|
436
|
+
var saasConfigSchema = z.object({
|
|
437
|
+
url: z.string().default(""),
|
|
438
|
+
authType: z.enum(["none", "basic", "bearer", "session"]).default("none"),
|
|
439
|
+
username: z.string().optional(),
|
|
440
|
+
password: z.string().optional()
|
|
441
|
+
});
|
|
442
|
+
var githubConfigSchema = z.object({
|
|
443
|
+
token: z.string().min(1, "GITHUB_TOKEN is required when GitHub is configured"),
|
|
444
|
+
owner: z.string().default(""),
|
|
445
|
+
repo: z.string().default("")
|
|
446
|
+
});
|
|
447
|
+
var agentConfigSchema = z.object({
|
|
448
|
+
intervalMs: z.number().int().positive().default(36e5),
|
|
449
|
+
maxIterations: z.number().int().positive().default(20),
|
|
450
|
+
autoStart: z.boolean().default(false)
|
|
451
|
+
});
|
|
452
|
+
var webConfigSchema = z.object({
|
|
453
|
+
port: z.number().int().min(1).max(65535).default(4242),
|
|
454
|
+
host: z.string().default("0.0.0.0")
|
|
455
|
+
});
|
|
456
|
+
var databaseConfigSchema = z.object({
|
|
457
|
+
path: z.string().default("./data/openqa.db")
|
|
458
|
+
});
|
|
459
|
+
var notificationsConfigSchema = z.object({
|
|
460
|
+
slack: z.string().url().optional(),
|
|
461
|
+
discord: z.string().url().optional()
|
|
462
|
+
});
|
|
463
|
+
var openQAConfigSchema = z.object({
|
|
464
|
+
llm: llmConfigSchema,
|
|
465
|
+
saas: saasConfigSchema,
|
|
466
|
+
github: githubConfigSchema.optional(),
|
|
467
|
+
agent: agentConfigSchema,
|
|
468
|
+
web: webConfigSchema,
|
|
469
|
+
database: databaseConfigSchema,
|
|
470
|
+
notifications: notificationsConfigSchema.optional()
|
|
471
|
+
});
|
|
472
|
+
var saasAppConfigSchema = z.object({
|
|
473
|
+
name: z.string().min(1, "SaaS application name is required"),
|
|
474
|
+
description: z.string().min(1, "SaaS application description is required"),
|
|
475
|
+
url: z.string().url("SaaS application URL must be a valid URL"),
|
|
476
|
+
repoUrl: z.string().url().optional(),
|
|
477
|
+
localPath: z.string().optional(),
|
|
478
|
+
techStack: z.array(z.string()).optional(),
|
|
479
|
+
authInfo: z.object({
|
|
480
|
+
type: z.enum(["none", "basic", "oauth", "session"]),
|
|
481
|
+
testCredentials: z.object({
|
|
482
|
+
username: z.string(),
|
|
483
|
+
password: z.string()
|
|
484
|
+
}).optional()
|
|
485
|
+
}).optional(),
|
|
486
|
+
directives: z.array(z.string()).optional()
|
|
487
|
+
});
|
|
488
|
+
function validateSaaSAppConfigSafe(config) {
|
|
489
|
+
const result = saasAppConfigSchema.safeParse(config);
|
|
490
|
+
if (result.success) {
|
|
491
|
+
return { success: true, data: result.data };
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
success: false,
|
|
495
|
+
errors: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function validateConfigSafe(config) {
|
|
499
|
+
const result = openQAConfigSchema.safeParse(config);
|
|
500
|
+
if (result.success) {
|
|
501
|
+
return { success: true, data: result.data };
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
success: false,
|
|
505
|
+
errors: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// agent/config/index.ts
|
|
510
|
+
dotenvConfig();
|
|
511
|
+
var ConfigManager = class {
|
|
512
|
+
db = null;
|
|
513
|
+
envConfig;
|
|
514
|
+
constructor(dbPath) {
|
|
515
|
+
this.envConfig = this.loadFromEnv();
|
|
516
|
+
}
|
|
517
|
+
loadFromEnv() {
|
|
518
|
+
const raw = {
|
|
519
|
+
llm: {
|
|
520
|
+
provider: process.env.LLM_PROVIDER || "openai",
|
|
521
|
+
apiKey: process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY,
|
|
522
|
+
model: process.env.LLM_MODEL,
|
|
523
|
+
baseUrl: process.env.OLLAMA_BASE_URL
|
|
524
|
+
},
|
|
525
|
+
saas: {
|
|
526
|
+
url: process.env.SAAS_URL || "",
|
|
527
|
+
authType: process.env.SAAS_AUTH_TYPE || "none",
|
|
528
|
+
username: process.env.SAAS_USERNAME,
|
|
529
|
+
password: process.env.SAAS_PASSWORD
|
|
530
|
+
},
|
|
531
|
+
github: process.env.GITHUB_TOKEN ? {
|
|
532
|
+
token: process.env.GITHUB_TOKEN,
|
|
533
|
+
owner: process.env.GITHUB_OWNER || "",
|
|
534
|
+
repo: process.env.GITHUB_REPO || ""
|
|
535
|
+
} : void 0,
|
|
536
|
+
agent: {
|
|
537
|
+
intervalMs: parseInt(process.env.AGENT_INTERVAL_MS || "3600000"),
|
|
538
|
+
maxIterations: parseInt(process.env.AGENT_MAX_ITERATIONS || "20"),
|
|
539
|
+
autoStart: process.env.AGENT_AUTO_START === "true"
|
|
540
|
+
},
|
|
541
|
+
web: {
|
|
542
|
+
port: parseInt(process.env.WEB_PORT || "4242"),
|
|
543
|
+
host: process.env.WEB_HOST || "0.0.0.0"
|
|
544
|
+
},
|
|
545
|
+
database: {
|
|
546
|
+
path: process.env.DB_PATH || "./data/openqa.db"
|
|
547
|
+
},
|
|
548
|
+
notifications: {
|
|
549
|
+
slack: process.env.SLACK_WEBHOOK_URL,
|
|
550
|
+
discord: process.env.DISCORD_WEBHOOK_URL
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
const result = validateConfigSafe(raw);
|
|
554
|
+
if (!result.success) {
|
|
555
|
+
logger.warn("Config validation warnings", { errors: result.errors });
|
|
556
|
+
return raw;
|
|
557
|
+
}
|
|
558
|
+
return result.data;
|
|
559
|
+
}
|
|
560
|
+
getDB() {
|
|
561
|
+
if (!this.db) {
|
|
562
|
+
this.db = new OpenQADatabase("./data/openqa.json");
|
|
563
|
+
}
|
|
564
|
+
return this.db;
|
|
565
|
+
}
|
|
566
|
+
async get(key) {
|
|
567
|
+
const dbValue = await this.getDB().getConfig(key);
|
|
568
|
+
if (dbValue) return dbValue;
|
|
569
|
+
const keys = key.split(".");
|
|
570
|
+
let value = this.envConfig;
|
|
571
|
+
for (const k of keys) {
|
|
572
|
+
if (value && typeof value === "object") {
|
|
573
|
+
value = value[k];
|
|
574
|
+
} else {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return value != null ? String(value) : null;
|
|
579
|
+
}
|
|
580
|
+
async set(key, value) {
|
|
581
|
+
await this.getDB().setConfig(key, value);
|
|
582
|
+
}
|
|
583
|
+
async getAll() {
|
|
584
|
+
const dbConfig = await this.getDB().getAllConfig();
|
|
585
|
+
const merged = { ...this.envConfig };
|
|
586
|
+
for (const [key, value] of Object.entries(dbConfig)) {
|
|
587
|
+
const keys = key.split(".");
|
|
588
|
+
let obj = merged;
|
|
589
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
590
|
+
if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") obj[keys[i]] = {};
|
|
591
|
+
obj = obj[keys[i]];
|
|
592
|
+
}
|
|
593
|
+
obj[keys[keys.length - 1]] = value;
|
|
594
|
+
}
|
|
595
|
+
return merged;
|
|
596
|
+
}
|
|
597
|
+
async getConfig() {
|
|
598
|
+
return await this.getAll();
|
|
599
|
+
}
|
|
600
|
+
// Synchronous version that only uses env vars (no DB)
|
|
601
|
+
getConfigSync() {
|
|
602
|
+
return this.envConfig;
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
// agent/config/saas-config.ts
|
|
607
|
+
init_esm_shims();
|
|
608
|
+
import { readFileSync as readFileSync2, existsSync } from "fs";
|
|
609
|
+
import { join as join2 } from "path";
|
|
610
|
+
|
|
611
|
+
// agent/errors.ts
|
|
612
|
+
init_esm_shims();
|
|
613
|
+
var OpenQAError = class extends Error {
|
|
614
|
+
code;
|
|
615
|
+
constructor(message, code) {
|
|
616
|
+
super(message);
|
|
617
|
+
this.name = "OpenQAError";
|
|
618
|
+
this.code = code;
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
var ConfigError = class extends OpenQAError {
|
|
622
|
+
constructor(message, code = "CONFIG_ERROR") {
|
|
623
|
+
super(message, code);
|
|
624
|
+
this.name = "ConfigError";
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
var BrainError = class extends OpenQAError {
|
|
628
|
+
constructor(message, code = "BRAIN_ERROR") {
|
|
629
|
+
super(message, code);
|
|
630
|
+
this.name = "BrainError";
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
var ProjectRunnerError = class extends OpenQAError {
|
|
634
|
+
constructor(message, code = "PROJECT_RUNNER_ERROR") {
|
|
635
|
+
super(message, code);
|
|
636
|
+
this.name = "ProjectRunnerError";
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
// agent/config/saas-config.ts
|
|
641
|
+
var SaaSConfigManager = class {
|
|
642
|
+
db;
|
|
643
|
+
config = null;
|
|
644
|
+
constructor(db) {
|
|
645
|
+
this.db = db;
|
|
646
|
+
this.loadConfig();
|
|
647
|
+
}
|
|
648
|
+
loadConfig() {
|
|
649
|
+
}
|
|
650
|
+
saveConfig() {
|
|
651
|
+
}
|
|
652
|
+
configure(config) {
|
|
653
|
+
const result = validateSaaSAppConfigSafe(config);
|
|
654
|
+
if (!result.success) {
|
|
655
|
+
throw new ConfigError("Invalid SaaS configuration: " + result.errors.join(", "));
|
|
656
|
+
}
|
|
657
|
+
this.config = {
|
|
658
|
+
...config,
|
|
659
|
+
techStack: config.techStack || this.detectTechStack(config.localPath),
|
|
660
|
+
directives: config.directives || []
|
|
661
|
+
};
|
|
662
|
+
this.saveConfig();
|
|
663
|
+
return this.config;
|
|
664
|
+
}
|
|
665
|
+
detectTechStack(localPath) {
|
|
666
|
+
if (!localPath || !existsSync(localPath)) return [];
|
|
667
|
+
const stack = [];
|
|
668
|
+
try {
|
|
669
|
+
const pkgPath = join2(localPath, "package.json");
|
|
670
|
+
if (existsSync(pkgPath)) {
|
|
671
|
+
const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
|
|
672
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
673
|
+
if (deps["react"]) stack.push("React");
|
|
674
|
+
if (deps["vue"]) stack.push("Vue");
|
|
675
|
+
if (deps["svelte"]) stack.push("Svelte");
|
|
676
|
+
if (deps["angular"]) stack.push("Angular");
|
|
677
|
+
if (deps["next"]) stack.push("Next.js");
|
|
678
|
+
if (deps["nuxt"]) stack.push("Nuxt");
|
|
679
|
+
if (deps["express"]) stack.push("Express");
|
|
680
|
+
if (deps["fastify"]) stack.push("Fastify");
|
|
681
|
+
if (deps["nestjs"] || deps["@nestjs/core"]) stack.push("NestJS");
|
|
682
|
+
if (deps["prisma"] || deps["@prisma/client"]) stack.push("Prisma");
|
|
683
|
+
if (deps["mongoose"]) stack.push("MongoDB");
|
|
684
|
+
if (deps["pg"] || deps["postgres"]) stack.push("PostgreSQL");
|
|
685
|
+
if (deps["mysql"] || deps["mysql2"]) stack.push("MySQL");
|
|
686
|
+
if (deps["redis"] || deps["ioredis"]) stack.push("Redis");
|
|
687
|
+
if (deps["typescript"]) stack.push("TypeScript");
|
|
688
|
+
if (deps["tailwindcss"]) stack.push("TailwindCSS");
|
|
689
|
+
}
|
|
690
|
+
if (existsSync(join2(localPath, "requirements.txt")) || existsSync(join2(localPath, "pyproject.toml"))) {
|
|
691
|
+
stack.push("Python");
|
|
692
|
+
}
|
|
693
|
+
if (existsSync(join2(localPath, "go.mod"))) {
|
|
694
|
+
stack.push("Go");
|
|
695
|
+
}
|
|
696
|
+
if (existsSync(join2(localPath, "Cargo.toml"))) {
|
|
697
|
+
stack.push("Rust");
|
|
698
|
+
}
|
|
699
|
+
} catch (e) {
|
|
700
|
+
}
|
|
701
|
+
return stack;
|
|
702
|
+
}
|
|
703
|
+
getConfig() {
|
|
704
|
+
return this.config;
|
|
705
|
+
}
|
|
706
|
+
addDirective(directive) {
|
|
707
|
+
if (!this.config) return;
|
|
708
|
+
if (!this.config.directives) this.config.directives = [];
|
|
709
|
+
this.config.directives.push(directive);
|
|
710
|
+
this.saveConfig();
|
|
711
|
+
}
|
|
712
|
+
removeDirective(index) {
|
|
713
|
+
if (!this.config?.directives) return;
|
|
714
|
+
this.config.directives.splice(index, 1);
|
|
715
|
+
this.saveConfig();
|
|
716
|
+
}
|
|
717
|
+
updateDirectives(directives) {
|
|
718
|
+
if (!this.config) return;
|
|
719
|
+
this.config.directives = directives;
|
|
720
|
+
this.saveConfig();
|
|
721
|
+
}
|
|
722
|
+
setRepoUrl(url) {
|
|
723
|
+
if (!this.config) return;
|
|
724
|
+
this.config.repoUrl = url;
|
|
725
|
+
this.saveConfig();
|
|
726
|
+
}
|
|
727
|
+
setLocalPath(path2) {
|
|
728
|
+
if (!this.config) return;
|
|
729
|
+
this.config.localPath = path2;
|
|
730
|
+
this.config.techStack = this.detectTechStack(path2);
|
|
731
|
+
this.saveConfig();
|
|
732
|
+
}
|
|
733
|
+
setAuthInfo(authInfo) {
|
|
734
|
+
if (!this.config) return;
|
|
735
|
+
this.config.authInfo = authInfo;
|
|
736
|
+
this.saveConfig();
|
|
737
|
+
}
|
|
738
|
+
isConfigured() {
|
|
739
|
+
return this.config !== null && !!this.config.name && !!this.config.url;
|
|
740
|
+
}
|
|
741
|
+
exportConfig() {
|
|
742
|
+
return JSON.stringify(this.config, null, 2);
|
|
743
|
+
}
|
|
744
|
+
importConfig(json) {
|
|
745
|
+
const config = JSON.parse(json);
|
|
746
|
+
return this.configure(config);
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
var DEFAULT_DIRECTIVES = [
|
|
750
|
+
"Test all forms for validation errors and edge cases",
|
|
751
|
+
"Check for security vulnerabilities (XSS, SQL injection, CSRF)",
|
|
752
|
+
"Verify authentication and authorization work correctly",
|
|
753
|
+
"Test responsive design on mobile viewports",
|
|
754
|
+
"Check for accessibility issues (WCAG compliance)",
|
|
755
|
+
"Monitor console for JavaScript errors",
|
|
756
|
+
"Test error handling and user feedback",
|
|
757
|
+
"Verify all links work and navigation is correct"
|
|
758
|
+
];
|
|
759
|
+
function createQuickConfig(name, description, url, options) {
|
|
760
|
+
return {
|
|
761
|
+
name,
|
|
762
|
+
description,
|
|
763
|
+
url,
|
|
764
|
+
repoUrl: options?.repoUrl,
|
|
765
|
+
localPath: options?.localPath,
|
|
766
|
+
directives: options?.directives || DEFAULT_DIRECTIVES,
|
|
767
|
+
authInfo: { type: "none" }
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// agent/brain/index.ts
|
|
772
|
+
init_esm_shims();
|
|
773
|
+
import { EventEmitter as EventEmitter2 } from "events";
|
|
774
|
+
import { writeFileSync as writeFileSync2, readFileSync as readFileSync3, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
775
|
+
import { join as join3 } from "path";
|
|
776
|
+
import { execSync } from "child_process";
|
|
777
|
+
|
|
778
|
+
// agent/brain/llm-resilience.ts
|
|
779
|
+
init_esm_shims();
|
|
780
|
+
import { OpenAIAdapter } from "@orka-js/openai";
|
|
781
|
+
import { AnthropicAdapter } from "@orka-js/anthropic";
|
|
782
|
+
import { EventEmitter } from "events";
|
|
783
|
+
|
|
784
|
+
// agent/brain/llm-cache.ts
|
|
785
|
+
init_esm_shims();
|
|
786
|
+
import { createHash } from "crypto";
|
|
787
|
+
var LLMCache = class {
|
|
788
|
+
store = /* @__PURE__ */ new Map();
|
|
789
|
+
ttlMs;
|
|
790
|
+
maxSize;
|
|
791
|
+
constructor(options) {
|
|
792
|
+
this.ttlMs = options?.ttlMs ?? 36e5;
|
|
793
|
+
this.maxSize = options?.maxSize ?? 500;
|
|
794
|
+
}
|
|
795
|
+
key(prompt) {
|
|
796
|
+
return createHash("sha256").update(prompt).digest("hex");
|
|
797
|
+
}
|
|
798
|
+
get(prompt) {
|
|
799
|
+
const k = this.key(prompt);
|
|
800
|
+
const entry = this.store.get(k);
|
|
801
|
+
if (!entry) return null;
|
|
802
|
+
if (Date.now() > entry.expiresAt) {
|
|
803
|
+
this.store.delete(k);
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
return entry.response;
|
|
807
|
+
}
|
|
808
|
+
set(prompt, response) {
|
|
809
|
+
if (this.store.size >= this.maxSize) {
|
|
810
|
+
const oldest = this.store.keys().next().value;
|
|
811
|
+
if (oldest) this.store.delete(oldest);
|
|
812
|
+
}
|
|
813
|
+
this.store.set(this.key(prompt), {
|
|
814
|
+
response,
|
|
815
|
+
expiresAt: Date.now() + this.ttlMs
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
clear() {
|
|
819
|
+
this.store.clear();
|
|
820
|
+
}
|
|
821
|
+
get size() {
|
|
822
|
+
return this.store.size;
|
|
823
|
+
}
|
|
824
|
+
stats() {
|
|
825
|
+
return { size: this.store.size, ttlMs: this.ttlMs, maxSize: this.maxSize };
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
// agent/metrics.ts
|
|
830
|
+
init_esm_shims();
|
|
831
|
+
var startedAt = Date.now();
|
|
832
|
+
var counters = {
|
|
833
|
+
llm_calls: 0,
|
|
834
|
+
llm_cache_hits: 0,
|
|
835
|
+
llm_retries: 0,
|
|
836
|
+
llm_fallbacks: 0,
|
|
837
|
+
llm_circuit_opens: 0,
|
|
838
|
+
tests_generated: 0,
|
|
839
|
+
tests_run: 0,
|
|
840
|
+
tests_passed: 0,
|
|
841
|
+
tests_failed: 0,
|
|
842
|
+
bugs_found: 0,
|
|
843
|
+
sessions_started: 0,
|
|
844
|
+
ws_connections: 0,
|
|
845
|
+
http_requests: 0
|
|
846
|
+
};
|
|
847
|
+
var metrics = {
|
|
848
|
+
inc(key, by = 1) {
|
|
849
|
+
if (key in counters) counters[key] += by;
|
|
850
|
+
},
|
|
851
|
+
snapshot() {
|
|
852
|
+
const memMB = process.memoryUsage();
|
|
853
|
+
return {
|
|
854
|
+
uptimeSeconds: Math.floor((Date.now() - startedAt) / 1e3),
|
|
855
|
+
memory: {
|
|
856
|
+
heapUsedMB: Math.round(memMB.heapUsed / 1024 / 1024),
|
|
857
|
+
heapTotalMB: Math.round(memMB.heapTotal / 1024 / 1024),
|
|
858
|
+
rssMB: Math.round(memMB.rss / 1024 / 1024)
|
|
859
|
+
},
|
|
860
|
+
counters: { ...counters },
|
|
861
|
+
cacheHitRate: counters.llm_calls > 0 ? Math.round(counters.llm_cache_hits / (counters.llm_calls + counters.llm_cache_hits) * 100) : 0
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
// agent/brain/llm-resilience.ts
|
|
867
|
+
var ResilientLLM = class extends EventEmitter {
|
|
868
|
+
config;
|
|
869
|
+
circuit = { failures: 0, lastFailure: 0, isOpen: false };
|
|
870
|
+
maxRetries;
|
|
871
|
+
circuitThreshold;
|
|
872
|
+
circuitResetMs;
|
|
873
|
+
cache;
|
|
874
|
+
constructor(config) {
|
|
875
|
+
super();
|
|
876
|
+
this.config = config;
|
|
877
|
+
this.maxRetries = config.maxRetries ?? 3;
|
|
878
|
+
this.circuitThreshold = config.circuitThreshold ?? 5;
|
|
879
|
+
this.circuitResetMs = config.circuitResetMs ?? 3e4;
|
|
880
|
+
this.cache = new LLMCache({
|
|
881
|
+
ttlMs: config.cacheTtlMs,
|
|
882
|
+
maxSize: config.cacheMaxSize
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
createAdapter(provider, apiKey, model) {
|
|
886
|
+
if (provider === "anthropic") {
|
|
887
|
+
return new AnthropicAdapter({
|
|
888
|
+
apiKey,
|
|
889
|
+
model: model || "claude-3-5-sonnet-20241022"
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
return new OpenAIAdapter({
|
|
893
|
+
apiKey,
|
|
894
|
+
model: model || "gpt-4"
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
isCircuitOpen() {
|
|
898
|
+
if (!this.circuit.isOpen) return false;
|
|
899
|
+
if (Date.now() - this.circuit.lastFailure >= this.circuitResetMs) {
|
|
900
|
+
this.circuit.isOpen = false;
|
|
901
|
+
this.emit("llm-circuit-half-open", { provider: this.config.provider });
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
recordFailure() {
|
|
907
|
+
this.circuit.failures++;
|
|
908
|
+
this.circuit.lastFailure = Date.now();
|
|
909
|
+
if (this.circuit.failures >= this.circuitThreshold) {
|
|
910
|
+
this.circuit.isOpen = true;
|
|
911
|
+
metrics.inc("llm_circuit_opens");
|
|
912
|
+
this.emit("llm-circuit-open", {
|
|
913
|
+
provider: this.config.provider,
|
|
914
|
+
failures: this.circuit.failures
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
recordSuccess() {
|
|
919
|
+
this.circuit.failures = 0;
|
|
920
|
+
this.circuit.isOpen = false;
|
|
921
|
+
}
|
|
922
|
+
/** Generate text, returning the string content */
|
|
923
|
+
async generate(prompt) {
|
|
924
|
+
const cached = this.cache.get(prompt);
|
|
925
|
+
if (cached !== null) {
|
|
926
|
+
metrics.inc("llm_cache_hits");
|
|
927
|
+
this.emit("llm-cache-hit", { promptLength: prompt.length });
|
|
928
|
+
return cached;
|
|
929
|
+
}
|
|
930
|
+
metrics.inc("llm_calls");
|
|
931
|
+
if (!this.isCircuitOpen()) {
|
|
932
|
+
try {
|
|
933
|
+
return await this.generateWithRetry(
|
|
934
|
+
this.config.provider,
|
|
935
|
+
this.config.apiKey,
|
|
936
|
+
this.config.model,
|
|
937
|
+
prompt
|
|
938
|
+
);
|
|
939
|
+
} catch (e) {
|
|
940
|
+
this.recordFailure();
|
|
941
|
+
this.emit("llm-primary-failed", {
|
|
942
|
+
provider: this.config.provider,
|
|
943
|
+
error: e instanceof Error ? e.message : String(e)
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
if (this.config.fallbackProvider && this.config.fallbackApiKey) {
|
|
948
|
+
try {
|
|
949
|
+
metrics.inc("llm_fallbacks");
|
|
950
|
+
this.emit("llm-fallback", {
|
|
951
|
+
from: this.config.provider,
|
|
952
|
+
to: this.config.fallbackProvider
|
|
953
|
+
});
|
|
954
|
+
const result = await this.generateWithRetry(
|
|
955
|
+
this.config.fallbackProvider,
|
|
956
|
+
this.config.fallbackApiKey,
|
|
957
|
+
this.config.fallbackModel,
|
|
958
|
+
prompt
|
|
959
|
+
);
|
|
960
|
+
this.cache.set(prompt, result);
|
|
961
|
+
return result;
|
|
962
|
+
} catch (e) {
|
|
963
|
+
throw new BrainError(
|
|
964
|
+
`Both LLM providers failed. Primary: ${this.config.provider}, Fallback: ${this.config.fallbackProvider}`
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
throw new BrainError(
|
|
969
|
+
`LLM provider ${this.config.provider} failed and no fallback is configured`
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
async generateWithRetry(provider, apiKey, model, prompt) {
|
|
973
|
+
const adapter = this.createAdapter(provider, apiKey, model);
|
|
974
|
+
let lastError;
|
|
975
|
+
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
|
976
|
+
try {
|
|
977
|
+
const result = await adapter.generate(prompt);
|
|
978
|
+
const text = result.content;
|
|
979
|
+
this.recordSuccess();
|
|
980
|
+
this.cache.set(prompt, text);
|
|
981
|
+
return text;
|
|
982
|
+
} catch (e) {
|
|
983
|
+
lastError = e;
|
|
984
|
+
if (attempt < this.maxRetries) {
|
|
985
|
+
const delayMs = Math.pow(2, attempt - 1) * 1e3;
|
|
986
|
+
metrics.inc("llm_retries");
|
|
987
|
+
this.emit("llm-retry", {
|
|
988
|
+
provider,
|
|
989
|
+
attempt,
|
|
990
|
+
maxRetries: this.maxRetries,
|
|
991
|
+
delayMs,
|
|
992
|
+
error: e instanceof Error ? e.message : String(e)
|
|
993
|
+
});
|
|
994
|
+
await new Promise((resolve2) => setTimeout(resolve2, delayMs));
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
throw lastError;
|
|
999
|
+
}
|
|
1000
|
+
getCircuitState() {
|
|
1001
|
+
return {
|
|
1002
|
+
isOpen: this.isCircuitOpen(),
|
|
1003
|
+
failures: this.circuit.failures
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
getCacheStats() {
|
|
1007
|
+
return this.cache.stats();
|
|
1008
|
+
}
|
|
1009
|
+
clearCache() {
|
|
1010
|
+
this.cache.clear();
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
// agent/brain/index.ts
|
|
1015
|
+
var OpenQABrain = class extends EventEmitter2 {
|
|
1016
|
+
db;
|
|
1017
|
+
llm;
|
|
1018
|
+
saasConfig;
|
|
1019
|
+
generatedTests = /* @__PURE__ */ new Map();
|
|
1020
|
+
dynamicAgents = /* @__PURE__ */ new Map();
|
|
1021
|
+
workDir = "./data/workspace";
|
|
1022
|
+
testsDir = "./data/generated-tests";
|
|
1023
|
+
constructor(db, llmConfig, saasConfig) {
|
|
1024
|
+
super();
|
|
1025
|
+
this.db = db;
|
|
1026
|
+
this.saasConfig = saasConfig;
|
|
1027
|
+
this.llm = new ResilientLLM({
|
|
1028
|
+
provider: llmConfig.provider,
|
|
1029
|
+
apiKey: llmConfig.apiKey,
|
|
1030
|
+
model: llmConfig.model,
|
|
1031
|
+
fallbackProvider: llmConfig.fallbackProvider,
|
|
1032
|
+
fallbackApiKey: llmConfig.fallbackApiKey,
|
|
1033
|
+
fallbackModel: llmConfig.fallbackModel
|
|
1034
|
+
});
|
|
1035
|
+
for (const event of ["llm-retry", "llm-fallback", "llm-circuit-open", "llm-circuit-half-open", "llm-primary-failed"]) {
|
|
1036
|
+
this.llm.on(event, (data) => this.emit(event, data));
|
|
1037
|
+
}
|
|
1038
|
+
mkdirSync2(this.workDir, { recursive: true });
|
|
1039
|
+
mkdirSync2(this.testsDir, { recursive: true });
|
|
1040
|
+
}
|
|
1041
|
+
async analyze() {
|
|
1042
|
+
let codeContext = "";
|
|
1043
|
+
if (this.saasConfig.repoUrl || this.saasConfig.localPath) {
|
|
1044
|
+
codeContext = await this.analyzeCodebase();
|
|
1045
|
+
}
|
|
1046
|
+
const prompt = `You are OpenQA Brain, an autonomous QA system that thinks like a senior QA engineer.
|
|
1047
|
+
|
|
1048
|
+
## SaaS Application to Test
|
|
1049
|
+
- **Name**: ${this.saasConfig.name}
|
|
1050
|
+
- **Description**: ${this.saasConfig.description}
|
|
1051
|
+
- **URL**: ${this.saasConfig.url}
|
|
1052
|
+
- **Tech Stack**: ${this.saasConfig.techStack?.join(", ") || "Unknown"}
|
|
1053
|
+
- **Auth Type**: ${this.saasConfig.authInfo?.type || "none"}
|
|
1054
|
+
|
|
1055
|
+
## User Directives
|
|
1056
|
+
${this.saasConfig.directives?.map((d) => `- ${d}`).join("\n") || "None specified"}
|
|
1057
|
+
|
|
1058
|
+
${codeContext ? `## Code Analysis
|
|
1059
|
+
${codeContext}` : ""}
|
|
1060
|
+
|
|
1061
|
+
## Your Task
|
|
1062
|
+
Analyze this application and provide:
|
|
1063
|
+
|
|
1064
|
+
1. **Understanding**: A brief summary of what this application does and its critical paths
|
|
1065
|
+
2. **Suggested Tests**: List specific tests you would create (be concrete, not generic)
|
|
1066
|
+
3. **Suggested Agents**: Custom agents you would create for this specific app
|
|
1067
|
+
4. **Risks**: Potential issues or vulnerabilities to focus on
|
|
1068
|
+
|
|
1069
|
+
Think deeply about what could go wrong with THIS specific application.
|
|
1070
|
+
|
|
1071
|
+
Respond in JSON format:
|
|
1072
|
+
{
|
|
1073
|
+
"understanding": "...",
|
|
1074
|
+
"suggestedTests": ["Test 1: ...", "Test 2: ..."],
|
|
1075
|
+
"suggestedAgents": ["Agent for X", "Agent for Y"],
|
|
1076
|
+
"risks": ["Risk 1", "Risk 2"]
|
|
1077
|
+
}`;
|
|
1078
|
+
const response = await this.llm.generate(prompt);
|
|
1079
|
+
try {
|
|
1080
|
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
|
1081
|
+
if (jsonMatch) {
|
|
1082
|
+
return JSON.parse(jsonMatch[0]);
|
|
1083
|
+
}
|
|
1084
|
+
} catch (e) {
|
|
1085
|
+
logger.warn("Failed to parse analysis", { error: e instanceof Error ? e.message : String(e) });
|
|
1086
|
+
}
|
|
1087
|
+
return {
|
|
1088
|
+
understanding: response,
|
|
1089
|
+
suggestedTests: [],
|
|
1090
|
+
suggestedAgents: [],
|
|
1091
|
+
risks: []
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
async analyzeCodebase() {
|
|
1095
|
+
let repoPath = this.saasConfig.localPath;
|
|
1096
|
+
if (this.saasConfig.repoUrl && !this.saasConfig.localPath) {
|
|
1097
|
+
repoPath = join3(this.workDir, "repo");
|
|
1098
|
+
if (!existsSync2(repoPath)) {
|
|
1099
|
+
logger.info("Cloning repository", { url: this.saasConfig.repoUrl });
|
|
1100
|
+
try {
|
|
1101
|
+
execSync(`git clone --depth 1 ${this.saasConfig.repoUrl} ${repoPath}`, {
|
|
1102
|
+
stdio: "pipe"
|
|
1103
|
+
});
|
|
1104
|
+
} catch (e) {
|
|
1105
|
+
logger.error("Failed to clone repository", { error: e instanceof Error ? e.message : String(e) });
|
|
1106
|
+
return "";
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
if (!repoPath || !existsSync2(repoPath)) {
|
|
1111
|
+
return "";
|
|
1112
|
+
}
|
|
1113
|
+
const analysis = [];
|
|
1114
|
+
try {
|
|
1115
|
+
const packageJsonPath = join3(repoPath, "package.json");
|
|
1116
|
+
if (existsSync2(packageJsonPath)) {
|
|
1117
|
+
const pkg = JSON.parse(readFileSync3(packageJsonPath, "utf-8"));
|
|
1118
|
+
analysis.push(`### Package Info`);
|
|
1119
|
+
analysis.push(`- Name: ${pkg.name}`);
|
|
1120
|
+
analysis.push(`- Dependencies: ${Object.keys(pkg.dependencies || {}).slice(0, 20).join(", ")}`);
|
|
1121
|
+
analysis.push(`- Scripts: ${Object.keys(pkg.scripts || {}).join(", ")}`);
|
|
1122
|
+
}
|
|
1123
|
+
const srcFiles = this.findFiles(repoPath, [".ts", ".tsx", ".js", ".jsx", ".vue", ".svelte"], 50);
|
|
1124
|
+
if (srcFiles.length > 0) {
|
|
1125
|
+
analysis.push(`
|
|
1126
|
+
### Source Files (${srcFiles.length} found)`);
|
|
1127
|
+
srcFiles.slice(0, 20).forEach((f) => {
|
|
1128
|
+
analysis.push(`- ${f.replace(repoPath, "")}`);
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
const testFiles = this.findFiles(repoPath, [".test.ts", ".test.js", ".spec.ts", ".spec.js"], 20);
|
|
1132
|
+
if (testFiles.length > 0) {
|
|
1133
|
+
analysis.push(`
|
|
1134
|
+
### Existing Tests (${testFiles.length} found)`);
|
|
1135
|
+
testFiles.slice(0, 10).forEach((f) => {
|
|
1136
|
+
analysis.push(`- ${f.replace(repoPath, "")}`);
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
const routePatterns = ["routes", "pages", "views", "controllers", "api"];
|
|
1140
|
+
for (const pattern of routePatterns) {
|
|
1141
|
+
const routeDir = join3(repoPath, "src", pattern);
|
|
1142
|
+
if (existsSync2(routeDir)) {
|
|
1143
|
+
const routes = this.findFiles(routeDir, [".ts", ".tsx", ".js", ".jsx"], 20);
|
|
1144
|
+
if (routes.length > 0) {
|
|
1145
|
+
analysis.push(`
|
|
1146
|
+
### Routes/Pages`);
|
|
1147
|
+
routes.forEach((f) => {
|
|
1148
|
+
analysis.push(`- ${f.replace(repoPath, "")}`);
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
break;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
} catch (e) {
|
|
1155
|
+
logger.error("Error analyzing codebase", { error: e instanceof Error ? e.message : String(e) });
|
|
1156
|
+
}
|
|
1157
|
+
return analysis.join("\n");
|
|
1158
|
+
}
|
|
1159
|
+
findFiles(dir, extensions, limit) {
|
|
1160
|
+
const results = [];
|
|
1161
|
+
try {
|
|
1162
|
+
const find = (d) => {
|
|
1163
|
+
if (results.length >= limit) return;
|
|
1164
|
+
const { readdirSync, statSync: statSync2 } = __require("fs");
|
|
1165
|
+
const items = readdirSync(d);
|
|
1166
|
+
for (const item of items) {
|
|
1167
|
+
if (results.length >= limit) return;
|
|
1168
|
+
if (item.startsWith(".") || item === "node_modules" || item === "dist" || item === "build") continue;
|
|
1169
|
+
const fullPath = join3(d, item);
|
|
1170
|
+
const stat = statSync2(fullPath);
|
|
1171
|
+
if (stat.isDirectory()) {
|
|
1172
|
+
find(fullPath);
|
|
1173
|
+
} else if (extensions.some((ext) => item.endsWith(ext))) {
|
|
1174
|
+
results.push(fullPath);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
find(dir);
|
|
1179
|
+
} catch (e) {
|
|
1180
|
+
}
|
|
1181
|
+
return results;
|
|
1182
|
+
}
|
|
1183
|
+
async generateTest(type, target, context) {
|
|
1184
|
+
const prompt = `You are OpenQA, an autonomous QA engineer. Generate a ${type} test.
|
|
1185
|
+
|
|
1186
|
+
## Application
|
|
1187
|
+
- **Name**: ${this.saasConfig.name}
|
|
1188
|
+
- **Description**: ${this.saasConfig.description}
|
|
1189
|
+
- **URL**: ${this.saasConfig.url}
|
|
1190
|
+
|
|
1191
|
+
## Test Target
|
|
1192
|
+
${target}
|
|
1193
|
+
|
|
1194
|
+
${context ? `## Additional Context
|
|
1195
|
+
${context}` : ""}
|
|
1196
|
+
|
|
1197
|
+
## Instructions
|
|
1198
|
+
Generate a complete, runnable test. Use Playwright for E2E/functional tests.
|
|
1199
|
+
|
|
1200
|
+
For ${type} tests:
|
|
1201
|
+
${type === "unit" ? "- Test isolated functions/components\n- Mock dependencies\n- Use Jest or Vitest syntax" : ""}
|
|
1202
|
+
${type === "functional" ? "- Test user workflows\n- Use Playwright\n- Include assertions" : ""}
|
|
1203
|
+
${type === "e2e" ? "- Test complete user journeys\n- Use Playwright\n- Handle auth if needed" : ""}
|
|
1204
|
+
${type === "regression" ? "- Test previously broken functionality\n- Verify bug fixes\n- Include edge cases" : ""}
|
|
1205
|
+
${type === "security" ? "- Test for vulnerabilities\n- SQL injection, XSS, auth bypass\n- Use safe payloads" : ""}
|
|
1206
|
+
${type === "performance" ? "- Measure load times\n- Check resource usage\n- Set thresholds" : ""}
|
|
1207
|
+
|
|
1208
|
+
Respond with JSON:
|
|
1209
|
+
{
|
|
1210
|
+
"name": "descriptive test name",
|
|
1211
|
+
"description": "what this test verifies",
|
|
1212
|
+
"code": "// complete test code here",
|
|
1213
|
+
"priority": 1-5
|
|
1214
|
+
}`;
|
|
1215
|
+
const response = await this.llm.generate(prompt);
|
|
1216
|
+
let testData = { name: target, description: "", code: "", priority: 3 };
|
|
1217
|
+
try {
|
|
1218
|
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
|
1219
|
+
if (jsonMatch) {
|
|
1220
|
+
testData = JSON.parse(jsonMatch[0]);
|
|
1221
|
+
}
|
|
1222
|
+
} catch (e) {
|
|
1223
|
+
testData.code = response;
|
|
1224
|
+
}
|
|
1225
|
+
const test = {
|
|
1226
|
+
id: `test_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
1227
|
+
name: testData.name,
|
|
1228
|
+
type,
|
|
1229
|
+
description: testData.description,
|
|
1230
|
+
code: testData.code,
|
|
1231
|
+
priority: testData.priority,
|
|
1232
|
+
status: "pending",
|
|
1233
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
1234
|
+
};
|
|
1235
|
+
this.generatedTests.set(test.id, test);
|
|
1236
|
+
this.saveTest(test);
|
|
1237
|
+
this.emit("test-generated", test);
|
|
1238
|
+
return test;
|
|
1239
|
+
}
|
|
1240
|
+
saveTest(test) {
|
|
1241
|
+
const filename = `${test.type}_${test.id}.ts`;
|
|
1242
|
+
const filepath = join3(this.testsDir, filename);
|
|
1243
|
+
const content = `/**
|
|
1244
|
+
* Generated by OpenQA
|
|
1245
|
+
* Type: ${test.type}
|
|
1246
|
+
* Name: ${test.name}
|
|
1247
|
+
* Description: ${test.description}
|
|
1248
|
+
* Created: ${test.createdAt.toISOString()}
|
|
1249
|
+
*/
|
|
1250
|
+
|
|
1251
|
+
${test.code}
|
|
1252
|
+
`;
|
|
1253
|
+
writeFileSync2(filepath, content);
|
|
1254
|
+
test.targetFile = filepath;
|
|
1255
|
+
}
|
|
1256
|
+
async createDynamicAgent(purpose) {
|
|
1257
|
+
const prompt = `You are OpenQA Brain. Create a specialized testing agent.
|
|
1258
|
+
|
|
1259
|
+
## Application Context
|
|
1260
|
+
- **Name**: ${this.saasConfig.name}
|
|
1261
|
+
- **Description**: ${this.saasConfig.description}
|
|
1262
|
+
- **URL**: ${this.saasConfig.url}
|
|
1263
|
+
|
|
1264
|
+
## Agent Purpose
|
|
1265
|
+
${purpose}
|
|
1266
|
+
|
|
1267
|
+
## Instructions
|
|
1268
|
+
Design a specialized agent for this specific purpose. The agent should:
|
|
1269
|
+
1. Have a clear, focused mission
|
|
1270
|
+
2. Know exactly what to test
|
|
1271
|
+
3. Know how to report findings
|
|
1272
|
+
|
|
1273
|
+
Respond with JSON:
|
|
1274
|
+
{
|
|
1275
|
+
"name": "Agent Name",
|
|
1276
|
+
"purpose": "Clear purpose statement",
|
|
1277
|
+
"prompt": "Complete system prompt for this agent (be specific and detailed)",
|
|
1278
|
+
"tools": ["tool1", "tool2"] // from: navigate, click, fill, screenshot, check_console, create_issue, create_ticket
|
|
1279
|
+
}`;
|
|
1280
|
+
const response = await this.llm.generate(prompt);
|
|
1281
|
+
let agentData = { name: purpose, purpose, prompt: "", tools: [] };
|
|
1282
|
+
try {
|
|
1283
|
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
|
1284
|
+
if (jsonMatch) {
|
|
1285
|
+
agentData = JSON.parse(jsonMatch[0]);
|
|
1286
|
+
}
|
|
1287
|
+
} catch (_e) {
|
|
1288
|
+
agentData.prompt = response;
|
|
1289
|
+
}
|
|
1290
|
+
const agent = {
|
|
1291
|
+
id: `agent_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
1292
|
+
name: agentData.name,
|
|
1293
|
+
purpose: agentData.purpose,
|
|
1294
|
+
prompt: agentData.prompt,
|
|
1295
|
+
tools: agentData.tools,
|
|
1296
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1297
|
+
executionCount: 0,
|
|
1298
|
+
successRate: 0
|
|
1299
|
+
};
|
|
1300
|
+
this.dynamicAgents.set(agent.id, agent);
|
|
1301
|
+
this.emit("agent-created", agent);
|
|
1302
|
+
return agent;
|
|
1303
|
+
}
|
|
1304
|
+
async executeTest(testId) {
|
|
1305
|
+
const test = this.generatedTests.get(testId);
|
|
1306
|
+
if (!test) throw new Error(`Test ${testId} not found`);
|
|
1307
|
+
test.status = "running";
|
|
1308
|
+
test.executedAt = /* @__PURE__ */ new Date();
|
|
1309
|
+
this.emit("test-started", test);
|
|
1310
|
+
try {
|
|
1311
|
+
if (test.targetFile && existsSync2(test.targetFile)) {
|
|
1312
|
+
const result = execSync(`npx playwright test ${test.targetFile} --reporter=json`, {
|
|
1313
|
+
cwd: this.testsDir,
|
|
1314
|
+
stdio: "pipe",
|
|
1315
|
+
timeout: 12e4
|
|
1316
|
+
});
|
|
1317
|
+
test.status = "passed";
|
|
1318
|
+
test.result = result.toString();
|
|
1319
|
+
} else {
|
|
1320
|
+
test.status = "passed";
|
|
1321
|
+
test.result = "Test code generated (execution skipped - no test runner configured)";
|
|
1322
|
+
}
|
|
1323
|
+
} catch (e) {
|
|
1324
|
+
test.status = "failed";
|
|
1325
|
+
test.error = e instanceof Error ? e.message : String(e);
|
|
1326
|
+
}
|
|
1327
|
+
this.emit("test-completed", test);
|
|
1328
|
+
return test;
|
|
1329
|
+
}
|
|
1330
|
+
async think() {
|
|
1331
|
+
const recentTests = Array.from(this.generatedTests.values()).slice(-10);
|
|
1332
|
+
const recentAgents = Array.from(this.dynamicAgents.values()).slice(-5);
|
|
1333
|
+
const prompt = `You are OpenQA Brain, an autonomous QA system. Think about what to do next.
|
|
1334
|
+
|
|
1335
|
+
## Application
|
|
1336
|
+
- **Name**: ${this.saasConfig.name}
|
|
1337
|
+
- **Description**: ${this.saasConfig.description}
|
|
1338
|
+
- **URL**: ${this.saasConfig.url}
|
|
1339
|
+
|
|
1340
|
+
## User Directives
|
|
1341
|
+
${this.saasConfig.directives?.map((d) => `- ${d}`).join("\n") || "None"}
|
|
1342
|
+
|
|
1343
|
+
## Recent Tests (${recentTests.length})
|
|
1344
|
+
${recentTests.map((t) => `- [${t.status}] ${t.type}: ${t.name}`).join("\n") || "None yet"}
|
|
1345
|
+
|
|
1346
|
+
## Active Agents (${recentAgents.length})
|
|
1347
|
+
${recentAgents.map((a) => `- ${a.name}: ${a.purpose}`).join("\n") || "None yet"}
|
|
1348
|
+
|
|
1349
|
+
## Your Task
|
|
1350
|
+
Decide what to do next. Consider:
|
|
1351
|
+
1. What areas haven't been tested yet?
|
|
1352
|
+
2. Are there any failed tests that need investigation?
|
|
1353
|
+
3. Should you create new specialized agents?
|
|
1354
|
+
4. What tests would be most valuable right now?
|
|
1355
|
+
|
|
1356
|
+
Respond with JSON:
|
|
1357
|
+
{
|
|
1358
|
+
"decision": "Brief explanation of your reasoning",
|
|
1359
|
+
"actions": [
|
|
1360
|
+
{ "type": "generate_test", "target": "what to test", "reason": "why" },
|
|
1361
|
+
{ "type": "create_agent", "target": "agent purpose", "reason": "why" },
|
|
1362
|
+
{ "type": "run_test", "target": "test_id", "reason": "why" },
|
|
1363
|
+
{ "type": "analyze", "target": "what to analyze", "reason": "why" }
|
|
1364
|
+
]
|
|
1365
|
+
}`;
|
|
1366
|
+
const response = await this.llm.generate(prompt);
|
|
1367
|
+
try {
|
|
1368
|
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
|
1369
|
+
if (jsonMatch) {
|
|
1370
|
+
return JSON.parse(jsonMatch[0]);
|
|
1371
|
+
}
|
|
1372
|
+
} catch (e) {
|
|
1373
|
+
}
|
|
1374
|
+
return {
|
|
1375
|
+
decision: response,
|
|
1376
|
+
actions: []
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
async runAutonomously(maxIterations = 10) {
|
|
1380
|
+
const log = logger.child({ app: this.saasConfig.name });
|
|
1381
|
+
log.info("Brain starting autonomous mode", { maxIterations });
|
|
1382
|
+
const analysis = await this.analyze();
|
|
1383
|
+
log.info("Analysis complete", { suggestedTests: analysis.suggestedTests.length });
|
|
1384
|
+
this.emit("analysis-complete", analysis);
|
|
1385
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
1386
|
+
log.info("Iteration", { iteration: i + 1, maxIterations });
|
|
1387
|
+
const thought = await this.think();
|
|
1388
|
+
log.debug("Decision", { decision: thought.decision });
|
|
1389
|
+
this.emit("thinking", thought);
|
|
1390
|
+
for (const action of thought.actions) {
|
|
1391
|
+
log.debug("Action", { type: action.type, target: action.target });
|
|
1392
|
+
try {
|
|
1393
|
+
switch (action.type) {
|
|
1394
|
+
case "generate_test":
|
|
1395
|
+
const testType = this.inferTestType(action.target);
|
|
1396
|
+
await this.generateTest(testType, action.target, action.reason);
|
|
1397
|
+
break;
|
|
1398
|
+
case "create_agent":
|
|
1399
|
+
await this.createDynamicAgent(action.target);
|
|
1400
|
+
break;
|
|
1401
|
+
case "run_test":
|
|
1402
|
+
if (this.generatedTests.has(action.target)) {
|
|
1403
|
+
await this.executeTest(action.target);
|
|
1404
|
+
}
|
|
1405
|
+
break;
|
|
1406
|
+
case "analyze":
|
|
1407
|
+
break;
|
|
1408
|
+
}
|
|
1409
|
+
} catch (e) {
|
|
1410
|
+
log.error("Action failed", { type: action.type, error: e instanceof Error ? e.message : String(e) });
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
if (thought.actions.length === 0) {
|
|
1414
|
+
log.info("No more actions needed");
|
|
1415
|
+
break;
|
|
1416
|
+
}
|
|
1417
|
+
await new Promise((resolve2) => setTimeout(resolve2, 2e3));
|
|
1418
|
+
}
|
|
1419
|
+
log.info("Autonomous session complete");
|
|
1420
|
+
this.emit("session-complete", {
|
|
1421
|
+
testsGenerated: this.generatedTests.size,
|
|
1422
|
+
agentsCreated: this.dynamicAgents.size
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
inferTestType(target) {
|
|
1426
|
+
const lower = target.toLowerCase();
|
|
1427
|
+
if (lower.includes("security") || lower.includes("injection") || lower.includes("xss")) return "security";
|
|
1428
|
+
if (lower.includes("performance") || lower.includes("load") || lower.includes("speed")) return "performance";
|
|
1429
|
+
if (lower.includes("regression") || lower.includes("bug") || lower.includes("fix")) return "regression";
|
|
1430
|
+
if (lower.includes("unit") || lower.includes("function") || lower.includes("component")) return "unit";
|
|
1431
|
+
if (lower.includes("e2e") || lower.includes("journey") || lower.includes("flow")) return "e2e";
|
|
1432
|
+
return "functional";
|
|
1433
|
+
}
|
|
1434
|
+
getGeneratedTests() {
|
|
1435
|
+
return Array.from(this.generatedTests.values());
|
|
1436
|
+
}
|
|
1437
|
+
getDynamicAgents() {
|
|
1438
|
+
return Array.from(this.dynamicAgents.values());
|
|
1439
|
+
}
|
|
1440
|
+
getStats() {
|
|
1441
|
+
const tests = this.getGeneratedTests();
|
|
1442
|
+
return {
|
|
1443
|
+
totalTests: tests.length,
|
|
1444
|
+
passed: tests.filter((t) => t.status === "passed").length,
|
|
1445
|
+
failed: tests.filter((t) => t.status === "failed").length,
|
|
1446
|
+
pending: tests.filter((t) => t.status === "pending").length,
|
|
1447
|
+
agents: this.dynamicAgents.size,
|
|
1448
|
+
byType: {
|
|
1449
|
+
unit: tests.filter((t) => t.type === "unit").length,
|
|
1450
|
+
functional: tests.filter((t) => t.type === "functional").length,
|
|
1451
|
+
e2e: tests.filter((t) => t.type === "e2e").length,
|
|
1452
|
+
regression: tests.filter((t) => t.type === "regression").length,
|
|
1453
|
+
security: tests.filter((t) => t.type === "security").length,
|
|
1454
|
+
performance: tests.filter((t) => t.type === "performance").length
|
|
1455
|
+
}
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
};
|
|
1459
|
+
|
|
1460
|
+
// agent/tools/browser.ts
|
|
1461
|
+
init_esm_shims();
|
|
1462
|
+
import { chromium } from "playwright";
|
|
1463
|
+
import { mkdirSync as mkdirSync3 } from "fs";
|
|
1464
|
+
import { join as join4 } from "path";
|
|
1465
|
+
var BrowserTools = class {
|
|
1466
|
+
browser = null;
|
|
1467
|
+
page = null;
|
|
1468
|
+
db;
|
|
1469
|
+
sessionId;
|
|
1470
|
+
screenshotDir = "./data/screenshots";
|
|
1471
|
+
constructor(db, sessionId) {
|
|
1472
|
+
this.db = db;
|
|
1473
|
+
this.sessionId = sessionId;
|
|
1474
|
+
mkdirSync3(this.screenshotDir, { recursive: true });
|
|
1475
|
+
}
|
|
1476
|
+
async initialize() {
|
|
1477
|
+
this.browser = await chromium.launch({ headless: true });
|
|
1478
|
+
const context = await this.browser.newContext({
|
|
1479
|
+
viewport: { width: 1920, height: 1080 },
|
|
1480
|
+
userAgent: "OpenQA/1.0 (Automated Testing Agent)"
|
|
1481
|
+
});
|
|
1482
|
+
this.page = await context.newPage();
|
|
1483
|
+
}
|
|
1484
|
+
getTools() {
|
|
1485
|
+
return [
|
|
1486
|
+
{
|
|
1487
|
+
name: "navigate_to_page",
|
|
1488
|
+
description: "Navigate to a specific URL in the application",
|
|
1489
|
+
parameters: {
|
|
1490
|
+
type: "object",
|
|
1491
|
+
properties: {
|
|
1492
|
+
url: { type: "string", description: "The URL to navigate to" }
|
|
1493
|
+
},
|
|
1494
|
+
required: ["url"]
|
|
1495
|
+
},
|
|
1496
|
+
execute: async ({ url }) => {
|
|
1497
|
+
if (!this.page) await this.initialize();
|
|
1498
|
+
try {
|
|
1499
|
+
await this.page.goto(url, { waitUntil: "networkidle" });
|
|
1500
|
+
const title = await this.page.title();
|
|
1501
|
+
this.db.createAction({
|
|
1502
|
+
session_id: this.sessionId,
|
|
1503
|
+
type: "navigate",
|
|
1504
|
+
description: `Navigated to ${url}`,
|
|
1505
|
+
input: url,
|
|
1506
|
+
output: `Page title: ${title}`
|
|
1507
|
+
});
|
|
1508
|
+
return `Successfully navigated to ${url}. Page title: "${title}"`;
|
|
1509
|
+
} catch (error) {
|
|
1510
|
+
return `Failed to navigate: ${error instanceof Error ? error.message : String(error)}`;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
},
|
|
1514
|
+
{
|
|
1515
|
+
name: "click_element",
|
|
1516
|
+
description: "Click on an element using a CSS selector",
|
|
1517
|
+
parameters: {
|
|
1518
|
+
type: "object",
|
|
1519
|
+
properties: {
|
|
1520
|
+
selector: { type: "string", description: "CSS selector of the element to click" }
|
|
1521
|
+
},
|
|
1522
|
+
required: ["selector"]
|
|
1523
|
+
},
|
|
1524
|
+
execute: async ({ selector }) => {
|
|
1525
|
+
if (!this.page) return "Browser not initialized. Navigate to a page first.";
|
|
1526
|
+
try {
|
|
1527
|
+
await this.page.click(selector, { timeout: 5e3 });
|
|
1528
|
+
this.db.createAction({
|
|
1529
|
+
session_id: this.sessionId,
|
|
1530
|
+
type: "click",
|
|
1531
|
+
description: `Clicked element: ${selector}`,
|
|
1532
|
+
input: selector
|
|
1533
|
+
});
|
|
1534
|
+
return `Successfully clicked element: ${selector}`;
|
|
1535
|
+
} catch (error) {
|
|
1536
|
+
return `Failed to click element: ${error instanceof Error ? error.message : String(error)}`;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
},
|
|
1540
|
+
{
|
|
1541
|
+
name: "fill_input",
|
|
1542
|
+
description: "Fill an input field with text",
|
|
1543
|
+
parameters: {
|
|
1544
|
+
type: "object",
|
|
1545
|
+
properties: {
|
|
1546
|
+
selector: { type: "string", description: "CSS selector of the input field" },
|
|
1547
|
+
text: { type: "string", description: "Text to fill in the input" }
|
|
1548
|
+
},
|
|
1549
|
+
required: ["selector", "text"]
|
|
1550
|
+
},
|
|
1551
|
+
execute: async ({ selector, text }) => {
|
|
1552
|
+
if (!this.page) return "Browser not initialized. Navigate to a page first.";
|
|
1553
|
+
try {
|
|
1554
|
+
await this.page.fill(selector, text);
|
|
1555
|
+
this.db.createAction({
|
|
1556
|
+
session_id: this.sessionId,
|
|
1557
|
+
type: "fill",
|
|
1558
|
+
description: `Filled input ${selector}`,
|
|
1559
|
+
input: `${selector} = ${text}`
|
|
1560
|
+
});
|
|
1561
|
+
return `Successfully filled input ${selector} with text`;
|
|
1562
|
+
} catch (error) {
|
|
1563
|
+
return `Failed to fill input: ${error instanceof Error ? error.message : String(error)}`;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
},
|
|
1567
|
+
{
|
|
1568
|
+
name: "take_screenshot",
|
|
1569
|
+
description: "Take a screenshot of the current page for evidence",
|
|
1570
|
+
parameters: {
|
|
1571
|
+
type: "object",
|
|
1572
|
+
properties: {
|
|
1573
|
+
name: { type: "string", description: "Name for the screenshot file" }
|
|
1574
|
+
},
|
|
1575
|
+
required: ["name"]
|
|
1576
|
+
},
|
|
1577
|
+
execute: async ({ name }) => {
|
|
1578
|
+
if (!this.page) return "Browser not initialized. Navigate to a page first.";
|
|
1579
|
+
try {
|
|
1580
|
+
const filename = `${Date.now()}_${name}.png`;
|
|
1581
|
+
const path2 = join4(this.screenshotDir, filename);
|
|
1582
|
+
await this.page.screenshot({ path: path2, fullPage: true });
|
|
1583
|
+
this.db.createAction({
|
|
1584
|
+
session_id: this.sessionId,
|
|
1585
|
+
type: "screenshot",
|
|
1586
|
+
description: `Screenshot: ${name}`,
|
|
1587
|
+
screenshot_path: path2
|
|
1588
|
+
});
|
|
1589
|
+
return `Screenshot saved: ${path2}`;
|
|
1590
|
+
} catch (error) {
|
|
1591
|
+
return `Failed to take screenshot: ${error instanceof Error ? error.message : String(error)}`;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
},
|
|
1595
|
+
{
|
|
1596
|
+
name: "get_page_content",
|
|
1597
|
+
description: "Get the text content of the current page",
|
|
1598
|
+
parameters: {
|
|
1599
|
+
type: "object",
|
|
1600
|
+
properties: {}
|
|
1601
|
+
},
|
|
1602
|
+
execute: async () => {
|
|
1603
|
+
if (!this.page) return "Browser not initialized. Navigate to a page first.";
|
|
1604
|
+
try {
|
|
1605
|
+
const content = await this.page.textContent("body");
|
|
1606
|
+
return content?.slice(0, 1e3) || "No content found";
|
|
1607
|
+
} catch (error) {
|
|
1608
|
+
return `Failed to get content: ${error instanceof Error ? error.message : String(error)}`;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
},
|
|
1612
|
+
{
|
|
1613
|
+
name: "check_console_errors",
|
|
1614
|
+
description: "Check for JavaScript console errors on the page",
|
|
1615
|
+
parameters: {
|
|
1616
|
+
type: "object",
|
|
1617
|
+
properties: {}
|
|
1618
|
+
},
|
|
1619
|
+
execute: async () => {
|
|
1620
|
+
if (!this.page) return "Browser not initialized. Navigate to a page first.";
|
|
1621
|
+
const errors = [];
|
|
1622
|
+
this.page.on("console", (msg) => {
|
|
1623
|
+
if (msg.type() === "error") {
|
|
1624
|
+
errors.push(msg.text());
|
|
1625
|
+
}
|
|
1626
|
+
});
|
|
1627
|
+
await this.page.waitForTimeout(2e3);
|
|
1628
|
+
if (errors.length > 0) {
|
|
1629
|
+
return `Found ${errors.length} console errors:
|
|
1630
|
+
${errors.join("\n")}`;
|
|
1631
|
+
}
|
|
1632
|
+
return "No console errors detected";
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
];
|
|
1636
|
+
}
|
|
1637
|
+
async close() {
|
|
1638
|
+
if (this.browser) {
|
|
1639
|
+
await this.browser.close();
|
|
1640
|
+
this.browser = null;
|
|
1641
|
+
this.page = null;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
};
|
|
1645
|
+
|
|
1646
|
+
// agent/webhooks/git-listener.ts
|
|
1647
|
+
init_esm_shims();
|
|
1648
|
+
import { EventEmitter as EventEmitter3 } from "events";
|
|
1649
|
+
import { Octokit } from "@octokit/rest";
|
|
1650
|
+
var GitListener = class extends EventEmitter3 {
|
|
1651
|
+
config;
|
|
1652
|
+
octokit = null;
|
|
1653
|
+
lastCommitSha = null;
|
|
1654
|
+
lastPipelineId = null;
|
|
1655
|
+
pollInterval = null;
|
|
1656
|
+
isRunning = false;
|
|
1657
|
+
constructor(config) {
|
|
1658
|
+
super();
|
|
1659
|
+
this.config = {
|
|
1660
|
+
branch: "main",
|
|
1661
|
+
pollIntervalMs: 6e4,
|
|
1662
|
+
gitlabUrl: "https://gitlab.com",
|
|
1663
|
+
...config
|
|
1664
|
+
};
|
|
1665
|
+
if (config.provider === "github" && config.token) {
|
|
1666
|
+
this.octokit = new Octokit({ auth: config.token });
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
async start() {
|
|
1670
|
+
if (this.isRunning) return;
|
|
1671
|
+
this.isRunning = true;
|
|
1672
|
+
logger.info("GitListener started", { provider: this.config.provider, owner: this.config.owner, repo: this.config.repo });
|
|
1673
|
+
await this.checkInitialState();
|
|
1674
|
+
this.pollInterval = setInterval(() => {
|
|
1675
|
+
this.poll().catch((e) => logger.error("Poll error", { error: e instanceof Error ? e.message : String(e) }));
|
|
1676
|
+
}, this.config.pollIntervalMs);
|
|
1677
|
+
}
|
|
1678
|
+
stop() {
|
|
1679
|
+
this.isRunning = false;
|
|
1680
|
+
if (this.pollInterval) {
|
|
1681
|
+
clearInterval(this.pollInterval);
|
|
1682
|
+
this.pollInterval = null;
|
|
1683
|
+
}
|
|
1684
|
+
logger.info("GitListener stopped");
|
|
1685
|
+
}
|
|
1686
|
+
async checkInitialState() {
|
|
1687
|
+
try {
|
|
1688
|
+
if (this.config.provider === "github") {
|
|
1689
|
+
await this.checkGitHubState();
|
|
1690
|
+
} else {
|
|
1691
|
+
await this.checkGitLabState();
|
|
1692
|
+
}
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
logger.error("Failed to check initial state", { error: error instanceof Error ? error.message : String(error) });
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
async poll() {
|
|
1698
|
+
try {
|
|
1699
|
+
if (this.config.provider === "github") {
|
|
1700
|
+
await this.pollGitHub();
|
|
1701
|
+
} else {
|
|
1702
|
+
await this.pollGitLab();
|
|
1703
|
+
}
|
|
1704
|
+
} catch (error) {
|
|
1705
|
+
logger.error("Poll error", { error: error instanceof Error ? error.message : String(error) });
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
async checkGitHubState() {
|
|
1709
|
+
if (!this.octokit) return;
|
|
1710
|
+
const { data: commits } = await this.octokit.repos.listCommits({
|
|
1711
|
+
owner: this.config.owner,
|
|
1712
|
+
repo: this.config.repo,
|
|
1713
|
+
sha: this.config.branch,
|
|
1714
|
+
per_page: 1
|
|
1715
|
+
});
|
|
1716
|
+
if (commits.length > 0) {
|
|
1717
|
+
this.lastCommitSha = commits[0].sha;
|
|
1718
|
+
}
|
|
1719
|
+
try {
|
|
1720
|
+
const { data: runs } = await this.octokit.actions.listWorkflowRunsForRepo({
|
|
1721
|
+
owner: this.config.owner,
|
|
1722
|
+
repo: this.config.repo,
|
|
1723
|
+
branch: this.config.branch,
|
|
1724
|
+
per_page: 1
|
|
1725
|
+
});
|
|
1726
|
+
if (runs.workflow_runs.length > 0) {
|
|
1727
|
+
this.lastPipelineId = runs.workflow_runs[0].id.toString();
|
|
1728
|
+
}
|
|
1729
|
+
} catch {
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
async pollGitHub() {
|
|
1733
|
+
if (!this.octokit) return;
|
|
1734
|
+
const { data: commits } = await this.octokit.repos.listCommits({
|
|
1735
|
+
owner: this.config.owner,
|
|
1736
|
+
repo: this.config.repo,
|
|
1737
|
+
sha: this.config.branch,
|
|
1738
|
+
per_page: 5
|
|
1739
|
+
});
|
|
1740
|
+
for (const commit of commits) {
|
|
1741
|
+
if (this.lastCommitSha && commit.sha === this.lastCommitSha) break;
|
|
1742
|
+
const isMerge = commit.parents && commit.parents.length > 1;
|
|
1743
|
+
const event = {
|
|
1744
|
+
type: isMerge ? "merge" : "push",
|
|
1745
|
+
provider: "github",
|
|
1746
|
+
branch: this.config.branch,
|
|
1747
|
+
commit: commit.sha,
|
|
1748
|
+
author: commit.commit.author?.name || "unknown",
|
|
1749
|
+
message: commit.commit.message,
|
|
1750
|
+
timestamp: new Date(commit.commit.author?.date || Date.now())
|
|
1751
|
+
};
|
|
1752
|
+
this.emit("git-event", event);
|
|
1753
|
+
if (isMerge) {
|
|
1754
|
+
this.emit("merge", event);
|
|
1755
|
+
logger.info("Merge detected", { branch: this.config.branch, sha: commit.sha.slice(0, 7) });
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
if (commits.length > 0) {
|
|
1759
|
+
this.lastCommitSha = commits[0].sha;
|
|
1760
|
+
}
|
|
1761
|
+
try {
|
|
1762
|
+
const { data: runs } = await this.octokit.actions.listWorkflowRunsForRepo({
|
|
1763
|
+
owner: this.config.owner,
|
|
1764
|
+
repo: this.config.repo,
|
|
1765
|
+
branch: this.config.branch,
|
|
1766
|
+
per_page: 5
|
|
1767
|
+
});
|
|
1768
|
+
for (const run of runs.workflow_runs) {
|
|
1769
|
+
if (this.lastPipelineId && run.id.toString() === this.lastPipelineId) break;
|
|
1770
|
+
if (run.status === "completed") {
|
|
1771
|
+
const event = {
|
|
1772
|
+
type: run.conclusion === "success" ? "pipeline_success" : "pipeline_failure",
|
|
1773
|
+
provider: "github",
|
|
1774
|
+
branch: this.config.branch,
|
|
1775
|
+
commit: run.head_sha,
|
|
1776
|
+
author: run.actor?.login || "unknown",
|
|
1777
|
+
message: run.name || "",
|
|
1778
|
+
timestamp: new Date(run.updated_at || Date.now()),
|
|
1779
|
+
pipelineId: run.id.toString(),
|
|
1780
|
+
pipelineStatus: run.conclusion || void 0
|
|
1781
|
+
};
|
|
1782
|
+
this.emit("git-event", event);
|
|
1783
|
+
if (run.conclusion === "success") {
|
|
1784
|
+
this.emit("pipeline-success", event);
|
|
1785
|
+
logger.info("Pipeline success", { name: run.name, id: run.id });
|
|
1786
|
+
} else {
|
|
1787
|
+
this.emit("pipeline-failure", event);
|
|
1788
|
+
logger.warn("Pipeline failure", { name: run.name, id: run.id });
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
if (runs.workflow_runs.length > 0) {
|
|
1793
|
+
this.lastPipelineId = runs.workflow_runs[0].id.toString();
|
|
1794
|
+
}
|
|
1795
|
+
} catch {
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
async checkGitLabState() {
|
|
1799
|
+
const headers = { "PRIVATE-TOKEN": this.config.token };
|
|
1800
|
+
const projectPath = encodeURIComponent(`${this.config.owner}/${this.config.repo}`);
|
|
1801
|
+
const baseUrl = this.config.gitlabUrl;
|
|
1802
|
+
try {
|
|
1803
|
+
const commitsRes = await fetch(
|
|
1804
|
+
`${baseUrl}/api/v4/projects/${projectPath}/repository/commits?ref_name=${this.config.branch}&per_page=1`,
|
|
1805
|
+
{ headers }
|
|
1806
|
+
);
|
|
1807
|
+
const commits = await commitsRes.json();
|
|
1808
|
+
if (commits.length > 0) {
|
|
1809
|
+
this.lastCommitSha = commits[0].id;
|
|
1810
|
+
}
|
|
1811
|
+
const pipelinesRes = await fetch(
|
|
1812
|
+
`${baseUrl}/api/v4/projects/${projectPath}/pipelines?ref=${this.config.branch}&per_page=1`,
|
|
1813
|
+
{ headers }
|
|
1814
|
+
);
|
|
1815
|
+
const pipelines = await pipelinesRes.json();
|
|
1816
|
+
if (pipelines.length > 0) {
|
|
1817
|
+
this.lastPipelineId = pipelines[0].id.toString();
|
|
1818
|
+
}
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
logger.error("GitLab initial state error", { error: error instanceof Error ? error.message : String(error) });
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
async pollGitLab() {
|
|
1824
|
+
const headers = { "PRIVATE-TOKEN": this.config.token };
|
|
1825
|
+
const projectPath = encodeURIComponent(`${this.config.owner}/${this.config.repo}`);
|
|
1826
|
+
const baseUrl = this.config.gitlabUrl;
|
|
1827
|
+
try {
|
|
1828
|
+
const commitsRes = await fetch(
|
|
1829
|
+
`${baseUrl}/api/v4/projects/${projectPath}/repository/commits?ref_name=${this.config.branch}&per_page=5`,
|
|
1830
|
+
{ headers }
|
|
1831
|
+
);
|
|
1832
|
+
const commits = await commitsRes.json();
|
|
1833
|
+
for (const commit of commits) {
|
|
1834
|
+
if (this.lastCommitSha && commit.id === this.lastCommitSha) break;
|
|
1835
|
+
const isMerge = commit.parent_ids && commit.parent_ids.length > 1;
|
|
1836
|
+
const event = {
|
|
1837
|
+
type: isMerge ? "merge" : "push",
|
|
1838
|
+
provider: "gitlab",
|
|
1839
|
+
branch: this.config.branch,
|
|
1840
|
+
commit: commit.id,
|
|
1841
|
+
author: commit.author_name,
|
|
1842
|
+
message: commit.message,
|
|
1843
|
+
timestamp: new Date(commit.created_at)
|
|
1844
|
+
};
|
|
1845
|
+
this.emit("git-event", event);
|
|
1846
|
+
if (isMerge) {
|
|
1847
|
+
this.emit("merge", event);
|
|
1848
|
+
logger.info("Merge detected", { branch: this.config.branch, id: commit.id.slice(0, 7) });
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
if (commits.length > 0) {
|
|
1852
|
+
this.lastCommitSha = commits[0].id;
|
|
1853
|
+
}
|
|
1854
|
+
const pipelinesRes = await fetch(
|
|
1855
|
+
`${baseUrl}/api/v4/projects/${projectPath}/pipelines?ref=${this.config.branch}&per_page=5`,
|
|
1856
|
+
{ headers }
|
|
1857
|
+
);
|
|
1858
|
+
const pipelines = await pipelinesRes.json();
|
|
1859
|
+
for (const pipeline of pipelines) {
|
|
1860
|
+
if (this.lastPipelineId && pipeline.id.toString() === this.lastPipelineId) break;
|
|
1861
|
+
if (pipeline.status === "success" || pipeline.status === "failed") {
|
|
1862
|
+
const event = {
|
|
1863
|
+
type: pipeline.status === "success" ? "pipeline_success" : "pipeline_failure",
|
|
1864
|
+
provider: "gitlab",
|
|
1865
|
+
branch: this.config.branch,
|
|
1866
|
+
commit: pipeline.sha,
|
|
1867
|
+
author: pipeline.user?.name || "unknown",
|
|
1868
|
+
message: `Pipeline #${pipeline.id}`,
|
|
1869
|
+
timestamp: new Date(pipeline.updated_at),
|
|
1870
|
+
pipelineId: pipeline.id.toString(),
|
|
1871
|
+
pipelineStatus: pipeline.status
|
|
1872
|
+
};
|
|
1873
|
+
this.emit("git-event", event);
|
|
1874
|
+
if (pipeline.status === "success") {
|
|
1875
|
+
this.emit("pipeline-success", event);
|
|
1876
|
+
logger.info("Pipeline success", { id: pipeline.id });
|
|
1877
|
+
} else {
|
|
1878
|
+
this.emit("pipeline-failure", event);
|
|
1879
|
+
logger.warn("Pipeline failure", { id: pipeline.id });
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
if (pipelines.length > 0) {
|
|
1884
|
+
this.lastPipelineId = pipelines[0].id.toString();
|
|
1885
|
+
}
|
|
1886
|
+
} catch (error) {
|
|
1887
|
+
logger.error("GitLab poll error", { error: error instanceof Error ? error.message : String(error) });
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
async setupWebhook(webhookUrl) {
|
|
1891
|
+
if (this.config.provider === "github") {
|
|
1892
|
+
return this.setupGitHubWebhook(webhookUrl);
|
|
1893
|
+
} else {
|
|
1894
|
+
return this.setupGitLabWebhook(webhookUrl);
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
async setupGitHubWebhook(webhookUrl) {
|
|
1898
|
+
if (!this.octokit) throw new Error("GitHub not configured");
|
|
1899
|
+
const { data } = await this.octokit.repos.createWebhook({
|
|
1900
|
+
owner: this.config.owner,
|
|
1901
|
+
repo: this.config.repo,
|
|
1902
|
+
config: {
|
|
1903
|
+
url: webhookUrl,
|
|
1904
|
+
content_type: "json"
|
|
1905
|
+
},
|
|
1906
|
+
events: ["push", "pull_request", "workflow_run"]
|
|
1907
|
+
});
|
|
1908
|
+
return data.id.toString();
|
|
1909
|
+
}
|
|
1910
|
+
async setupGitLabWebhook(webhookUrl) {
|
|
1911
|
+
const headers = {
|
|
1912
|
+
"PRIVATE-TOKEN": this.config.token,
|
|
1913
|
+
"Content-Type": "application/json"
|
|
1914
|
+
};
|
|
1915
|
+
const projectPath = encodeURIComponent(`${this.config.owner}/${this.config.repo}`);
|
|
1916
|
+
const res = await fetch(
|
|
1917
|
+
`${this.config.gitlabUrl}/api/v4/projects/${projectPath}/hooks`,
|
|
1918
|
+
{
|
|
1919
|
+
method: "POST",
|
|
1920
|
+
headers,
|
|
1921
|
+
body: JSON.stringify({
|
|
1922
|
+
url: webhookUrl,
|
|
1923
|
+
push_events: true,
|
|
1924
|
+
merge_requests_events: true,
|
|
1925
|
+
pipeline_events: true
|
|
1926
|
+
})
|
|
1927
|
+
}
|
|
1928
|
+
);
|
|
1929
|
+
const data = await res.json();
|
|
1930
|
+
return data.id.toString();
|
|
1931
|
+
}
|
|
1932
|
+
};
|
|
1933
|
+
|
|
1934
|
+
// agent/tools/project-runner.ts
|
|
1935
|
+
init_esm_shims();
|
|
1936
|
+
import { EventEmitter as EventEmitter4 } from "events";
|
|
1937
|
+
import { spawn } from "child_process";
|
|
1938
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4, statSync } from "fs";
|
|
1939
|
+
import { join as join5, resolve } from "path";
|
|
1940
|
+
function sanitizeRepoPath(inputPath) {
|
|
1941
|
+
if (typeof inputPath !== "string" || !inputPath.trim()) {
|
|
1942
|
+
throw new ProjectRunnerError("repoPath must be a non-empty string");
|
|
1943
|
+
}
|
|
1944
|
+
const resolved = resolve(inputPath);
|
|
1945
|
+
try {
|
|
1946
|
+
const stat = statSync(resolved);
|
|
1947
|
+
if (!stat.isDirectory()) {
|
|
1948
|
+
throw new ProjectRunnerError(`repoPath is not a directory: ${resolved}`);
|
|
1949
|
+
}
|
|
1950
|
+
} catch (err) {
|
|
1951
|
+
if (err instanceof ProjectRunnerError) throw err;
|
|
1952
|
+
throw new ProjectRunnerError(`repoPath does not exist: ${resolved}`);
|
|
1953
|
+
}
|
|
1954
|
+
return resolved;
|
|
1955
|
+
}
|
|
1956
|
+
var ProjectRunner = class extends EventEmitter4 {
|
|
1957
|
+
serverProcess = null;
|
|
1958
|
+
serverUrl = null;
|
|
1959
|
+
installed = false;
|
|
1960
|
+
projectType = null;
|
|
1961
|
+
repoPath = null;
|
|
1962
|
+
/**
|
|
1963
|
+
* Detect what kind of project lives at the given path
|
|
1964
|
+
*/
|
|
1965
|
+
detectProjectType(repoPath) {
|
|
1966
|
+
repoPath = sanitizeRepoPath(repoPath);
|
|
1967
|
+
const pkgPath = join5(repoPath, "package.json");
|
|
1968
|
+
if (existsSync3(pkgPath)) {
|
|
1969
|
+
return this.detectNodeProject(repoPath, pkgPath);
|
|
1970
|
+
}
|
|
1971
|
+
if (existsSync3(join5(repoPath, "requirements.txt")) || existsSync3(join5(repoPath, "pyproject.toml"))) {
|
|
1972
|
+
return this.detectPythonProject(repoPath);
|
|
1973
|
+
}
|
|
1974
|
+
if (existsSync3(join5(repoPath, "go.mod"))) {
|
|
1975
|
+
return {
|
|
1976
|
+
language: "go",
|
|
1977
|
+
packageManager: "go",
|
|
1978
|
+
scripts: {},
|
|
1979
|
+
testRunner: "go test",
|
|
1980
|
+
devCommand: "go run ."
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
if (existsSync3(join5(repoPath, "Cargo.toml"))) {
|
|
1984
|
+
return {
|
|
1985
|
+
language: "rust",
|
|
1986
|
+
packageManager: "cargo",
|
|
1987
|
+
scripts: {},
|
|
1988
|
+
testRunner: "cargo test",
|
|
1989
|
+
devCommand: "cargo run"
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
return { language: "unknown", packageManager: "unknown", scripts: {} };
|
|
1993
|
+
}
|
|
1994
|
+
detectNodeProject(repoPath, pkgPath) {
|
|
1995
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf-8"));
|
|
1996
|
+
const scripts = pkg.scripts || {};
|
|
1997
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1998
|
+
let packageManager = "npm";
|
|
1999
|
+
if (existsSync3(join5(repoPath, "pnpm-lock.yaml"))) packageManager = "pnpm";
|
|
2000
|
+
else if (existsSync3(join5(repoPath, "yarn.lock"))) packageManager = "yarn";
|
|
2001
|
+
else if (existsSync3(join5(repoPath, "bun.lockb"))) packageManager = "bun";
|
|
2002
|
+
let framework;
|
|
2003
|
+
if (deps["next"]) framework = "next";
|
|
2004
|
+
else if (deps["nuxt"]) framework = "nuxt";
|
|
2005
|
+
else if (deps["@angular/core"]) framework = "angular";
|
|
2006
|
+
else if (deps["svelte"] || deps["@sveltejs/kit"]) framework = "svelte";
|
|
2007
|
+
else if (deps["vue"]) framework = "vue";
|
|
2008
|
+
else if (deps["react"]) framework = "react";
|
|
2009
|
+
else if (deps["express"]) framework = "express";
|
|
2010
|
+
else if (deps["fastify"]) framework = "fastify";
|
|
2011
|
+
else if (deps["@nestjs/core"]) framework = "nestjs";
|
|
2012
|
+
let testRunner;
|
|
2013
|
+
if (deps["vitest"]) testRunner = "vitest";
|
|
2014
|
+
else if (deps["jest"]) testRunner = "jest";
|
|
2015
|
+
else if (deps["mocha"]) testRunner = "mocha";
|
|
2016
|
+
else if (deps["playwright"] || deps["@playwright/test"]) testRunner = "playwright";
|
|
2017
|
+
else if (deps["cypress"]) testRunner = "cypress";
|
|
2018
|
+
let devCommand;
|
|
2019
|
+
if (scripts["dev"]) devCommand = `${packageManager} run dev`;
|
|
2020
|
+
else if (scripts["start"]) devCommand = `${packageManager} run start`;
|
|
2021
|
+
else if (scripts["serve"]) devCommand = `${packageManager} run serve`;
|
|
2022
|
+
let buildCommand;
|
|
2023
|
+
if (scripts["build"]) buildCommand = `${packageManager} run build`;
|
|
2024
|
+
let port;
|
|
2025
|
+
const devScript = scripts["dev"] || scripts["start"] || "";
|
|
2026
|
+
const portMatch = devScript.match(/--port[= ](\d+)|-p[= ]?(\d+)/);
|
|
2027
|
+
if (portMatch) {
|
|
2028
|
+
port = parseInt(portMatch[1] || portMatch[2]);
|
|
2029
|
+
} else if (framework === "next") {
|
|
2030
|
+
port = 3e3;
|
|
2031
|
+
} else if (framework === "nuxt" || framework === "vue") {
|
|
2032
|
+
port = 3e3;
|
|
2033
|
+
} else if (framework === "angular") {
|
|
2034
|
+
port = 4200;
|
|
2035
|
+
} else if (framework === "svelte") {
|
|
2036
|
+
port = 5173;
|
|
2037
|
+
} else if (framework === "react") {
|
|
2038
|
+
port = 3e3;
|
|
2039
|
+
}
|
|
2040
|
+
return {
|
|
2041
|
+
language: "node",
|
|
2042
|
+
framework,
|
|
2043
|
+
packageManager,
|
|
2044
|
+
scripts,
|
|
2045
|
+
testRunner,
|
|
2046
|
+
devCommand,
|
|
2047
|
+
buildCommand,
|
|
2048
|
+
port
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
detectPythonProject(repoPath) {
|
|
2052
|
+
let testRunner;
|
|
2053
|
+
const reqPath = join5(repoPath, "requirements.txt");
|
|
2054
|
+
if (existsSync3(reqPath)) {
|
|
2055
|
+
const content = readFileSync4(reqPath, "utf-8");
|
|
2056
|
+
if (content.includes("pytest")) testRunner = "pytest";
|
|
2057
|
+
}
|
|
2058
|
+
const pyprojectPath = join5(repoPath, "pyproject.toml");
|
|
2059
|
+
if (existsSync3(pyprojectPath)) {
|
|
2060
|
+
const content = readFileSync4(pyprojectPath, "utf-8");
|
|
2061
|
+
if (content.includes("pytest")) testRunner = "pytest";
|
|
2062
|
+
}
|
|
2063
|
+
return {
|
|
2064
|
+
language: "python",
|
|
2065
|
+
packageManager: "pip",
|
|
2066
|
+
scripts: {},
|
|
2067
|
+
testRunner: testRunner || "pytest",
|
|
2068
|
+
devCommand: "python -m flask run"
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2072
|
+
* Install project dependencies
|
|
2073
|
+
*/
|
|
2074
|
+
async installDependencies(repoPath) {
|
|
2075
|
+
repoPath = sanitizeRepoPath(repoPath);
|
|
2076
|
+
const projectType = this.detectProjectType(repoPath);
|
|
2077
|
+
this.projectType = projectType;
|
|
2078
|
+
this.repoPath = repoPath;
|
|
2079
|
+
let command;
|
|
2080
|
+
let args;
|
|
2081
|
+
switch (projectType.packageManager) {
|
|
2082
|
+
case "pnpm":
|
|
2083
|
+
command = "pnpm";
|
|
2084
|
+
args = ["install"];
|
|
2085
|
+
break;
|
|
2086
|
+
case "yarn":
|
|
2087
|
+
command = "yarn";
|
|
2088
|
+
args = ["install"];
|
|
2089
|
+
break;
|
|
2090
|
+
case "bun":
|
|
2091
|
+
command = "bun";
|
|
2092
|
+
args = ["install"];
|
|
2093
|
+
break;
|
|
2094
|
+
case "npm":
|
|
2095
|
+
command = "npm";
|
|
2096
|
+
args = ["install"];
|
|
2097
|
+
break;
|
|
2098
|
+
case "pip":
|
|
2099
|
+
command = "pip";
|
|
2100
|
+
args = ["install", "-r", "requirements.txt"];
|
|
2101
|
+
break;
|
|
2102
|
+
case "cargo":
|
|
2103
|
+
command = "cargo";
|
|
2104
|
+
args = ["build"];
|
|
2105
|
+
break;
|
|
2106
|
+
case "go":
|
|
2107
|
+
command = "go";
|
|
2108
|
+
args = ["mod", "download"];
|
|
2109
|
+
break;
|
|
2110
|
+
default:
|
|
2111
|
+
throw new ProjectRunnerError(`Unknown package manager: ${projectType.packageManager}`);
|
|
2112
|
+
}
|
|
2113
|
+
this.emit("install-start", { command, packageManager: projectType.packageManager });
|
|
2114
|
+
return new Promise((resolve2, reject) => {
|
|
2115
|
+
const proc = spawn(command, args, {
|
|
2116
|
+
cwd: repoPath,
|
|
2117
|
+
stdio: "pipe",
|
|
2118
|
+
timeout: 3e5
|
|
2119
|
+
// 5 min
|
|
2120
|
+
});
|
|
2121
|
+
let output = "";
|
|
2122
|
+
proc.stdout?.on("data", (data) => {
|
|
2123
|
+
const text = data.toString();
|
|
2124
|
+
output += text;
|
|
2125
|
+
this.emit("install-progress", { text });
|
|
2126
|
+
});
|
|
2127
|
+
proc.stderr?.on("data", (data) => {
|
|
2128
|
+
const text = data.toString();
|
|
2129
|
+
output += text;
|
|
2130
|
+
this.emit("install-progress", { text });
|
|
2131
|
+
});
|
|
2132
|
+
proc.on("close", (code) => {
|
|
2133
|
+
if (code === 0) {
|
|
2134
|
+
this.installed = true;
|
|
2135
|
+
this.emit("install-complete", { success: true });
|
|
2136
|
+
resolve2();
|
|
2137
|
+
} else {
|
|
2138
|
+
const err = new ProjectRunnerError(
|
|
2139
|
+
`Install failed (exit code ${code}): ${output.slice(-500)}`
|
|
2140
|
+
);
|
|
2141
|
+
this.emit("install-complete", { success: false, error: err.message });
|
|
2142
|
+
reject(err);
|
|
2143
|
+
}
|
|
2144
|
+
});
|
|
2145
|
+
proc.on("error", (err) => {
|
|
2146
|
+
reject(new ProjectRunnerError(`Failed to spawn ${command}: ${err.message}`));
|
|
2147
|
+
});
|
|
2148
|
+
});
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
2151
|
+
* Start the project's dev server
|
|
2152
|
+
*/
|
|
2153
|
+
async startDevServer(repoPath) {
|
|
2154
|
+
repoPath = sanitizeRepoPath(repoPath);
|
|
2155
|
+
const projectType = this.projectType || this.detectProjectType(repoPath);
|
|
2156
|
+
this.repoPath = repoPath;
|
|
2157
|
+
if (!projectType.devCommand) {
|
|
2158
|
+
throw new ProjectRunnerError("No dev command detected for this project");
|
|
2159
|
+
}
|
|
2160
|
+
const [cmd, ...args] = projectType.devCommand.split(" ");
|
|
2161
|
+
this.emit("server-starting", { command: projectType.devCommand });
|
|
2162
|
+
return new Promise((resolve2, reject) => {
|
|
2163
|
+
const proc = spawn(cmd, args, {
|
|
2164
|
+
cwd: repoPath,
|
|
2165
|
+
stdio: "pipe",
|
|
2166
|
+
detached: true
|
|
2167
|
+
});
|
|
2168
|
+
this.serverProcess = proc;
|
|
2169
|
+
let resolved = false;
|
|
2170
|
+
const readyPatterns = [
|
|
2171
|
+
/ready on\s+(https?:\/\/\S+)/i,
|
|
2172
|
+
/listening on\s+(https?:\/\/\S+)/i,
|
|
2173
|
+
/started on\s+(https?:\/\/\S+)/i,
|
|
2174
|
+
/Local:\s+(https?:\/\/\S+)/i,
|
|
2175
|
+
/http:\/\/localhost:(\d+)/,
|
|
2176
|
+
/http:\/\/127\.0\.0\.1:(\d+)/,
|
|
2177
|
+
/http:\/\/0\.0\.0\.0:(\d+)/,
|
|
2178
|
+
/ready in \d+/i,
|
|
2179
|
+
/compiled successfully/i
|
|
2180
|
+
];
|
|
2181
|
+
const checkOutput = (text) => {
|
|
2182
|
+
if (resolved) return;
|
|
2183
|
+
for (const pattern of readyPatterns) {
|
|
2184
|
+
const match = text.match(pattern);
|
|
2185
|
+
if (match) {
|
|
2186
|
+
resolved = true;
|
|
2187
|
+
let url;
|
|
2188
|
+
if (match[1]?.startsWith("http")) {
|
|
2189
|
+
url = match[1];
|
|
2190
|
+
} else if (match[1]) {
|
|
2191
|
+
url = `http://localhost:${match[1]}`;
|
|
2192
|
+
} else if (projectType.port) {
|
|
2193
|
+
url = `http://localhost:${projectType.port}`;
|
|
2194
|
+
} else {
|
|
2195
|
+
url = "http://localhost:3000";
|
|
2196
|
+
}
|
|
2197
|
+
this.serverUrl = url;
|
|
2198
|
+
this.emit("server-ready", { url, pid: proc.pid });
|
|
2199
|
+
resolve2({ url, pid: proc.pid });
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
};
|
|
2204
|
+
proc.stdout?.on("data", (data) => {
|
|
2205
|
+
checkOutput(data.toString());
|
|
2206
|
+
});
|
|
2207
|
+
proc.stderr?.on("data", (data) => {
|
|
2208
|
+
checkOutput(data.toString());
|
|
2209
|
+
});
|
|
2210
|
+
proc.on("error", (err) => {
|
|
2211
|
+
if (!resolved) {
|
|
2212
|
+
reject(new ProjectRunnerError(`Failed to start dev server: ${err.message}`));
|
|
2213
|
+
}
|
|
2214
|
+
});
|
|
2215
|
+
proc.on("close", (code) => {
|
|
2216
|
+
if (!resolved) {
|
|
2217
|
+
reject(new ProjectRunnerError(`Dev server exited with code ${code}`));
|
|
2218
|
+
}
|
|
2219
|
+
});
|
|
2220
|
+
setTimeout(async () => {
|
|
2221
|
+
if (resolved) return;
|
|
2222
|
+
const port = projectType.port || 3e3;
|
|
2223
|
+
const candidatePorts = [port, 5173, 8080, 4200, 3001];
|
|
2224
|
+
for (const p of candidatePorts) {
|
|
2225
|
+
try {
|
|
2226
|
+
const controller = new AbortController();
|
|
2227
|
+
const timeoutId = setTimeout(() => controller.abort(), 2e3);
|
|
2228
|
+
await fetch(`http://localhost:${p}`, { signal: controller.signal });
|
|
2229
|
+
clearTimeout(timeoutId);
|
|
2230
|
+
resolved = true;
|
|
2231
|
+
const url = `http://localhost:${p}`;
|
|
2232
|
+
this.serverUrl = url;
|
|
2233
|
+
this.emit("server-ready", { url, pid: proc.pid });
|
|
2234
|
+
resolve2({ url, pid: proc.pid });
|
|
2235
|
+
return;
|
|
2236
|
+
} catch {
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
reject(new ProjectRunnerError("Dev server did not become ready within 60s"));
|
|
2240
|
+
}, 6e4);
|
|
2241
|
+
});
|
|
2242
|
+
}
|
|
2243
|
+
/**
|
|
2244
|
+
* Run the project's existing test suite
|
|
2245
|
+
*/
|
|
2246
|
+
async runExistingTests(repoPath) {
|
|
2247
|
+
repoPath = sanitizeRepoPath(repoPath);
|
|
2248
|
+
const projectType = this.projectType || this.detectProjectType(repoPath);
|
|
2249
|
+
if (!projectType.testRunner) {
|
|
2250
|
+
throw new ProjectRunnerError("No test runner detected for this project");
|
|
2251
|
+
}
|
|
2252
|
+
let command;
|
|
2253
|
+
let args;
|
|
2254
|
+
switch (projectType.testRunner) {
|
|
2255
|
+
case "vitest":
|
|
2256
|
+
command = "npx";
|
|
2257
|
+
args = ["vitest", "run", "--reporter=json"];
|
|
2258
|
+
break;
|
|
2259
|
+
case "jest":
|
|
2260
|
+
command = "npx";
|
|
2261
|
+
args = ["jest", "--json", "--forceExit"];
|
|
2262
|
+
break;
|
|
2263
|
+
case "mocha":
|
|
2264
|
+
command = "npx";
|
|
2265
|
+
args = ["mocha", "--reporter", "json"];
|
|
2266
|
+
break;
|
|
2267
|
+
case "playwright":
|
|
2268
|
+
command = "npx";
|
|
2269
|
+
args = ["playwright", "test", "--reporter=json"];
|
|
2270
|
+
break;
|
|
2271
|
+
case "pytest":
|
|
2272
|
+
command = "python";
|
|
2273
|
+
args = ["-m", "pytest", "--tb=short", "-q"];
|
|
2274
|
+
break;
|
|
2275
|
+
case "go test":
|
|
2276
|
+
command = "go";
|
|
2277
|
+
args = ["test", "-json", "./..."];
|
|
2278
|
+
break;
|
|
2279
|
+
case "cargo test":
|
|
2280
|
+
command = "cargo";
|
|
2281
|
+
args = ["test", "--", "--format=json"];
|
|
2282
|
+
break;
|
|
2283
|
+
default:
|
|
2284
|
+
command = "npx";
|
|
2285
|
+
args = [projectType.testRunner, "--json"];
|
|
2286
|
+
}
|
|
2287
|
+
this.emit("test-start", { runner: projectType.testRunner });
|
|
2288
|
+
const startTime = Date.now();
|
|
2289
|
+
return new Promise((resolve2, reject) => {
|
|
2290
|
+
const proc = spawn(command, args, {
|
|
2291
|
+
cwd: repoPath,
|
|
2292
|
+
stdio: "pipe",
|
|
2293
|
+
timeout: 3e5
|
|
2294
|
+
});
|
|
2295
|
+
let stdout = "";
|
|
2296
|
+
let stderr = "";
|
|
2297
|
+
proc.stdout?.on("data", (data) => {
|
|
2298
|
+
stdout += data.toString();
|
|
2299
|
+
this.emit("test-progress", { text: data.toString() });
|
|
2300
|
+
});
|
|
2301
|
+
proc.stderr?.on("data", (data) => {
|
|
2302
|
+
stderr += data.toString();
|
|
2303
|
+
});
|
|
2304
|
+
proc.on("close", (code) => {
|
|
2305
|
+
const durationMs = Date.now() - startTime;
|
|
2306
|
+
const raw = stdout + "\n" + stderr;
|
|
2307
|
+
const result = this.parseTestResults(
|
|
2308
|
+
projectType.testRunner,
|
|
2309
|
+
stdout,
|
|
2310
|
+
stderr,
|
|
2311
|
+
durationMs
|
|
2312
|
+
);
|
|
2313
|
+
this.emit("test-complete", result);
|
|
2314
|
+
resolve2(result);
|
|
2315
|
+
});
|
|
2316
|
+
proc.on("error", (err) => {
|
|
2317
|
+
reject(new ProjectRunnerError(`Failed to run tests: ${err.message}`));
|
|
2318
|
+
});
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
parseTestResults(runner, stdout, stderr, durationMs) {
|
|
2322
|
+
const raw = stdout + "\n" + stderr;
|
|
2323
|
+
if (runner === "vitest" || runner === "jest") {
|
|
2324
|
+
try {
|
|
2325
|
+
const jsonMatch = stdout.match(/\{[\s\S]*"numPassedTests"[\s\S]*\}/);
|
|
2326
|
+
if (jsonMatch) {
|
|
2327
|
+
const json = JSON.parse(jsonMatch[0]);
|
|
2328
|
+
return {
|
|
2329
|
+
runner,
|
|
2330
|
+
passed: json.numPassedTests || 0,
|
|
2331
|
+
failed: json.numFailedTests || 0,
|
|
2332
|
+
skipped: json.numPendingTests || 0,
|
|
2333
|
+
total: json.numTotalTests || 0,
|
|
2334
|
+
durationMs,
|
|
2335
|
+
raw
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2338
|
+
} catch {
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
let passed = 0, failed = 0, skipped = 0;
|
|
2342
|
+
const summaryMatch = raw.match(/(\d+)\s*passed.*?(\d+)\s*failed/i);
|
|
2343
|
+
if (summaryMatch) {
|
|
2344
|
+
passed = parseInt(summaryMatch[1]);
|
|
2345
|
+
failed = parseInt(summaryMatch[2]);
|
|
2346
|
+
}
|
|
2347
|
+
const passedMatch = raw.match(/(\d+)\s*pass(?:ed|ing)/i);
|
|
2348
|
+
if (passedMatch && !summaryMatch) passed = parseInt(passedMatch[1]);
|
|
2349
|
+
const failedMatch = raw.match(/(\d+)\s*fail(?:ed|ing|ure)/i);
|
|
2350
|
+
if (failedMatch && !summaryMatch) failed = parseInt(failedMatch[1]);
|
|
2351
|
+
const skippedMatch = raw.match(/(\d+)\s*(?:skip(?:ped)?|pending|todo)/i);
|
|
2352
|
+
if (skippedMatch) skipped = parseInt(skippedMatch[1]);
|
|
2353
|
+
return {
|
|
2354
|
+
runner,
|
|
2355
|
+
passed,
|
|
2356
|
+
failed,
|
|
2357
|
+
skipped,
|
|
2358
|
+
total: passed + failed + skipped,
|
|
2359
|
+
durationMs,
|
|
2360
|
+
raw
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2363
|
+
/**
|
|
2364
|
+
* Stop the dev server and clean up
|
|
2365
|
+
*/
|
|
2366
|
+
cleanup() {
|
|
2367
|
+
if (this.serverProcess) {
|
|
2368
|
+
try {
|
|
2369
|
+
if (this.serverProcess.pid) {
|
|
2370
|
+
process.kill(-this.serverProcess.pid, "SIGTERM");
|
|
2371
|
+
}
|
|
2372
|
+
} catch {
|
|
2373
|
+
try {
|
|
2374
|
+
this.serverProcess.kill("SIGTERM");
|
|
2375
|
+
} catch {
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
this.serverProcess = null;
|
|
2379
|
+
this.serverUrl = null;
|
|
2380
|
+
this.emit("server-stopped");
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
getStatus() {
|
|
2384
|
+
return {
|
|
2385
|
+
repoPath: this.repoPath || "",
|
|
2386
|
+
projectType: this.projectType,
|
|
2387
|
+
installed: this.installed,
|
|
2388
|
+
serverRunning: this.serverProcess !== null && !this.serverProcess.killed,
|
|
2389
|
+
serverUrl: this.serverUrl,
|
|
2390
|
+
serverPid: this.serverProcess?.pid || null
|
|
2391
|
+
};
|
|
2392
|
+
}
|
|
2393
|
+
};
|
|
2394
|
+
|
|
2395
|
+
// agent/notifications/index.ts
|
|
2396
|
+
init_esm_shims();
|
|
2397
|
+
var NotificationService = class {
|
|
2398
|
+
constructor(config) {
|
|
2399
|
+
this.config = config;
|
|
2400
|
+
}
|
|
2401
|
+
async notify(payload) {
|
|
2402
|
+
const promises = [];
|
|
2403
|
+
if (this.config.slack) {
|
|
2404
|
+
promises.push(this.sendSlack(payload));
|
|
2405
|
+
}
|
|
2406
|
+
if (this.config.discord) {
|
|
2407
|
+
promises.push(this.sendDiscord(payload));
|
|
2408
|
+
}
|
|
2409
|
+
await Promise.allSettled(promises);
|
|
2410
|
+
}
|
|
2411
|
+
async notifyBug(bug) {
|
|
2412
|
+
const severityEmoji = {
|
|
2413
|
+
critical: "\u{1F534}",
|
|
2414
|
+
high: "\u{1F7E0}",
|
|
2415
|
+
medium: "\u{1F7E1}",
|
|
2416
|
+
low: "\u{1F7E2}"
|
|
2417
|
+
};
|
|
2418
|
+
await this.notify({
|
|
2419
|
+
title: `${severityEmoji[bug.severity] || "\u26A0\uFE0F"} Bug Found: ${bug.title}`,
|
|
2420
|
+
message: bug.description,
|
|
2421
|
+
severity: bug.severity === "critical" || bug.severity === "high" ? "error" : "warning",
|
|
2422
|
+
fields: [
|
|
2423
|
+
{ name: "Severity", value: bug.severity },
|
|
2424
|
+
{ name: "Status", value: bug.status },
|
|
2425
|
+
...bug.github_issue_url ? [{ name: "GitHub Issue", value: bug.github_issue_url }] : []
|
|
2426
|
+
]
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
2429
|
+
async notifySessionComplete(stats) {
|
|
2430
|
+
await this.notify({
|
|
2431
|
+
title: "\u2705 OpenQA Session Complete",
|
|
2432
|
+
message: `Session ${stats.sessionId} finished.`,
|
|
2433
|
+
severity: "success",
|
|
2434
|
+
fields: [
|
|
2435
|
+
{ name: "Tests Generated", value: String(stats.testsGenerated) },
|
|
2436
|
+
{ name: "Agents Created", value: String(stats.agentsCreated) }
|
|
2437
|
+
]
|
|
2438
|
+
});
|
|
2439
|
+
}
|
|
2440
|
+
async sendSlack(payload) {
|
|
2441
|
+
const colorMap = {
|
|
2442
|
+
info: "#36a64f",
|
|
2443
|
+
warning: "#ff9800",
|
|
2444
|
+
error: "#dc3545",
|
|
2445
|
+
success: "#28a745"
|
|
2446
|
+
};
|
|
2447
|
+
const color = colorMap[payload.severity || "info"];
|
|
2448
|
+
const body = {
|
|
2449
|
+
attachments: [{
|
|
2450
|
+
color,
|
|
2451
|
+
title: payload.title,
|
|
2452
|
+
text: payload.message,
|
|
2453
|
+
fields: payload.fields?.map((f) => ({ title: f.name, value: f.value, short: true })) || [],
|
|
2454
|
+
footer: "OpenQA",
|
|
2455
|
+
ts: Math.floor(Date.now() / 1e3)
|
|
2456
|
+
}]
|
|
2457
|
+
};
|
|
2458
|
+
const res = await fetch(this.config.slack, {
|
|
2459
|
+
method: "POST",
|
|
2460
|
+
headers: { "Content-Type": "application/json" },
|
|
2461
|
+
body: JSON.stringify(body)
|
|
2462
|
+
});
|
|
2463
|
+
if (!res.ok) {
|
|
2464
|
+
throw new Error(`Slack notification failed: ${res.status} ${res.statusText}`);
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
async sendDiscord(payload) {
|
|
2468
|
+
const colorMap = {
|
|
2469
|
+
info: 3581519,
|
|
2470
|
+
warning: 16750592,
|
|
2471
|
+
error: 14431557,
|
|
2472
|
+
success: 2664261
|
|
2473
|
+
};
|
|
2474
|
+
const color = colorMap[payload.severity || "info"];
|
|
2475
|
+
const body = {
|
|
2476
|
+
embeds: [{
|
|
2477
|
+
title: payload.title,
|
|
2478
|
+
description: payload.message,
|
|
2479
|
+
color,
|
|
2480
|
+
fields: payload.fields?.map((f) => ({ name: f.name, value: f.value, inline: true })) || [],
|
|
2481
|
+
footer: { text: "OpenQA" },
|
|
2482
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2483
|
+
}]
|
|
2484
|
+
};
|
|
2485
|
+
const res = await fetch(this.config.discord, {
|
|
2486
|
+
method: "POST",
|
|
2487
|
+
headers: { "Content-Type": "application/json" },
|
|
2488
|
+
body: JSON.stringify(body)
|
|
2489
|
+
});
|
|
2490
|
+
if (!res.ok) {
|
|
2491
|
+
throw new Error(`Discord notification failed: ${res.status} ${res.statusText}`);
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
isConfigured() {
|
|
2495
|
+
return !!(this.config.slack || this.config.discord);
|
|
2496
|
+
}
|
|
2497
|
+
};
|
|
2498
|
+
|
|
2499
|
+
// agent/index-v2.ts
|
|
2500
|
+
var OpenQAAgentV2 = class extends EventEmitter5 {
|
|
2501
|
+
db;
|
|
2502
|
+
config;
|
|
2503
|
+
saasConfigManager;
|
|
2504
|
+
brain = null;
|
|
2505
|
+
browserTools = null;
|
|
2506
|
+
gitListener = null;
|
|
2507
|
+
projectRunner;
|
|
2508
|
+
notifications = null;
|
|
2509
|
+
sessionId = "";
|
|
2510
|
+
isRunning = false;
|
|
2511
|
+
constructor(configPath) {
|
|
2512
|
+
super();
|
|
2513
|
+
this.config = new ConfigManager(configPath);
|
|
2514
|
+
const cfg = this.config.getConfigSync();
|
|
2515
|
+
this.db = new OpenQADatabase(cfg.database?.path || void 0);
|
|
2516
|
+
this.saasConfigManager = new SaaSConfigManager(this.db);
|
|
2517
|
+
this.projectRunner = new ProjectRunner();
|
|
2518
|
+
for (const event of ["install-start", "install-progress", "install-complete", "server-starting", "server-ready", "server-stopped", "test-start", "test-progress", "test-complete"]) {
|
|
2519
|
+
this.projectRunner.on(event, (data) => this.emit(event, data));
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
/**
|
|
2523
|
+
* Configure the SaaS application to test
|
|
2524
|
+
* This is the main entry point for users
|
|
2525
|
+
*/
|
|
2526
|
+
configureSaaS(config) {
|
|
2527
|
+
const saasConfig = {
|
|
2528
|
+
name: config.name,
|
|
2529
|
+
description: config.description,
|
|
2530
|
+
url: config.url,
|
|
2531
|
+
repoUrl: config.repoUrl,
|
|
2532
|
+
localPath: config.localPath,
|
|
2533
|
+
directives: config.directives,
|
|
2534
|
+
authInfo: config.auth ? {
|
|
2535
|
+
type: config.auth.type,
|
|
2536
|
+
testCredentials: config.auth.credentials
|
|
2537
|
+
} : { type: "none" }
|
|
2538
|
+
};
|
|
2539
|
+
return this.saasConfigManager.configure(saasConfig);
|
|
2540
|
+
}
|
|
2541
|
+
/**
|
|
2542
|
+
* Quick setup with minimal configuration
|
|
2543
|
+
*/
|
|
2544
|
+
quickSetup(name, description, url) {
|
|
2545
|
+
return this.saasConfigManager.configure(
|
|
2546
|
+
createQuickConfig(name, description, url)
|
|
2547
|
+
);
|
|
2548
|
+
}
|
|
2549
|
+
/**
|
|
2550
|
+
* Add a directive (instruction for the agent)
|
|
2551
|
+
*/
|
|
2552
|
+
addDirective(directive) {
|
|
2553
|
+
this.saasConfigManager.addDirective(directive);
|
|
2554
|
+
}
|
|
2555
|
+
/**
|
|
2556
|
+
* Set repository URL for code analysis
|
|
2557
|
+
*/
|
|
2558
|
+
setRepository(url) {
|
|
2559
|
+
this.saasConfigManager.setRepoUrl(url);
|
|
2560
|
+
}
|
|
2561
|
+
/**
|
|
2562
|
+
* Set local path for code analysis
|
|
2563
|
+
*/
|
|
2564
|
+
setLocalPath(path2) {
|
|
2565
|
+
this.saasConfigManager.setLocalPath(path2);
|
|
2566
|
+
}
|
|
2567
|
+
/**
|
|
2568
|
+
* Initialize the brain and start analyzing
|
|
2569
|
+
*/
|
|
2570
|
+
async initialize() {
|
|
2571
|
+
const saasConfig = this.saasConfigManager.getConfig();
|
|
2572
|
+
if (!saasConfig) {
|
|
2573
|
+
throw new Error("SaaS not configured. Call configureSaaS() first.");
|
|
2574
|
+
}
|
|
2575
|
+
const cfg = await this.config.getConfig();
|
|
2576
|
+
this.sessionId = `session_${Date.now()}`;
|
|
2577
|
+
await this.db.createSession(this.sessionId, {
|
|
2578
|
+
saas: saasConfig,
|
|
2579
|
+
started_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2580
|
+
});
|
|
2581
|
+
if (cfg.notifications?.slack || cfg.notifications?.discord) {
|
|
2582
|
+
this.notifications = new NotificationService({
|
|
2583
|
+
slack: cfg.notifications.slack,
|
|
2584
|
+
discord: cfg.notifications.discord
|
|
2585
|
+
});
|
|
2586
|
+
}
|
|
2587
|
+
this.brain = new OpenQABrain(
|
|
2588
|
+
this.db,
|
|
2589
|
+
{
|
|
2590
|
+
provider: cfg.llm.provider,
|
|
2591
|
+
apiKey: cfg.llm.apiKey || process.env.OPENAI_API_KEY || "",
|
|
2592
|
+
model: cfg.llm.model
|
|
2593
|
+
},
|
|
2594
|
+
saasConfig
|
|
2595
|
+
);
|
|
2596
|
+
this.browserTools = new BrowserTools(this.db, this.sessionId);
|
|
2597
|
+
const log = logger.child({ session: this.sessionId, app: saasConfig.name });
|
|
2598
|
+
this.brain.on("test-generated", (test) => {
|
|
2599
|
+
this.emit("test-generated", test);
|
|
2600
|
+
log.info("Test generated", { name: test.name, type: test.type });
|
|
2601
|
+
});
|
|
2602
|
+
this.brain.on("agent-created", (agent) => {
|
|
2603
|
+
this.emit("agent-created", agent);
|
|
2604
|
+
log.info("Agent created", { name: agent.name });
|
|
2605
|
+
});
|
|
2606
|
+
this.brain.on("test-started", (test) => {
|
|
2607
|
+
this.emit("test-started", test);
|
|
2608
|
+
});
|
|
2609
|
+
this.brain.on("test-completed", (test) => {
|
|
2610
|
+
this.emit("test-completed", test);
|
|
2611
|
+
log.info("Test completed", { name: test.name, status: test.status });
|
|
2612
|
+
});
|
|
2613
|
+
this.brain.on("thinking", (thought) => {
|
|
2614
|
+
this.emit("thinking", thought);
|
|
2615
|
+
});
|
|
2616
|
+
this.brain.on("analysis-complete", (analysis) => {
|
|
2617
|
+
this.emit("analysis-complete", analysis);
|
|
2618
|
+
});
|
|
2619
|
+
this.brain.on("session-complete", (stats) => {
|
|
2620
|
+
this.emit("session-complete", stats);
|
|
2621
|
+
if (this.notifications) {
|
|
2622
|
+
this.notifications.notifySessionComplete({
|
|
2623
|
+
sessionId: this.sessionId,
|
|
2624
|
+
testsGenerated: Number(stats.testsGenerated ?? 0),
|
|
2625
|
+
agentsCreated: Number(stats.agentsCreated ?? 0)
|
|
2626
|
+
}).catch((e) => log.error("Notification failed", { error: e instanceof Error ? e.message : String(e) }));
|
|
2627
|
+
}
|
|
2628
|
+
});
|
|
2629
|
+
log.info("OpenQA initialized", {
|
|
2630
|
+
url: saasConfig.url,
|
|
2631
|
+
repoUrl: saasConfig.repoUrl,
|
|
2632
|
+
localPath: saasConfig.localPath,
|
|
2633
|
+
directives: saasConfig.directives?.length ?? 0
|
|
2634
|
+
});
|
|
2635
|
+
}
|
|
2636
|
+
/**
|
|
2637
|
+
* Run the brain in autonomous mode
|
|
2638
|
+
* The agent will analyze, think, generate tests, and execute them
|
|
2639
|
+
*/
|
|
2640
|
+
async runAutonomous(maxIterations = 10) {
|
|
2641
|
+
if (!this.brain) {
|
|
2642
|
+
await this.initialize();
|
|
2643
|
+
}
|
|
2644
|
+
this.isRunning = true;
|
|
2645
|
+
logger.info("Starting autonomous QA session", { session: this.sessionId });
|
|
2646
|
+
await this.brain.runAutonomously(maxIterations);
|
|
2647
|
+
this.isRunning = false;
|
|
2648
|
+
}
|
|
2649
|
+
/**
|
|
2650
|
+
* Analyze the application and get suggestions
|
|
2651
|
+
*/
|
|
2652
|
+
async analyze() {
|
|
2653
|
+
if (!this.brain) {
|
|
2654
|
+
await this.initialize();
|
|
2655
|
+
}
|
|
2656
|
+
return this.brain.analyze();
|
|
2657
|
+
}
|
|
2658
|
+
/**
|
|
2659
|
+
* Generate a specific test
|
|
2660
|
+
*/
|
|
2661
|
+
async generateTest(type, target, context) {
|
|
2662
|
+
if (!this.brain) {
|
|
2663
|
+
await this.initialize();
|
|
2664
|
+
}
|
|
2665
|
+
return this.brain.generateTest(type, target, context);
|
|
2666
|
+
}
|
|
2667
|
+
/**
|
|
2668
|
+
* Create a custom agent for a specific purpose
|
|
2669
|
+
*/
|
|
2670
|
+
async createAgent(purpose) {
|
|
2671
|
+
if (!this.brain) {
|
|
2672
|
+
await this.initialize();
|
|
2673
|
+
}
|
|
2674
|
+
return this.brain.createDynamicAgent(purpose);
|
|
2675
|
+
}
|
|
2676
|
+
/**
|
|
2677
|
+
* Execute a generated test
|
|
2678
|
+
*/
|
|
2679
|
+
async runTest(testId) {
|
|
2680
|
+
if (!this.brain) {
|
|
2681
|
+
throw new Error("Brain not initialized");
|
|
2682
|
+
}
|
|
2683
|
+
return this.brain.executeTest(testId);
|
|
2684
|
+
}
|
|
2685
|
+
/**
|
|
2686
|
+
* Start listening for Git events (merges, pipelines)
|
|
2687
|
+
*/
|
|
2688
|
+
async startGitListener() {
|
|
2689
|
+
const cfg = await this.config.getConfig();
|
|
2690
|
+
const saasConfig = this.saasConfigManager.getConfig();
|
|
2691
|
+
if (cfg.github?.token && cfg.github?.owner && cfg.github?.repo) {
|
|
2692
|
+
this.gitListener = new GitListener({
|
|
2693
|
+
provider: "github",
|
|
2694
|
+
token: cfg.github.token,
|
|
2695
|
+
owner: cfg.github.owner,
|
|
2696
|
+
repo: cfg.github.repo,
|
|
2697
|
+
branch: "main",
|
|
2698
|
+
pollIntervalMs: 6e4
|
|
2699
|
+
});
|
|
2700
|
+
this.gitListener.on("merge", async (event) => {
|
|
2701
|
+
logger.info("Merge detected", { branch: event.branch });
|
|
2702
|
+
this.emit("git-merge", event);
|
|
2703
|
+
await this.runAutonomous();
|
|
2704
|
+
});
|
|
2705
|
+
this.gitListener.on("pipeline-success", async (event) => {
|
|
2706
|
+
logger.info("Pipeline success");
|
|
2707
|
+
this.emit("git-pipeline-success", event);
|
|
2708
|
+
await this.runAutonomous();
|
|
2709
|
+
});
|
|
2710
|
+
await this.gitListener.start();
|
|
2711
|
+
logger.info("Git listener started");
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
/**
|
|
2715
|
+
* Setup a project: detect, install deps, optionally start dev server
|
|
2716
|
+
*/
|
|
2717
|
+
async setupProject(repoPath, options) {
|
|
2718
|
+
const projectType = this.projectRunner.detectProjectType(repoPath);
|
|
2719
|
+
logger.info("Project detected", { language: projectType.language, framework: projectType.framework, packageManager: projectType.packageManager });
|
|
2720
|
+
await this.projectRunner.installDependencies(repoPath);
|
|
2721
|
+
logger.info("Dependencies installed");
|
|
2722
|
+
if (options?.startServer && projectType.devCommand) {
|
|
2723
|
+
const { url } = await this.projectRunner.startDevServer(repoPath);
|
|
2724
|
+
logger.info("Dev server ready", { url });
|
|
2725
|
+
}
|
|
2726
|
+
return this.projectRunner.getStatus();
|
|
2727
|
+
}
|
|
2728
|
+
/**
|
|
2729
|
+
* Run the project's existing test suite
|
|
2730
|
+
*/
|
|
2731
|
+
async runProjectTests(repoPath) {
|
|
2732
|
+
return this.projectRunner.runExistingTests(repoPath);
|
|
2733
|
+
}
|
|
2734
|
+
/**
|
|
2735
|
+
* Get project runner status
|
|
2736
|
+
*/
|
|
2737
|
+
getProjectStatus() {
|
|
2738
|
+
return this.projectRunner.getStatus();
|
|
2739
|
+
}
|
|
2740
|
+
/**
|
|
2741
|
+
* Stop the agent
|
|
2742
|
+
*/
|
|
2743
|
+
stop() {
|
|
2744
|
+
this.isRunning = false;
|
|
2745
|
+
if (this.gitListener) {
|
|
2746
|
+
this.gitListener.stop();
|
|
2747
|
+
this.gitListener = null;
|
|
2748
|
+
}
|
|
2749
|
+
if (this.browserTools) {
|
|
2750
|
+
this.browserTools.close();
|
|
2751
|
+
}
|
|
2752
|
+
this.projectRunner.cleanup();
|
|
2753
|
+
logger.info("OpenQA stopped");
|
|
2754
|
+
}
|
|
2755
|
+
/**
|
|
2756
|
+
* Get all generated tests
|
|
2757
|
+
*/
|
|
2758
|
+
getTests() {
|
|
2759
|
+
return this.brain?.getGeneratedTests() || [];
|
|
2760
|
+
}
|
|
2761
|
+
/**
|
|
2762
|
+
* Get all created agents
|
|
2763
|
+
*/
|
|
2764
|
+
getAgents() {
|
|
2765
|
+
return this.brain?.getDynamicAgents() || [];
|
|
2766
|
+
}
|
|
2767
|
+
/**
|
|
2768
|
+
* Get statistics
|
|
2769
|
+
*/
|
|
2770
|
+
getStats() {
|
|
2771
|
+
return {
|
|
2772
|
+
isRunning: this.isRunning,
|
|
2773
|
+
sessionId: this.sessionId,
|
|
2774
|
+
saas: this.saasConfigManager.getConfig(),
|
|
2775
|
+
brain: this.brain?.getStats() || null,
|
|
2776
|
+
gitListenerActive: !!this.gitListener
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
/**
|
|
2780
|
+
* Get the SaaS configuration
|
|
2781
|
+
*/
|
|
2782
|
+
getSaaSConfig() {
|
|
2783
|
+
return this.saasConfigManager.getConfig();
|
|
2784
|
+
}
|
|
2785
|
+
/**
|
|
2786
|
+
* Check if configured
|
|
2787
|
+
*/
|
|
2788
|
+
isConfigured() {
|
|
2789
|
+
return this.saasConfigManager.isConfigured();
|
|
2790
|
+
}
|
|
2791
|
+
};
|
|
2792
|
+
export {
|
|
2793
|
+
OpenQAAgentV2
|
|
2794
|
+
};
|
|
2795
|
+
//# sourceMappingURL=index-v2.js.map
|