@hasna/testers 0.0.1 → 0.0.3
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 +72 -1
- package/dist/cli/index.js +2444 -373
- package/dist/db/auth-presets.d.ts +20 -0
- package/dist/db/auth-presets.d.ts.map +1 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/schedules.d.ts +9 -0
- package/dist/db/schedules.d.ts.map +1 -0
- package/dist/db/screenshots.d.ts +3 -0
- package/dist/db/screenshots.d.ts.map +1 -1
- package/dist/index.d.ts +21 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2426 -399
- package/dist/lib/ai-client.d.ts +6 -0
- package/dist/lib/ai-client.d.ts.map +1 -1
- package/dist/lib/costs.d.ts +36 -0
- package/dist/lib/costs.d.ts.map +1 -0
- package/dist/lib/diff.d.ts +25 -0
- package/dist/lib/diff.d.ts.map +1 -0
- package/dist/lib/init.d.ts +28 -0
- package/dist/lib/init.d.ts.map +1 -0
- package/dist/lib/report.d.ts +4 -0
- package/dist/lib/report.d.ts.map +1 -0
- package/dist/lib/runner.d.ts.map +1 -1
- package/dist/lib/scheduler.d.ts +71 -0
- package/dist/lib/scheduler.d.ts.map +1 -0
- package/dist/lib/screenshotter.d.ts +27 -25
- package/dist/lib/screenshotter.d.ts.map +1 -1
- package/dist/lib/smoke.d.ts +25 -0
- package/dist/lib/smoke.d.ts.map +1 -0
- package/dist/lib/templates.d.ts +5 -0
- package/dist/lib/templates.d.ts.map +1 -0
- package/dist/lib/watch.d.ts +9 -0
- package/dist/lib/watch.d.ts.map +1 -0
- package/dist/lib/webhooks.d.ts +41 -0
- package/dist/lib/webhooks.d.ts.map +1 -0
- package/dist/mcp/index.js +839 -25
- package/dist/server/index.js +818 -25
- package/dist/types/index.d.ts +86 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
|
|
3
|
-
var
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __export = (target, all) => {
|
|
4
|
+
for (var name in all)
|
|
5
|
+
__defProp(target, name, {
|
|
6
|
+
get: all[name],
|
|
7
|
+
enumerable: true,
|
|
8
|
+
configurable: true,
|
|
9
|
+
set: (newValue) => all[name] = () => newValue
|
|
10
|
+
});
|
|
7
11
|
};
|
|
12
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
13
|
+
|
|
14
|
+
// src/types/index.ts
|
|
8
15
|
function projectFromRow(row) {
|
|
9
16
|
return {
|
|
10
17
|
id: row.id,
|
|
@@ -91,78 +98,106 @@ function screenshotFromRow(row) {
|
|
|
91
98
|
filePath: row.file_path,
|
|
92
99
|
width: row.width,
|
|
93
100
|
height: row.height,
|
|
94
|
-
timestamp: row.timestamp
|
|
101
|
+
timestamp: row.timestamp,
|
|
102
|
+
description: row.description,
|
|
103
|
+
pageUrl: row.page_url,
|
|
104
|
+
thumbnailPath: row.thumbnail_path
|
|
95
105
|
};
|
|
96
106
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
class VersionConflictError extends Error {
|
|
120
|
-
constructor(entity, id) {
|
|
121
|
-
super(`Version conflict on ${entity}: ${id}`);
|
|
122
|
-
this.name = "VersionConflictError";
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
class BrowserError extends Error {
|
|
127
|
-
constructor(message) {
|
|
128
|
-
super(message);
|
|
129
|
-
this.name = "BrowserError";
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
class AIClientError extends Error {
|
|
134
|
-
constructor(message) {
|
|
135
|
-
super(message);
|
|
136
|
-
this.name = "AIClientError";
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
class TodosConnectionError extends Error {
|
|
141
|
-
constructor(message) {
|
|
142
|
-
super(message);
|
|
143
|
-
this.name = "TodosConnectionError";
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
class ProjectNotFoundError extends Error {
|
|
148
|
-
constructor(id) {
|
|
149
|
-
super(`Project not found: ${id}`);
|
|
150
|
-
this.name = "ProjectNotFoundError";
|
|
151
|
-
}
|
|
107
|
+
function scheduleFromRow(row) {
|
|
108
|
+
return {
|
|
109
|
+
id: row.id,
|
|
110
|
+
projectId: row.project_id,
|
|
111
|
+
name: row.name,
|
|
112
|
+
cronExpression: row.cron_expression,
|
|
113
|
+
url: row.url,
|
|
114
|
+
scenarioFilter: JSON.parse(row.scenario_filter),
|
|
115
|
+
model: row.model,
|
|
116
|
+
headed: row.headed === 1,
|
|
117
|
+
parallel: row.parallel,
|
|
118
|
+
timeoutMs: row.timeout_ms,
|
|
119
|
+
enabled: row.enabled === 1,
|
|
120
|
+
lastRunId: row.last_run_id,
|
|
121
|
+
lastRunAt: row.last_run_at,
|
|
122
|
+
nextRunAt: row.next_run_at,
|
|
123
|
+
createdAt: row.created_at,
|
|
124
|
+
updatedAt: row.updated_at
|
|
125
|
+
};
|
|
152
126
|
}
|
|
127
|
+
var MODEL_MAP, ScenarioNotFoundError, RunNotFoundError, ResultNotFoundError, VersionConflictError, BrowserError, AIClientError, TodosConnectionError, ProjectNotFoundError, AgentNotFoundError, ScheduleNotFoundError;
|
|
128
|
+
var init_types = __esm(() => {
|
|
129
|
+
MODEL_MAP = {
|
|
130
|
+
quick: "claude-haiku-4-5-20251001",
|
|
131
|
+
thorough: "claude-sonnet-4-6-20260311",
|
|
132
|
+
deep: "claude-opus-4-6-20260311"
|
|
133
|
+
};
|
|
134
|
+
ScenarioNotFoundError = class ScenarioNotFoundError extends Error {
|
|
135
|
+
constructor(id) {
|
|
136
|
+
super(`Scenario not found: ${id}`);
|
|
137
|
+
this.name = "ScenarioNotFoundError";
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
RunNotFoundError = class RunNotFoundError extends Error {
|
|
141
|
+
constructor(id) {
|
|
142
|
+
super(`Run not found: ${id}`);
|
|
143
|
+
this.name = "RunNotFoundError";
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
ResultNotFoundError = class ResultNotFoundError extends Error {
|
|
147
|
+
constructor(id) {
|
|
148
|
+
super(`Result not found: ${id}`);
|
|
149
|
+
this.name = "ResultNotFoundError";
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
VersionConflictError = class VersionConflictError extends Error {
|
|
153
|
+
constructor(entity, id) {
|
|
154
|
+
super(`Version conflict on ${entity}: ${id}`);
|
|
155
|
+
this.name = "VersionConflictError";
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
BrowserError = class BrowserError extends Error {
|
|
159
|
+
constructor(message) {
|
|
160
|
+
super(message);
|
|
161
|
+
this.name = "BrowserError";
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
AIClientError = class AIClientError extends Error {
|
|
165
|
+
constructor(message) {
|
|
166
|
+
super(message);
|
|
167
|
+
this.name = "AIClientError";
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
TodosConnectionError = class TodosConnectionError extends Error {
|
|
171
|
+
constructor(message) {
|
|
172
|
+
super(message);
|
|
173
|
+
this.name = "TodosConnectionError";
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
ProjectNotFoundError = class ProjectNotFoundError extends Error {
|
|
177
|
+
constructor(id) {
|
|
178
|
+
super(`Project not found: ${id}`);
|
|
179
|
+
this.name = "ProjectNotFoundError";
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
AgentNotFoundError = class AgentNotFoundError extends Error {
|
|
183
|
+
constructor(id) {
|
|
184
|
+
super(`Agent not found: ${id}`);
|
|
185
|
+
this.name = "AgentNotFoundError";
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
ScheduleNotFoundError = class ScheduleNotFoundError extends Error {
|
|
189
|
+
constructor(id) {
|
|
190
|
+
super(`Schedule not found: ${id}`);
|
|
191
|
+
this.name = "ScheduleNotFoundError";
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
});
|
|
153
195
|
|
|
154
|
-
class AgentNotFoundError extends Error {
|
|
155
|
-
constructor(id) {
|
|
156
|
-
super(`Agent not found: ${id}`);
|
|
157
|
-
this.name = "AgentNotFoundError";
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
196
|
// src/db/database.ts
|
|
161
197
|
import { Database } from "bun:sqlite";
|
|
162
198
|
import { mkdirSync, existsSync } from "fs";
|
|
163
199
|
import { dirname, join } from "path";
|
|
164
200
|
import { homedir } from "os";
|
|
165
|
-
var db = null;
|
|
166
201
|
function now() {
|
|
167
202
|
return new Date().toISOString();
|
|
168
203
|
}
|
|
@@ -181,8 +216,69 @@ function resolveDbPath() {
|
|
|
181
216
|
mkdirSync(dir, { recursive: true });
|
|
182
217
|
return join(dir, "testers.db");
|
|
183
218
|
}
|
|
184
|
-
|
|
185
|
-
|
|
219
|
+
function applyMigrations(database) {
|
|
220
|
+
const applied = database.query("SELECT id FROM _migrations ORDER BY id").all();
|
|
221
|
+
const appliedIds = new Set(applied.map((r) => r.id));
|
|
222
|
+
for (let i = 0;i < MIGRATIONS.length; i++) {
|
|
223
|
+
const migrationId = i + 1;
|
|
224
|
+
if (appliedIds.has(migrationId))
|
|
225
|
+
continue;
|
|
226
|
+
const migration = MIGRATIONS[i];
|
|
227
|
+
database.exec(migration);
|
|
228
|
+
database.query("INSERT INTO _migrations (id, applied_at) VALUES (?, ?)").run(migrationId, now());
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function getDatabase() {
|
|
232
|
+
if (db)
|
|
233
|
+
return db;
|
|
234
|
+
const dbPath = resolveDbPath();
|
|
235
|
+
const dir = dirname(dbPath);
|
|
236
|
+
if (dbPath !== ":memory:" && !existsSync(dir)) {
|
|
237
|
+
mkdirSync(dir, { recursive: true });
|
|
238
|
+
}
|
|
239
|
+
db = new Database(dbPath);
|
|
240
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
241
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
242
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
243
|
+
db.exec(`
|
|
244
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
245
|
+
id INTEGER PRIMARY KEY,
|
|
246
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
247
|
+
);
|
|
248
|
+
`);
|
|
249
|
+
applyMigrations(db);
|
|
250
|
+
return db;
|
|
251
|
+
}
|
|
252
|
+
function closeDatabase() {
|
|
253
|
+
if (db) {
|
|
254
|
+
db.close();
|
|
255
|
+
db = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function resetDatabase() {
|
|
259
|
+
closeDatabase();
|
|
260
|
+
const database = getDatabase();
|
|
261
|
+
database.exec("DELETE FROM screenshots");
|
|
262
|
+
database.exec("DELETE FROM results");
|
|
263
|
+
database.exec("DELETE FROM webhooks");
|
|
264
|
+
database.exec("DELETE FROM auth_presets");
|
|
265
|
+
database.exec("DELETE FROM schedules");
|
|
266
|
+
database.exec("DELETE FROM runs");
|
|
267
|
+
database.exec("DELETE FROM scenarios");
|
|
268
|
+
database.exec("DELETE FROM agents");
|
|
269
|
+
database.exec("DELETE FROM projects");
|
|
270
|
+
}
|
|
271
|
+
function resolvePartialId(table, partialId) {
|
|
272
|
+
const database = getDatabase();
|
|
273
|
+
const rows = database.query(`SELECT id FROM ${table} WHERE id LIKE ? || '%'`).all(partialId);
|
|
274
|
+
if (rows.length === 1)
|
|
275
|
+
return rows[0].id;
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
var db = null, MIGRATIONS;
|
|
279
|
+
var init_database = __esm(() => {
|
|
280
|
+
MIGRATIONS = [
|
|
281
|
+
`
|
|
186
282
|
CREATE TABLE IF NOT EXISTS projects (
|
|
187
283
|
id TEXT PRIMARY KEY,
|
|
188
284
|
name TEXT NOT NULL UNIQUE,
|
|
@@ -271,7 +367,7 @@ var MIGRATIONS = [
|
|
|
271
367
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
272
368
|
);
|
|
273
369
|
`,
|
|
274
|
-
|
|
370
|
+
`
|
|
275
371
|
CREATE INDEX IF NOT EXISTS idx_scenarios_project ON scenarios(project_id);
|
|
276
372
|
CREATE INDEX IF NOT EXISTS idx_scenarios_priority ON scenarios(priority);
|
|
277
373
|
CREATE INDEX IF NOT EXISTS idx_scenarios_short_id ON scenarios(short_id);
|
|
@@ -282,113 +378,98 @@ var MIGRATIONS = [
|
|
|
282
378
|
CREATE INDEX IF NOT EXISTS idx_results_status ON results(status);
|
|
283
379
|
CREATE INDEX IF NOT EXISTS idx_screenshots_result ON screenshots(result_id);
|
|
284
380
|
`,
|
|
285
|
-
|
|
381
|
+
`
|
|
286
382
|
ALTER TABLE projects ADD COLUMN scenario_prefix TEXT DEFAULT 'TST';
|
|
287
383
|
ALTER TABLE projects ADD COLUMN scenario_counter INTEGER DEFAULT 0;
|
|
384
|
+
`,
|
|
385
|
+
`
|
|
386
|
+
CREATE TABLE IF NOT EXISTS schedules (
|
|
387
|
+
id TEXT PRIMARY KEY,
|
|
388
|
+
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
389
|
+
name TEXT NOT NULL,
|
|
390
|
+
cron_expression TEXT NOT NULL,
|
|
391
|
+
url TEXT NOT NULL,
|
|
392
|
+
scenario_filter TEXT NOT NULL DEFAULT '{}',
|
|
393
|
+
model TEXT,
|
|
394
|
+
headed INTEGER NOT NULL DEFAULT 0,
|
|
395
|
+
parallel INTEGER NOT NULL DEFAULT 1,
|
|
396
|
+
timeout_ms INTEGER,
|
|
397
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
398
|
+
last_run_id TEXT REFERENCES runs(id) ON DELETE SET NULL,
|
|
399
|
+
last_run_at TEXT,
|
|
400
|
+
next_run_at TEXT,
|
|
401
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
402
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
CREATE INDEX IF NOT EXISTS idx_schedules_project ON schedules(project_id);
|
|
406
|
+
CREATE INDEX IF NOT EXISTS idx_schedules_enabled ON schedules(enabled);
|
|
407
|
+
CREATE INDEX IF NOT EXISTS idx_schedules_next_run ON schedules(next_run_at);
|
|
408
|
+
`,
|
|
409
|
+
`
|
|
410
|
+
ALTER TABLE screenshots ADD COLUMN description TEXT;
|
|
411
|
+
ALTER TABLE screenshots ADD COLUMN page_url TEXT;
|
|
412
|
+
ALTER TABLE screenshots ADD COLUMN thumbnail_path TEXT;
|
|
413
|
+
`,
|
|
414
|
+
`
|
|
415
|
+
CREATE TABLE IF NOT EXISTS auth_presets (
|
|
416
|
+
id TEXT PRIMARY KEY,
|
|
417
|
+
name TEXT NOT NULL UNIQUE,
|
|
418
|
+
email TEXT NOT NULL,
|
|
419
|
+
password TEXT NOT NULL,
|
|
420
|
+
login_path TEXT DEFAULT '/login',
|
|
421
|
+
metadata TEXT DEFAULT '{}',
|
|
422
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
423
|
+
);
|
|
424
|
+
`,
|
|
425
|
+
`
|
|
426
|
+
CREATE TABLE IF NOT EXISTS webhooks (
|
|
427
|
+
id TEXT PRIMARY KEY,
|
|
428
|
+
url TEXT NOT NULL,
|
|
429
|
+
events TEXT NOT NULL DEFAULT '["failed"]',
|
|
430
|
+
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
431
|
+
secret TEXT,
|
|
432
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
433
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
434
|
+
);
|
|
435
|
+
CREATE INDEX IF NOT EXISTS idx_webhooks_active ON webhooks(active);
|
|
288
436
|
`
|
|
289
|
-
];
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
function getDatabase() {
|
|
303
|
-
if (db)
|
|
304
|
-
return db;
|
|
305
|
-
const dbPath = resolveDbPath();
|
|
306
|
-
const dir = dirname(dbPath);
|
|
307
|
-
if (dbPath !== ":memory:" && !existsSync(dir)) {
|
|
308
|
-
mkdirSync(dir, { recursive: true });
|
|
309
|
-
}
|
|
310
|
-
db = new Database(dbPath);
|
|
311
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
312
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
313
|
-
db.exec("PRAGMA busy_timeout = 5000");
|
|
314
|
-
db.exec(`
|
|
315
|
-
CREATE TABLE IF NOT EXISTS _migrations (
|
|
316
|
-
id INTEGER PRIMARY KEY,
|
|
317
|
-
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
318
|
-
);
|
|
319
|
-
`);
|
|
320
|
-
applyMigrations(db);
|
|
321
|
-
return db;
|
|
322
|
-
}
|
|
323
|
-
function closeDatabase() {
|
|
324
|
-
if (db) {
|
|
325
|
-
db.close();
|
|
326
|
-
db = null;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
function resetDatabase() {
|
|
330
|
-
closeDatabase();
|
|
331
|
-
const database = getDatabase();
|
|
332
|
-
database.exec("DELETE FROM screenshots");
|
|
333
|
-
database.exec("DELETE FROM results");
|
|
334
|
-
database.exec("DELETE FROM runs");
|
|
335
|
-
database.exec("DELETE FROM scenarios");
|
|
336
|
-
database.exec("DELETE FROM agents");
|
|
337
|
-
database.exec("DELETE FROM projects");
|
|
338
|
-
}
|
|
339
|
-
function resolvePartialId(table, partialId) {
|
|
340
|
-
const database = getDatabase();
|
|
341
|
-
const rows = database.query(`SELECT id FROM ${table} WHERE id LIKE ? || '%'`).all(partialId);
|
|
342
|
-
if (rows.length === 1)
|
|
343
|
-
return rows[0].id;
|
|
344
|
-
return null;
|
|
345
|
-
}
|
|
346
|
-
// src/db/scenarios.ts
|
|
347
|
-
function nextShortId(projectId) {
|
|
348
|
-
const db2 = getDatabase();
|
|
349
|
-
if (projectId) {
|
|
350
|
-
const project = db2.query("SELECT scenario_prefix, scenario_counter FROM projects WHERE id = ?").get(projectId);
|
|
351
|
-
if (project) {
|
|
352
|
-
const next = project.scenario_counter + 1;
|
|
353
|
-
db2.query("UPDATE projects SET scenario_counter = ? WHERE id = ?").run(next, projectId);
|
|
354
|
-
return `${project.scenario_prefix}-${next}`;
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
return shortUuid();
|
|
358
|
-
}
|
|
359
|
-
function createScenario(input) {
|
|
437
|
+
];
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// src/db/runs.ts
|
|
441
|
+
var exports_runs = {};
|
|
442
|
+
__export(exports_runs, {
|
|
443
|
+
updateRun: () => updateRun,
|
|
444
|
+
listRuns: () => listRuns,
|
|
445
|
+
getRun: () => getRun,
|
|
446
|
+
deleteRun: () => deleteRun,
|
|
447
|
+
createRun: () => createRun
|
|
448
|
+
});
|
|
449
|
+
function createRun(input) {
|
|
360
450
|
const db2 = getDatabase();
|
|
361
451
|
const id = uuid();
|
|
362
|
-
const short_id = nextShortId(input.projectId);
|
|
363
452
|
const timestamp = now();
|
|
364
453
|
db2.query(`
|
|
365
|
-
INSERT INTO
|
|
366
|
-
VALUES (?, ?,
|
|
367
|
-
`).run(id,
|
|
368
|
-
return
|
|
454
|
+
INSERT INTO runs (id, project_id, status, url, model, headed, parallel, total, passed, failed, started_at, finished_at, metadata)
|
|
455
|
+
VALUES (?, ?, 'pending', ?, ?, ?, ?, 0, 0, 0, ?, NULL, ?)
|
|
456
|
+
`).run(id, input.projectId ?? null, input.url, input.model, input.headed ? 1 : 0, input.parallel ?? 1, timestamp, input.model ? JSON.stringify({}) : null);
|
|
457
|
+
return getRun(id);
|
|
369
458
|
}
|
|
370
|
-
function
|
|
459
|
+
function getRun(id) {
|
|
371
460
|
const db2 = getDatabase();
|
|
372
|
-
let row = db2.query("SELECT * FROM
|
|
373
|
-
if (row)
|
|
374
|
-
return scenarioFromRow(row);
|
|
375
|
-
row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(id);
|
|
461
|
+
let row = db2.query("SELECT * FROM runs WHERE id = ?").get(id);
|
|
376
462
|
if (row)
|
|
377
|
-
return
|
|
378
|
-
const fullId = resolvePartialId("
|
|
463
|
+
return runFromRow(row);
|
|
464
|
+
const fullId = resolvePartialId("runs", id);
|
|
379
465
|
if (fullId) {
|
|
380
|
-
row = db2.query("SELECT * FROM
|
|
466
|
+
row = db2.query("SELECT * FROM runs WHERE id = ?").get(fullId);
|
|
381
467
|
if (row)
|
|
382
|
-
return
|
|
468
|
+
return runFromRow(row);
|
|
383
469
|
}
|
|
384
470
|
return null;
|
|
385
471
|
}
|
|
386
|
-
function
|
|
387
|
-
const db2 = getDatabase();
|
|
388
|
-
const row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(shortId);
|
|
389
|
-
return row ? scenarioFromRow(row) : null;
|
|
390
|
-
}
|
|
391
|
-
function listScenarios(filter) {
|
|
472
|
+
function listRuns(filter) {
|
|
392
473
|
const db2 = getDatabase();
|
|
393
474
|
const conditions = [];
|
|
394
475
|
const params = [];
|
|
@@ -396,7 +477,158 @@ function listScenarios(filter) {
|
|
|
396
477
|
conditions.push("project_id = ?");
|
|
397
478
|
params.push(filter.projectId);
|
|
398
479
|
}
|
|
399
|
-
if (filter?.
|
|
480
|
+
if (filter?.status) {
|
|
481
|
+
conditions.push("status = ?");
|
|
482
|
+
params.push(filter.status);
|
|
483
|
+
}
|
|
484
|
+
let sql = "SELECT * FROM runs";
|
|
485
|
+
if (conditions.length > 0) {
|
|
486
|
+
sql += " WHERE " + conditions.join(" AND ");
|
|
487
|
+
}
|
|
488
|
+
sql += " ORDER BY started_at DESC";
|
|
489
|
+
if (filter?.limit) {
|
|
490
|
+
sql += " LIMIT ?";
|
|
491
|
+
params.push(filter.limit);
|
|
492
|
+
}
|
|
493
|
+
if (filter?.offset) {
|
|
494
|
+
sql += " OFFSET ?";
|
|
495
|
+
params.push(filter.offset);
|
|
496
|
+
}
|
|
497
|
+
const rows = db2.query(sql).all(...params);
|
|
498
|
+
return rows.map(runFromRow);
|
|
499
|
+
}
|
|
500
|
+
function updateRun(id, updates) {
|
|
501
|
+
const db2 = getDatabase();
|
|
502
|
+
const existing = getRun(id);
|
|
503
|
+
if (!existing) {
|
|
504
|
+
throw new Error(`Run not found: ${id}`);
|
|
505
|
+
}
|
|
506
|
+
const sets = [];
|
|
507
|
+
const params = [];
|
|
508
|
+
if (updates.status !== undefined) {
|
|
509
|
+
sets.push("status = ?");
|
|
510
|
+
params.push(updates.status);
|
|
511
|
+
}
|
|
512
|
+
if (updates.url !== undefined) {
|
|
513
|
+
sets.push("url = ?");
|
|
514
|
+
params.push(updates.url);
|
|
515
|
+
}
|
|
516
|
+
if (updates.model !== undefined) {
|
|
517
|
+
sets.push("model = ?");
|
|
518
|
+
params.push(updates.model);
|
|
519
|
+
}
|
|
520
|
+
if (updates.headed !== undefined) {
|
|
521
|
+
sets.push("headed = ?");
|
|
522
|
+
params.push(updates.headed);
|
|
523
|
+
}
|
|
524
|
+
if (updates.parallel !== undefined) {
|
|
525
|
+
sets.push("parallel = ?");
|
|
526
|
+
params.push(updates.parallel);
|
|
527
|
+
}
|
|
528
|
+
if (updates.total !== undefined) {
|
|
529
|
+
sets.push("total = ?");
|
|
530
|
+
params.push(updates.total);
|
|
531
|
+
}
|
|
532
|
+
if (updates.passed !== undefined) {
|
|
533
|
+
sets.push("passed = ?");
|
|
534
|
+
params.push(updates.passed);
|
|
535
|
+
}
|
|
536
|
+
if (updates.failed !== undefined) {
|
|
537
|
+
sets.push("failed = ?");
|
|
538
|
+
params.push(updates.failed);
|
|
539
|
+
}
|
|
540
|
+
if (updates.started_at !== undefined) {
|
|
541
|
+
sets.push("started_at = ?");
|
|
542
|
+
params.push(updates.started_at);
|
|
543
|
+
}
|
|
544
|
+
if (updates.finished_at !== undefined) {
|
|
545
|
+
sets.push("finished_at = ?");
|
|
546
|
+
params.push(updates.finished_at);
|
|
547
|
+
}
|
|
548
|
+
if (updates.metadata !== undefined) {
|
|
549
|
+
sets.push("metadata = ?");
|
|
550
|
+
params.push(updates.metadata);
|
|
551
|
+
}
|
|
552
|
+
if (sets.length === 0) {
|
|
553
|
+
return existing;
|
|
554
|
+
}
|
|
555
|
+
params.push(existing.id);
|
|
556
|
+
db2.query(`UPDATE runs SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
557
|
+
return getRun(existing.id);
|
|
558
|
+
}
|
|
559
|
+
function deleteRun(id) {
|
|
560
|
+
const db2 = getDatabase();
|
|
561
|
+
const run = getRun(id);
|
|
562
|
+
if (!run)
|
|
563
|
+
return false;
|
|
564
|
+
const result = db2.query("DELETE FROM runs WHERE id = ?").run(run.id);
|
|
565
|
+
return result.changes > 0;
|
|
566
|
+
}
|
|
567
|
+
var init_runs = __esm(() => {
|
|
568
|
+
init_types();
|
|
569
|
+
init_database();
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// src/index.ts
|
|
573
|
+
init_types();
|
|
574
|
+
init_database();
|
|
575
|
+
|
|
576
|
+
// src/db/scenarios.ts
|
|
577
|
+
init_types();
|
|
578
|
+
init_database();
|
|
579
|
+
function nextShortId(projectId) {
|
|
580
|
+
const db2 = getDatabase();
|
|
581
|
+
if (projectId) {
|
|
582
|
+
const project = db2.query("SELECT scenario_prefix, scenario_counter FROM projects WHERE id = ?").get(projectId);
|
|
583
|
+
if (project) {
|
|
584
|
+
const next = project.scenario_counter + 1;
|
|
585
|
+
db2.query("UPDATE projects SET scenario_counter = ? WHERE id = ?").run(next, projectId);
|
|
586
|
+
return `${project.scenario_prefix}-${next}`;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return shortUuid();
|
|
590
|
+
}
|
|
591
|
+
function createScenario(input) {
|
|
592
|
+
const db2 = getDatabase();
|
|
593
|
+
const id = uuid();
|
|
594
|
+
const short_id = nextShortId(input.projectId);
|
|
595
|
+
const timestamp = now();
|
|
596
|
+
db2.query(`
|
|
597
|
+
INSERT INTO scenarios (id, short_id, project_id, name, description, steps, tags, priority, model, timeout_ms, target_path, requires_auth, auth_config, metadata, version, created_at, updated_at)
|
|
598
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
|
599
|
+
`).run(id, short_id, input.projectId ?? null, input.name, input.description, JSON.stringify(input.steps ?? []), JSON.stringify(input.tags ?? []), input.priority ?? "medium", input.model ?? null, input.timeoutMs ?? null, input.targetPath ?? null, input.requiresAuth ? 1 : 0, input.authConfig ? JSON.stringify(input.authConfig) : null, input.metadata ? JSON.stringify(input.metadata) : null, timestamp, timestamp);
|
|
600
|
+
return getScenario(id);
|
|
601
|
+
}
|
|
602
|
+
function getScenario(id) {
|
|
603
|
+
const db2 = getDatabase();
|
|
604
|
+
let row = db2.query("SELECT * FROM scenarios WHERE id = ?").get(id);
|
|
605
|
+
if (row)
|
|
606
|
+
return scenarioFromRow(row);
|
|
607
|
+
row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(id);
|
|
608
|
+
if (row)
|
|
609
|
+
return scenarioFromRow(row);
|
|
610
|
+
const fullId = resolvePartialId("scenarios", id);
|
|
611
|
+
if (fullId) {
|
|
612
|
+
row = db2.query("SELECT * FROM scenarios WHERE id = ?").get(fullId);
|
|
613
|
+
if (row)
|
|
614
|
+
return scenarioFromRow(row);
|
|
615
|
+
}
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
function getScenarioByShortId(shortId) {
|
|
619
|
+
const db2 = getDatabase();
|
|
620
|
+
const row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(shortId);
|
|
621
|
+
return row ? scenarioFromRow(row) : null;
|
|
622
|
+
}
|
|
623
|
+
function listScenarios(filter) {
|
|
624
|
+
const db2 = getDatabase();
|
|
625
|
+
const conditions = [];
|
|
626
|
+
const params = [];
|
|
627
|
+
if (filter?.projectId) {
|
|
628
|
+
conditions.push("project_id = ?");
|
|
629
|
+
params.push(filter.projectId);
|
|
630
|
+
}
|
|
631
|
+
if (filter?.tags && filter.tags.length > 0) {
|
|
400
632
|
for (const tag of filter.tags) {
|
|
401
633
|
conditions.push("tags LIKE ?");
|
|
402
634
|
params.push(`%"${tag}"%`);
|
|
@@ -505,63 +737,46 @@ function deleteScenario(id) {
|
|
|
505
737
|
const result = db2.query("DELETE FROM scenarios WHERE id = ?").run(scenario.id);
|
|
506
738
|
return result.changes > 0;
|
|
507
739
|
}
|
|
508
|
-
|
|
509
|
-
|
|
740
|
+
|
|
741
|
+
// src/index.ts
|
|
742
|
+
init_runs();
|
|
743
|
+
|
|
744
|
+
// src/db/results.ts
|
|
745
|
+
init_types();
|
|
746
|
+
init_database();
|
|
747
|
+
function createResult(input) {
|
|
510
748
|
const db2 = getDatabase();
|
|
511
749
|
const id = uuid();
|
|
512
750
|
const timestamp = now();
|
|
513
751
|
db2.query(`
|
|
514
|
-
INSERT INTO
|
|
515
|
-
VALUES (?, ?, '
|
|
516
|
-
`).run(id, input.
|
|
517
|
-
return
|
|
752
|
+
INSERT INTO results (id, run_id, scenario_id, status, reasoning, error, steps_completed, steps_total, duration_ms, model, tokens_used, cost_cents, metadata, created_at)
|
|
753
|
+
VALUES (?, ?, ?, 'skipped', NULL, NULL, 0, ?, 0, ?, 0, 0, '{}', ?)
|
|
754
|
+
`).run(id, input.runId, input.scenarioId, input.stepsTotal, input.model, timestamp);
|
|
755
|
+
return getResult(id);
|
|
518
756
|
}
|
|
519
|
-
function
|
|
757
|
+
function getResult(id) {
|
|
520
758
|
const db2 = getDatabase();
|
|
521
|
-
let row = db2.query("SELECT * FROM
|
|
759
|
+
let row = db2.query("SELECT * FROM results WHERE id = ?").get(id);
|
|
522
760
|
if (row)
|
|
523
|
-
return
|
|
524
|
-
const fullId = resolvePartialId("
|
|
761
|
+
return resultFromRow(row);
|
|
762
|
+
const fullId = resolvePartialId("results", id);
|
|
525
763
|
if (fullId) {
|
|
526
|
-
row = db2.query("SELECT * FROM
|
|
764
|
+
row = db2.query("SELECT * FROM results WHERE id = ?").get(fullId);
|
|
527
765
|
if (row)
|
|
528
|
-
return
|
|
766
|
+
return resultFromRow(row);
|
|
529
767
|
}
|
|
530
768
|
return null;
|
|
531
769
|
}
|
|
532
|
-
function
|
|
770
|
+
function listResults(runId) {
|
|
533
771
|
const db2 = getDatabase();
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
if (filter?.projectId) {
|
|
537
|
-
conditions.push("project_id = ?");
|
|
538
|
-
params.push(filter.projectId);
|
|
539
|
-
}
|
|
540
|
-
if (filter?.status) {
|
|
541
|
-
conditions.push("status = ?");
|
|
542
|
-
params.push(filter.status);
|
|
543
|
-
}
|
|
544
|
-
let sql = "SELECT * FROM runs";
|
|
545
|
-
if (conditions.length > 0) {
|
|
546
|
-
sql += " WHERE " + conditions.join(" AND ");
|
|
547
|
-
}
|
|
548
|
-
sql += " ORDER BY started_at DESC";
|
|
549
|
-
if (filter?.limit) {
|
|
550
|
-
sql += " LIMIT ?";
|
|
551
|
-
params.push(filter.limit);
|
|
552
|
-
}
|
|
553
|
-
if (filter?.offset) {
|
|
554
|
-
sql += " OFFSET ?";
|
|
555
|
-
params.push(filter.offset);
|
|
556
|
-
}
|
|
557
|
-
const rows = db2.query(sql).all(...params);
|
|
558
|
-
return rows.map(runFromRow);
|
|
772
|
+
const rows = db2.query("SELECT * FROM results WHERE run_id = ? ORDER BY created_at ASC").all(runId);
|
|
773
|
+
return rows.map(resultFromRow);
|
|
559
774
|
}
|
|
560
|
-
function
|
|
775
|
+
function updateResult(id, updates) {
|
|
561
776
|
const db2 = getDatabase();
|
|
562
|
-
const existing =
|
|
777
|
+
const existing = getResult(id);
|
|
563
778
|
if (!existing) {
|
|
564
|
-
throw new Error(`
|
|
779
|
+
throw new Error(`Result not found: ${id}`);
|
|
565
780
|
}
|
|
566
781
|
const sets = [];
|
|
567
782
|
const params = [];
|
|
@@ -569,128 +784,32 @@ function updateRun(id, updates) {
|
|
|
569
784
|
sets.push("status = ?");
|
|
570
785
|
params.push(updates.status);
|
|
571
786
|
}
|
|
572
|
-
if (updates.
|
|
573
|
-
sets.push("
|
|
574
|
-
params.push(updates.
|
|
575
|
-
}
|
|
576
|
-
if (updates.model !== undefined) {
|
|
577
|
-
sets.push("model = ?");
|
|
578
|
-
params.push(updates.model);
|
|
579
|
-
}
|
|
580
|
-
if (updates.headed !== undefined) {
|
|
581
|
-
sets.push("headed = ?");
|
|
582
|
-
params.push(updates.headed);
|
|
787
|
+
if (updates.reasoning !== undefined) {
|
|
788
|
+
sets.push("reasoning = ?");
|
|
789
|
+
params.push(updates.reasoning);
|
|
583
790
|
}
|
|
584
|
-
if (updates.
|
|
585
|
-
sets.push("
|
|
586
|
-
params.push(updates.
|
|
791
|
+
if (updates.error !== undefined) {
|
|
792
|
+
sets.push("error = ?");
|
|
793
|
+
params.push(updates.error);
|
|
587
794
|
}
|
|
588
|
-
if (updates.
|
|
589
|
-
sets.push("
|
|
590
|
-
params.push(updates.
|
|
795
|
+
if (updates.stepsCompleted !== undefined) {
|
|
796
|
+
sets.push("steps_completed = ?");
|
|
797
|
+
params.push(updates.stepsCompleted);
|
|
591
798
|
}
|
|
592
|
-
if (updates.
|
|
593
|
-
sets.push("
|
|
594
|
-
params.push(updates.
|
|
799
|
+
if (updates.durationMs !== undefined) {
|
|
800
|
+
sets.push("duration_ms = ?");
|
|
801
|
+
params.push(updates.durationMs);
|
|
595
802
|
}
|
|
596
|
-
if (updates.
|
|
597
|
-
sets.push("
|
|
598
|
-
params.push(updates.
|
|
803
|
+
if (updates.tokensUsed !== undefined) {
|
|
804
|
+
sets.push("tokens_used = ?");
|
|
805
|
+
params.push(updates.tokensUsed);
|
|
599
806
|
}
|
|
600
|
-
if (updates.
|
|
601
|
-
sets.push("
|
|
602
|
-
params.push(updates.
|
|
807
|
+
if (updates.costCents !== undefined) {
|
|
808
|
+
sets.push("cost_cents = ?");
|
|
809
|
+
params.push(updates.costCents);
|
|
603
810
|
}
|
|
604
|
-
if (
|
|
605
|
-
|
|
606
|
-
params.push(updates.finished_at);
|
|
607
|
-
}
|
|
608
|
-
if (updates.metadata !== undefined) {
|
|
609
|
-
sets.push("metadata = ?");
|
|
610
|
-
params.push(updates.metadata);
|
|
611
|
-
}
|
|
612
|
-
if (sets.length === 0) {
|
|
613
|
-
return existing;
|
|
614
|
-
}
|
|
615
|
-
params.push(existing.id);
|
|
616
|
-
db2.query(`UPDATE runs SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
617
|
-
return getRun(existing.id);
|
|
618
|
-
}
|
|
619
|
-
function deleteRun(id) {
|
|
620
|
-
const db2 = getDatabase();
|
|
621
|
-
const run = getRun(id);
|
|
622
|
-
if (!run)
|
|
623
|
-
return false;
|
|
624
|
-
const result = db2.query("DELETE FROM runs WHERE id = ?").run(run.id);
|
|
625
|
-
return result.changes > 0;
|
|
626
|
-
}
|
|
627
|
-
// src/db/results.ts
|
|
628
|
-
function createResult(input) {
|
|
629
|
-
const db2 = getDatabase();
|
|
630
|
-
const id = uuid();
|
|
631
|
-
const timestamp = now();
|
|
632
|
-
db2.query(`
|
|
633
|
-
INSERT INTO results (id, run_id, scenario_id, status, reasoning, error, steps_completed, steps_total, duration_ms, model, tokens_used, cost_cents, metadata, created_at)
|
|
634
|
-
VALUES (?, ?, ?, 'skipped', NULL, NULL, 0, ?, 0, ?, 0, 0, '{}', ?)
|
|
635
|
-
`).run(id, input.runId, input.scenarioId, input.stepsTotal, input.model, timestamp);
|
|
636
|
-
return getResult(id);
|
|
637
|
-
}
|
|
638
|
-
function getResult(id) {
|
|
639
|
-
const db2 = getDatabase();
|
|
640
|
-
let row = db2.query("SELECT * FROM results WHERE id = ?").get(id);
|
|
641
|
-
if (row)
|
|
642
|
-
return resultFromRow(row);
|
|
643
|
-
const fullId = resolvePartialId("results", id);
|
|
644
|
-
if (fullId) {
|
|
645
|
-
row = db2.query("SELECT * FROM results WHERE id = ?").get(fullId);
|
|
646
|
-
if (row)
|
|
647
|
-
return resultFromRow(row);
|
|
648
|
-
}
|
|
649
|
-
return null;
|
|
650
|
-
}
|
|
651
|
-
function listResults(runId) {
|
|
652
|
-
const db2 = getDatabase();
|
|
653
|
-
const rows = db2.query("SELECT * FROM results WHERE run_id = ? ORDER BY created_at ASC").all(runId);
|
|
654
|
-
return rows.map(resultFromRow);
|
|
655
|
-
}
|
|
656
|
-
function updateResult(id, updates) {
|
|
657
|
-
const db2 = getDatabase();
|
|
658
|
-
const existing = getResult(id);
|
|
659
|
-
if (!existing) {
|
|
660
|
-
throw new Error(`Result not found: ${id}`);
|
|
661
|
-
}
|
|
662
|
-
const sets = [];
|
|
663
|
-
const params = [];
|
|
664
|
-
if (updates.status !== undefined) {
|
|
665
|
-
sets.push("status = ?");
|
|
666
|
-
params.push(updates.status);
|
|
667
|
-
}
|
|
668
|
-
if (updates.reasoning !== undefined) {
|
|
669
|
-
sets.push("reasoning = ?");
|
|
670
|
-
params.push(updates.reasoning);
|
|
671
|
-
}
|
|
672
|
-
if (updates.error !== undefined) {
|
|
673
|
-
sets.push("error = ?");
|
|
674
|
-
params.push(updates.error);
|
|
675
|
-
}
|
|
676
|
-
if (updates.stepsCompleted !== undefined) {
|
|
677
|
-
sets.push("steps_completed = ?");
|
|
678
|
-
params.push(updates.stepsCompleted);
|
|
679
|
-
}
|
|
680
|
-
if (updates.durationMs !== undefined) {
|
|
681
|
-
sets.push("duration_ms = ?");
|
|
682
|
-
params.push(updates.durationMs);
|
|
683
|
-
}
|
|
684
|
-
if (updates.tokensUsed !== undefined) {
|
|
685
|
-
sets.push("tokens_used = ?");
|
|
686
|
-
params.push(updates.tokensUsed);
|
|
687
|
-
}
|
|
688
|
-
if (updates.costCents !== undefined) {
|
|
689
|
-
sets.push("cost_cents = ?");
|
|
690
|
-
params.push(updates.costCents);
|
|
691
|
-
}
|
|
692
|
-
if (sets.length === 0) {
|
|
693
|
-
return existing;
|
|
811
|
+
if (sets.length === 0) {
|
|
812
|
+
return existing;
|
|
694
813
|
}
|
|
695
814
|
params.push(existing.id);
|
|
696
815
|
db2.query(`UPDATE results SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
@@ -700,14 +819,16 @@ function getResultsByRun(runId) {
|
|
|
700
819
|
return listResults(runId);
|
|
701
820
|
}
|
|
702
821
|
// src/db/screenshots.ts
|
|
822
|
+
init_types();
|
|
823
|
+
init_database();
|
|
703
824
|
function createScreenshot(input) {
|
|
704
825
|
const db2 = getDatabase();
|
|
705
826
|
const id = uuid();
|
|
706
827
|
const timestamp = now();
|
|
707
828
|
db2.query(`
|
|
708
|
-
INSERT INTO screenshots (id, result_id, step_number, action, file_path, width, height, timestamp)
|
|
709
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
710
|
-
`).run(id, input.resultId, input.stepNumber, input.action, input.filePath, input.width, input.height, timestamp);
|
|
829
|
+
INSERT INTO screenshots (id, result_id, step_number, action, file_path, width, height, timestamp, description, page_url, thumbnail_path)
|
|
830
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
831
|
+
`).run(id, input.resultId, input.stepNumber, input.action, input.filePath, input.width, input.height, timestamp, input.description ?? null, input.pageUrl ?? null, input.thumbnailPath ?? null);
|
|
711
832
|
return getScreenshot(id);
|
|
712
833
|
}
|
|
713
834
|
function getScreenshot(id) {
|
|
@@ -724,6 +845,8 @@ function getScreenshotsByResult(resultId) {
|
|
|
724
845
|
return listScreenshots(resultId);
|
|
725
846
|
}
|
|
726
847
|
// src/db/projects.ts
|
|
848
|
+
init_types();
|
|
849
|
+
init_database();
|
|
727
850
|
function createProject(input) {
|
|
728
851
|
const db2 = getDatabase();
|
|
729
852
|
const id = uuid();
|
|
@@ -760,6 +883,8 @@ function ensureProject(name, path) {
|
|
|
760
883
|
return createProject({ name, path });
|
|
761
884
|
}
|
|
762
885
|
// src/db/agents.ts
|
|
886
|
+
init_types();
|
|
887
|
+
init_database();
|
|
763
888
|
function registerAgent(input) {
|
|
764
889
|
const db2 = getDatabase();
|
|
765
890
|
const existing = db2.query("SELECT * FROM agents WHERE name = ?").get(input.name);
|
|
@@ -790,7 +915,136 @@ function listAgents() {
|
|
|
790
915
|
const rows = db2.query("SELECT * FROM agents ORDER BY created_at DESC").all();
|
|
791
916
|
return rows.map(agentFromRow);
|
|
792
917
|
}
|
|
918
|
+
// src/db/schedules.ts
|
|
919
|
+
init_database();
|
|
920
|
+
init_types();
|
|
921
|
+
init_database();
|
|
922
|
+
function createSchedule(input) {
|
|
923
|
+
const db2 = getDatabase();
|
|
924
|
+
const id = uuid();
|
|
925
|
+
const timestamp = now();
|
|
926
|
+
db2.query(`
|
|
927
|
+
INSERT INTO schedules (id, project_id, name, cron_expression, url, scenario_filter, model, headed, parallel, timeout_ms, enabled, created_at, updated_at)
|
|
928
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
|
929
|
+
`).run(id, input.projectId ?? null, input.name, input.cronExpression, input.url, JSON.stringify(input.scenarioFilter ?? {}), input.model ?? null, input.headed ? 1 : 0, input.parallel ?? 1, input.timeoutMs ?? null, timestamp, timestamp);
|
|
930
|
+
return getSchedule(id);
|
|
931
|
+
}
|
|
932
|
+
function getSchedule(id) {
|
|
933
|
+
const db2 = getDatabase();
|
|
934
|
+
let row = db2.query("SELECT * FROM schedules WHERE id = ?").get(id);
|
|
935
|
+
if (row)
|
|
936
|
+
return scheduleFromRow(row);
|
|
937
|
+
const fullId = resolvePartialId("schedules", id);
|
|
938
|
+
if (fullId) {
|
|
939
|
+
row = db2.query("SELECT * FROM schedules WHERE id = ?").get(fullId);
|
|
940
|
+
if (row)
|
|
941
|
+
return scheduleFromRow(row);
|
|
942
|
+
}
|
|
943
|
+
return null;
|
|
944
|
+
}
|
|
945
|
+
function listSchedules(filter) {
|
|
946
|
+
const db2 = getDatabase();
|
|
947
|
+
const conditions = [];
|
|
948
|
+
const params = [];
|
|
949
|
+
if (filter?.projectId) {
|
|
950
|
+
conditions.push("project_id = ?");
|
|
951
|
+
params.push(filter.projectId);
|
|
952
|
+
}
|
|
953
|
+
if (filter?.enabled !== undefined) {
|
|
954
|
+
conditions.push("enabled = ?");
|
|
955
|
+
params.push(filter.enabled ? 1 : 0);
|
|
956
|
+
}
|
|
957
|
+
let sql = "SELECT * FROM schedules";
|
|
958
|
+
if (conditions.length > 0) {
|
|
959
|
+
sql += " WHERE " + conditions.join(" AND ");
|
|
960
|
+
}
|
|
961
|
+
sql += " ORDER BY created_at DESC";
|
|
962
|
+
if (filter?.limit) {
|
|
963
|
+
sql += " LIMIT ?";
|
|
964
|
+
params.push(filter.limit);
|
|
965
|
+
}
|
|
966
|
+
if (filter?.offset) {
|
|
967
|
+
sql += " OFFSET ?";
|
|
968
|
+
params.push(filter.offset);
|
|
969
|
+
}
|
|
970
|
+
const rows = db2.query(sql).all(...params);
|
|
971
|
+
return rows.map(scheduleFromRow);
|
|
972
|
+
}
|
|
973
|
+
function updateSchedule(id, input) {
|
|
974
|
+
const db2 = getDatabase();
|
|
975
|
+
const existing = getSchedule(id);
|
|
976
|
+
if (!existing) {
|
|
977
|
+
throw new ScheduleNotFoundError(id);
|
|
978
|
+
}
|
|
979
|
+
const sets = [];
|
|
980
|
+
const params = [];
|
|
981
|
+
if (input.name !== undefined) {
|
|
982
|
+
sets.push("name = ?");
|
|
983
|
+
params.push(input.name);
|
|
984
|
+
}
|
|
985
|
+
if (input.cronExpression !== undefined) {
|
|
986
|
+
sets.push("cron_expression = ?");
|
|
987
|
+
params.push(input.cronExpression);
|
|
988
|
+
}
|
|
989
|
+
if (input.url !== undefined) {
|
|
990
|
+
sets.push("url = ?");
|
|
991
|
+
params.push(input.url);
|
|
992
|
+
}
|
|
993
|
+
if (input.scenarioFilter !== undefined) {
|
|
994
|
+
sets.push("scenario_filter = ?");
|
|
995
|
+
params.push(JSON.stringify(input.scenarioFilter));
|
|
996
|
+
}
|
|
997
|
+
if (input.model !== undefined) {
|
|
998
|
+
sets.push("model = ?");
|
|
999
|
+
params.push(input.model);
|
|
1000
|
+
}
|
|
1001
|
+
if (input.headed !== undefined) {
|
|
1002
|
+
sets.push("headed = ?");
|
|
1003
|
+
params.push(input.headed ? 1 : 0);
|
|
1004
|
+
}
|
|
1005
|
+
if (input.parallel !== undefined) {
|
|
1006
|
+
sets.push("parallel = ?");
|
|
1007
|
+
params.push(input.parallel);
|
|
1008
|
+
}
|
|
1009
|
+
if (input.timeoutMs !== undefined) {
|
|
1010
|
+
sets.push("timeout_ms = ?");
|
|
1011
|
+
params.push(input.timeoutMs);
|
|
1012
|
+
}
|
|
1013
|
+
if (input.enabled !== undefined) {
|
|
1014
|
+
sets.push("enabled = ?");
|
|
1015
|
+
params.push(input.enabled ? 1 : 0);
|
|
1016
|
+
}
|
|
1017
|
+
if (sets.length === 0) {
|
|
1018
|
+
return existing;
|
|
1019
|
+
}
|
|
1020
|
+
sets.push("updated_at = ?");
|
|
1021
|
+
params.push(now());
|
|
1022
|
+
params.push(existing.id);
|
|
1023
|
+
db2.query(`UPDATE schedules SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
1024
|
+
return getSchedule(existing.id);
|
|
1025
|
+
}
|
|
1026
|
+
function deleteSchedule(id) {
|
|
1027
|
+
const db2 = getDatabase();
|
|
1028
|
+
const schedule = getSchedule(id);
|
|
1029
|
+
if (!schedule)
|
|
1030
|
+
return false;
|
|
1031
|
+
const result = db2.query("DELETE FROM schedules WHERE id = ?").run(schedule.id);
|
|
1032
|
+
return result.changes > 0;
|
|
1033
|
+
}
|
|
1034
|
+
function getEnabledSchedules() {
|
|
1035
|
+
const db2 = getDatabase();
|
|
1036
|
+
const rows = db2.query("SELECT * FROM schedules WHERE enabled = 1 ORDER BY created_at DESC").all();
|
|
1037
|
+
return rows.map(scheduleFromRow);
|
|
1038
|
+
}
|
|
1039
|
+
function updateLastRun(id, runId, nextRunAt) {
|
|
1040
|
+
const db2 = getDatabase();
|
|
1041
|
+
const timestamp = now();
|
|
1042
|
+
db2.query(`
|
|
1043
|
+
UPDATE schedules SET last_run_id = ?, last_run_at = ?, next_run_at = ?, updated_at = ? WHERE id = ?
|
|
1044
|
+
`).run(runId, timestamp, nextRunAt, timestamp, id);
|
|
1045
|
+
}
|
|
793
1046
|
// src/lib/config.ts
|
|
1047
|
+
init_types();
|
|
794
1048
|
import { homedir as homedir2 } from "os";
|
|
795
1049
|
import { join as join2 } from "path";
|
|
796
1050
|
import { readFileSync, existsSync as existsSync2 } from "fs";
|
|
@@ -851,6 +1105,7 @@ function resolveModel(nameOrId) {
|
|
|
851
1105
|
return nameOrId;
|
|
852
1106
|
}
|
|
853
1107
|
// src/lib/browser.ts
|
|
1108
|
+
init_types();
|
|
854
1109
|
import { chromium } from "playwright";
|
|
855
1110
|
import { execSync } from "child_process";
|
|
856
1111
|
var DEFAULT_VIEWPORT = { width: 1280, height: 720 };
|
|
@@ -955,7 +1210,7 @@ async function installBrowser() {
|
|
|
955
1210
|
}
|
|
956
1211
|
}
|
|
957
1212
|
// src/lib/screenshotter.ts
|
|
958
|
-
import { mkdirSync as mkdirSync2, existsSync as existsSync3 } from "fs";
|
|
1213
|
+
import { mkdirSync as mkdirSync2, existsSync as existsSync3, writeFileSync } from "fs";
|
|
959
1214
|
import { join as join3 } from "path";
|
|
960
1215
|
import { homedir as homedir3 } from "os";
|
|
961
1216
|
function slugify(text) {
|
|
@@ -964,16 +1219,63 @@ function slugify(text) {
|
|
|
964
1219
|
function generateFilename(stepNumber, action) {
|
|
965
1220
|
const padded = String(stepNumber).padStart(3, "0");
|
|
966
1221
|
const slug = slugify(action);
|
|
967
|
-
return `${padded}
|
|
1222
|
+
return `${padded}_${slug}.png`;
|
|
1223
|
+
}
|
|
1224
|
+
function formatDate(date) {
|
|
1225
|
+
return date.toISOString().slice(0, 10);
|
|
1226
|
+
}
|
|
1227
|
+
function formatTime(date) {
|
|
1228
|
+
return date.toISOString().slice(11, 19).replace(/:/g, "-");
|
|
968
1229
|
}
|
|
969
|
-
function getScreenshotDir(baseDir, runId, scenarioSlug) {
|
|
970
|
-
|
|
1230
|
+
function getScreenshotDir(baseDir, runId, scenarioSlug, projectName, timestamp) {
|
|
1231
|
+
const now2 = timestamp ?? new Date;
|
|
1232
|
+
const project = projectName ?? "default";
|
|
1233
|
+
const dateDir = formatDate(now2);
|
|
1234
|
+
const timeDir = `${formatTime(now2)}_${runId.slice(0, 8)}`;
|
|
1235
|
+
return join3(baseDir, project, dateDir, timeDir, scenarioSlug);
|
|
971
1236
|
}
|
|
972
1237
|
function ensureDir(dirPath) {
|
|
973
1238
|
if (!existsSync3(dirPath)) {
|
|
974
1239
|
mkdirSync2(dirPath, { recursive: true });
|
|
975
1240
|
}
|
|
976
1241
|
}
|
|
1242
|
+
function writeMetaSidecar(screenshotPath, meta) {
|
|
1243
|
+
const metaPath = screenshotPath.replace(/\.png$/, ".meta.json").replace(/\.jpeg$/, ".meta.json");
|
|
1244
|
+
try {
|
|
1245
|
+
writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf-8");
|
|
1246
|
+
} catch {}
|
|
1247
|
+
}
|
|
1248
|
+
function writeRunMeta(dir, meta) {
|
|
1249
|
+
ensureDir(dir);
|
|
1250
|
+
try {
|
|
1251
|
+
writeFileSync(join3(dir, "_run-meta.json"), JSON.stringify(meta, null, 2), "utf-8");
|
|
1252
|
+
} catch {}
|
|
1253
|
+
}
|
|
1254
|
+
function writeScenarioMeta(dir, meta) {
|
|
1255
|
+
ensureDir(dir);
|
|
1256
|
+
try {
|
|
1257
|
+
writeFileSync(join3(dir, "_scenario-meta.json"), JSON.stringify(meta, null, 2), "utf-8");
|
|
1258
|
+
} catch {}
|
|
1259
|
+
}
|
|
1260
|
+
async function generateThumbnail(page, screenshotDir, filename) {
|
|
1261
|
+
try {
|
|
1262
|
+
const thumbDir = join3(screenshotDir, "_thumbnail");
|
|
1263
|
+
ensureDir(thumbDir);
|
|
1264
|
+
const thumbFilename = filename.replace(/\.(png|jpeg)$/, ".thumb.$1");
|
|
1265
|
+
const thumbPath = join3(thumbDir, thumbFilename);
|
|
1266
|
+
const viewport = page.viewportSize();
|
|
1267
|
+
if (viewport) {
|
|
1268
|
+
await page.screenshot({
|
|
1269
|
+
path: thumbPath,
|
|
1270
|
+
type: "png",
|
|
1271
|
+
clip: { x: 0, y: 0, width: Math.min(viewport.width, 1280), height: Math.min(viewport.height, 720) }
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
return thumbPath;
|
|
1275
|
+
} catch {
|
|
1276
|
+
return null;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
977
1279
|
var DEFAULT_BASE_DIR = join3(homedir3(), ".testers", "screenshots");
|
|
978
1280
|
|
|
979
1281
|
class Screenshotter {
|
|
@@ -981,15 +1283,20 @@ class Screenshotter {
|
|
|
981
1283
|
format;
|
|
982
1284
|
quality;
|
|
983
1285
|
fullPage;
|
|
1286
|
+
projectName;
|
|
1287
|
+
runTimestamp;
|
|
984
1288
|
constructor(options = {}) {
|
|
985
1289
|
this.baseDir = options.baseDir ?? DEFAULT_BASE_DIR;
|
|
986
1290
|
this.format = options.format ?? "png";
|
|
987
1291
|
this.quality = options.quality ?? 90;
|
|
988
1292
|
this.fullPage = options.fullPage ?? false;
|
|
1293
|
+
this.projectName = options.projectName ?? "default";
|
|
1294
|
+
this.runTimestamp = new Date;
|
|
989
1295
|
}
|
|
990
1296
|
async capture(page, options) {
|
|
991
|
-
const
|
|
992
|
-
const
|
|
1297
|
+
const action = options.description ?? options.action;
|
|
1298
|
+
const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug, this.projectName, this.runTimestamp);
|
|
1299
|
+
const filename = generateFilename(options.stepNumber, action);
|
|
993
1300
|
const filePath = join3(dir, filename);
|
|
994
1301
|
ensureDir(dir);
|
|
995
1302
|
await page.screenshot({
|
|
@@ -999,16 +1306,32 @@ class Screenshotter {
|
|
|
999
1306
|
quality: this.format === "jpeg" ? this.quality : undefined
|
|
1000
1307
|
});
|
|
1001
1308
|
const viewport = page.viewportSize() ?? { width: 0, height: 0 };
|
|
1309
|
+
const pageUrl = page.url();
|
|
1310
|
+
const timestamp = new Date().toISOString();
|
|
1311
|
+
writeMetaSidecar(filePath, {
|
|
1312
|
+
stepNumber: options.stepNumber,
|
|
1313
|
+
action: options.action,
|
|
1314
|
+
description: options.description ?? null,
|
|
1315
|
+
pageUrl,
|
|
1316
|
+
viewport,
|
|
1317
|
+
timestamp,
|
|
1318
|
+
filePath
|
|
1319
|
+
});
|
|
1320
|
+
const thumbnailPath = await generateThumbnail(page, dir, filename);
|
|
1002
1321
|
return {
|
|
1003
1322
|
filePath,
|
|
1004
1323
|
width: viewport.width,
|
|
1005
1324
|
height: viewport.height,
|
|
1006
|
-
timestamp
|
|
1325
|
+
timestamp,
|
|
1326
|
+
description: options.description ?? null,
|
|
1327
|
+
pageUrl,
|
|
1328
|
+
thumbnailPath
|
|
1007
1329
|
};
|
|
1008
1330
|
}
|
|
1009
1331
|
async captureFullPage(page, options) {
|
|
1010
|
-
const
|
|
1011
|
-
const
|
|
1332
|
+
const action = options.description ?? options.action;
|
|
1333
|
+
const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug, this.projectName, this.runTimestamp);
|
|
1334
|
+
const filename = generateFilename(options.stepNumber, action);
|
|
1012
1335
|
const filePath = join3(dir, filename);
|
|
1013
1336
|
ensureDir(dir);
|
|
1014
1337
|
await page.screenshot({
|
|
@@ -1018,16 +1341,32 @@ class Screenshotter {
|
|
|
1018
1341
|
quality: this.format === "jpeg" ? this.quality : undefined
|
|
1019
1342
|
});
|
|
1020
1343
|
const viewport = page.viewportSize() ?? { width: 0, height: 0 };
|
|
1344
|
+
const pageUrl = page.url();
|
|
1345
|
+
const timestamp = new Date().toISOString();
|
|
1346
|
+
writeMetaSidecar(filePath, {
|
|
1347
|
+
stepNumber: options.stepNumber,
|
|
1348
|
+
action: options.action,
|
|
1349
|
+
description: options.description ?? null,
|
|
1350
|
+
pageUrl,
|
|
1351
|
+
viewport,
|
|
1352
|
+
timestamp,
|
|
1353
|
+
filePath
|
|
1354
|
+
});
|
|
1355
|
+
const thumbnailPath = await generateThumbnail(page, dir, filename);
|
|
1021
1356
|
return {
|
|
1022
1357
|
filePath,
|
|
1023
1358
|
width: viewport.width,
|
|
1024
1359
|
height: viewport.height,
|
|
1025
|
-
timestamp
|
|
1360
|
+
timestamp,
|
|
1361
|
+
description: options.description ?? null,
|
|
1362
|
+
pageUrl,
|
|
1363
|
+
thumbnailPath
|
|
1026
1364
|
};
|
|
1027
1365
|
}
|
|
1028
1366
|
async captureElement(page, selector, options) {
|
|
1029
|
-
const
|
|
1030
|
-
const
|
|
1367
|
+
const action = options.description ?? options.action;
|
|
1368
|
+
const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug, this.projectName, this.runTimestamp);
|
|
1369
|
+
const filename = generateFilename(options.stepNumber, action);
|
|
1031
1370
|
const filePath = join3(dir, filename);
|
|
1032
1371
|
ensureDir(dir);
|
|
1033
1372
|
await page.locator(selector).screenshot({
|
|
@@ -1036,15 +1375,30 @@ class Screenshotter {
|
|
|
1036
1375
|
quality: this.format === "jpeg" ? this.quality : undefined
|
|
1037
1376
|
});
|
|
1038
1377
|
const viewport = page.viewportSize() ?? { width: 0, height: 0 };
|
|
1378
|
+
const pageUrl = page.url();
|
|
1379
|
+
const timestamp = new Date().toISOString();
|
|
1380
|
+
writeMetaSidecar(filePath, {
|
|
1381
|
+
stepNumber: options.stepNumber,
|
|
1382
|
+
action: options.action,
|
|
1383
|
+
description: options.description ?? null,
|
|
1384
|
+
pageUrl,
|
|
1385
|
+
viewport,
|
|
1386
|
+
timestamp,
|
|
1387
|
+
filePath
|
|
1388
|
+
});
|
|
1039
1389
|
return {
|
|
1040
1390
|
filePath,
|
|
1041
1391
|
width: viewport.width,
|
|
1042
1392
|
height: viewport.height,
|
|
1043
|
-
timestamp
|
|
1393
|
+
timestamp,
|
|
1394
|
+
description: options.description ?? null,
|
|
1395
|
+
pageUrl,
|
|
1396
|
+
thumbnailPath: null
|
|
1044
1397
|
};
|
|
1045
1398
|
}
|
|
1046
1399
|
}
|
|
1047
1400
|
// src/lib/ai-client.ts
|
|
1401
|
+
init_types();
|
|
1048
1402
|
import Anthropic from "@anthropic-ai/sdk";
|
|
1049
1403
|
function resolveModel2(nameOrPreset) {
|
|
1050
1404
|
if (nameOrPreset in MODEL_MAP) {
|
|
@@ -1216,69 +1570,190 @@ var BROWSER_TOOLS = [
|
|
|
1216
1570
|
}
|
|
1217
1571
|
},
|
|
1218
1572
|
{
|
|
1219
|
-
name: "
|
|
1220
|
-
description: "
|
|
1573
|
+
name: "scroll",
|
|
1574
|
+
description: "Scroll the page up or down by a given amount of pixels.",
|
|
1221
1575
|
input_schema: {
|
|
1222
1576
|
type: "object",
|
|
1223
1577
|
properties: {
|
|
1224
|
-
|
|
1578
|
+
direction: {
|
|
1225
1579
|
type: "string",
|
|
1226
|
-
enum: ["
|
|
1227
|
-
description: "
|
|
1580
|
+
enum: ["up", "down"],
|
|
1581
|
+
description: "Direction to scroll."
|
|
1228
1582
|
},
|
|
1229
|
-
|
|
1583
|
+
amount: {
|
|
1584
|
+
type: "number",
|
|
1585
|
+
description: "Number of pixels to scroll (default: 500)."
|
|
1586
|
+
}
|
|
1587
|
+
},
|
|
1588
|
+
required: ["direction"]
|
|
1589
|
+
}
|
|
1590
|
+
},
|
|
1591
|
+
{
|
|
1592
|
+
name: "get_page_html",
|
|
1593
|
+
description: "Get simplified HTML of the page body content, truncated to 8000 characters.",
|
|
1594
|
+
input_schema: {
|
|
1595
|
+
type: "object",
|
|
1596
|
+
properties: {},
|
|
1597
|
+
required: []
|
|
1598
|
+
}
|
|
1599
|
+
},
|
|
1600
|
+
{
|
|
1601
|
+
name: "get_elements",
|
|
1602
|
+
description: "List elements matching a CSS selector with their text, tag name, and key attributes (max 20 results).",
|
|
1603
|
+
input_schema: {
|
|
1604
|
+
type: "object",
|
|
1605
|
+
properties: {
|
|
1606
|
+
selector: {
|
|
1230
1607
|
type: "string",
|
|
1231
|
-
description: "
|
|
1608
|
+
description: "CSS selector to match elements."
|
|
1232
1609
|
}
|
|
1233
1610
|
},
|
|
1234
|
-
required: ["
|
|
1611
|
+
required: ["selector"]
|
|
1235
1612
|
}
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1613
|
+
},
|
|
1614
|
+
{
|
|
1615
|
+
name: "wait_for_navigation",
|
|
1616
|
+
description: "Wait for page navigation/load to complete (network idle).",
|
|
1617
|
+
input_schema: {
|
|
1618
|
+
type: "object",
|
|
1619
|
+
properties: {
|
|
1620
|
+
timeout: {
|
|
1621
|
+
type: "number",
|
|
1622
|
+
description: "Maximum time to wait in milliseconds (default: 10000)."
|
|
1623
|
+
}
|
|
1624
|
+
},
|
|
1625
|
+
required: []
|
|
1626
|
+
}
|
|
1627
|
+
},
|
|
1628
|
+
{
|
|
1629
|
+
name: "get_page_title",
|
|
1630
|
+
description: "Get the document title of the current page.",
|
|
1631
|
+
input_schema: {
|
|
1632
|
+
type: "object",
|
|
1633
|
+
properties: {},
|
|
1634
|
+
required: []
|
|
1635
|
+
}
|
|
1636
|
+
},
|
|
1637
|
+
{
|
|
1638
|
+
name: "count_elements",
|
|
1639
|
+
description: "Count the number of elements matching a CSS selector.",
|
|
1640
|
+
input_schema: {
|
|
1641
|
+
type: "object",
|
|
1642
|
+
properties: {
|
|
1643
|
+
selector: {
|
|
1644
|
+
type: "string",
|
|
1645
|
+
description: "CSS selector to count matching elements."
|
|
1646
|
+
}
|
|
1647
|
+
},
|
|
1648
|
+
required: ["selector"]
|
|
1649
|
+
}
|
|
1650
|
+
},
|
|
1651
|
+
{
|
|
1652
|
+
name: "hover",
|
|
1653
|
+
description: "Hover over an element matching the given CSS selector.",
|
|
1654
|
+
input_schema: {
|
|
1655
|
+
type: "object",
|
|
1656
|
+
properties: {
|
|
1657
|
+
selector: {
|
|
1658
|
+
type: "string",
|
|
1659
|
+
description: "CSS selector of the element to hover over."
|
|
1660
|
+
}
|
|
1661
|
+
},
|
|
1662
|
+
required: ["selector"]
|
|
1663
|
+
}
|
|
1664
|
+
},
|
|
1665
|
+
{
|
|
1666
|
+
name: "check",
|
|
1667
|
+
description: "Check a checkbox matching the given CSS selector.",
|
|
1668
|
+
input_schema: {
|
|
1669
|
+
type: "object",
|
|
1670
|
+
properties: {
|
|
1671
|
+
selector: {
|
|
1672
|
+
type: "string",
|
|
1673
|
+
description: "CSS selector of the checkbox to check."
|
|
1674
|
+
}
|
|
1675
|
+
},
|
|
1676
|
+
required: ["selector"]
|
|
1677
|
+
}
|
|
1678
|
+
},
|
|
1679
|
+
{
|
|
1680
|
+
name: "uncheck",
|
|
1681
|
+
description: "Uncheck a checkbox matching the given CSS selector.",
|
|
1682
|
+
input_schema: {
|
|
1683
|
+
type: "object",
|
|
1684
|
+
properties: {
|
|
1685
|
+
selector: {
|
|
1686
|
+
type: "string",
|
|
1687
|
+
description: "CSS selector of the checkbox to uncheck."
|
|
1688
|
+
}
|
|
1689
|
+
},
|
|
1690
|
+
required: ["selector"]
|
|
1691
|
+
}
|
|
1692
|
+
},
|
|
1693
|
+
{
|
|
1694
|
+
name: "report_result",
|
|
1695
|
+
description: "Report the final test result. Call this when you have completed testing the scenario. This MUST be the last tool you call.",
|
|
1696
|
+
input_schema: {
|
|
1697
|
+
type: "object",
|
|
1698
|
+
properties: {
|
|
1699
|
+
status: {
|
|
1700
|
+
type: "string",
|
|
1701
|
+
enum: ["passed", "failed"],
|
|
1702
|
+
description: "Whether the test scenario passed or failed."
|
|
1703
|
+
},
|
|
1704
|
+
reasoning: {
|
|
1705
|
+
type: "string",
|
|
1706
|
+
description: "Detailed explanation of why the test passed or failed, including any issues found."
|
|
1707
|
+
}
|
|
1708
|
+
},
|
|
1709
|
+
required: ["status", "reasoning"]
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
];
|
|
1713
|
+
async function executeTool(page, screenshotter, toolName, toolInput, context) {
|
|
1714
|
+
try {
|
|
1715
|
+
switch (toolName) {
|
|
1716
|
+
case "navigate": {
|
|
1717
|
+
const url = toolInput.url;
|
|
1718
|
+
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
1719
|
+
const screenshot = await screenshotter.capture(page, {
|
|
1720
|
+
runId: context.runId,
|
|
1721
|
+
scenarioSlug: context.scenarioSlug,
|
|
1722
|
+
stepNumber: context.stepNumber,
|
|
1723
|
+
action: "navigate"
|
|
1724
|
+
});
|
|
1725
|
+
return {
|
|
1726
|
+
result: `Navigated to ${url}`,
|
|
1727
|
+
screenshot
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
case "click": {
|
|
1731
|
+
const selector = toolInput.selector;
|
|
1732
|
+
await page.click(selector);
|
|
1733
|
+
const screenshot = await screenshotter.capture(page, {
|
|
1734
|
+
runId: context.runId,
|
|
1735
|
+
scenarioSlug: context.scenarioSlug,
|
|
1736
|
+
stepNumber: context.stepNumber,
|
|
1737
|
+
action: "click"
|
|
1738
|
+
});
|
|
1739
|
+
return {
|
|
1740
|
+
result: `Clicked element: ${selector}`,
|
|
1741
|
+
screenshot
|
|
1742
|
+
};
|
|
1743
|
+
}
|
|
1744
|
+
case "fill": {
|
|
1745
|
+
const selector = toolInput.selector;
|
|
1746
|
+
const value = toolInput.value;
|
|
1747
|
+
await page.fill(selector, value);
|
|
1748
|
+
return {
|
|
1749
|
+
result: `Filled "${selector}" with value`
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
case "select_option": {
|
|
1753
|
+
const selector = toolInput.selector;
|
|
1754
|
+
const value = toolInput.value;
|
|
1755
|
+
await page.selectOption(selector, value);
|
|
1756
|
+
return {
|
|
1282
1757
|
result: `Selected option "${value}" in ${selector}`
|
|
1283
1758
|
};
|
|
1284
1759
|
}
|
|
@@ -1346,6 +1821,113 @@ async function executeTool(page, screenshotter, toolName, toolInput, context) {
|
|
|
1346
1821
|
return { result: "false" };
|
|
1347
1822
|
}
|
|
1348
1823
|
}
|
|
1824
|
+
case "scroll": {
|
|
1825
|
+
const direction = toolInput.direction;
|
|
1826
|
+
const amount = typeof toolInput.amount === "number" ? toolInput.amount : 500;
|
|
1827
|
+
const scrollY = direction === "down" ? amount : -amount;
|
|
1828
|
+
await page.evaluate((y) => window.scrollBy(0, y), scrollY);
|
|
1829
|
+
const screenshot = await screenshotter.capture(page, {
|
|
1830
|
+
runId: context.runId,
|
|
1831
|
+
scenarioSlug: context.scenarioSlug,
|
|
1832
|
+
stepNumber: context.stepNumber,
|
|
1833
|
+
action: "scroll"
|
|
1834
|
+
});
|
|
1835
|
+
return {
|
|
1836
|
+
result: `Scrolled ${direction} by ${amount}px`,
|
|
1837
|
+
screenshot
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
case "get_page_html": {
|
|
1841
|
+
const html = await page.evaluate(() => document.body.innerHTML);
|
|
1842
|
+
const truncated = html.length > 8000 ? html.slice(0, 8000) + "..." : html;
|
|
1843
|
+
return {
|
|
1844
|
+
result: truncated
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
case "get_elements": {
|
|
1848
|
+
const selector = toolInput.selector;
|
|
1849
|
+
const allElements = await page.locator(selector).all();
|
|
1850
|
+
const elements = allElements.slice(0, 20);
|
|
1851
|
+
const results = [];
|
|
1852
|
+
for (let i = 0;i < elements.length; i++) {
|
|
1853
|
+
const el = elements[i];
|
|
1854
|
+
const tagName = await el.evaluate((e) => e.tagName.toLowerCase());
|
|
1855
|
+
const textContent = await el.textContent() ?? "";
|
|
1856
|
+
const trimmedText = textContent.trim().slice(0, 100);
|
|
1857
|
+
const id = await el.getAttribute("id");
|
|
1858
|
+
const className = await el.getAttribute("class");
|
|
1859
|
+
const href = await el.getAttribute("href");
|
|
1860
|
+
const type = await el.getAttribute("type");
|
|
1861
|
+
const placeholder = await el.getAttribute("placeholder");
|
|
1862
|
+
const ariaLabel = await el.getAttribute("aria-label");
|
|
1863
|
+
const attrs = [];
|
|
1864
|
+
if (id)
|
|
1865
|
+
attrs.push(`id="${id}"`);
|
|
1866
|
+
if (className)
|
|
1867
|
+
attrs.push(`class="${className}"`);
|
|
1868
|
+
if (href)
|
|
1869
|
+
attrs.push(`href="${href}"`);
|
|
1870
|
+
if (type)
|
|
1871
|
+
attrs.push(`type="${type}"`);
|
|
1872
|
+
if (placeholder)
|
|
1873
|
+
attrs.push(`placeholder="${placeholder}"`);
|
|
1874
|
+
if (ariaLabel)
|
|
1875
|
+
attrs.push(`aria-label="${ariaLabel}"`);
|
|
1876
|
+
results.push(`[${i}] <${tagName}${attrs.length ? " " + attrs.join(" ") : ""}> ${trimmedText}`);
|
|
1877
|
+
}
|
|
1878
|
+
return {
|
|
1879
|
+
result: results.length > 0 ? results.join(`
|
|
1880
|
+
`) : `No elements found matching "${selector}"`
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
case "wait_for_navigation": {
|
|
1884
|
+
const timeout = typeof toolInput.timeout === "number" ? toolInput.timeout : 1e4;
|
|
1885
|
+
await page.waitForLoadState("networkidle", { timeout });
|
|
1886
|
+
return {
|
|
1887
|
+
result: "Navigation/load completed"
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
case "get_page_title": {
|
|
1891
|
+
const title = await page.title();
|
|
1892
|
+
return {
|
|
1893
|
+
result: title || "(no title)"
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
case "count_elements": {
|
|
1897
|
+
const selector = toolInput.selector;
|
|
1898
|
+
const count = await page.locator(selector).count();
|
|
1899
|
+
return {
|
|
1900
|
+
result: `${count} element(s) matching "${selector}"`
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1903
|
+
case "hover": {
|
|
1904
|
+
const selector = toolInput.selector;
|
|
1905
|
+
await page.hover(selector);
|
|
1906
|
+
const screenshot = await screenshotter.capture(page, {
|
|
1907
|
+
runId: context.runId,
|
|
1908
|
+
scenarioSlug: context.scenarioSlug,
|
|
1909
|
+
stepNumber: context.stepNumber,
|
|
1910
|
+
action: "hover"
|
|
1911
|
+
});
|
|
1912
|
+
return {
|
|
1913
|
+
result: `Hovered over: ${selector}`,
|
|
1914
|
+
screenshot
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
case "check": {
|
|
1918
|
+
const selector = toolInput.selector;
|
|
1919
|
+
await page.check(selector);
|
|
1920
|
+
return {
|
|
1921
|
+
result: `Checked checkbox: ${selector}`
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
case "uncheck": {
|
|
1925
|
+
const selector = toolInput.selector;
|
|
1926
|
+
await page.uncheck(selector);
|
|
1927
|
+
return {
|
|
1928
|
+
result: `Unchecked checkbox: ${selector}`
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1349
1931
|
case "report_result": {
|
|
1350
1932
|
const status = toolInput.status;
|
|
1351
1933
|
const reasoning = toolInput.reasoning;
|
|
@@ -1372,13 +1954,26 @@ async function runAgentLoop(options) {
|
|
|
1372
1954
|
maxTurns = 30
|
|
1373
1955
|
} = options;
|
|
1374
1956
|
const systemPrompt = [
|
|
1375
|
-
"You are
|
|
1376
|
-
"
|
|
1377
|
-
"
|
|
1378
|
-
"
|
|
1379
|
-
"
|
|
1380
|
-
"
|
|
1381
|
-
|
|
1957
|
+
"You are an expert QA testing agent. Your job is to thoroughly test web application scenarios.",
|
|
1958
|
+
"You have browser tools to navigate, interact with, and inspect web pages.",
|
|
1959
|
+
"",
|
|
1960
|
+
"Strategy:",
|
|
1961
|
+
"1. First navigate to the target page and take a screenshot to understand the layout",
|
|
1962
|
+
"2. If you can't find an element, use get_elements or get_page_html to discover selectors",
|
|
1963
|
+
"3. Use scroll to discover content below the fold",
|
|
1964
|
+
"4. Use wait_for or wait_for_navigation after actions that trigger page loads",
|
|
1965
|
+
"5. Take screenshots after every meaningful state change",
|
|
1966
|
+
"6. Use assert_text and assert_visible to verify expected outcomes",
|
|
1967
|
+
"7. When done testing, call report_result with detailed pass/fail reasoning",
|
|
1968
|
+
"",
|
|
1969
|
+
"Tips:",
|
|
1970
|
+
"- Try multiple selector strategies: by text, by role, by class, by id",
|
|
1971
|
+
"- If a click triggers navigation, use wait_for_navigation after",
|
|
1972
|
+
"- For forms, fill all fields before submitting",
|
|
1973
|
+
"- Check for error messages after form submissions",
|
|
1974
|
+
"- Verify both positive and negative states"
|
|
1975
|
+
].join(`
|
|
1976
|
+
`);
|
|
1382
1977
|
const userParts = [
|
|
1383
1978
|
`**Scenario:** ${scenario.name}`,
|
|
1384
1979
|
`**Description:** ${scenario.description}`
|
|
@@ -1481,6 +2076,7 @@ function createClient(apiKey) {
|
|
|
1481
2076
|
return new Anthropic({ apiKey: key });
|
|
1482
2077
|
}
|
|
1483
2078
|
// src/lib/runner.ts
|
|
2079
|
+
init_runs();
|
|
1484
2080
|
var eventHandler = null;
|
|
1485
2081
|
function onRunEvent(handler) {
|
|
1486
2082
|
eventHandler = handler;
|
|
@@ -1528,7 +2124,10 @@ async function runSingleScenario(scenario, runId, options) {
|
|
|
1528
2124
|
action: ss.action,
|
|
1529
2125
|
filePath: ss.filePath,
|
|
1530
2126
|
width: ss.width,
|
|
1531
|
-
height: ss.height
|
|
2127
|
+
height: ss.height,
|
|
2128
|
+
description: ss.description,
|
|
2129
|
+
pageUrl: ss.pageUrl,
|
|
2130
|
+
thumbnailPath: ss.thumbnailPath
|
|
1532
2131
|
});
|
|
1533
2132
|
emit({ type: "screenshot:captured", screenshotPath: ss.filePath, scenarioId: scenario.id, runId });
|
|
1534
2133
|
}
|
|
@@ -2315,6 +2914,7 @@ import { Database as Database2 } from "bun:sqlite";
|
|
|
2315
2914
|
import { existsSync as existsSync4 } from "fs";
|
|
2316
2915
|
import { join as join4 } from "path";
|
|
2317
2916
|
import { homedir as homedir4 } from "os";
|
|
2917
|
+
init_types();
|
|
2318
2918
|
function resolveTodosDbPath() {
|
|
2319
2919
|
const envPath = process.env["TODOS_DB_PATH"];
|
|
2320
2920
|
if (envPath)
|
|
@@ -2425,16 +3025,1406 @@ function markTodoDone(taskId) {
|
|
|
2425
3025
|
db2.close();
|
|
2426
3026
|
}
|
|
2427
3027
|
}
|
|
3028
|
+
// src/lib/scheduler.ts
|
|
3029
|
+
init_types();
|
|
3030
|
+
function parseCronField(field, min, max) {
|
|
3031
|
+
const results = new Set;
|
|
3032
|
+
const parts = field.split(",");
|
|
3033
|
+
for (const part of parts) {
|
|
3034
|
+
const trimmed = part.trim();
|
|
3035
|
+
if (trimmed.includes("/")) {
|
|
3036
|
+
const slashParts = trimmed.split("/");
|
|
3037
|
+
const rangePart = slashParts[0] ?? "*";
|
|
3038
|
+
const stepStr = slashParts[1] ?? "1";
|
|
3039
|
+
const step = parseInt(stepStr, 10);
|
|
3040
|
+
if (isNaN(step) || step <= 0) {
|
|
3041
|
+
throw new Error(`Invalid step value in cron field: ${field}`);
|
|
3042
|
+
}
|
|
3043
|
+
let start;
|
|
3044
|
+
let end;
|
|
3045
|
+
if (rangePart === "*") {
|
|
3046
|
+
start = min;
|
|
3047
|
+
end = max;
|
|
3048
|
+
} else if (rangePart.includes("-")) {
|
|
3049
|
+
const dashParts = rangePart.split("-");
|
|
3050
|
+
start = parseInt(dashParts[0] ?? "0", 10);
|
|
3051
|
+
end = parseInt(dashParts[1] ?? "0", 10);
|
|
3052
|
+
} else {
|
|
3053
|
+
start = parseInt(rangePart, 10);
|
|
3054
|
+
end = max;
|
|
3055
|
+
}
|
|
3056
|
+
for (let i = start;i <= end; i += step) {
|
|
3057
|
+
if (i >= min && i <= max)
|
|
3058
|
+
results.add(i);
|
|
3059
|
+
}
|
|
3060
|
+
} else if (trimmed === "*") {
|
|
3061
|
+
for (let i = min;i <= max; i++) {
|
|
3062
|
+
results.add(i);
|
|
3063
|
+
}
|
|
3064
|
+
} else if (trimmed.includes("-")) {
|
|
3065
|
+
const dashParts = trimmed.split("-");
|
|
3066
|
+
const lo = parseInt(dashParts[0] ?? "0", 10);
|
|
3067
|
+
const hi = parseInt(dashParts[1] ?? "0", 10);
|
|
3068
|
+
if (isNaN(lo) || isNaN(hi)) {
|
|
3069
|
+
throw new Error(`Invalid range in cron field: ${field}`);
|
|
3070
|
+
}
|
|
3071
|
+
for (let i = lo;i <= hi; i++) {
|
|
3072
|
+
if (i >= min && i <= max)
|
|
3073
|
+
results.add(i);
|
|
3074
|
+
}
|
|
3075
|
+
} else {
|
|
3076
|
+
const val = parseInt(trimmed, 10);
|
|
3077
|
+
if (isNaN(val)) {
|
|
3078
|
+
throw new Error(`Invalid value in cron field: ${field}`);
|
|
3079
|
+
}
|
|
3080
|
+
if (val >= min && val <= max)
|
|
3081
|
+
results.add(val);
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
return Array.from(results).sort((a, b) => a - b);
|
|
3085
|
+
}
|
|
3086
|
+
function parseCron(expression) {
|
|
3087
|
+
const fields = expression.trim().split(/\s+/);
|
|
3088
|
+
if (fields.length !== 5) {
|
|
3089
|
+
throw new Error(`Invalid cron expression "${expression}": expected 5 fields, got ${fields.length}`);
|
|
3090
|
+
}
|
|
3091
|
+
return {
|
|
3092
|
+
minutes: parseCronField(fields[0], 0, 59),
|
|
3093
|
+
hours: parseCronField(fields[1], 0, 23),
|
|
3094
|
+
daysOfMonth: parseCronField(fields[2], 1, 31),
|
|
3095
|
+
months: parseCronField(fields[3], 1, 12),
|
|
3096
|
+
daysOfWeek: parseCronField(fields[4], 0, 6)
|
|
3097
|
+
};
|
|
3098
|
+
}
|
|
3099
|
+
function shouldRunAt(cronExpression, date) {
|
|
3100
|
+
const cron = parseCron(cronExpression);
|
|
3101
|
+
const minute = date.getMinutes();
|
|
3102
|
+
const hour = date.getHours();
|
|
3103
|
+
const dayOfMonth = date.getDate();
|
|
3104
|
+
const month = date.getMonth() + 1;
|
|
3105
|
+
const dayOfWeek = date.getDay();
|
|
3106
|
+
return cron.minutes.includes(minute) && cron.hours.includes(hour) && cron.daysOfMonth.includes(dayOfMonth) && cron.months.includes(month) && cron.daysOfWeek.includes(dayOfWeek);
|
|
3107
|
+
}
|
|
3108
|
+
function getNextRunTime(cronExpression, after) {
|
|
3109
|
+
parseCron(cronExpression);
|
|
3110
|
+
const start = after ? new Date(after.getTime()) : new Date;
|
|
3111
|
+
start.setSeconds(0, 0);
|
|
3112
|
+
start.setMinutes(start.getMinutes() + 1);
|
|
3113
|
+
const maxDate = new Date(start.getTime() + 366 * 24 * 60 * 60 * 1000);
|
|
3114
|
+
const cursor = new Date(start.getTime());
|
|
3115
|
+
while (cursor.getTime() <= maxDate.getTime()) {
|
|
3116
|
+
if (shouldRunAt(cronExpression, cursor)) {
|
|
3117
|
+
return cursor;
|
|
3118
|
+
}
|
|
3119
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
3120
|
+
}
|
|
3121
|
+
throw new Error(`No matching time found for cron expression "${cronExpression}" within 366 days`);
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
class Scheduler {
|
|
3125
|
+
interval = null;
|
|
3126
|
+
running = new Set;
|
|
3127
|
+
checkIntervalMs;
|
|
3128
|
+
onEvent;
|
|
3129
|
+
constructor(options) {
|
|
3130
|
+
this.checkIntervalMs = options?.checkIntervalMs ?? 60000;
|
|
3131
|
+
this.onEvent = options?.onEvent;
|
|
3132
|
+
}
|
|
3133
|
+
start() {
|
|
3134
|
+
if (this.interval)
|
|
3135
|
+
return;
|
|
3136
|
+
this.tick().catch(() => {});
|
|
3137
|
+
this.interval = setInterval(() => {
|
|
3138
|
+
this.tick().catch(() => {});
|
|
3139
|
+
}, this.checkIntervalMs);
|
|
3140
|
+
}
|
|
3141
|
+
stop() {
|
|
3142
|
+
if (this.interval) {
|
|
3143
|
+
clearInterval(this.interval);
|
|
3144
|
+
this.interval = null;
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
async tick() {
|
|
3148
|
+
const now2 = new Date;
|
|
3149
|
+
now2.setSeconds(0, 0);
|
|
3150
|
+
const schedules = getEnabledSchedules();
|
|
3151
|
+
for (const schedule of schedules) {
|
|
3152
|
+
if (this.running.has(schedule.id))
|
|
3153
|
+
continue;
|
|
3154
|
+
if (shouldRunAt(schedule.cronExpression, now2)) {
|
|
3155
|
+
this.running.add(schedule.id);
|
|
3156
|
+
this.emit({
|
|
3157
|
+
type: "schedule:triggered",
|
|
3158
|
+
scheduleId: schedule.id,
|
|
3159
|
+
scheduleName: schedule.name,
|
|
3160
|
+
timestamp: new Date().toISOString()
|
|
3161
|
+
});
|
|
3162
|
+
this.executeSchedule(schedule).then(({ runId }) => {
|
|
3163
|
+
const nextRun = getNextRunTime(schedule.cronExpression, new Date);
|
|
3164
|
+
updateLastRun(schedule.id, runId, nextRun.toISOString());
|
|
3165
|
+
this.emit({
|
|
3166
|
+
type: "schedule:completed",
|
|
3167
|
+
scheduleId: schedule.id,
|
|
3168
|
+
scheduleName: schedule.name,
|
|
3169
|
+
runId,
|
|
3170
|
+
timestamp: new Date().toISOString()
|
|
3171
|
+
});
|
|
3172
|
+
}).catch((err) => {
|
|
3173
|
+
this.emit({
|
|
3174
|
+
type: "schedule:failed",
|
|
3175
|
+
scheduleId: schedule.id,
|
|
3176
|
+
scheduleName: schedule.name,
|
|
3177
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3178
|
+
timestamp: new Date().toISOString()
|
|
3179
|
+
});
|
|
3180
|
+
}).finally(() => {
|
|
3181
|
+
this.running.delete(schedule.id);
|
|
3182
|
+
});
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
async runScheduleNow(scheduleId) {
|
|
3187
|
+
const schedule = getSchedule(scheduleId);
|
|
3188
|
+
if (!schedule) {
|
|
3189
|
+
throw new ScheduleNotFoundError(scheduleId);
|
|
3190
|
+
}
|
|
3191
|
+
this.running.add(schedule.id);
|
|
3192
|
+
this.emit({
|
|
3193
|
+
type: "schedule:triggered",
|
|
3194
|
+
scheduleId: schedule.id,
|
|
3195
|
+
scheduleName: schedule.name,
|
|
3196
|
+
timestamp: new Date().toISOString()
|
|
3197
|
+
});
|
|
3198
|
+
try {
|
|
3199
|
+
const { runId } = await this.executeSchedule(schedule);
|
|
3200
|
+
const nextRun = getNextRunTime(schedule.cronExpression, new Date);
|
|
3201
|
+
updateLastRun(schedule.id, runId, nextRun.toISOString());
|
|
3202
|
+
this.emit({
|
|
3203
|
+
type: "schedule:completed",
|
|
3204
|
+
scheduleId: schedule.id,
|
|
3205
|
+
scheduleName: schedule.name,
|
|
3206
|
+
runId,
|
|
3207
|
+
timestamp: new Date().toISOString()
|
|
3208
|
+
});
|
|
3209
|
+
} catch (err) {
|
|
3210
|
+
this.emit({
|
|
3211
|
+
type: "schedule:failed",
|
|
3212
|
+
scheduleId: schedule.id,
|
|
3213
|
+
scheduleName: schedule.name,
|
|
3214
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3215
|
+
timestamp: new Date().toISOString()
|
|
3216
|
+
});
|
|
3217
|
+
throw err;
|
|
3218
|
+
} finally {
|
|
3219
|
+
this.running.delete(schedule.id);
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
async executeSchedule(schedule) {
|
|
3223
|
+
const { run } = await runByFilter({
|
|
3224
|
+
url: schedule.url,
|
|
3225
|
+
model: schedule.model ?? undefined,
|
|
3226
|
+
headed: schedule.headed,
|
|
3227
|
+
parallel: schedule.parallel,
|
|
3228
|
+
timeout: schedule.timeoutMs ?? undefined,
|
|
3229
|
+
tags: schedule.scenarioFilter.tags,
|
|
3230
|
+
priority: schedule.scenarioFilter.priority,
|
|
3231
|
+
scenarioIds: schedule.scenarioFilter.scenarioIds
|
|
3232
|
+
});
|
|
3233
|
+
return { runId: run.id };
|
|
3234
|
+
}
|
|
3235
|
+
emit(event) {
|
|
3236
|
+
if (this.onEvent) {
|
|
3237
|
+
this.onEvent(event);
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
// src/lib/init.ts
|
|
3242
|
+
import { existsSync as existsSync5, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
|
|
3243
|
+
import { join as join5, basename } from "path";
|
|
3244
|
+
import { homedir as homedir5 } from "os";
|
|
3245
|
+
function detectFramework(dir) {
|
|
3246
|
+
const pkgPath = join5(dir, "package.json");
|
|
3247
|
+
if (!existsSync5(pkgPath))
|
|
3248
|
+
return null;
|
|
3249
|
+
let pkg;
|
|
3250
|
+
try {
|
|
3251
|
+
pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
|
|
3252
|
+
} catch {
|
|
3253
|
+
return null;
|
|
3254
|
+
}
|
|
3255
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
3256
|
+
const depNames = Object.keys(allDeps);
|
|
3257
|
+
const features = [];
|
|
3258
|
+
const hasAuth = depNames.some((d) => d === "next-auth" || d.startsWith("@auth/") || d === "passport" || d === "lucia");
|
|
3259
|
+
if (hasAuth)
|
|
3260
|
+
features.push("hasAuth");
|
|
3261
|
+
const hasForms = depNames.some((d) => d === "react-hook-form" || d === "formik" || d === "zod");
|
|
3262
|
+
if (hasForms)
|
|
3263
|
+
features.push("hasForms");
|
|
3264
|
+
if ("next" in allDeps) {
|
|
3265
|
+
return { name: "Next.js", defaultUrl: "http://localhost:3000", features };
|
|
3266
|
+
}
|
|
3267
|
+
if ("vite" in allDeps) {
|
|
3268
|
+
return { name: "Vite", defaultUrl: "http://localhost:5173", features };
|
|
3269
|
+
}
|
|
3270
|
+
if (depNames.some((d) => d.startsWith("@remix-run"))) {
|
|
3271
|
+
return { name: "Remix", defaultUrl: "http://localhost:3000", features };
|
|
3272
|
+
}
|
|
3273
|
+
if ("nuxt" in allDeps) {
|
|
3274
|
+
return { name: "Nuxt", defaultUrl: "http://localhost:3000", features };
|
|
3275
|
+
}
|
|
3276
|
+
if (depNames.some((d) => d.startsWith("svelte") || d === "@sveltejs/kit")) {
|
|
3277
|
+
return { name: "SvelteKit", defaultUrl: "http://localhost:5173", features };
|
|
3278
|
+
}
|
|
3279
|
+
if (depNames.some((d) => d.startsWith("@angular"))) {
|
|
3280
|
+
return { name: "Angular", defaultUrl: "http://localhost:4200", features };
|
|
3281
|
+
}
|
|
3282
|
+
if ("express" in allDeps) {
|
|
3283
|
+
return { name: "Express", defaultUrl: "http://localhost:3000", features };
|
|
3284
|
+
}
|
|
3285
|
+
return null;
|
|
3286
|
+
}
|
|
3287
|
+
function getStarterScenarios(framework, projectId) {
|
|
3288
|
+
const scenarios = [
|
|
3289
|
+
{
|
|
3290
|
+
name: "Landing page loads",
|
|
3291
|
+
description: "Navigate to the landing page and verify it loads correctly with no console errors. Check that the main heading, navigation, and primary CTA are visible.",
|
|
3292
|
+
tags: ["smoke"],
|
|
3293
|
+
priority: "high",
|
|
3294
|
+
projectId
|
|
3295
|
+
},
|
|
3296
|
+
{
|
|
3297
|
+
name: "Navigation works",
|
|
3298
|
+
description: "Click through main navigation links and verify each page loads without errors.",
|
|
3299
|
+
tags: ["smoke"],
|
|
3300
|
+
priority: "medium",
|
|
3301
|
+
projectId
|
|
3302
|
+
},
|
|
3303
|
+
{
|
|
3304
|
+
name: "No console errors",
|
|
3305
|
+
description: "Navigate through the main pages and check the browser console for any JavaScript errors or warnings.",
|
|
3306
|
+
tags: ["smoke"],
|
|
3307
|
+
priority: "high",
|
|
3308
|
+
projectId
|
|
3309
|
+
}
|
|
3310
|
+
];
|
|
3311
|
+
if (framework.features.includes("hasAuth")) {
|
|
3312
|
+
scenarios.push({
|
|
3313
|
+
name: "Login flow",
|
|
3314
|
+
description: "Navigate to the login page, enter valid credentials, and verify successful authentication and redirect.",
|
|
3315
|
+
tags: ["auth"],
|
|
3316
|
+
priority: "critical",
|
|
3317
|
+
projectId
|
|
3318
|
+
}, {
|
|
3319
|
+
name: "Signup flow",
|
|
3320
|
+
description: "Navigate to the signup page, fill in registration details, and verify account creation succeeds.",
|
|
3321
|
+
tags: ["auth"],
|
|
3322
|
+
priority: "medium",
|
|
3323
|
+
projectId
|
|
3324
|
+
});
|
|
3325
|
+
}
|
|
3326
|
+
if (framework.features.includes("hasForms")) {
|
|
3327
|
+
scenarios.push({
|
|
3328
|
+
name: "Form validation",
|
|
3329
|
+
description: "Submit forms with empty/invalid data and verify validation errors appear correctly.",
|
|
3330
|
+
tags: ["forms"],
|
|
3331
|
+
priority: "medium",
|
|
3332
|
+
projectId
|
|
3333
|
+
});
|
|
3334
|
+
}
|
|
3335
|
+
return scenarios;
|
|
3336
|
+
}
|
|
3337
|
+
function initProject(options) {
|
|
3338
|
+
const dir = options.dir ?? process.cwd();
|
|
3339
|
+
const name = options.name ?? basename(dir);
|
|
3340
|
+
const framework = detectFramework(dir);
|
|
3341
|
+
const url = options.url ?? framework?.defaultUrl ?? "http://localhost:3000";
|
|
3342
|
+
const projectPath = options.path ?? dir;
|
|
3343
|
+
const project = ensureProject(name, projectPath);
|
|
3344
|
+
const starterInputs = getStarterScenarios(framework ?? { name: "Unknown", features: [] }, project.id);
|
|
3345
|
+
const scenarios = starterInputs.map((input) => createScenario(input));
|
|
3346
|
+
const configDir = join5(homedir5(), ".testers");
|
|
3347
|
+
const configPath = join5(configDir, "config.json");
|
|
3348
|
+
if (!existsSync5(configDir)) {
|
|
3349
|
+
mkdirSync3(configDir, { recursive: true });
|
|
3350
|
+
}
|
|
3351
|
+
let config = {};
|
|
3352
|
+
if (existsSync5(configPath)) {
|
|
3353
|
+
try {
|
|
3354
|
+
config = JSON.parse(readFileSync2(configPath, "utf-8"));
|
|
3355
|
+
} catch {}
|
|
3356
|
+
}
|
|
3357
|
+
config.activeProject = project.id;
|
|
3358
|
+
writeFileSync2(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
3359
|
+
return { project, scenarios, framework, url };
|
|
3360
|
+
}
|
|
3361
|
+
// src/lib/smoke.ts
|
|
3362
|
+
init_runs();
|
|
3363
|
+
var SMOKE_DESCRIPTION = `You are performing an autonomous smoke test of this web application. Your job is to explore as many pages as possible and find issues. Follow these instructions:
|
|
3364
|
+
|
|
3365
|
+
1. Start at the given URL and take a screenshot
|
|
3366
|
+
2. Find all visible navigation links and click through each one
|
|
3367
|
+
3. On each page: check for visible error messages, broken layouts, missing images
|
|
3368
|
+
4. Use get_page_html to check for error indicators in the HTML
|
|
3369
|
+
5. Try clicking the main interactive elements (buttons, links, forms)
|
|
3370
|
+
6. Keep track of every page you visit
|
|
3371
|
+
7. After exploring at least 5 different pages (or all available pages), report your findings
|
|
3372
|
+
|
|
3373
|
+
In your report_result, include:
|
|
3374
|
+
- Total pages visited
|
|
3375
|
+
- Any JavaScript errors you noticed
|
|
3376
|
+
- Any broken links (pages that show 404 or error)
|
|
3377
|
+
- Any visual issues (broken layouts, missing images, overlapping text)
|
|
3378
|
+
- Any forms that don't work
|
|
3379
|
+
- Rate each issue as critical/high/medium/low severity`;
|
|
3380
|
+
async function runSmoke(options) {
|
|
3381
|
+
const config = loadConfig();
|
|
3382
|
+
const model = resolveModel2(options.model ?? config.defaultModel);
|
|
3383
|
+
const scenario = createScenario({
|
|
3384
|
+
name: "Smoke Test",
|
|
3385
|
+
description: SMOKE_DESCRIPTION,
|
|
3386
|
+
tags: ["smoke", "auto"],
|
|
3387
|
+
priority: "high",
|
|
3388
|
+
projectId: options.projectId
|
|
3389
|
+
});
|
|
3390
|
+
const run = createRun({
|
|
3391
|
+
url: options.url,
|
|
3392
|
+
model,
|
|
3393
|
+
headed: options.headed,
|
|
3394
|
+
parallel: 1,
|
|
3395
|
+
projectId: options.projectId
|
|
3396
|
+
});
|
|
3397
|
+
updateRun(run.id, { status: "running", total: 1 });
|
|
3398
|
+
let result;
|
|
3399
|
+
try {
|
|
3400
|
+
result = await runSingleScenario(scenario, run.id, {
|
|
3401
|
+
url: options.url,
|
|
3402
|
+
model: options.model,
|
|
3403
|
+
headed: options.headed,
|
|
3404
|
+
timeout: options.timeout,
|
|
3405
|
+
projectId: options.projectId,
|
|
3406
|
+
apiKey: options.apiKey
|
|
3407
|
+
});
|
|
3408
|
+
const finalStatus = result.status === "passed" ? "passed" : "failed";
|
|
3409
|
+
updateRun(run.id, {
|
|
3410
|
+
status: finalStatus,
|
|
3411
|
+
passed: result.status === "passed" ? 1 : 0,
|
|
3412
|
+
failed: result.status === "passed" ? 0 : 1,
|
|
3413
|
+
total: 1,
|
|
3414
|
+
finished_at: new Date().toISOString()
|
|
3415
|
+
});
|
|
3416
|
+
} catch (error) {
|
|
3417
|
+
updateRun(run.id, {
|
|
3418
|
+
status: "failed",
|
|
3419
|
+
failed: 1,
|
|
3420
|
+
total: 1,
|
|
3421
|
+
finished_at: new Date().toISOString()
|
|
3422
|
+
});
|
|
3423
|
+
throw error;
|
|
3424
|
+
} finally {
|
|
3425
|
+
deleteScenario(scenario.id);
|
|
3426
|
+
}
|
|
3427
|
+
const issues = parseSmokeIssues(result.reasoning ?? "");
|
|
3428
|
+
const pagesVisited = extractPagesVisited(result.reasoning ?? "");
|
|
3429
|
+
const { getRun: getRun2 } = await Promise.resolve().then(() => (init_runs(), exports_runs));
|
|
3430
|
+
const finalRun = getRun2(run.id);
|
|
3431
|
+
return {
|
|
3432
|
+
run: finalRun,
|
|
3433
|
+
result,
|
|
3434
|
+
pagesVisited,
|
|
3435
|
+
issuesFound: issues
|
|
3436
|
+
};
|
|
3437
|
+
}
|
|
3438
|
+
var SEVERITY_PATTERN = /\b(CRITICAL|HIGH|MEDIUM|LOW)\b[:\s-]*(.+)/gi;
|
|
3439
|
+
var PAGES_VISITED_PATTERN = /(\d+)\s*(?:pages?\s*visited|pages?\s*explored|pages?\s*checked|total\s*pages?)/i;
|
|
3440
|
+
var URL_PATTERN = /https?:\/\/[^\s,)]+/g;
|
|
3441
|
+
var ISSUE_TYPE_MAP = {
|
|
3442
|
+
javascript: "js-error",
|
|
3443
|
+
"js error": "js-error",
|
|
3444
|
+
"js-error": "js-error",
|
|
3445
|
+
"console error": "js-error",
|
|
3446
|
+
"404": "404",
|
|
3447
|
+
"not found": "404",
|
|
3448
|
+
"broken link": "broken-link",
|
|
3449
|
+
"dead link": "broken-link",
|
|
3450
|
+
"broken image": "broken-image",
|
|
3451
|
+
"missing image": "broken-image",
|
|
3452
|
+
visual: "visual",
|
|
3453
|
+
layout: "visual",
|
|
3454
|
+
overlap: "visual",
|
|
3455
|
+
"broken layout": "visual",
|
|
3456
|
+
performance: "performance",
|
|
3457
|
+
slow: "performance"
|
|
3458
|
+
};
|
|
3459
|
+
function inferIssueType(text) {
|
|
3460
|
+
const lower = text.toLowerCase();
|
|
3461
|
+
for (const [keyword, type] of Object.entries(ISSUE_TYPE_MAP)) {
|
|
3462
|
+
if (lower.includes(keyword))
|
|
3463
|
+
return type;
|
|
3464
|
+
}
|
|
3465
|
+
return "visual";
|
|
3466
|
+
}
|
|
3467
|
+
function extractUrl(text, fallback = "") {
|
|
3468
|
+
const match = text.match(URL_PATTERN);
|
|
3469
|
+
return match ? match[0] : fallback;
|
|
3470
|
+
}
|
|
3471
|
+
function parseSmokeIssues(reasoning) {
|
|
3472
|
+
const issues = [];
|
|
3473
|
+
const seen = new Set;
|
|
3474
|
+
let match;
|
|
3475
|
+
const severityRegex = new RegExp(SEVERITY_PATTERN.source, "gi");
|
|
3476
|
+
while ((match = severityRegex.exec(reasoning)) !== null) {
|
|
3477
|
+
const severity = match[1].toLowerCase();
|
|
3478
|
+
const description = match[2].trim();
|
|
3479
|
+
const key = `${severity}:${description.slice(0, 80)}`;
|
|
3480
|
+
if (seen.has(key))
|
|
3481
|
+
continue;
|
|
3482
|
+
seen.add(key);
|
|
3483
|
+
issues.push({
|
|
3484
|
+
type: inferIssueType(description),
|
|
3485
|
+
severity,
|
|
3486
|
+
description,
|
|
3487
|
+
url: extractUrl(description)
|
|
3488
|
+
});
|
|
3489
|
+
}
|
|
3490
|
+
const bulletLines = reasoning.split(`
|
|
3491
|
+
`).filter((line) => /^\s*[-*]\s/.test(line) && /\b(error|broken|missing|404|fail|issue|bug|problem)\b/i.test(line));
|
|
3492
|
+
for (const line of bulletLines) {
|
|
3493
|
+
const cleaned = line.replace(/^\s*[-*]\s*/, "").trim();
|
|
3494
|
+
const key = `bullet:${cleaned.slice(0, 80)}`;
|
|
3495
|
+
if (seen.has(key))
|
|
3496
|
+
continue;
|
|
3497
|
+
seen.add(key);
|
|
3498
|
+
let severity = "medium";
|
|
3499
|
+
if (/\bcritical\b/i.test(cleaned))
|
|
3500
|
+
severity = "critical";
|
|
3501
|
+
else if (/\bhigh\b/i.test(cleaned))
|
|
3502
|
+
severity = "high";
|
|
3503
|
+
else if (/\blow\b/i.test(cleaned))
|
|
3504
|
+
severity = "low";
|
|
3505
|
+
else if (/\b(error|fail|broken|crash)\b/i.test(cleaned))
|
|
3506
|
+
severity = "high";
|
|
3507
|
+
issues.push({
|
|
3508
|
+
type: inferIssueType(cleaned),
|
|
3509
|
+
severity,
|
|
3510
|
+
description: cleaned,
|
|
3511
|
+
url: extractUrl(cleaned)
|
|
3512
|
+
});
|
|
3513
|
+
}
|
|
3514
|
+
return issues;
|
|
3515
|
+
}
|
|
3516
|
+
function extractPagesVisited(reasoning) {
|
|
3517
|
+
const match = reasoning.match(PAGES_VISITED_PATTERN);
|
|
3518
|
+
if (match)
|
|
3519
|
+
return parseInt(match[1], 10);
|
|
3520
|
+
const urls = reasoning.match(URL_PATTERN);
|
|
3521
|
+
if (urls) {
|
|
3522
|
+
const unique = new Set(urls.map((u) => new URL(u).pathname));
|
|
3523
|
+
return unique.size;
|
|
3524
|
+
}
|
|
3525
|
+
return 0;
|
|
3526
|
+
}
|
|
3527
|
+
var SEVERITY_COLORS = {
|
|
3528
|
+
critical: (t) => `\x1B[41m\x1B[37m ${t} \x1B[0m`,
|
|
3529
|
+
high: (t) => `\x1B[31m${t}\x1B[0m`,
|
|
3530
|
+
medium: (t) => `\x1B[33m${t}\x1B[0m`,
|
|
3531
|
+
low: (t) => `\x1B[36m${t}\x1B[0m`
|
|
3532
|
+
};
|
|
3533
|
+
var SEVERITY_ORDER = ["critical", "high", "medium", "low"];
|
|
3534
|
+
function formatSmokeReport(result) {
|
|
3535
|
+
const lines = [];
|
|
3536
|
+
const url = result.run.url;
|
|
3537
|
+
lines.push("");
|
|
3538
|
+
lines.push(`\x1B[1m Smoke Test Report \x1B[2m- ${url}\x1B[0m`);
|
|
3539
|
+
lines.push(` ${"\u2500".repeat(60)}`);
|
|
3540
|
+
const issueCount = result.issuesFound.length;
|
|
3541
|
+
const criticalCount = result.issuesFound.filter((i) => i.severity === "critical").length;
|
|
3542
|
+
const highCount = result.issuesFound.filter((i) => i.severity === "high").length;
|
|
3543
|
+
lines.push("");
|
|
3544
|
+
lines.push(` Pages visited: \x1B[1m${result.pagesVisited}\x1B[0m`);
|
|
3545
|
+
lines.push(` Issues found: \x1B[1m${issueCount}\x1B[0m`);
|
|
3546
|
+
lines.push(` Duration: ${result.result.durationMs ? `${(result.result.durationMs / 1000).toFixed(1)}s` : "N/A"}`);
|
|
3547
|
+
lines.push(` Model: ${result.run.model}`);
|
|
3548
|
+
lines.push(` Tokens used: ${result.result.tokensUsed}`);
|
|
3549
|
+
if (issueCount > 0) {
|
|
3550
|
+
lines.push("");
|
|
3551
|
+
lines.push(`\x1B[1m Issues\x1B[0m`);
|
|
3552
|
+
lines.push("");
|
|
3553
|
+
for (const severity of SEVERITY_ORDER) {
|
|
3554
|
+
const group = result.issuesFound.filter((i) => i.severity === severity);
|
|
3555
|
+
if (group.length === 0)
|
|
3556
|
+
continue;
|
|
3557
|
+
const badge = SEVERITY_COLORS[severity](severity.toUpperCase());
|
|
3558
|
+
lines.push(` ${badge}`);
|
|
3559
|
+
for (const issue of group) {
|
|
3560
|
+
const urlSuffix = issue.url ? ` \x1B[2m(${issue.url})\x1B[0m` : "";
|
|
3561
|
+
lines.push(` - ${issue.description}${urlSuffix}`);
|
|
3562
|
+
}
|
|
3563
|
+
lines.push("");
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
lines.push(` ${"\u2500".repeat(60)}`);
|
|
3567
|
+
const hasCritical = criticalCount > 0 || highCount > 0;
|
|
3568
|
+
if (hasCritical) {
|
|
3569
|
+
lines.push(` Verdict: \x1B[31m\x1B[1mFAIL\x1B[0m \x1B[2m(${criticalCount} critical, ${highCount} high severity issues)\x1B[0m`);
|
|
3570
|
+
} else if (issueCount > 0) {
|
|
3571
|
+
lines.push(` Verdict: \x1B[33m\x1B[1mWARN\x1B[0m \x1B[2m(${issueCount} issues found, none critical/high)\x1B[0m`);
|
|
3572
|
+
} else {
|
|
3573
|
+
lines.push(` Verdict: \x1B[32m\x1B[1mPASS\x1B[0m \x1B[2m(no issues found)\x1B[0m`);
|
|
3574
|
+
}
|
|
3575
|
+
lines.push("");
|
|
3576
|
+
return lines.join(`
|
|
3577
|
+
`);
|
|
3578
|
+
}
|
|
3579
|
+
// src/lib/diff.ts
|
|
3580
|
+
init_runs();
|
|
3581
|
+
function diffRuns(runId1, runId2) {
|
|
3582
|
+
const run1 = getRun(runId1);
|
|
3583
|
+
if (!run1) {
|
|
3584
|
+
throw new Error(`Run not found: ${runId1}`);
|
|
3585
|
+
}
|
|
3586
|
+
const run2 = getRun(runId2);
|
|
3587
|
+
if (!run2) {
|
|
3588
|
+
throw new Error(`Run not found: ${runId2}`);
|
|
3589
|
+
}
|
|
3590
|
+
const results1 = getResultsByRun(run1.id);
|
|
3591
|
+
const results2 = getResultsByRun(run2.id);
|
|
3592
|
+
const map1 = new Map;
|
|
3593
|
+
for (const r of results1) {
|
|
3594
|
+
map1.set(r.scenarioId, r);
|
|
3595
|
+
}
|
|
3596
|
+
const map2 = new Map;
|
|
3597
|
+
for (const r of results2) {
|
|
3598
|
+
map2.set(r.scenarioId, r);
|
|
3599
|
+
}
|
|
3600
|
+
const allScenarioIds = new Set([...map1.keys(), ...map2.keys()]);
|
|
3601
|
+
const regressions = [];
|
|
3602
|
+
const fixes = [];
|
|
3603
|
+
const unchanged = [];
|
|
3604
|
+
const newScenarios = [];
|
|
3605
|
+
const removedScenarios = [];
|
|
3606
|
+
for (const scenarioId of allScenarioIds) {
|
|
3607
|
+
const r1 = map1.get(scenarioId) ?? null;
|
|
3608
|
+
const r2 = map2.get(scenarioId) ?? null;
|
|
3609
|
+
const scenario = getScenario(scenarioId);
|
|
3610
|
+
const diff = {
|
|
3611
|
+
scenarioId,
|
|
3612
|
+
scenarioName: scenario?.name ?? null,
|
|
3613
|
+
scenarioShortId: scenario?.shortId ?? null,
|
|
3614
|
+
status1: r1?.status ?? null,
|
|
3615
|
+
status2: r2?.status ?? null,
|
|
3616
|
+
duration1: r1?.durationMs ?? null,
|
|
3617
|
+
duration2: r2?.durationMs ?? null,
|
|
3618
|
+
tokens1: r1?.tokensUsed ?? null,
|
|
3619
|
+
tokens2: r2?.tokensUsed ?? null
|
|
3620
|
+
};
|
|
3621
|
+
if (!r1 && r2) {
|
|
3622
|
+
newScenarios.push(diff);
|
|
3623
|
+
} else if (r1 && !r2) {
|
|
3624
|
+
removedScenarios.push(diff);
|
|
3625
|
+
} else if (r1 && r2) {
|
|
3626
|
+
const wasPass = r1.status === "passed";
|
|
3627
|
+
const nowPass = r2.status === "passed";
|
|
3628
|
+
const wasFail = r1.status === "failed" || r1.status === "error";
|
|
3629
|
+
const nowFail = r2.status === "failed" || r2.status === "error";
|
|
3630
|
+
if (wasPass && nowFail) {
|
|
3631
|
+
regressions.push(diff);
|
|
3632
|
+
} else if (wasFail && nowPass) {
|
|
3633
|
+
fixes.push(diff);
|
|
3634
|
+
} else {
|
|
3635
|
+
unchanged.push(diff);
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
return { run1, run2, regressions, fixes, unchanged, newScenarios, removedScenarios };
|
|
3640
|
+
}
|
|
3641
|
+
function formatScenarioLabel(diff) {
|
|
3642
|
+
if (diff.scenarioShortId && diff.scenarioName) {
|
|
3643
|
+
return `${diff.scenarioShortId}: ${diff.scenarioName}`;
|
|
3644
|
+
}
|
|
3645
|
+
if (diff.scenarioName) {
|
|
3646
|
+
return diff.scenarioName;
|
|
3647
|
+
}
|
|
3648
|
+
return diff.scenarioId.slice(0, 8);
|
|
3649
|
+
}
|
|
3650
|
+
function formatDuration(ms) {
|
|
3651
|
+
if (ms === null)
|
|
3652
|
+
return "-";
|
|
3653
|
+
if (ms < 1000)
|
|
3654
|
+
return `${ms}ms`;
|
|
3655
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
3656
|
+
}
|
|
3657
|
+
function formatDurationComparison(d1, d2) {
|
|
3658
|
+
const s1 = formatDuration(d1);
|
|
3659
|
+
const s2 = formatDuration(d2);
|
|
3660
|
+
if (d1 !== null && d2 !== null) {
|
|
3661
|
+
const delta = d2 - d1;
|
|
3662
|
+
const sign = delta > 0 ? "+" : "";
|
|
3663
|
+
return `${s1} -> ${s2} (${sign}${formatDuration(delta)})`;
|
|
3664
|
+
}
|
|
3665
|
+
return `${s1} -> ${s2}`;
|
|
3666
|
+
}
|
|
3667
|
+
function formatDiffTerminal(diff) {
|
|
3668
|
+
const lines = [];
|
|
3669
|
+
lines.push("");
|
|
3670
|
+
lines.push(source_default.bold(" Run Comparison"));
|
|
3671
|
+
lines.push(` Run 1: ${source_default.dim(diff.run1.id.slice(0, 8))} (${diff.run1.status}) \u2014 ${diff.run1.startedAt}`);
|
|
3672
|
+
lines.push(` Run 2: ${source_default.dim(diff.run2.id.slice(0, 8))} (${diff.run2.status}) \u2014 ${diff.run2.startedAt}`);
|
|
3673
|
+
lines.push("");
|
|
3674
|
+
if (diff.regressions.length > 0) {
|
|
3675
|
+
lines.push(source_default.red.bold(` Regressions (${diff.regressions.length}):`));
|
|
3676
|
+
for (const d of diff.regressions) {
|
|
3677
|
+
const label = formatScenarioLabel(d);
|
|
3678
|
+
const dur = formatDurationComparison(d.duration1, d.duration2);
|
|
3679
|
+
lines.push(source_default.red(` \u2B07 ${label} ${d.status1} -> ${d.status2} ${source_default.dim(dur)}`));
|
|
3680
|
+
}
|
|
3681
|
+
lines.push("");
|
|
3682
|
+
}
|
|
3683
|
+
if (diff.fixes.length > 0) {
|
|
3684
|
+
lines.push(source_default.green.bold(` Fixes (${diff.fixes.length}):`));
|
|
3685
|
+
for (const d of diff.fixes) {
|
|
3686
|
+
const label = formatScenarioLabel(d);
|
|
3687
|
+
const dur = formatDurationComparison(d.duration1, d.duration2);
|
|
3688
|
+
lines.push(source_default.green(` \u2B06 ${label} ${d.status1} -> ${d.status2} ${source_default.dim(dur)}`));
|
|
3689
|
+
}
|
|
3690
|
+
lines.push("");
|
|
3691
|
+
}
|
|
3692
|
+
if (diff.unchanged.length > 0) {
|
|
3693
|
+
lines.push(source_default.dim(` Unchanged (${diff.unchanged.length}):`));
|
|
3694
|
+
for (const d of diff.unchanged) {
|
|
3695
|
+
const label = formatScenarioLabel(d);
|
|
3696
|
+
const dur = formatDurationComparison(d.duration1, d.duration2);
|
|
3697
|
+
lines.push(source_default.dim(` = ${label} ${d.status2} ${dur}`));
|
|
3698
|
+
}
|
|
3699
|
+
lines.push("");
|
|
3700
|
+
}
|
|
3701
|
+
if (diff.newScenarios.length > 0) {
|
|
3702
|
+
lines.push(source_default.cyan(` New in run 2 (${diff.newScenarios.length}):`));
|
|
3703
|
+
for (const d of diff.newScenarios) {
|
|
3704
|
+
const label = formatScenarioLabel(d);
|
|
3705
|
+
lines.push(source_default.cyan(` + ${label} ${d.status2}`));
|
|
3706
|
+
}
|
|
3707
|
+
lines.push("");
|
|
3708
|
+
}
|
|
3709
|
+
if (diff.removedScenarios.length > 0) {
|
|
3710
|
+
lines.push(source_default.yellow(` Removed from run 2 (${diff.removedScenarios.length}):`));
|
|
3711
|
+
for (const d of diff.removedScenarios) {
|
|
3712
|
+
const label = formatScenarioLabel(d);
|
|
3713
|
+
lines.push(source_default.yellow(` - ${label} was ${d.status1}`));
|
|
3714
|
+
}
|
|
3715
|
+
lines.push("");
|
|
3716
|
+
}
|
|
3717
|
+
lines.push(source_default.bold(` Summary: ${diff.regressions.length} regressions, ${diff.fixes.length} fixes, ${diff.unchanged.length} unchanged`));
|
|
3718
|
+
lines.push("");
|
|
3719
|
+
return lines.join(`
|
|
3720
|
+
`);
|
|
3721
|
+
}
|
|
3722
|
+
function formatDiffJSON(diff) {
|
|
3723
|
+
return JSON.stringify(diff, null, 2);
|
|
3724
|
+
}
|
|
3725
|
+
// src/lib/templates.ts
|
|
3726
|
+
var SCENARIO_TEMPLATES = {
|
|
3727
|
+
auth: [
|
|
3728
|
+
{ name: "Login with valid credentials", description: "Navigate to the login page, enter valid credentials, submit the form, and verify redirect to authenticated area. Check that user menu/avatar is visible.", tags: ["auth", "smoke"], priority: "critical", requiresAuth: false, steps: ["Navigate to login page", "Enter email and password", "Submit login form", "Verify redirect to dashboard/home", "Verify user menu or avatar is visible"] },
|
|
3729
|
+
{ name: "Signup flow", description: "Navigate to signup page, fill all required fields with valid data, submit, and verify account creation succeeds.", tags: ["auth"], priority: "high", steps: ["Navigate to signup page", "Fill all required fields", "Submit registration form", "Verify success message or redirect"] },
|
|
3730
|
+
{ name: "Logout flow", description: "While authenticated, find and click the logout button/link, verify redirect to public page.", tags: ["auth"], priority: "medium", requiresAuth: true, steps: ["Click user menu or profile", "Click logout", "Verify redirect to login or home page"] }
|
|
3731
|
+
],
|
|
3732
|
+
crud: [
|
|
3733
|
+
{ name: "Create new item", description: "Navigate to the create form, fill all fields, submit, and verify the new item appears in the list.", tags: ["crud"], priority: "high", steps: ["Navigate to the list/index page", "Click create/add button", "Fill all required fields", "Submit the form", "Verify new item appears in list"] },
|
|
3734
|
+
{ name: "Read/view item details", description: "Click on an existing item to view its details page. Verify all fields are displayed correctly.", tags: ["crud"], priority: "medium", steps: ["Navigate to list page", "Click on an item", "Verify detail page shows all fields"] },
|
|
3735
|
+
{ name: "Update existing item", description: "Edit an existing item, change some fields, save, and verify changes persisted.", tags: ["crud"], priority: "high", steps: ["Navigate to item detail", "Click edit button", "Modify fields", "Save changes", "Verify updated values"] },
|
|
3736
|
+
{ name: "Delete item", description: "Delete an existing item and verify it's removed from the list.", tags: ["crud"], priority: "medium", steps: ["Navigate to list page", "Click delete on an item", "Confirm deletion", "Verify item removed from list"] }
|
|
3737
|
+
],
|
|
3738
|
+
forms: [
|
|
3739
|
+
{ name: "Form validation - empty submission", description: "Submit a form with all fields empty and verify validation errors appear for required fields.", tags: ["forms", "validation"], priority: "high", steps: ["Navigate to form page", "Click submit without filling fields", "Verify validation errors appear for each required field"] },
|
|
3740
|
+
{ name: "Form validation - invalid data", description: "Submit a form with invalid data (bad email, short password, etc) and verify appropriate error messages.", tags: ["forms", "validation"], priority: "high", steps: ["Navigate to form page", "Enter invalid email format", "Enter too-short password", "Submit form", "Verify specific validation error messages"] },
|
|
3741
|
+
{ name: "Form successful submission", description: "Fill form with valid data, submit, and verify success state (redirect, success message, or data saved).", tags: ["forms"], priority: "high", steps: ["Navigate to form page", "Fill all fields with valid data", "Submit form", "Verify success state"] }
|
|
3742
|
+
],
|
|
3743
|
+
nav: [
|
|
3744
|
+
{ name: "Main navigation links work", description: "Click through each main navigation link and verify each page loads correctly without errors.", tags: ["navigation", "smoke"], priority: "high", steps: ["Click each nav link", "Verify page loads", "Verify no error states", "Verify breadcrumbs if present"] },
|
|
3745
|
+
{ name: "Mobile navigation", description: "At mobile viewport, verify hamburger menu opens, navigation links are accessible, and pages load correctly.", tags: ["navigation", "responsive"], priority: "medium", steps: ["Resize to mobile viewport", "Click hamburger/menu icon", "Verify nav links appear", "Click a nav link", "Verify page loads"] }
|
|
3746
|
+
],
|
|
3747
|
+
a11y: [
|
|
3748
|
+
{ name: "Keyboard navigation", description: "Navigate the page using only keyboard (Tab, Enter, Escape). Verify all interactive elements are reachable and focusable.", tags: ["a11y", "keyboard"], priority: "high", steps: ["Press Tab to move through elements", "Verify focus indicators are visible", "Press Enter on buttons/links", "Verify actions trigger correctly", "Press Escape to close modals/dropdowns"] },
|
|
3749
|
+
{ name: "Image alt text", description: "Check that all images have meaningful alt text attributes.", tags: ["a11y"], priority: "medium", steps: ["Find all images on the page", "Check each image has an alt attribute", "Verify alt text is descriptive, not empty or generic"] }
|
|
3750
|
+
]
|
|
3751
|
+
};
|
|
3752
|
+
function getTemplate(name) {
|
|
3753
|
+
return SCENARIO_TEMPLATES[name] ?? null;
|
|
3754
|
+
}
|
|
3755
|
+
function listTemplateNames() {
|
|
3756
|
+
return Object.keys(SCENARIO_TEMPLATES);
|
|
3757
|
+
}
|
|
3758
|
+
// src/db/auth-presets.ts
|
|
3759
|
+
init_database();
|
|
3760
|
+
function fromRow(row) {
|
|
3761
|
+
return {
|
|
3762
|
+
id: row.id,
|
|
3763
|
+
name: row.name,
|
|
3764
|
+
email: row.email,
|
|
3765
|
+
password: row.password,
|
|
3766
|
+
loginPath: row.login_path,
|
|
3767
|
+
metadata: JSON.parse(row.metadata),
|
|
3768
|
+
createdAt: row.created_at
|
|
3769
|
+
};
|
|
3770
|
+
}
|
|
3771
|
+
function createAuthPreset(input) {
|
|
3772
|
+
const db2 = getDatabase();
|
|
3773
|
+
const id = uuid();
|
|
3774
|
+
const timestamp = now();
|
|
3775
|
+
db2.query(`
|
|
3776
|
+
INSERT INTO auth_presets (id, name, email, password, login_path, metadata, created_at)
|
|
3777
|
+
VALUES (?, ?, ?, ?, ?, '{}', ?)
|
|
3778
|
+
`).run(id, input.name, input.email, input.password, input.loginPath ?? "/login", timestamp);
|
|
3779
|
+
return getAuthPreset(input.name);
|
|
3780
|
+
}
|
|
3781
|
+
function getAuthPreset(name) {
|
|
3782
|
+
const db2 = getDatabase();
|
|
3783
|
+
const row = db2.query("SELECT * FROM auth_presets WHERE name = ?").get(name);
|
|
3784
|
+
return row ? fromRow(row) : null;
|
|
3785
|
+
}
|
|
3786
|
+
function listAuthPresets() {
|
|
3787
|
+
const db2 = getDatabase();
|
|
3788
|
+
const rows = db2.query("SELECT * FROM auth_presets ORDER BY created_at DESC").all();
|
|
3789
|
+
return rows.map(fromRow);
|
|
3790
|
+
}
|
|
3791
|
+
function deleteAuthPreset(name) {
|
|
3792
|
+
const db2 = getDatabase();
|
|
3793
|
+
const result = db2.query("DELETE FROM auth_presets WHERE name = ?").run(name);
|
|
3794
|
+
return result.changes > 0;
|
|
3795
|
+
}
|
|
3796
|
+
// src/lib/report.ts
|
|
3797
|
+
init_runs();
|
|
3798
|
+
import { readFileSync as readFileSync3, existsSync as existsSync6 } from "fs";
|
|
3799
|
+
function imageToBase64(filePath) {
|
|
3800
|
+
if (!filePath || !existsSync6(filePath))
|
|
3801
|
+
return "";
|
|
3802
|
+
try {
|
|
3803
|
+
const buffer = readFileSync3(filePath);
|
|
3804
|
+
const base64 = buffer.toString("base64");
|
|
3805
|
+
return `data:image/png;base64,${base64}`;
|
|
3806
|
+
} catch {
|
|
3807
|
+
return "";
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
function escapeHtml(text) {
|
|
3811
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3812
|
+
}
|
|
3813
|
+
function formatDuration2(ms) {
|
|
3814
|
+
if (ms < 1000)
|
|
3815
|
+
return `${ms}ms`;
|
|
3816
|
+
if (ms < 60000)
|
|
3817
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
3818
|
+
const mins = Math.floor(ms / 60000);
|
|
3819
|
+
const secs = (ms % 60000 / 1000).toFixed(0);
|
|
3820
|
+
return `${mins}m ${secs}s`;
|
|
3821
|
+
}
|
|
3822
|
+
function formatCost(cents) {
|
|
3823
|
+
if (cents < 1)
|
|
3824
|
+
return `$${(cents / 100).toFixed(4)}`;
|
|
3825
|
+
return `$${(cents / 100).toFixed(2)}`;
|
|
3826
|
+
}
|
|
3827
|
+
function statusBadge(status) {
|
|
3828
|
+
const colors = {
|
|
3829
|
+
passed: { bg: "#22c55e", text: "#000" },
|
|
3830
|
+
failed: { bg: "#ef4444", text: "#fff" },
|
|
3831
|
+
error: { bg: "#eab308", text: "#000" },
|
|
3832
|
+
skipped: { bg: "#6b7280", text: "#fff" }
|
|
3833
|
+
};
|
|
3834
|
+
const c = colors[status] ?? { bg: "#6b7280", text: "#fff" };
|
|
3835
|
+
const label = status.toUpperCase();
|
|
3836
|
+
return `<span style="display:inline-block;padding:2px 10px;border-radius:4px;font-size:12px;font-weight:700;background:${c.bg};color:${c.text};letter-spacing:0.5px;">${label}</span>`;
|
|
3837
|
+
}
|
|
3838
|
+
function renderScreenshots(screenshots) {
|
|
3839
|
+
if (screenshots.length === 0)
|
|
3840
|
+
return "";
|
|
3841
|
+
let html = `<div style="display:flex;flex-wrap:wrap;gap:12px;margin-top:12px;">`;
|
|
3842
|
+
for (let i = 0;i < screenshots.length; i++) {
|
|
3843
|
+
const ss = screenshots[i];
|
|
3844
|
+
const dataUri = imageToBase64(ss.filePath);
|
|
3845
|
+
const checkId = `ss-${ss.id}`;
|
|
3846
|
+
if (dataUri) {
|
|
3847
|
+
html += `
|
|
3848
|
+
<div style="flex:0 0 auto;">
|
|
3849
|
+
<input type="checkbox" id="${checkId}" style="display:none;" />
|
|
3850
|
+
<label for="${checkId}" style="cursor:pointer;">
|
|
3851
|
+
<img src="${dataUri}" alt="Step ${ss.stepNumber}: ${escapeHtml(ss.action)}"
|
|
3852
|
+
style="max-width:200px;max-height:150px;border-radius:6px;border:1px solid #262626;display:block;" />
|
|
3853
|
+
</label>
|
|
3854
|
+
<div style="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:1000;display:none;align-items:center;justify-content:center;">
|
|
3855
|
+
<label for="${checkId}" style="position:absolute;top:0;left:0;width:100%;height:100%;cursor:pointer;"></label>
|
|
3856
|
+
<img src="${dataUri}" alt="Step ${ss.stepNumber}: ${escapeHtml(ss.action)}"
|
|
3857
|
+
style="max-width:600px;max-height:90vh;border-radius:8px;position:relative;z-index:1001;" />
|
|
3858
|
+
</div>
|
|
3859
|
+
<div style="font-size:11px;color:#888;margin-top:4px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
|
3860
|
+
${ss.stepNumber}. ${escapeHtml(ss.action)}
|
|
3861
|
+
</div>
|
|
3862
|
+
</div>`;
|
|
3863
|
+
} else {
|
|
3864
|
+
html += `
|
|
3865
|
+
<div style="flex:0 0 auto;width:200px;height:150px;background:#1a1a1a;border:1px dashed #333;border-radius:6px;display:flex;align-items:center;justify-content:center;color:#555;font-size:12px;">
|
|
3866
|
+
Screenshot not found
|
|
3867
|
+
<div style="font-size:11px;color:#888;margin-top:4px;">${ss.stepNumber}. ${escapeHtml(ss.action)}</div>
|
|
3868
|
+
</div>`;
|
|
3869
|
+
}
|
|
3870
|
+
}
|
|
3871
|
+
html += `</div>`;
|
|
3872
|
+
return html;
|
|
3873
|
+
}
|
|
3874
|
+
function generateHtmlReport(runId) {
|
|
3875
|
+
const run = getRun(runId);
|
|
3876
|
+
if (!run)
|
|
3877
|
+
throw new Error(`Run not found: ${runId}`);
|
|
3878
|
+
const results = getResultsByRun(run.id);
|
|
3879
|
+
const resultData = [];
|
|
3880
|
+
for (const result of results) {
|
|
3881
|
+
const screenshots = listScreenshots(result.id);
|
|
3882
|
+
const scenario = getScenario(result.scenarioId);
|
|
3883
|
+
resultData.push({
|
|
3884
|
+
result,
|
|
3885
|
+
scenarioName: scenario?.name ?? "Unknown Scenario",
|
|
3886
|
+
scenarioShortId: scenario?.shortId ?? result.scenarioId.slice(0, 8),
|
|
3887
|
+
screenshots
|
|
3888
|
+
});
|
|
3889
|
+
}
|
|
3890
|
+
const passedCount = results.filter((r) => r.status === "passed").length;
|
|
3891
|
+
const failedCount = results.filter((r) => r.status === "failed").length;
|
|
3892
|
+
const errorCount = results.filter((r) => r.status === "error").length;
|
|
3893
|
+
const totalCount = results.length;
|
|
3894
|
+
const totalTokens = results.reduce((sum, r) => sum + r.tokensUsed, 0);
|
|
3895
|
+
const totalCostCents = results.reduce((sum, r) => sum + r.costCents, 0);
|
|
3896
|
+
const totalDurationMs = run.finishedAt && run.startedAt ? new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime() : results.reduce((sum, r) => sum + r.durationMs, 0);
|
|
3897
|
+
const generatedAt = new Date().toISOString();
|
|
3898
|
+
let resultCards = "";
|
|
3899
|
+
for (const { result, scenarioName, scenarioShortId, screenshots } of resultData) {
|
|
3900
|
+
resultCards += `
|
|
3901
|
+
<div style="background:#141414;border:1px solid #262626;border-radius:8px;padding:20px;margin-bottom:16px;">
|
|
3902
|
+
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
|
|
3903
|
+
${statusBadge(result.status)}
|
|
3904
|
+
<span style="font-size:16px;font-weight:600;color:#e5e5e5;">${escapeHtml(scenarioName)}</span>
|
|
3905
|
+
<span style="font-size:12px;color:#666;font-family:monospace;">${escapeHtml(scenarioShortId)}</span>
|
|
3906
|
+
</div>
|
|
3907
|
+
|
|
3908
|
+
${result.reasoning ? `<div style="color:#a3a3a3;font-size:14px;line-height:1.6;margin-bottom:12px;padding:12px;background:#0d0d0d;border-radius:6px;border-left:3px solid #333;">${escapeHtml(result.reasoning)}</div>` : ""}
|
|
3909
|
+
|
|
3910
|
+
${result.error ? `<div style="color:#ef4444;font-size:13px;margin-bottom:12px;padding:12px;background:#1a0a0a;border-radius:6px;border-left:3px solid #ef4444;font-family:monospace;">${escapeHtml(result.error)}</div>` : ""}
|
|
3911
|
+
|
|
3912
|
+
<div style="display:flex;gap:24px;font-size:13px;color:#888;">
|
|
3913
|
+
<span>Duration: <span style="color:#d4d4d4;">${formatDuration2(result.durationMs)}</span></span>
|
|
3914
|
+
<span>Steps: <span style="color:#d4d4d4;">${result.stepsCompleted}/${result.stepsTotal}</span></span>
|
|
3915
|
+
<span>Tokens: <span style="color:#d4d4d4;">${result.tokensUsed.toLocaleString()}</span></span>
|
|
3916
|
+
<span>Cost: <span style="color:#d4d4d4;">${formatCost(result.costCents)}</span></span>
|
|
3917
|
+
<span>Model: <span style="color:#d4d4d4;">${escapeHtml(result.model)}</span></span>
|
|
3918
|
+
</div>
|
|
3919
|
+
|
|
3920
|
+
${renderScreenshots(screenshots)}
|
|
3921
|
+
</div>`;
|
|
3922
|
+
}
|
|
3923
|
+
return `<!DOCTYPE html>
|
|
3924
|
+
<html lang="en">
|
|
3925
|
+
<head>
|
|
3926
|
+
<meta charset="UTF-8" />
|
|
3927
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
3928
|
+
<title>Test Report - ${escapeHtml(run.id.slice(0, 8))}</title>
|
|
3929
|
+
<style>
|
|
3930
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
3931
|
+
body { background: #0a0a0a; color: #e5e5e5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 40px 20px; }
|
|
3932
|
+
.container { max-width: 960px; margin: 0 auto; }
|
|
3933
|
+
input[type="checkbox"]:checked ~ div:last-of-type { display: flex !important; }
|
|
3934
|
+
</style>
|
|
3935
|
+
</head>
|
|
3936
|
+
<body>
|
|
3937
|
+
<div class="container">
|
|
3938
|
+
<!-- Header -->
|
|
3939
|
+
<div style="margin-bottom:32px;">
|
|
3940
|
+
<h1 style="font-size:28px;font-weight:700;margin-bottom:8px;color:#fff;">Test Report</h1>
|
|
3941
|
+
<div style="display:flex;flex-wrap:wrap;gap:24px;font-size:14px;color:#888;">
|
|
3942
|
+
<span>Run: <span style="color:#d4d4d4;font-family:monospace;">${escapeHtml(run.id.slice(0, 8))}</span></span>
|
|
3943
|
+
<span>URL: <a href="${escapeHtml(run.url)}" style="color:#60a5fa;text-decoration:none;">${escapeHtml(run.url)}</a></span>
|
|
3944
|
+
<span>Model: <span style="color:#d4d4d4;">${escapeHtml(run.model)}</span></span>
|
|
3945
|
+
<span>Date: <span style="color:#d4d4d4;">${escapeHtml(run.startedAt)}</span></span>
|
|
3946
|
+
<span>Duration: <span style="color:#d4d4d4;">${formatDuration2(totalDurationMs)}</span></span>
|
|
3947
|
+
<span>Status: ${statusBadge(run.status)}</span>
|
|
3948
|
+
</div>
|
|
3949
|
+
</div>
|
|
3950
|
+
|
|
3951
|
+
<!-- Summary Bar -->
|
|
3952
|
+
<div style="display:flex;gap:16px;margin-bottom:32px;">
|
|
3953
|
+
<div style="flex:1;background:#141414;border:1px solid #262626;border-radius:8px;padding:16px;text-align:center;">
|
|
3954
|
+
<div style="font-size:28px;font-weight:700;color:#e5e5e5;">${totalCount}</div>
|
|
3955
|
+
<div style="font-size:12px;color:#888;margin-top:4px;">TOTAL</div>
|
|
3956
|
+
</div>
|
|
3957
|
+
<div style="flex:1;background:#141414;border:1px solid #262626;border-radius:8px;padding:16px;text-align:center;">
|
|
3958
|
+
<div style="font-size:28px;font-weight:700;color:#22c55e;">${passedCount}</div>
|
|
3959
|
+
<div style="font-size:12px;color:#888;margin-top:4px;">PASSED</div>
|
|
3960
|
+
</div>
|
|
3961
|
+
<div style="flex:1;background:#141414;border:1px solid #262626;border-radius:8px;padding:16px;text-align:center;">
|
|
3962
|
+
<div style="font-size:28px;font-weight:700;color:#ef4444;">${failedCount}</div>
|
|
3963
|
+
<div style="font-size:12px;color:#888;margin-top:4px;">FAILED</div>
|
|
3964
|
+
</div>
|
|
3965
|
+
${errorCount > 0 ? `
|
|
3966
|
+
<div style="flex:1;background:#141414;border:1px solid #262626;border-radius:8px;padding:16px;text-align:center;">
|
|
3967
|
+
<div style="font-size:28px;font-weight:700;color:#eab308;">${errorCount}</div>
|
|
3968
|
+
<div style="font-size:12px;color:#888;margin-top:4px;">ERRORS</div>
|
|
3969
|
+
</div>` : ""}
|
|
3970
|
+
</div>
|
|
3971
|
+
|
|
3972
|
+
<!-- Results -->
|
|
3973
|
+
${resultCards}
|
|
3974
|
+
|
|
3975
|
+
<!-- Footer -->
|
|
3976
|
+
<div style="margin-top:32px;padding-top:20px;border-top:1px solid #262626;display:flex;justify-content:space-between;font-size:13px;color:#666;">
|
|
3977
|
+
<div>
|
|
3978
|
+
Total tokens: ${totalTokens.toLocaleString()} | Total cost: ${formatCost(totalCostCents)}
|
|
3979
|
+
</div>
|
|
3980
|
+
<div>
|
|
3981
|
+
Generated: ${escapeHtml(generatedAt)}
|
|
3982
|
+
</div>
|
|
3983
|
+
</div>
|
|
3984
|
+
</div>
|
|
3985
|
+
</body>
|
|
3986
|
+
</html>`;
|
|
3987
|
+
}
|
|
3988
|
+
function generateLatestReport() {
|
|
3989
|
+
const runs = listRuns({ limit: 1 });
|
|
3990
|
+
if (runs.length === 0)
|
|
3991
|
+
throw new Error("No runs found");
|
|
3992
|
+
return generateHtmlReport(runs[0].id);
|
|
3993
|
+
}
|
|
3994
|
+
// src/lib/costs.ts
|
|
3995
|
+
init_database();
|
|
3996
|
+
function getDateFilter(period) {
|
|
3997
|
+
switch (period) {
|
|
3998
|
+
case "day":
|
|
3999
|
+
return "AND r.created_at >= date('now', 'start of day')";
|
|
4000
|
+
case "week":
|
|
4001
|
+
return "AND r.created_at >= date('now', '-7 days')";
|
|
4002
|
+
case "month":
|
|
4003
|
+
return "AND r.created_at >= date('now', '-30 days')";
|
|
4004
|
+
case "all":
|
|
4005
|
+
return "";
|
|
4006
|
+
}
|
|
4007
|
+
}
|
|
4008
|
+
function getPeriodDays(period) {
|
|
4009
|
+
switch (period) {
|
|
4010
|
+
case "day":
|
|
4011
|
+
return 1;
|
|
4012
|
+
case "week":
|
|
4013
|
+
return 7;
|
|
4014
|
+
case "month":
|
|
4015
|
+
return 30;
|
|
4016
|
+
case "all":
|
|
4017
|
+
return 30;
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
function loadBudgetConfig() {
|
|
4021
|
+
const config = loadConfig();
|
|
4022
|
+
const budget = config.budget;
|
|
4023
|
+
return {
|
|
4024
|
+
maxPerRunCents: budget?.maxPerRunCents ?? 50,
|
|
4025
|
+
maxPerDayCents: budget?.maxPerDayCents ?? 500,
|
|
4026
|
+
warnAtPercent: budget?.warnAtPercent ?? 0.8
|
|
4027
|
+
};
|
|
4028
|
+
}
|
|
4029
|
+
function getCostSummary(options) {
|
|
4030
|
+
const db2 = getDatabase();
|
|
4031
|
+
const period = options?.period ?? "month";
|
|
4032
|
+
const projectId = options?.projectId;
|
|
4033
|
+
const dateFilter = getDateFilter(period);
|
|
4034
|
+
const projectFilter = projectId ? "AND ru.project_id = ?" : "";
|
|
4035
|
+
const projectParams = projectId ? [projectId] : [];
|
|
4036
|
+
const totalsRow = db2.query(`SELECT
|
|
4037
|
+
COALESCE(SUM(r.cost_cents), 0) as total_cost,
|
|
4038
|
+
COALESCE(SUM(r.tokens_used), 0) as total_tokens,
|
|
4039
|
+
COUNT(DISTINCT r.run_id) as run_count
|
|
4040
|
+
FROM results r
|
|
4041
|
+
JOIN runs ru ON r.run_id = ru.id
|
|
4042
|
+
WHERE 1=1 ${dateFilter} ${projectFilter}`).get(...projectParams);
|
|
4043
|
+
const modelRows = db2.query(`SELECT
|
|
4044
|
+
r.model,
|
|
4045
|
+
COALESCE(SUM(r.cost_cents), 0) as cost_cents,
|
|
4046
|
+
COALESCE(SUM(r.tokens_used), 0) as tokens,
|
|
4047
|
+
COUNT(DISTINCT r.run_id) as runs
|
|
4048
|
+
FROM results r
|
|
4049
|
+
JOIN runs ru ON r.run_id = ru.id
|
|
4050
|
+
WHERE 1=1 ${dateFilter} ${projectFilter}
|
|
4051
|
+
GROUP BY r.model
|
|
4052
|
+
ORDER BY cost_cents DESC`).all(...projectParams);
|
|
4053
|
+
const byModel = {};
|
|
4054
|
+
for (const row of modelRows) {
|
|
4055
|
+
byModel[row.model] = {
|
|
4056
|
+
costCents: row.cost_cents,
|
|
4057
|
+
tokens: row.tokens,
|
|
4058
|
+
runs: row.runs
|
|
4059
|
+
};
|
|
4060
|
+
}
|
|
4061
|
+
const scenarioRows = db2.query(`SELECT
|
|
4062
|
+
r.scenario_id,
|
|
4063
|
+
COALESCE(s.name, r.scenario_id) as name,
|
|
4064
|
+
COALESCE(SUM(r.cost_cents), 0) as cost_cents,
|
|
4065
|
+
COALESCE(SUM(r.tokens_used), 0) as tokens,
|
|
4066
|
+
COUNT(DISTINCT r.run_id) as runs
|
|
4067
|
+
FROM results r
|
|
4068
|
+
JOIN runs ru ON r.run_id = ru.id
|
|
4069
|
+
LEFT JOIN scenarios s ON r.scenario_id = s.id
|
|
4070
|
+
WHERE 1=1 ${dateFilter} ${projectFilter}
|
|
4071
|
+
GROUP BY r.scenario_id
|
|
4072
|
+
ORDER BY cost_cents DESC
|
|
4073
|
+
LIMIT 10`).all(...projectParams);
|
|
4074
|
+
const byScenario = scenarioRows.map((row) => ({
|
|
4075
|
+
scenarioId: row.scenario_id,
|
|
4076
|
+
name: row.name,
|
|
4077
|
+
costCents: row.cost_cents,
|
|
4078
|
+
tokens: row.tokens,
|
|
4079
|
+
runs: row.runs
|
|
4080
|
+
}));
|
|
4081
|
+
const runCount = totalsRow.run_count;
|
|
4082
|
+
const avgCostPerRun = runCount > 0 ? totalsRow.total_cost / runCount : 0;
|
|
4083
|
+
const periodDays = getPeriodDays(period);
|
|
4084
|
+
const estimatedMonthlyCents = periodDays > 0 ? totalsRow.total_cost / periodDays * 30 : 0;
|
|
4085
|
+
return {
|
|
4086
|
+
period,
|
|
4087
|
+
totalCostCents: totalsRow.total_cost,
|
|
4088
|
+
totalTokens: totalsRow.total_tokens,
|
|
4089
|
+
runCount,
|
|
4090
|
+
byModel,
|
|
4091
|
+
byScenario,
|
|
4092
|
+
avgCostPerRun,
|
|
4093
|
+
estimatedMonthlyCents
|
|
4094
|
+
};
|
|
4095
|
+
}
|
|
4096
|
+
function checkBudget(estimatedCostCents) {
|
|
4097
|
+
const budget = loadBudgetConfig();
|
|
4098
|
+
if (estimatedCostCents > budget.maxPerRunCents) {
|
|
4099
|
+
return {
|
|
4100
|
+
allowed: false,
|
|
4101
|
+
warning: `Estimated cost (${formatDollars(estimatedCostCents)}) exceeds per-run limit (${formatDollars(budget.maxPerRunCents)})`
|
|
4102
|
+
};
|
|
4103
|
+
}
|
|
4104
|
+
const todaySummary = getCostSummary({ period: "day" });
|
|
4105
|
+
const projectedDaily = todaySummary.totalCostCents + estimatedCostCents;
|
|
4106
|
+
if (projectedDaily > budget.maxPerDayCents) {
|
|
4107
|
+
return {
|
|
4108
|
+
allowed: false,
|
|
4109
|
+
warning: `Daily spending (${formatDollars(todaySummary.totalCostCents)}) + this run (${formatDollars(estimatedCostCents)}) would exceed daily limit (${formatDollars(budget.maxPerDayCents)})`
|
|
4110
|
+
};
|
|
4111
|
+
}
|
|
4112
|
+
if (projectedDaily > budget.maxPerDayCents * budget.warnAtPercent) {
|
|
4113
|
+
return {
|
|
4114
|
+
allowed: true,
|
|
4115
|
+
warning: `Approaching daily limit: ${formatDollars(projectedDaily)} of ${formatDollars(budget.maxPerDayCents)} (${Math.round(projectedDaily / budget.maxPerDayCents * 100)}%)`
|
|
4116
|
+
};
|
|
4117
|
+
}
|
|
4118
|
+
return { allowed: true };
|
|
4119
|
+
}
|
|
4120
|
+
function formatDollars(cents) {
|
|
4121
|
+
return `$${(cents / 100).toFixed(2)}`;
|
|
4122
|
+
}
|
|
4123
|
+
function formatTokens(tokens) {
|
|
4124
|
+
if (tokens >= 1e6)
|
|
4125
|
+
return `${(tokens / 1e6).toFixed(1)}M`;
|
|
4126
|
+
if (tokens >= 1000)
|
|
4127
|
+
return `${(tokens / 1000).toFixed(1)}K`;
|
|
4128
|
+
return String(tokens);
|
|
4129
|
+
}
|
|
4130
|
+
function formatCostsTerminal(summary) {
|
|
4131
|
+
const lines = [];
|
|
4132
|
+
lines.push("");
|
|
4133
|
+
lines.push(source_default.bold(` Cost Summary (${summary.period})`));
|
|
4134
|
+
lines.push("");
|
|
4135
|
+
lines.push(` Total: ${source_default.yellow(formatDollars(summary.totalCostCents))} (${formatTokens(summary.totalTokens)} tokens across ${summary.runCount} runs)`);
|
|
4136
|
+
lines.push(` Avg/run: ${source_default.yellow(formatDollars(summary.avgCostPerRun))}`);
|
|
4137
|
+
lines.push(` Est/month: ${source_default.yellow(formatDollars(summary.estimatedMonthlyCents))}`);
|
|
4138
|
+
const modelEntries = Object.entries(summary.byModel);
|
|
4139
|
+
if (modelEntries.length > 0) {
|
|
4140
|
+
lines.push("");
|
|
4141
|
+
lines.push(source_default.bold(" By Model"));
|
|
4142
|
+
lines.push(` ${"Model".padEnd(40)} ${"Cost".padEnd(12)} ${"Tokens".padEnd(12)} Runs`);
|
|
4143
|
+
lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(12)} ${"\u2500".repeat(12)} ${"\u2500".repeat(6)}`);
|
|
4144
|
+
for (const [model, data] of modelEntries) {
|
|
4145
|
+
lines.push(` ${model.padEnd(40)} ${formatDollars(data.costCents).padEnd(12)} ${formatTokens(data.tokens).padEnd(12)} ${data.runs}`);
|
|
4146
|
+
}
|
|
4147
|
+
}
|
|
4148
|
+
if (summary.byScenario.length > 0) {
|
|
4149
|
+
lines.push("");
|
|
4150
|
+
lines.push(source_default.bold(" Top Scenarios by Cost"));
|
|
4151
|
+
lines.push(` ${"Scenario".padEnd(40)} ${"Cost".padEnd(12)} ${"Tokens".padEnd(12)} Runs`);
|
|
4152
|
+
lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(12)} ${"\u2500".repeat(12)} ${"\u2500".repeat(6)}`);
|
|
4153
|
+
for (const s of summary.byScenario) {
|
|
4154
|
+
const label = s.name.length > 38 ? s.name.slice(0, 35) + "..." : s.name;
|
|
4155
|
+
lines.push(` ${label.padEnd(40)} ${formatDollars(s.costCents).padEnd(12)} ${formatTokens(s.tokens).padEnd(12)} ${s.runs}`);
|
|
4156
|
+
}
|
|
4157
|
+
}
|
|
4158
|
+
lines.push("");
|
|
4159
|
+
return lines.join(`
|
|
4160
|
+
`);
|
|
4161
|
+
}
|
|
4162
|
+
function formatCostsJSON(summary) {
|
|
4163
|
+
return JSON.stringify(summary, null, 2);
|
|
4164
|
+
}
|
|
4165
|
+
// src/lib/watch.ts
|
|
4166
|
+
import { watch } from "fs";
|
|
4167
|
+
import { resolve } from "path";
|
|
4168
|
+
async function startWatcher(options) {
|
|
4169
|
+
const {
|
|
4170
|
+
dir,
|
|
4171
|
+
url,
|
|
4172
|
+
debounceMs = 2000,
|
|
4173
|
+
tags,
|
|
4174
|
+
priority,
|
|
4175
|
+
...runOpts
|
|
4176
|
+
} = options;
|
|
4177
|
+
const watchDir = resolve(dir);
|
|
4178
|
+
let debounceTimer = null;
|
|
4179
|
+
let isRunning = false;
|
|
4180
|
+
let lastChange = null;
|
|
4181
|
+
console.log("");
|
|
4182
|
+
console.log(source_default.bold(" Testers Watch Mode"));
|
|
4183
|
+
console.log(source_default.dim(` Watching: ${watchDir}`));
|
|
4184
|
+
console.log(source_default.dim(` Target: ${url}`));
|
|
4185
|
+
console.log(source_default.dim(` Debounce: ${debounceMs}ms`));
|
|
4186
|
+
console.log("");
|
|
4187
|
+
console.log(source_default.dim(" Waiting for file changes... (Ctrl+C to stop)"));
|
|
4188
|
+
console.log("");
|
|
4189
|
+
const runTests = async () => {
|
|
4190
|
+
if (isRunning)
|
|
4191
|
+
return;
|
|
4192
|
+
isRunning = true;
|
|
4193
|
+
console.log(source_default.blue(` [running] Testing against ${url}...`));
|
|
4194
|
+
if (lastChange) {
|
|
4195
|
+
console.log(source_default.dim(` Triggered by: ${lastChange}`));
|
|
4196
|
+
}
|
|
4197
|
+
console.log("");
|
|
4198
|
+
try {
|
|
4199
|
+
const { run, results } = await runByFilter({
|
|
4200
|
+
url,
|
|
4201
|
+
tags,
|
|
4202
|
+
priority,
|
|
4203
|
+
...runOpts
|
|
4204
|
+
});
|
|
4205
|
+
console.log(formatTerminal(run, results));
|
|
4206
|
+
const exitCode = getExitCode(run);
|
|
4207
|
+
if (exitCode === 0) {
|
|
4208
|
+
console.log(source_default.green(" All tests passed!"));
|
|
4209
|
+
} else {
|
|
4210
|
+
console.log(source_default.red(` ${run.failed} test(s) failed.`));
|
|
4211
|
+
}
|
|
4212
|
+
} catch (error) {
|
|
4213
|
+
console.error(source_default.red(` Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
4214
|
+
} finally {
|
|
4215
|
+
isRunning = false;
|
|
4216
|
+
console.log("");
|
|
4217
|
+
console.log(source_default.dim(" Waiting for file changes..."));
|
|
4218
|
+
console.log("");
|
|
4219
|
+
}
|
|
4220
|
+
};
|
|
4221
|
+
const watcher = watch(watchDir, { recursive: true }, (_event, filename) => {
|
|
4222
|
+
if (!filename)
|
|
4223
|
+
return;
|
|
4224
|
+
const ignored = [
|
|
4225
|
+
"node_modules",
|
|
4226
|
+
".git",
|
|
4227
|
+
"dist",
|
|
4228
|
+
".testers",
|
|
4229
|
+
".next",
|
|
4230
|
+
".nuxt",
|
|
4231
|
+
".svelte-kit"
|
|
4232
|
+
];
|
|
4233
|
+
if (ignored.some((dir2) => filename.includes(dir2)))
|
|
4234
|
+
return;
|
|
4235
|
+
const extensions = [".ts", ".tsx", ".js", ".jsx", ".vue", ".svelte", ".css", ".html"];
|
|
4236
|
+
if (!extensions.some((ext) => filename.endsWith(ext)))
|
|
4237
|
+
return;
|
|
4238
|
+
lastChange = filename;
|
|
4239
|
+
console.log(source_default.yellow(` [change] ${filename}`));
|
|
4240
|
+
if (debounceTimer)
|
|
4241
|
+
clearTimeout(debounceTimer);
|
|
4242
|
+
debounceTimer = setTimeout(() => {
|
|
4243
|
+
runTests();
|
|
4244
|
+
}, debounceMs);
|
|
4245
|
+
});
|
|
4246
|
+
const cleanup = () => {
|
|
4247
|
+
watcher.close();
|
|
4248
|
+
if (debounceTimer)
|
|
4249
|
+
clearTimeout(debounceTimer);
|
|
4250
|
+
console.log("");
|
|
4251
|
+
console.log(source_default.dim(" Watch mode stopped."));
|
|
4252
|
+
process.exit(0);
|
|
4253
|
+
};
|
|
4254
|
+
process.on("SIGINT", cleanup);
|
|
4255
|
+
process.on("SIGTERM", cleanup);
|
|
4256
|
+
await new Promise(() => {});
|
|
4257
|
+
}
|
|
4258
|
+
// src/lib/webhooks.ts
|
|
4259
|
+
init_database();
|
|
4260
|
+
function fromRow2(row) {
|
|
4261
|
+
return {
|
|
4262
|
+
id: row.id,
|
|
4263
|
+
url: row.url,
|
|
4264
|
+
events: JSON.parse(row.events),
|
|
4265
|
+
projectId: row.project_id,
|
|
4266
|
+
secret: row.secret,
|
|
4267
|
+
active: row.active === 1,
|
|
4268
|
+
createdAt: row.created_at
|
|
4269
|
+
};
|
|
4270
|
+
}
|
|
4271
|
+
function createWebhook(input) {
|
|
4272
|
+
const db2 = getDatabase();
|
|
4273
|
+
const id = uuid();
|
|
4274
|
+
const events = input.events ?? ["failed"];
|
|
4275
|
+
const secret = input.secret ?? crypto.randomUUID().replace(/-/g, "");
|
|
4276
|
+
db2.query(`
|
|
4277
|
+
INSERT INTO webhooks (id, url, events, project_id, secret, active, created_at)
|
|
4278
|
+
VALUES (?, ?, ?, ?, ?, 1, ?)
|
|
4279
|
+
`).run(id, input.url, JSON.stringify(events), input.projectId ?? null, secret, now());
|
|
4280
|
+
return getWebhook(id);
|
|
4281
|
+
}
|
|
4282
|
+
function getWebhook(id) {
|
|
4283
|
+
const db2 = getDatabase();
|
|
4284
|
+
const row = db2.query("SELECT * FROM webhooks WHERE id = ?").get(id);
|
|
4285
|
+
if (!row) {
|
|
4286
|
+
const rows = db2.query("SELECT * FROM webhooks WHERE id LIKE ? || '%'").all(id);
|
|
4287
|
+
if (rows.length === 1)
|
|
4288
|
+
return fromRow2(rows[0]);
|
|
4289
|
+
return null;
|
|
4290
|
+
}
|
|
4291
|
+
return fromRow2(row);
|
|
4292
|
+
}
|
|
4293
|
+
function listWebhooks(projectId) {
|
|
4294
|
+
const db2 = getDatabase();
|
|
4295
|
+
let query = "SELECT * FROM webhooks WHERE active = 1";
|
|
4296
|
+
const params = [];
|
|
4297
|
+
if (projectId) {
|
|
4298
|
+
query += " AND (project_id = ? OR project_id IS NULL)";
|
|
4299
|
+
params.push(projectId);
|
|
4300
|
+
}
|
|
4301
|
+
query += " ORDER BY created_at DESC";
|
|
4302
|
+
const rows = db2.query(query).all(...params);
|
|
4303
|
+
return rows.map(fromRow2);
|
|
4304
|
+
}
|
|
4305
|
+
function deleteWebhook(id) {
|
|
4306
|
+
const db2 = getDatabase();
|
|
4307
|
+
const webhook = getWebhook(id);
|
|
4308
|
+
if (!webhook)
|
|
4309
|
+
return false;
|
|
4310
|
+
db2.query("DELETE FROM webhooks WHERE id = ?").run(webhook.id);
|
|
4311
|
+
return true;
|
|
4312
|
+
}
|
|
4313
|
+
function signPayload(body, secret) {
|
|
4314
|
+
const encoder = new TextEncoder;
|
|
4315
|
+
const key = encoder.encode(secret);
|
|
4316
|
+
const data = encoder.encode(body);
|
|
4317
|
+
let hash = 0;
|
|
4318
|
+
for (let i = 0;i < data.length; i++) {
|
|
4319
|
+
hash = (hash << 5) - hash + data[i] + (key[i % key.length] ?? 0) | 0;
|
|
4320
|
+
}
|
|
4321
|
+
return `sha256=${Math.abs(hash).toString(16).padStart(16, "0")}`;
|
|
4322
|
+
}
|
|
4323
|
+
function formatSlackPayload(payload) {
|
|
4324
|
+
const status = payload.run.status === "passed" ? ":white_check_mark:" : ":x:";
|
|
4325
|
+
const color = payload.run.status === "passed" ? "#22c55e" : "#ef4444";
|
|
4326
|
+
return {
|
|
4327
|
+
attachments: [
|
|
4328
|
+
{
|
|
4329
|
+
color,
|
|
4330
|
+
blocks: [
|
|
4331
|
+
{
|
|
4332
|
+
type: "section",
|
|
4333
|
+
text: {
|
|
4334
|
+
type: "mrkdwn",
|
|
4335
|
+
text: `${status} *Test Run ${payload.run.status.toUpperCase()}*
|
|
4336
|
+
` + `URL: ${payload.run.url}
|
|
4337
|
+
` + `Results: ${payload.run.passed}/${payload.run.total} passed` + (payload.run.failed > 0 ? ` (${payload.run.failed} failed)` : "") + (payload.schedule ? `
|
|
4338
|
+
Schedule: ${payload.schedule.name}` : "")
|
|
4339
|
+
}
|
|
4340
|
+
}
|
|
4341
|
+
]
|
|
4342
|
+
}
|
|
4343
|
+
]
|
|
4344
|
+
};
|
|
4345
|
+
}
|
|
4346
|
+
async function dispatchWebhooks(event, run, schedule) {
|
|
4347
|
+
const webhooks = listWebhooks(run.projectId ?? undefined);
|
|
4348
|
+
const payload = {
|
|
4349
|
+
event,
|
|
4350
|
+
run: {
|
|
4351
|
+
id: run.id,
|
|
4352
|
+
url: run.url,
|
|
4353
|
+
status: run.status,
|
|
4354
|
+
passed: run.passed,
|
|
4355
|
+
failed: run.failed,
|
|
4356
|
+
total: run.total
|
|
4357
|
+
},
|
|
4358
|
+
schedule,
|
|
4359
|
+
timestamp: new Date().toISOString()
|
|
4360
|
+
};
|
|
4361
|
+
for (const webhook of webhooks) {
|
|
4362
|
+
if (!webhook.events.includes(event) && !webhook.events.includes("*"))
|
|
4363
|
+
continue;
|
|
4364
|
+
const isSlack = webhook.url.includes("hooks.slack.com");
|
|
4365
|
+
const body = isSlack ? JSON.stringify(formatSlackPayload(payload)) : JSON.stringify(payload);
|
|
4366
|
+
const headers = {
|
|
4367
|
+
"Content-Type": "application/json"
|
|
4368
|
+
};
|
|
4369
|
+
if (webhook.secret) {
|
|
4370
|
+
headers["X-Testers-Signature"] = signPayload(body, webhook.secret);
|
|
4371
|
+
}
|
|
4372
|
+
try {
|
|
4373
|
+
const response = await fetch(webhook.url, {
|
|
4374
|
+
method: "POST",
|
|
4375
|
+
headers,
|
|
4376
|
+
body
|
|
4377
|
+
});
|
|
4378
|
+
if (!response.ok) {
|
|
4379
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
4380
|
+
await fetch(webhook.url, { method: "POST", headers, body });
|
|
4381
|
+
}
|
|
4382
|
+
} catch {}
|
|
4383
|
+
}
|
|
4384
|
+
}
|
|
4385
|
+
async function testWebhook(id) {
|
|
4386
|
+
const webhook = getWebhook(id);
|
|
4387
|
+
if (!webhook)
|
|
4388
|
+
return false;
|
|
4389
|
+
const testPayload = {
|
|
4390
|
+
event: "test",
|
|
4391
|
+
run: { id: "test-run", url: "http://localhost:3000", status: "passed", passed: 3, failed: 0, total: 3 },
|
|
4392
|
+
timestamp: new Date().toISOString()
|
|
4393
|
+
};
|
|
4394
|
+
try {
|
|
4395
|
+
const body = JSON.stringify(testPayload);
|
|
4396
|
+
const response = await fetch(webhook.url, {
|
|
4397
|
+
method: "POST",
|
|
4398
|
+
headers: {
|
|
4399
|
+
"Content-Type": "application/json",
|
|
4400
|
+
...webhook.secret ? { "X-Testers-Signature": signPayload(body, webhook.secret) } : {}
|
|
4401
|
+
},
|
|
4402
|
+
body
|
|
4403
|
+
});
|
|
4404
|
+
return response.ok;
|
|
4405
|
+
} catch {
|
|
4406
|
+
return false;
|
|
4407
|
+
}
|
|
4408
|
+
}
|
|
2428
4409
|
export {
|
|
4410
|
+
writeScenarioMeta,
|
|
4411
|
+
writeRunMeta,
|
|
2429
4412
|
uuid,
|
|
4413
|
+
updateSchedule,
|
|
2430
4414
|
updateScenario,
|
|
2431
4415
|
updateRun,
|
|
2432
4416
|
updateResult,
|
|
4417
|
+
updateLastRun,
|
|
4418
|
+
testWebhook,
|
|
2433
4419
|
taskToScenarioInput,
|
|
4420
|
+
startWatcher,
|
|
2434
4421
|
slugify,
|
|
4422
|
+
shouldRunAt,
|
|
2435
4423
|
shortUuid,
|
|
2436
4424
|
screenshotFromRow,
|
|
4425
|
+
scheduleFromRow,
|
|
2437
4426
|
scenarioFromRow,
|
|
4427
|
+
runSmoke,
|
|
2438
4428
|
runSingleScenario,
|
|
2439
4429
|
runFromRow,
|
|
2440
4430
|
runByFilter,
|
|
@@ -2448,22 +4438,35 @@ export {
|
|
|
2448
4438
|
registerAgent,
|
|
2449
4439
|
pullTasks,
|
|
2450
4440
|
projectFromRow,
|
|
4441
|
+
parseSmokeIssues,
|
|
4442
|
+
parseCronField,
|
|
4443
|
+
parseCron,
|
|
2451
4444
|
onRunEvent,
|
|
2452
4445
|
now,
|
|
2453
4446
|
markTodoDone,
|
|
2454
4447
|
loadConfig,
|
|
4448
|
+
listWebhooks,
|
|
4449
|
+
listTemplateNames,
|
|
2455
4450
|
listScreenshots,
|
|
4451
|
+
listSchedules,
|
|
2456
4452
|
listScenarios,
|
|
2457
4453
|
listRuns,
|
|
2458
4454
|
listResults,
|
|
2459
4455
|
listProjects,
|
|
4456
|
+
listAuthPresets,
|
|
2460
4457
|
listAgents,
|
|
2461
4458
|
launchBrowser,
|
|
2462
4459
|
installBrowser,
|
|
4460
|
+
initProject,
|
|
2463
4461
|
importFromTodos,
|
|
4462
|
+
imageToBase64,
|
|
4463
|
+
getWebhook,
|
|
4464
|
+
getTemplate,
|
|
4465
|
+
getStarterScenarios,
|
|
2464
4466
|
getScreenshotsByResult,
|
|
2465
4467
|
getScreenshotDir,
|
|
2466
4468
|
getScreenshot,
|
|
4469
|
+
getSchedule,
|
|
2467
4470
|
getScenarioByShortId,
|
|
2468
4471
|
getScenario,
|
|
2469
4472
|
getRun,
|
|
@@ -2472,37 +4475,61 @@ export {
|
|
|
2472
4475
|
getProjectByPath,
|
|
2473
4476
|
getProject,
|
|
2474
4477
|
getPage,
|
|
4478
|
+
getNextRunTime,
|
|
2475
4479
|
getExitCode,
|
|
4480
|
+
getEnabledSchedules,
|
|
2476
4481
|
getDefaultConfig,
|
|
2477
4482
|
getDatabase,
|
|
4483
|
+
getCostSummary,
|
|
4484
|
+
getAuthPreset,
|
|
2478
4485
|
getAgentByName,
|
|
2479
4486
|
getAgent,
|
|
4487
|
+
generateLatestReport,
|
|
4488
|
+
generateHtmlReport,
|
|
2480
4489
|
generateFilename,
|
|
2481
4490
|
formatTerminal,
|
|
2482
4491
|
formatSummary,
|
|
4492
|
+
formatSmokeReport,
|
|
2483
4493
|
formatScenarioList,
|
|
2484
4494
|
formatRunList,
|
|
2485
4495
|
formatResultDetail,
|
|
2486
4496
|
formatJSON,
|
|
4497
|
+
formatDiffTerminal,
|
|
4498
|
+
formatDiffJSON,
|
|
4499
|
+
formatCostsTerminal,
|
|
4500
|
+
formatCostsJSON,
|
|
2487
4501
|
executeTool,
|
|
2488
4502
|
ensureProject,
|
|
2489
4503
|
ensureDir,
|
|
4504
|
+
dispatchWebhooks,
|
|
4505
|
+
diffRuns,
|
|
4506
|
+
detectFramework,
|
|
4507
|
+
deleteWebhook,
|
|
4508
|
+
deleteSchedule,
|
|
2490
4509
|
deleteScenario,
|
|
2491
4510
|
deleteRun,
|
|
4511
|
+
deleteAuthPreset,
|
|
4512
|
+
createWebhook,
|
|
2492
4513
|
createScreenshot,
|
|
4514
|
+
createSchedule,
|
|
2493
4515
|
createScenario,
|
|
2494
4516
|
createRun,
|
|
2495
4517
|
createResult,
|
|
2496
4518
|
createProject,
|
|
2497
4519
|
createClient,
|
|
4520
|
+
createAuthPreset,
|
|
2498
4521
|
connectToTodos,
|
|
2499
4522
|
closeDatabase,
|
|
2500
4523
|
closeBrowser,
|
|
4524
|
+
checkBudget,
|
|
2501
4525
|
agentFromRow,
|
|
2502
4526
|
VersionConflictError,
|
|
2503
4527
|
TodosConnectionError,
|
|
2504
4528
|
Screenshotter,
|
|
4529
|
+
Scheduler,
|
|
4530
|
+
ScheduleNotFoundError,
|
|
2505
4531
|
ScenarioNotFoundError,
|
|
4532
|
+
SCENARIO_TEMPLATES,
|
|
2506
4533
|
RunNotFoundError,
|
|
2507
4534
|
ResultNotFoundError,
|
|
2508
4535
|
ProjectNotFoundError,
|