@openqa/cli 1.0.13 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/index.js +5 -0
- package/dist/agent/index.js.map +1 -1
- package/dist/cli/index.js +728 -1630
- package/dist/cli/server.js +695 -0
- package/dist/database/index.js +5 -0
- package/dist/database/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
// agent/config/index.ts
|
|
2
|
+
import { config as dotenvConfig } from "dotenv";
|
|
3
|
+
|
|
4
|
+
// database/index.ts
|
|
5
|
+
import { Low } from "lowdb";
|
|
6
|
+
import { JSONFile } from "lowdb/node";
|
|
7
|
+
import { dirname } from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import { mkdirSync } from "fs";
|
|
10
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
var __dirname = dirname(__filename);
|
|
12
|
+
var OpenQADatabase = class {
|
|
13
|
+
constructor(dbPath = "./data/openqa.json") {
|
|
14
|
+
this.dbPath = dbPath;
|
|
15
|
+
this.initialize();
|
|
16
|
+
}
|
|
17
|
+
db = null;
|
|
18
|
+
initialize() {
|
|
19
|
+
const dir = dirname(this.dbPath);
|
|
20
|
+
mkdirSync(dir, { recursive: true });
|
|
21
|
+
const adapter = new JSONFile(this.dbPath);
|
|
22
|
+
this.db = new Low(adapter, {
|
|
23
|
+
config: {},
|
|
24
|
+
test_sessions: [],
|
|
25
|
+
actions: [],
|
|
26
|
+
bugs: [],
|
|
27
|
+
kanban_tickets: []
|
|
28
|
+
});
|
|
29
|
+
this.db.read();
|
|
30
|
+
if (!this.db.data) {
|
|
31
|
+
this.db.data = {
|
|
32
|
+
config: {},
|
|
33
|
+
test_sessions: [],
|
|
34
|
+
actions: [],
|
|
35
|
+
bugs: [],
|
|
36
|
+
kanban_tickets: []
|
|
37
|
+
};
|
|
38
|
+
this.db.write();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async ensureInitialized() {
|
|
42
|
+
if (!this.db) {
|
|
43
|
+
this.initialize();
|
|
44
|
+
}
|
|
45
|
+
await this.db.read();
|
|
46
|
+
}
|
|
47
|
+
async getConfig(key) {
|
|
48
|
+
await this.ensureInitialized();
|
|
49
|
+
return this.db.data.config[key] || null;
|
|
50
|
+
}
|
|
51
|
+
async setConfig(key, value) {
|
|
52
|
+
await this.ensureInitialized();
|
|
53
|
+
this.db.data.config[key] = value;
|
|
54
|
+
await this.db.write();
|
|
55
|
+
}
|
|
56
|
+
async getAllConfig() {
|
|
57
|
+
await this.ensureInitialized();
|
|
58
|
+
return this.db.data.config;
|
|
59
|
+
}
|
|
60
|
+
async createSession(id, metadata) {
|
|
61
|
+
await this.ensureInitialized();
|
|
62
|
+
const session = {
|
|
63
|
+
id,
|
|
64
|
+
started_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
65
|
+
status: "running",
|
|
66
|
+
total_actions: 0,
|
|
67
|
+
bugs_found: 0,
|
|
68
|
+
metadata: metadata ? JSON.stringify(metadata) : void 0
|
|
69
|
+
};
|
|
70
|
+
this.db.data.test_sessions.push(session);
|
|
71
|
+
await this.db.write();
|
|
72
|
+
return session;
|
|
73
|
+
}
|
|
74
|
+
async getSession(id) {
|
|
75
|
+
await this.ensureInitialized();
|
|
76
|
+
return this.db.data.test_sessions.find((s) => s.id === id) || null;
|
|
77
|
+
}
|
|
78
|
+
async updateSession(id, updates) {
|
|
79
|
+
await this.ensureInitialized();
|
|
80
|
+
const index = this.db.data.test_sessions.findIndex((s) => s.id === id);
|
|
81
|
+
if (index !== -1) {
|
|
82
|
+
this.db.data.test_sessions[index] = { ...this.db.data.test_sessions[index], ...updates };
|
|
83
|
+
await this.db.write();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async getRecentSessions(limit = 10) {
|
|
87
|
+
await this.ensureInitialized();
|
|
88
|
+
return this.db.data.test_sessions.sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime()).slice(0, limit);
|
|
89
|
+
}
|
|
90
|
+
async createAction(action) {
|
|
91
|
+
await this.ensureInitialized();
|
|
92
|
+
const newAction = {
|
|
93
|
+
id: `action_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
94
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
95
|
+
...action
|
|
96
|
+
};
|
|
97
|
+
this.db.data.actions.push(newAction);
|
|
98
|
+
await this.db.write();
|
|
99
|
+
return newAction;
|
|
100
|
+
}
|
|
101
|
+
async getSessionActions(sessionId) {
|
|
102
|
+
await this.ensureInitialized();
|
|
103
|
+
return this.db.data.actions.filter((a) => a.session_id === sessionId).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
104
|
+
}
|
|
105
|
+
async createBug(bug) {
|
|
106
|
+
await this.ensureInitialized();
|
|
107
|
+
const newBug = {
|
|
108
|
+
id: `bug_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
109
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
110
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
111
|
+
...bug
|
|
112
|
+
};
|
|
113
|
+
this.db.data.bugs.push(newBug);
|
|
114
|
+
await this.db.write();
|
|
115
|
+
return newBug;
|
|
116
|
+
}
|
|
117
|
+
async updateBug(id, updates) {
|
|
118
|
+
await this.ensureInitialized();
|
|
119
|
+
const index = this.db.data.bugs.findIndex((b) => b.id === id);
|
|
120
|
+
if (index !== -1) {
|
|
121
|
+
this.db.data.bugs[index] = {
|
|
122
|
+
...this.db.data.bugs[index],
|
|
123
|
+
...updates,
|
|
124
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
125
|
+
};
|
|
126
|
+
await this.db.write();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async getAllBugs() {
|
|
130
|
+
await this.ensureInitialized();
|
|
131
|
+
return this.db.data.bugs.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
|
132
|
+
}
|
|
133
|
+
async getBugsByStatus(status) {
|
|
134
|
+
await this.ensureInitialized();
|
|
135
|
+
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());
|
|
136
|
+
}
|
|
137
|
+
async createKanbanTicket(ticket) {
|
|
138
|
+
await this.ensureInitialized();
|
|
139
|
+
const newTicket = {
|
|
140
|
+
id: `ticket_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
141
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
142
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
143
|
+
...ticket
|
|
144
|
+
};
|
|
145
|
+
this.db.data.kanban_tickets.push(newTicket);
|
|
146
|
+
await this.db.write();
|
|
147
|
+
return newTicket;
|
|
148
|
+
}
|
|
149
|
+
async updateKanbanTicket(id, updates) {
|
|
150
|
+
await this.ensureInitialized();
|
|
151
|
+
const index = this.db.data.kanban_tickets.findIndex((t) => t.id === id);
|
|
152
|
+
if (index !== -1) {
|
|
153
|
+
this.db.data.kanban_tickets[index] = {
|
|
154
|
+
...this.db.data.kanban_tickets[index],
|
|
155
|
+
...updates,
|
|
156
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
157
|
+
};
|
|
158
|
+
await this.db.write();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async getKanbanTickets() {
|
|
162
|
+
await this.ensureInitialized();
|
|
163
|
+
return this.db.data.kanban_tickets.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
|
164
|
+
}
|
|
165
|
+
async getKanbanTicketsByColumn(column) {
|
|
166
|
+
await this.ensureInitialized();
|
|
167
|
+
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());
|
|
168
|
+
}
|
|
169
|
+
async clearAllConfig() {
|
|
170
|
+
await this.ensureInitialized();
|
|
171
|
+
this.db.data.config = {};
|
|
172
|
+
await this.db.write();
|
|
173
|
+
}
|
|
174
|
+
async close() {
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// agent/config/index.ts
|
|
179
|
+
dotenvConfig();
|
|
180
|
+
var ConfigManager = class {
|
|
181
|
+
db = null;
|
|
182
|
+
envConfig;
|
|
183
|
+
constructor(dbPath) {
|
|
184
|
+
this.envConfig = this.loadFromEnv();
|
|
185
|
+
}
|
|
186
|
+
loadFromEnv() {
|
|
187
|
+
return {
|
|
188
|
+
llm: {
|
|
189
|
+
provider: process.env.LLM_PROVIDER || "openai",
|
|
190
|
+
apiKey: process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY,
|
|
191
|
+
model: process.env.LLM_MODEL,
|
|
192
|
+
baseUrl: process.env.OLLAMA_BASE_URL
|
|
193
|
+
},
|
|
194
|
+
saas: {
|
|
195
|
+
url: process.env.SAAS_URL || "",
|
|
196
|
+
authType: process.env.SAAS_AUTH_TYPE || "none",
|
|
197
|
+
username: process.env.SAAS_USERNAME,
|
|
198
|
+
password: process.env.SAAS_PASSWORD
|
|
199
|
+
},
|
|
200
|
+
github: process.env.GITHUB_TOKEN ? {
|
|
201
|
+
token: process.env.GITHUB_TOKEN,
|
|
202
|
+
owner: process.env.GITHUB_OWNER || "",
|
|
203
|
+
repo: process.env.GITHUB_REPO || ""
|
|
204
|
+
} : void 0,
|
|
205
|
+
agent: {
|
|
206
|
+
intervalMs: parseInt(process.env.AGENT_INTERVAL_MS || "3600000"),
|
|
207
|
+
maxIterations: parseInt(process.env.AGENT_MAX_ITERATIONS || "20"),
|
|
208
|
+
autoStart: process.env.AGENT_AUTO_START === "true"
|
|
209
|
+
},
|
|
210
|
+
web: {
|
|
211
|
+
port: parseInt(process.env.WEB_PORT || "4242"),
|
|
212
|
+
host: process.env.WEB_HOST || "0.0.0.0"
|
|
213
|
+
},
|
|
214
|
+
database: {
|
|
215
|
+
path: process.env.DB_PATH || "./data/openqa.db"
|
|
216
|
+
},
|
|
217
|
+
notifications: {
|
|
218
|
+
slack: process.env.SLACK_WEBHOOK_URL,
|
|
219
|
+
discord: process.env.DISCORD_WEBHOOK_URL
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
getDB() {
|
|
224
|
+
if (!this.db) {
|
|
225
|
+
this.db = new OpenQADatabase("./data/openqa.json");
|
|
226
|
+
}
|
|
227
|
+
return this.db;
|
|
228
|
+
}
|
|
229
|
+
async get(key) {
|
|
230
|
+
const dbValue = await this.getDB().getConfig(key);
|
|
231
|
+
if (dbValue) return dbValue;
|
|
232
|
+
const keys = key.split(".");
|
|
233
|
+
let value = this.envConfig;
|
|
234
|
+
for (const k of keys) {
|
|
235
|
+
value = value?.[k];
|
|
236
|
+
}
|
|
237
|
+
return value?.toString() || null;
|
|
238
|
+
}
|
|
239
|
+
async set(key, value) {
|
|
240
|
+
await this.getDB().setConfig(key, value);
|
|
241
|
+
}
|
|
242
|
+
async getAll() {
|
|
243
|
+
const dbConfig = await this.getDB().getAllConfig();
|
|
244
|
+
const merged = { ...this.envConfig };
|
|
245
|
+
for (const [key, value] of Object.entries(dbConfig)) {
|
|
246
|
+
const keys = key.split(".");
|
|
247
|
+
let obj = merged;
|
|
248
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
249
|
+
if (!obj[keys[i]]) obj[keys[i]] = {};
|
|
250
|
+
obj = obj[keys[i]];
|
|
251
|
+
}
|
|
252
|
+
obj[keys[keys.length - 1]] = value;
|
|
253
|
+
}
|
|
254
|
+
return merged;
|
|
255
|
+
}
|
|
256
|
+
async getConfig() {
|
|
257
|
+
return await this.getAll();
|
|
258
|
+
}
|
|
259
|
+
// Synchronous version that only uses env vars (no DB)
|
|
260
|
+
getConfigSync() {
|
|
261
|
+
return this.envConfig;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// cli/server.ts
|
|
266
|
+
import express from "express";
|
|
267
|
+
import { WebSocketServer } from "ws";
|
|
268
|
+
import chalk from "chalk";
|
|
269
|
+
async function startWebServer() {
|
|
270
|
+
const config = new ConfigManager();
|
|
271
|
+
const cfg = config.getConfigSync();
|
|
272
|
+
const db = new OpenQADatabase("./data/openqa.json");
|
|
273
|
+
const app = express();
|
|
274
|
+
app.use(express.json());
|
|
275
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
276
|
+
app.get("/api/status", async (req, res) => {
|
|
277
|
+
res.json({
|
|
278
|
+
isRunning: true,
|
|
279
|
+
target: cfg.saas.url || "Not configured"
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
app.get("/api/sessions", async (req, res) => {
|
|
283
|
+
const limit = parseInt(req.query.limit) || 10;
|
|
284
|
+
const sessions = await db.getRecentSessions(limit);
|
|
285
|
+
res.json(sessions);
|
|
286
|
+
});
|
|
287
|
+
app.get("/api/sessions/:id/actions", async (req, res) => {
|
|
288
|
+
const actions = await db.getSessionActions(req.params.id);
|
|
289
|
+
res.json(actions);
|
|
290
|
+
});
|
|
291
|
+
app.get("/api/bugs", async (req, res) => {
|
|
292
|
+
const status = req.query.status;
|
|
293
|
+
const bugs = status ? await db.getBugsByStatus(status) : await db.getAllBugs();
|
|
294
|
+
res.json(bugs);
|
|
295
|
+
});
|
|
296
|
+
app.get("/api/kanban/tickets", async (req, res) => {
|
|
297
|
+
const column = req.query.column;
|
|
298
|
+
const tickets = column ? await db.getKanbanTicketsByColumn(column) : await db.getKanbanTickets();
|
|
299
|
+
res.json(tickets);
|
|
300
|
+
});
|
|
301
|
+
app.patch("/api/kanban/tickets/:id", async (req, res) => {
|
|
302
|
+
const { id } = req.params;
|
|
303
|
+
const updates = req.body;
|
|
304
|
+
await db.updateKanbanTicket(id, updates);
|
|
305
|
+
res.json({ success: true });
|
|
306
|
+
});
|
|
307
|
+
app.get("/api/config", (req, res) => {
|
|
308
|
+
res.json(cfg);
|
|
309
|
+
});
|
|
310
|
+
app.post("/api/config", async (req, res) => {
|
|
311
|
+
try {
|
|
312
|
+
const configData = req.body;
|
|
313
|
+
for (const [key, value] of Object.entries(configData)) {
|
|
314
|
+
await config.set(key, String(value));
|
|
315
|
+
}
|
|
316
|
+
res.json({ success: true });
|
|
317
|
+
} catch (error) {
|
|
318
|
+
res.status(500).json({ success: false, error: error.message });
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
app.post("/api/config/reset", async (req, res) => {
|
|
322
|
+
try {
|
|
323
|
+
await db.clearAllConfig();
|
|
324
|
+
res.json({ success: true });
|
|
325
|
+
} catch (error) {
|
|
326
|
+
res.status(500).json({ success: false, error: error.message });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
app.get("/", (req, res) => {
|
|
330
|
+
res.send(`
|
|
331
|
+
<!DOCTYPE html>
|
|
332
|
+
<html>
|
|
333
|
+
<head>
|
|
334
|
+
<title>OpenQA - Dashboard</title>
|
|
335
|
+
<style>
|
|
336
|
+
body { font-family: system-ui; max-width: 1200px; margin: 40px auto; padding: 20px; background: #0f172a; color: #e2e8f0; }
|
|
337
|
+
h1 { color: #38bdf8; }
|
|
338
|
+
.card { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 20px; margin: 20px 0; }
|
|
339
|
+
.status { display: inline-block; padding: 4px 12px; border-radius: 4px; font-size: 14px; }
|
|
340
|
+
.status.running { background: #10b981; color: white; }
|
|
341
|
+
.status.idle { background: #f59e0b; color: white; }
|
|
342
|
+
a { color: #38bdf8; text-decoration: none; }
|
|
343
|
+
a:hover { text-decoration: underline; }
|
|
344
|
+
nav { margin: 20px 0; }
|
|
345
|
+
nav a { margin-right: 20px; }
|
|
346
|
+
</style>
|
|
347
|
+
</head>
|
|
348
|
+
<body>
|
|
349
|
+
<h1>\u{1F916} OpenQA Dashboard</h1>
|
|
350
|
+
<nav>
|
|
351
|
+
<a href="/">Dashboard</a>
|
|
352
|
+
<a href="/kanban">Kanban</a>
|
|
353
|
+
<a href="/config">Config</a>
|
|
354
|
+
</nav>
|
|
355
|
+
<div class="card">
|
|
356
|
+
<h2>Status</h2>
|
|
357
|
+
<p>Agent: <span class="status idle">Idle</span></p>
|
|
358
|
+
<p>Target: ${cfg.saas.url || "Not configured"}</p>
|
|
359
|
+
<p>Auto-start: ${cfg.agent.autoStart ? "Enabled" : "Disabled"}</p>
|
|
360
|
+
</div>
|
|
361
|
+
<div class="card">
|
|
362
|
+
<h2>Quick Links</h2>
|
|
363
|
+
<ul>
|
|
364
|
+
<li><a href="/kanban">View Kanban Board</a></li>
|
|
365
|
+
<li><a href="/config">Configure OpenQA</a></li>
|
|
366
|
+
</ul>
|
|
367
|
+
</div>
|
|
368
|
+
<div class="card">
|
|
369
|
+
<h2>Getting Started</h2>
|
|
370
|
+
<p>Configure your SaaS application target and start testing:</p>
|
|
371
|
+
<ol>
|
|
372
|
+
<li>Set SAAS_URL environment variable or use the <a href="/config">Config page</a></li>
|
|
373
|
+
<li>Enable auto-start: <code>export AGENT_AUTO_START=true</code></li>
|
|
374
|
+
<li>Restart OpenQA</li>
|
|
375
|
+
</ol>
|
|
376
|
+
</div>
|
|
377
|
+
</body>
|
|
378
|
+
</html>
|
|
379
|
+
`);
|
|
380
|
+
});
|
|
381
|
+
app.get("/kanban", (req, res) => {
|
|
382
|
+
res.send(`
|
|
383
|
+
<!DOCTYPE html>
|
|
384
|
+
<html>
|
|
385
|
+
<head>
|
|
386
|
+
<title>OpenQA - Kanban Board</title>
|
|
387
|
+
<style>
|
|
388
|
+
body { font-family: system-ui; max-width: 1400px; margin: 40px auto; padding: 20px; background: #0f172a; color: #e2e8f0; }
|
|
389
|
+
h1 { color: #38bdf8; }
|
|
390
|
+
nav { margin: 20px 0; }
|
|
391
|
+
nav a { color: #38bdf8; text-decoration: none; margin-right: 20px; }
|
|
392
|
+
nav a:hover { text-decoration: underline; }
|
|
393
|
+
.board { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-top: 30px; }
|
|
394
|
+
.column { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 15px; }
|
|
395
|
+
.column h3 { margin-top: 0; color: #38bdf8; }
|
|
396
|
+
.ticket { background: #334155; padding: 12px; margin: 10px 0; border-radius: 6px; border-left: 3px solid #38bdf8; }
|
|
397
|
+
.ticket h4 { margin: 0 0 8px 0; font-size: 14px; }
|
|
398
|
+
.ticket p { margin: 0; font-size: 12px; color: #94a3b8; }
|
|
399
|
+
</style>
|
|
400
|
+
</head>
|
|
401
|
+
<body>
|
|
402
|
+
<h1>\u{1F4CB} Kanban Board</h1>
|
|
403
|
+
<nav>
|
|
404
|
+
<a href="/">Dashboard</a>
|
|
405
|
+
<a href="/kanban">Kanban</a>
|
|
406
|
+
<a href="/config">Config</a>
|
|
407
|
+
</nav>
|
|
408
|
+
<div class="board">
|
|
409
|
+
<div class="column">
|
|
410
|
+
<h3>Backlog</h3>
|
|
411
|
+
<p style="color: #64748b;">No tickets yet</p>
|
|
412
|
+
</div>
|
|
413
|
+
<div class="column">
|
|
414
|
+
<h3>To Do</h3>
|
|
415
|
+
<p style="color: #64748b;">No tickets yet</p>
|
|
416
|
+
</div>
|
|
417
|
+
<div class="column">
|
|
418
|
+
<h3>In Progress</h3>
|
|
419
|
+
<p style="color: #64748b;">No tickets yet</p>
|
|
420
|
+
</div>
|
|
421
|
+
<div class="column">
|
|
422
|
+
<h3>Done</h3>
|
|
423
|
+
<p style="color: #64748b;">No tickets yet</p>
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
<p style="margin-top: 40px; color: #64748b;">Tickets will appear here when the agent starts finding bugs and creating tasks.</p>
|
|
427
|
+
</body>
|
|
428
|
+
</html>
|
|
429
|
+
`);
|
|
430
|
+
});
|
|
431
|
+
app.get("/config", (req, res) => {
|
|
432
|
+
res.send(`
|
|
433
|
+
<!DOCTYPE html>
|
|
434
|
+
<html>
|
|
435
|
+
<head>
|
|
436
|
+
<title>OpenQA - Configuration</title>
|
|
437
|
+
<style>
|
|
438
|
+
body { font-family: system-ui; max-width: 800px; margin: 40px auto; padding: 20px; background: #0f172a; color: #e2e8f0; }
|
|
439
|
+
h1 { color: #38bdf8; }
|
|
440
|
+
nav { margin: 20px 0; }
|
|
441
|
+
nav a { color: #38bdf8; text-decoration: none; margin-right: 20px; }
|
|
442
|
+
nav a:hover { text-decoration: underline; }
|
|
443
|
+
.section { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 20px; margin: 20px 0; }
|
|
444
|
+
.section h2 { margin-top: 0; color: #38bdf8; font-size: 18px; }
|
|
445
|
+
.config-item { margin: 15px 0; }
|
|
446
|
+
.config-item label { display: block; margin-bottom: 5px; color: #94a3b8; font-size: 14px; }
|
|
447
|
+
.config-item input, .config-item select {
|
|
448
|
+
background: #334155;
|
|
449
|
+
border: 1px solid #475569;
|
|
450
|
+
color: #e2e8f0;
|
|
451
|
+
padding: 8px 12px;
|
|
452
|
+
border-radius: 4px;
|
|
453
|
+
font-family: monospace;
|
|
454
|
+
font-size: 14px;
|
|
455
|
+
width: 100%;
|
|
456
|
+
max-width: 400px;
|
|
457
|
+
}
|
|
458
|
+
.config-item input:focus, .config-item select:focus {
|
|
459
|
+
outline: none;
|
|
460
|
+
border-color: #38bdf8;
|
|
461
|
+
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.1);
|
|
462
|
+
}
|
|
463
|
+
.btn {
|
|
464
|
+
background: #38bdf8;
|
|
465
|
+
color: white;
|
|
466
|
+
border: none;
|
|
467
|
+
padding: 10px 20px;
|
|
468
|
+
border-radius: 6px;
|
|
469
|
+
cursor: pointer;
|
|
470
|
+
font-size: 14px;
|
|
471
|
+
margin-right: 10px;
|
|
472
|
+
}
|
|
473
|
+
.btn:hover { background: #0ea5e9; }
|
|
474
|
+
.btn-secondary { background: #64748b; }
|
|
475
|
+
.btn-secondary:hover { background: #475569; }
|
|
476
|
+
.success { color: #10b981; margin-left: 10px; }
|
|
477
|
+
.error { color: #ef4444; margin-left: 10px; }
|
|
478
|
+
code { background: #334155; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
|
|
479
|
+
.checkbox { margin-right: 8px; }
|
|
480
|
+
</style>
|
|
481
|
+
</head>
|
|
482
|
+
<body>
|
|
483
|
+
<h1>\u2699\uFE0F Configuration</h1>
|
|
484
|
+
<nav>
|
|
485
|
+
<a href="/">Dashboard</a>
|
|
486
|
+
<a href="/kanban">Kanban</a>
|
|
487
|
+
<a href="/config">Config</a>
|
|
488
|
+
</nav>
|
|
489
|
+
|
|
490
|
+
<div class="section">
|
|
491
|
+
<h2>SaaS Target</h2>
|
|
492
|
+
<form id="configForm">
|
|
493
|
+
<div class="config-item">
|
|
494
|
+
<label>URL</label>
|
|
495
|
+
<input type="url" id="saas_url" name="saas.url" value="${cfg.saas.url || ""}" placeholder="https://your-app.com">
|
|
496
|
+
</div>
|
|
497
|
+
<div class="config-item">
|
|
498
|
+
<label>Auth Type</label>
|
|
499
|
+
<select id="saas_authType" name="saas.authType">
|
|
500
|
+
<option value="none" ${cfg.saas.authType === "none" ? "selected" : ""}>None</option>
|
|
501
|
+
<option value="basic" ${cfg.saas.authType === "basic" ? "selected" : ""}>Basic Auth</option>
|
|
502
|
+
<option value="bearer" ${cfg.saas.authType === "bearer" ? "selected" : ""}>Bearer Token</option>
|
|
503
|
+
<option value="session" ${cfg.saas.authType === "session" ? "selected" : ""}>Session</option>
|
|
504
|
+
</select>
|
|
505
|
+
</div>
|
|
506
|
+
<div class="config-item">
|
|
507
|
+
<label>Username (for Basic Auth)</label>
|
|
508
|
+
<input type="text" id="saas_username" name="saas.username" value="${cfg.saas.username || ""}" placeholder="username">
|
|
509
|
+
</div>
|
|
510
|
+
<div class="config-item">
|
|
511
|
+
<label>Password (for Basic Auth)</label>
|
|
512
|
+
<input type="password" id="saas_password" name="saas.password" value="${cfg.saas.password || ""}" placeholder="password">
|
|
513
|
+
</div>
|
|
514
|
+
</form>
|
|
515
|
+
</div>
|
|
516
|
+
|
|
517
|
+
<div class="section">
|
|
518
|
+
<h2>LLM Configuration</h2>
|
|
519
|
+
<form id="configForm">
|
|
520
|
+
<div class="config-item">
|
|
521
|
+
<label>Provider</label>
|
|
522
|
+
<select id="llm_provider" name="llm.provider">
|
|
523
|
+
<option value="openai" ${cfg.llm.provider === "openai" ? "selected" : ""}>OpenAI</option>
|
|
524
|
+
<option value="anthropic" ${cfg.llm.provider === "anthropic" ? "selected" : ""}>Anthropic</option>
|
|
525
|
+
<option value="ollama" ${cfg.llm.provider === "ollama" ? "selected" : ""}>Ollama</option>
|
|
526
|
+
</select>
|
|
527
|
+
</div>
|
|
528
|
+
<div class="config-item">
|
|
529
|
+
<label>Model</label>
|
|
530
|
+
<input type="text" id="llm_model" name="llm.model" value="${cfg.llm.model || ""}" placeholder="gpt-4, claude-3-sonnet, etc.">
|
|
531
|
+
</div>
|
|
532
|
+
<div class="config-item">
|
|
533
|
+
<label>API Key</label>
|
|
534
|
+
<input type="password" id="llm_apiKey" name="llm.apiKey" value="${cfg.llm.apiKey || ""}" placeholder="Your API key">
|
|
535
|
+
</div>
|
|
536
|
+
<div class="config-item">
|
|
537
|
+
<label>Base URL (for Ollama)</label>
|
|
538
|
+
<input type="url" id="llm_baseUrl" name="llm.baseUrl" value="${cfg.llm.baseUrl || ""}" placeholder="http://localhost:11434">
|
|
539
|
+
</div>
|
|
540
|
+
</form>
|
|
541
|
+
</div>
|
|
542
|
+
|
|
543
|
+
<div class="section">
|
|
544
|
+
<h2>Agent Settings</h2>
|
|
545
|
+
<form id="configForm">
|
|
546
|
+
<div class="config-item">
|
|
547
|
+
<label>
|
|
548
|
+
<input type="checkbox" id="agent_autoStart" name="agent.autoStart" class="checkbox" ${cfg.agent.autoStart ? "checked" : ""}>
|
|
549
|
+
Auto-start
|
|
550
|
+
</label>
|
|
551
|
+
</div>
|
|
552
|
+
<div class="config-item">
|
|
553
|
+
<label>Interval (ms)</label>
|
|
554
|
+
<input type="number" id="agent_intervalMs" name="agent.intervalMs" value="${cfg.agent.intervalMs}" min="60000">
|
|
555
|
+
</div>
|
|
556
|
+
<div class="config-item">
|
|
557
|
+
<label>Max Iterations</label>
|
|
558
|
+
<input type="number" id="agent_maxIterations" name="agent.maxIterations" value="${cfg.agent.maxIterations}" min="1" max="100">
|
|
559
|
+
</div>
|
|
560
|
+
</form>
|
|
561
|
+
</div>
|
|
562
|
+
|
|
563
|
+
<div class="section">
|
|
564
|
+
<h2>Actions</h2>
|
|
565
|
+
<button type="button" class="btn" onclick="saveConfig()">Save Configuration</button>
|
|
566
|
+
<button type="button" class="btn btn-secondary" onclick="resetConfig()">Reset to Defaults</button>
|
|
567
|
+
<span id="message"></span>
|
|
568
|
+
</div>
|
|
569
|
+
|
|
570
|
+
<div class="section">
|
|
571
|
+
<h2>Environment Variables</h2>
|
|
572
|
+
<p>You can also set these environment variables before starting OpenQA:</p>
|
|
573
|
+
<pre style="background: #334155; padding: 15px; border-radius: 6px; overflow-x: auto;"><code>export SAAS_URL="https://your-app.com"
|
|
574
|
+
export AGENT_AUTO_START=true
|
|
575
|
+
export LLM_PROVIDER=openai
|
|
576
|
+
export OPENAI_API_KEY="your-key"
|
|
577
|
+
|
|
578
|
+
openqa start</code></pre>
|
|
579
|
+
</div>
|
|
580
|
+
|
|
581
|
+
<script>
|
|
582
|
+
async function saveConfig() {
|
|
583
|
+
const form = document.getElementById('configForm');
|
|
584
|
+
const formData = new FormData(form);
|
|
585
|
+
const config = {};
|
|
586
|
+
|
|
587
|
+
for (let [key, value] of formData.entries()) {
|
|
588
|
+
if (value === '') continue;
|
|
589
|
+
|
|
590
|
+
// Handle nested keys like "saas.url"
|
|
591
|
+
const keys = key.split('.');
|
|
592
|
+
let obj = config;
|
|
593
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
594
|
+
if (!obj[keys[i]]) obj[keys[i]] = {};
|
|
595
|
+
obj = obj[keys[i]];
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Convert checkbox values to boolean
|
|
599
|
+
if (key.includes('autoStart')) {
|
|
600
|
+
obj[keys[keys.length - 1]] = value === 'on';
|
|
601
|
+
} else if (key.includes('intervalMs') || key.includes('maxIterations')) {
|
|
602
|
+
obj[keys[keys.length - 1]] = parseInt(value);
|
|
603
|
+
} else {
|
|
604
|
+
obj[keys[keys.length - 1]] = value;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
const response = await fetch('/api/config', {
|
|
610
|
+
method: 'POST',
|
|
611
|
+
headers: { 'Content-Type': 'application/json' },
|
|
612
|
+
body: JSON.stringify(config)
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const result = await response.json();
|
|
616
|
+
if (result.success) {
|
|
617
|
+
showMessage('Configuration saved successfully!', 'success');
|
|
618
|
+
setTimeout(() => location.reload(), 1500);
|
|
619
|
+
} else {
|
|
620
|
+
showMessage('Failed to save configuration', 'error');
|
|
621
|
+
}
|
|
622
|
+
} catch (error) {
|
|
623
|
+
showMessage('Error: ' + error.message, 'error');
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function resetConfig() {
|
|
628
|
+
if (confirm('Are you sure you want to reset all configuration to defaults?')) {
|
|
629
|
+
try {
|
|
630
|
+
const response = await fetch('/api/config/reset', { method: 'POST' });
|
|
631
|
+
const result = await response.json();
|
|
632
|
+
if (result.success) {
|
|
633
|
+
showMessage('Configuration reset to defaults', 'success');
|
|
634
|
+
setTimeout(() => location.reload(), 1500);
|
|
635
|
+
}
|
|
636
|
+
} catch (error) {
|
|
637
|
+
showMessage('Error: ' + error.message, 'error');
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function showMessage(text, type) {
|
|
643
|
+
const messageEl = document.getElementById('message');
|
|
644
|
+
messageEl.textContent = text;
|
|
645
|
+
messageEl.className = type;
|
|
646
|
+
setTimeout(() => {
|
|
647
|
+
messageEl.textContent = '';
|
|
648
|
+
messageEl.className = '';
|
|
649
|
+
}, 3000);
|
|
650
|
+
}
|
|
651
|
+
</script>
|
|
652
|
+
</body>
|
|
653
|
+
</html>
|
|
654
|
+
`);
|
|
655
|
+
});
|
|
656
|
+
const server = app.listen(cfg.web.port, cfg.web.host, () => {
|
|
657
|
+
console.log(chalk.cyan("\n\u{1F4CA} OpenQA Status:"));
|
|
658
|
+
console.log(chalk.white(` Agent: ${cfg.agent.autoStart ? "Auto-start enabled" : "Idle"}`));
|
|
659
|
+
console.log(chalk.white(` Target: ${cfg.saas.url || "Not configured"}`));
|
|
660
|
+
console.log(chalk.white(` Dashboard: http://localhost:${cfg.web.port}`));
|
|
661
|
+
console.log(chalk.white(` Kanban: http://localhost:${cfg.web.port}/kanban`));
|
|
662
|
+
console.log(chalk.white(` Config: http://localhost:${cfg.web.port}/config`));
|
|
663
|
+
console.log(chalk.gray("\nPress Ctrl+C to stop\n"));
|
|
664
|
+
if (!cfg.agent.autoStart) {
|
|
665
|
+
console.log(chalk.yellow("\u{1F4A1} Auto-start disabled. Agent is idle."));
|
|
666
|
+
console.log(chalk.cyan(" Set AGENT_AUTO_START=true to enable autonomous mode\n"));
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
server.on("upgrade", (request, socket, head) => {
|
|
670
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
671
|
+
wss.emit("connection", ws, request);
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
wss.on("connection", (ws) => {
|
|
675
|
+
console.log("WebSocket client connected");
|
|
676
|
+
ws.on("close", () => {
|
|
677
|
+
console.log("WebSocket client disconnected");
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
process.on("SIGTERM", () => {
|
|
681
|
+
console.log("Received SIGTERM, shutting down gracefully...");
|
|
682
|
+
server.close(() => {
|
|
683
|
+
process.exit(0);
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
process.on("SIGINT", () => {
|
|
687
|
+
console.log("\nShutting down gracefully...");
|
|
688
|
+
server.close(() => {
|
|
689
|
+
process.exit(0);
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
export {
|
|
694
|
+
startWebServer
|
|
695
|
+
};
|