@hasna/testers 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,10 +1,17 @@
1
1
  // @bun
2
- // src/types/index.ts
3
- var MODEL_MAP = {
4
- quick: "claude-haiku-4-5-20251001",
5
- thorough: "claude-sonnet-4-6-20260311",
6
- deep: "claude-opus-4-6-20260311"
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,7 +98,10 @@ 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
107
  function scheduleFromRow(row) {
@@ -114,82 +124,80 @@ function scheduleFromRow(row) {
114
124
  updatedAt: row.updated_at
115
125
  };
116
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
+ });
117
195
 
118
- class ScenarioNotFoundError extends Error {
119
- constructor(id) {
120
- super(`Scenario not found: ${id}`);
121
- this.name = "ScenarioNotFoundError";
122
- }
123
- }
124
-
125
- class RunNotFoundError extends Error {
126
- constructor(id) {
127
- super(`Run not found: ${id}`);
128
- this.name = "RunNotFoundError";
129
- }
130
- }
131
-
132
- class ResultNotFoundError extends Error {
133
- constructor(id) {
134
- super(`Result not found: ${id}`);
135
- this.name = "ResultNotFoundError";
136
- }
137
- }
138
-
139
- class VersionConflictError extends Error {
140
- constructor(entity, id) {
141
- super(`Version conflict on ${entity}: ${id}`);
142
- this.name = "VersionConflictError";
143
- }
144
- }
145
-
146
- class BrowserError extends Error {
147
- constructor(message) {
148
- super(message);
149
- this.name = "BrowserError";
150
- }
151
- }
152
-
153
- class AIClientError extends Error {
154
- constructor(message) {
155
- super(message);
156
- this.name = "AIClientError";
157
- }
158
- }
159
-
160
- class TodosConnectionError extends Error {
161
- constructor(message) {
162
- super(message);
163
- this.name = "TodosConnectionError";
164
- }
165
- }
166
-
167
- class ProjectNotFoundError extends Error {
168
- constructor(id) {
169
- super(`Project not found: ${id}`);
170
- this.name = "ProjectNotFoundError";
171
- }
172
- }
173
-
174
- class AgentNotFoundError extends Error {
175
- constructor(id) {
176
- super(`Agent not found: ${id}`);
177
- this.name = "AgentNotFoundError";
178
- }
179
- }
180
-
181
- class ScheduleNotFoundError extends Error {
182
- constructor(id) {
183
- super(`Schedule not found: ${id}`);
184
- this.name = "ScheduleNotFoundError";
185
- }
186
- }
187
196
  // src/db/database.ts
188
197
  import { Database } from "bun:sqlite";
189
198
  import { mkdirSync, existsSync } from "fs";
190
199
  import { dirname, join } from "path";
191
200
  import { homedir } from "os";
192
- var db = null;
193
201
  function now() {
194
202
  return new Date().toISOString();
195
203
  }
@@ -208,8 +216,69 @@ function resolveDbPath() {
208
216
  mkdirSync(dir, { recursive: true });
209
217
  return join(dir, "testers.db");
210
218
  }
211
- var MIGRATIONS = [
212
- `
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
+ `
213
282
  CREATE TABLE IF NOT EXISTS projects (
214
283
  id TEXT PRIMARY KEY,
215
284
  name TEXT NOT NULL UNIQUE,
@@ -298,7 +367,7 @@ var MIGRATIONS = [
298
367
  applied_at TEXT NOT NULL DEFAULT (datetime('now'))
299
368
  );
300
369
  `,
301
- `
370
+ `
302
371
  CREATE INDEX IF NOT EXISTS idx_scenarios_project ON scenarios(project_id);
303
372
  CREATE INDEX IF NOT EXISTS idx_scenarios_priority ON scenarios(priority);
304
373
  CREATE INDEX IF NOT EXISTS idx_scenarios_short_id ON scenarios(short_id);
@@ -309,11 +378,11 @@ var MIGRATIONS = [
309
378
  CREATE INDEX IF NOT EXISTS idx_results_status ON results(status);
310
379
  CREATE INDEX IF NOT EXISTS idx_screenshots_result ON screenshots(result_id);
311
380
  `,
312
- `
381
+ `
313
382
  ALTER TABLE projects ADD COLUMN scenario_prefix TEXT DEFAULT 'TST';
314
383
  ALTER TABLE projects ADD COLUMN scenario_counter INTEGER DEFAULT 0;
315
384
  `,
316
- `
385
+ `
317
386
  CREATE TABLE IF NOT EXISTS schedules (
318
387
  id TEXT PRIMARY KEY,
319
388
  project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
@@ -336,111 +405,71 @@ var MIGRATIONS = [
336
405
  CREATE INDEX IF NOT EXISTS idx_schedules_project ON schedules(project_id);
337
406
  CREATE INDEX IF NOT EXISTS idx_schedules_enabled ON schedules(enabled);
338
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);
339
436
  `
340
- ];
341
- function applyMigrations(database) {
342
- const applied = database.query("SELECT id FROM _migrations ORDER BY id").all();
343
- const appliedIds = new Set(applied.map((r) => r.id));
344
- for (let i = 0;i < MIGRATIONS.length; i++) {
345
- const migrationId = i + 1;
346
- if (appliedIds.has(migrationId))
347
- continue;
348
- const migration = MIGRATIONS[i];
349
- database.exec(migration);
350
- database.query("INSERT INTO _migrations (id, applied_at) VALUES (?, ?)").run(migrationId, now());
351
- }
352
- }
353
- function getDatabase() {
354
- if (db)
355
- return db;
356
- const dbPath = resolveDbPath();
357
- const dir = dirname(dbPath);
358
- if (dbPath !== ":memory:" && !existsSync(dir)) {
359
- mkdirSync(dir, { recursive: true });
360
- }
361
- db = new Database(dbPath);
362
- db.exec("PRAGMA journal_mode = WAL");
363
- db.exec("PRAGMA foreign_keys = ON");
364
- db.exec("PRAGMA busy_timeout = 5000");
365
- db.exec(`
366
- CREATE TABLE IF NOT EXISTS _migrations (
367
- id INTEGER PRIMARY KEY,
368
- applied_at TEXT NOT NULL DEFAULT (datetime('now'))
369
- );
370
- `);
371
- applyMigrations(db);
372
- return db;
373
- }
374
- function closeDatabase() {
375
- if (db) {
376
- db.close();
377
- db = null;
378
- }
379
- }
380
- function resetDatabase() {
381
- closeDatabase();
382
- const database = getDatabase();
383
- database.exec("DELETE FROM screenshots");
384
- database.exec("DELETE FROM results");
385
- database.exec("DELETE FROM schedules");
386
- database.exec("DELETE FROM runs");
387
- database.exec("DELETE FROM scenarios");
388
- database.exec("DELETE FROM agents");
389
- database.exec("DELETE FROM projects");
390
- }
391
- function resolvePartialId(table, partialId) {
392
- const database = getDatabase();
393
- const rows = database.query(`SELECT id FROM ${table} WHERE id LIKE ? || '%'`).all(partialId);
394
- if (rows.length === 1)
395
- return rows[0].id;
396
- return null;
397
- }
398
- // src/db/scenarios.ts
399
- function nextShortId(projectId) {
400
- const db2 = getDatabase();
401
- if (projectId) {
402
- const project = db2.query("SELECT scenario_prefix, scenario_counter FROM projects WHERE id = ?").get(projectId);
403
- if (project) {
404
- const next = project.scenario_counter + 1;
405
- db2.query("UPDATE projects SET scenario_counter = ? WHERE id = ?").run(next, projectId);
406
- return `${project.scenario_prefix}-${next}`;
407
- }
408
- }
409
- return shortUuid();
410
- }
411
- 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) {
412
450
  const db2 = getDatabase();
413
451
  const id = uuid();
414
- const short_id = nextShortId(input.projectId);
415
452
  const timestamp = now();
416
453
  db2.query(`
417
- 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)
418
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
419
- `).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);
420
- return getScenario(id);
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);
421
458
  }
422
- function getScenario(id) {
459
+ function getRun(id) {
423
460
  const db2 = getDatabase();
424
- let row = db2.query("SELECT * FROM scenarios WHERE id = ?").get(id);
425
- if (row)
426
- return scenarioFromRow(row);
427
- row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(id);
461
+ let row = db2.query("SELECT * FROM runs WHERE id = ?").get(id);
428
462
  if (row)
429
- return scenarioFromRow(row);
430
- const fullId = resolvePartialId("scenarios", id);
463
+ return runFromRow(row);
464
+ const fullId = resolvePartialId("runs", id);
431
465
  if (fullId) {
432
- row = db2.query("SELECT * FROM scenarios WHERE id = ?").get(fullId);
466
+ row = db2.query("SELECT * FROM runs WHERE id = ?").get(fullId);
433
467
  if (row)
434
- return scenarioFromRow(row);
468
+ return runFromRow(row);
435
469
  }
436
470
  return null;
437
471
  }
438
- function getScenarioByShortId(shortId) {
439
- const db2 = getDatabase();
440
- const row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(shortId);
441
- return row ? scenarioFromRow(row) : null;
442
- }
443
- function listScenarios(filter) {
472
+ function listRuns(filter) {
444
473
  const db2 = getDatabase();
445
474
  const conditions = [];
446
475
  const params = [];
@@ -448,19 +477,170 @@ function listScenarios(filter) {
448
477
  conditions.push("project_id = ?");
449
478
  params.push(filter.projectId);
450
479
  }
451
- if (filter?.tags && filter.tags.length > 0) {
452
- for (const tag of filter.tags) {
453
- conditions.push("tags LIKE ?");
454
- params.push(`%"${tag}"%`);
455
- }
480
+ if (filter?.status) {
481
+ conditions.push("status = ?");
482
+ params.push(filter.status);
456
483
  }
457
- if (filter?.priority) {
458
- conditions.push("priority = ?");
459
- params.push(filter.priority);
484
+ let sql = "SELECT * FROM runs";
485
+ if (conditions.length > 0) {
486
+ sql += " WHERE " + conditions.join(" AND ");
460
487
  }
461
- if (filter?.search) {
462
- conditions.push("(name LIKE ? OR description LIKE ?)");
463
- const term = `%${filter.search}%`;
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) {
632
+ for (const tag of filter.tags) {
633
+ conditions.push("tags LIKE ?");
634
+ params.push(`%"${tag}"%`);
635
+ }
636
+ }
637
+ if (filter?.priority) {
638
+ conditions.push("priority = ?");
639
+ params.push(filter.priority);
640
+ }
641
+ if (filter?.search) {
642
+ conditions.push("(name LIKE ? OR description LIKE ?)");
643
+ const term = `%${filter.search}%`;
464
644
  params.push(term, term);
465
645
  }
466
646
  let sql = "SELECT * FROM scenarios";
@@ -557,63 +737,46 @@ function deleteScenario(id) {
557
737
  const result = db2.query("DELETE FROM scenarios WHERE id = ?").run(scenario.id);
558
738
  return result.changes > 0;
559
739
  }
560
- // src/db/runs.ts
561
- function createRun(input) {
740
+
741
+ // src/index.ts
742
+ init_runs();
743
+
744
+ // src/db/results.ts
745
+ init_types();
746
+ init_database();
747
+ function createResult(input) {
562
748
  const db2 = getDatabase();
563
749
  const id = uuid();
564
750
  const timestamp = now();
565
751
  db2.query(`
566
- INSERT INTO runs (id, project_id, status, url, model, headed, parallel, total, passed, failed, started_at, finished_at, metadata)
567
- VALUES (?, ?, 'pending', ?, ?, ?, ?, 0, 0, 0, ?, NULL, ?)
568
- `).run(id, input.projectId ?? null, input.url, input.model, input.headed ? 1 : 0, input.parallel ?? 1, timestamp, input.model ? JSON.stringify({}) : null);
569
- return getRun(id);
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);
570
756
  }
571
- function getRun(id) {
757
+ function getResult(id) {
572
758
  const db2 = getDatabase();
573
- let row = db2.query("SELECT * FROM runs WHERE id = ?").get(id);
759
+ let row = db2.query("SELECT * FROM results WHERE id = ?").get(id);
574
760
  if (row)
575
- return runFromRow(row);
576
- const fullId = resolvePartialId("runs", id);
761
+ return resultFromRow(row);
762
+ const fullId = resolvePartialId("results", id);
577
763
  if (fullId) {
578
- row = db2.query("SELECT * FROM runs WHERE id = ?").get(fullId);
764
+ row = db2.query("SELECT * FROM results WHERE id = ?").get(fullId);
579
765
  if (row)
580
- return runFromRow(row);
766
+ return resultFromRow(row);
581
767
  }
582
768
  return null;
583
769
  }
584
- function listRuns(filter) {
770
+ function listResults(runId) {
585
771
  const db2 = getDatabase();
586
- const conditions = [];
587
- const params = [];
588
- if (filter?.projectId) {
589
- conditions.push("project_id = ?");
590
- params.push(filter.projectId);
591
- }
592
- if (filter?.status) {
593
- conditions.push("status = ?");
594
- params.push(filter.status);
595
- }
596
- let sql = "SELECT * FROM runs";
597
- if (conditions.length > 0) {
598
- sql += " WHERE " + conditions.join(" AND ");
599
- }
600
- sql += " ORDER BY started_at DESC";
601
- if (filter?.limit) {
602
- sql += " LIMIT ?";
603
- params.push(filter.limit);
604
- }
605
- if (filter?.offset) {
606
- sql += " OFFSET ?";
607
- params.push(filter.offset);
608
- }
609
- const rows = db2.query(sql).all(...params);
610
- 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);
611
774
  }
612
- function updateRun(id, updates) {
775
+ function updateResult(id, updates) {
613
776
  const db2 = getDatabase();
614
- const existing = getRun(id);
777
+ const existing = getResult(id);
615
778
  if (!existing) {
616
- throw new Error(`Run not found: ${id}`);
779
+ throw new Error(`Result not found: ${id}`);
617
780
  }
618
781
  const sets = [];
619
782
  const params = [];
@@ -621,128 +784,32 @@ function updateRun(id, updates) {
621
784
  sets.push("status = ?");
622
785
  params.push(updates.status);
623
786
  }
624
- if (updates.url !== undefined) {
625
- sets.push("url = ?");
626
- params.push(updates.url);
627
- }
628
- if (updates.model !== undefined) {
629
- sets.push("model = ?");
630
- params.push(updates.model);
787
+ if (updates.reasoning !== undefined) {
788
+ sets.push("reasoning = ?");
789
+ params.push(updates.reasoning);
631
790
  }
632
- if (updates.headed !== undefined) {
633
- sets.push("headed = ?");
634
- params.push(updates.headed);
791
+ if (updates.error !== undefined) {
792
+ sets.push("error = ?");
793
+ params.push(updates.error);
635
794
  }
636
- if (updates.parallel !== undefined) {
637
- sets.push("parallel = ?");
638
- params.push(updates.parallel);
795
+ if (updates.stepsCompleted !== undefined) {
796
+ sets.push("steps_completed = ?");
797
+ params.push(updates.stepsCompleted);
639
798
  }
640
- if (updates.total !== undefined) {
641
- sets.push("total = ?");
642
- params.push(updates.total);
799
+ if (updates.durationMs !== undefined) {
800
+ sets.push("duration_ms = ?");
801
+ params.push(updates.durationMs);
643
802
  }
644
- if (updates.passed !== undefined) {
645
- sets.push("passed = ?");
646
- params.push(updates.passed);
803
+ if (updates.tokensUsed !== undefined) {
804
+ sets.push("tokens_used = ?");
805
+ params.push(updates.tokensUsed);
647
806
  }
648
- if (updates.failed !== undefined) {
649
- sets.push("failed = ?");
650
- params.push(updates.failed);
807
+ if (updates.costCents !== undefined) {
808
+ sets.push("cost_cents = ?");
809
+ params.push(updates.costCents);
651
810
  }
652
- if (updates.started_at !== undefined) {
653
- sets.push("started_at = ?");
654
- params.push(updates.started_at);
655
- }
656
- if (updates.finished_at !== undefined) {
657
- sets.push("finished_at = ?");
658
- params.push(updates.finished_at);
659
- }
660
- if (updates.metadata !== undefined) {
661
- sets.push("metadata = ?");
662
- params.push(updates.metadata);
663
- }
664
- if (sets.length === 0) {
665
- return existing;
666
- }
667
- params.push(existing.id);
668
- db2.query(`UPDATE runs SET ${sets.join(", ")} WHERE id = ?`).run(...params);
669
- return getRun(existing.id);
670
- }
671
- function deleteRun(id) {
672
- const db2 = getDatabase();
673
- const run = getRun(id);
674
- if (!run)
675
- return false;
676
- const result = db2.query("DELETE FROM runs WHERE id = ?").run(run.id);
677
- return result.changes > 0;
678
- }
679
- // src/db/results.ts
680
- function createResult(input) {
681
- const db2 = getDatabase();
682
- const id = uuid();
683
- const timestamp = now();
684
- db2.query(`
685
- 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)
686
- VALUES (?, ?, ?, 'skipped', NULL, NULL, 0, ?, 0, ?, 0, 0, '{}', ?)
687
- `).run(id, input.runId, input.scenarioId, input.stepsTotal, input.model, timestamp);
688
- return getResult(id);
689
- }
690
- function getResult(id) {
691
- const db2 = getDatabase();
692
- let row = db2.query("SELECT * FROM results WHERE id = ?").get(id);
693
- if (row)
694
- return resultFromRow(row);
695
- const fullId = resolvePartialId("results", id);
696
- if (fullId) {
697
- row = db2.query("SELECT * FROM results WHERE id = ?").get(fullId);
698
- if (row)
699
- return resultFromRow(row);
700
- }
701
- return null;
702
- }
703
- function listResults(runId) {
704
- const db2 = getDatabase();
705
- const rows = db2.query("SELECT * FROM results WHERE run_id = ? ORDER BY created_at ASC").all(runId);
706
- return rows.map(resultFromRow);
707
- }
708
- function updateResult(id, updates) {
709
- const db2 = getDatabase();
710
- const existing = getResult(id);
711
- if (!existing) {
712
- throw new Error(`Result not found: ${id}`);
713
- }
714
- const sets = [];
715
- const params = [];
716
- if (updates.status !== undefined) {
717
- sets.push("status = ?");
718
- params.push(updates.status);
719
- }
720
- if (updates.reasoning !== undefined) {
721
- sets.push("reasoning = ?");
722
- params.push(updates.reasoning);
723
- }
724
- if (updates.error !== undefined) {
725
- sets.push("error = ?");
726
- params.push(updates.error);
727
- }
728
- if (updates.stepsCompleted !== undefined) {
729
- sets.push("steps_completed = ?");
730
- params.push(updates.stepsCompleted);
731
- }
732
- if (updates.durationMs !== undefined) {
733
- sets.push("duration_ms = ?");
734
- params.push(updates.durationMs);
735
- }
736
- if (updates.tokensUsed !== undefined) {
737
- sets.push("tokens_used = ?");
738
- params.push(updates.tokensUsed);
739
- }
740
- if (updates.costCents !== undefined) {
741
- sets.push("cost_cents = ?");
742
- params.push(updates.costCents);
743
- }
744
- if (sets.length === 0) {
745
- return existing;
811
+ if (sets.length === 0) {
812
+ return existing;
746
813
  }
747
814
  params.push(existing.id);
748
815
  db2.query(`UPDATE results SET ${sets.join(", ")} WHERE id = ?`).run(...params);
@@ -752,14 +819,16 @@ function getResultsByRun(runId) {
752
819
  return listResults(runId);
753
820
  }
754
821
  // src/db/screenshots.ts
822
+ init_types();
823
+ init_database();
755
824
  function createScreenshot(input) {
756
825
  const db2 = getDatabase();
757
826
  const id = uuid();
758
827
  const timestamp = now();
759
828
  db2.query(`
760
- INSERT INTO screenshots (id, result_id, step_number, action, file_path, width, height, timestamp)
761
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
762
- `).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);
763
832
  return getScreenshot(id);
764
833
  }
765
834
  function getScreenshot(id) {
@@ -776,6 +845,8 @@ function getScreenshotsByResult(resultId) {
776
845
  return listScreenshots(resultId);
777
846
  }
778
847
  // src/db/projects.ts
848
+ init_types();
849
+ init_database();
779
850
  function createProject(input) {
780
851
  const db2 = getDatabase();
781
852
  const id = uuid();
@@ -812,6 +883,8 @@ function ensureProject(name, path) {
812
883
  return createProject({ name, path });
813
884
  }
814
885
  // src/db/agents.ts
886
+ init_types();
887
+ init_database();
815
888
  function registerAgent(input) {
816
889
  const db2 = getDatabase();
817
890
  const existing = db2.query("SELECT * FROM agents WHERE name = ?").get(input.name);
@@ -843,6 +916,9 @@ function listAgents() {
843
916
  return rows.map(agentFromRow);
844
917
  }
845
918
  // src/db/schedules.ts
919
+ init_database();
920
+ init_types();
921
+ init_database();
846
922
  function createSchedule(input) {
847
923
  const db2 = getDatabase();
848
924
  const id = uuid();
@@ -968,6 +1044,7 @@ function updateLastRun(id, runId, nextRunAt) {
968
1044
  `).run(runId, timestamp, nextRunAt, timestamp, id);
969
1045
  }
970
1046
  // src/lib/config.ts
1047
+ init_types();
971
1048
  import { homedir as homedir2 } from "os";
972
1049
  import { join as join2 } from "path";
973
1050
  import { readFileSync, existsSync as existsSync2 } from "fs";
@@ -1028,6 +1105,7 @@ function resolveModel(nameOrId) {
1028
1105
  return nameOrId;
1029
1106
  }
1030
1107
  // src/lib/browser.ts
1108
+ init_types();
1031
1109
  import { chromium } from "playwright";
1032
1110
  import { execSync } from "child_process";
1033
1111
  var DEFAULT_VIEWPORT = { width: 1280, height: 720 };
@@ -1132,7 +1210,7 @@ async function installBrowser() {
1132
1210
  }
1133
1211
  }
1134
1212
  // src/lib/screenshotter.ts
1135
- import { mkdirSync as mkdirSync2, existsSync as existsSync3 } from "fs";
1213
+ import { mkdirSync as mkdirSync2, existsSync as existsSync3, writeFileSync } from "fs";
1136
1214
  import { join as join3 } from "path";
1137
1215
  import { homedir as homedir3 } from "os";
1138
1216
  function slugify(text) {
@@ -1141,16 +1219,63 @@ function slugify(text) {
1141
1219
  function generateFilename(stepNumber, action) {
1142
1220
  const padded = String(stepNumber).padStart(3, "0");
1143
1221
  const slug = slugify(action);
1144
- return `${padded}-${slug}.png`;
1222
+ return `${padded}_${slug}.png`;
1145
1223
  }
1146
- function getScreenshotDir(baseDir, runId, scenarioSlug) {
1147
- return join3(baseDir, runId, scenarioSlug);
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, "-");
1229
+ }
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);
1148
1236
  }
1149
1237
  function ensureDir(dirPath) {
1150
1238
  if (!existsSync3(dirPath)) {
1151
1239
  mkdirSync2(dirPath, { recursive: true });
1152
1240
  }
1153
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
+ }
1154
1279
  var DEFAULT_BASE_DIR = join3(homedir3(), ".testers", "screenshots");
1155
1280
 
1156
1281
  class Screenshotter {
@@ -1158,15 +1283,20 @@ class Screenshotter {
1158
1283
  format;
1159
1284
  quality;
1160
1285
  fullPage;
1286
+ projectName;
1287
+ runTimestamp;
1161
1288
  constructor(options = {}) {
1162
1289
  this.baseDir = options.baseDir ?? DEFAULT_BASE_DIR;
1163
1290
  this.format = options.format ?? "png";
1164
1291
  this.quality = options.quality ?? 90;
1165
1292
  this.fullPage = options.fullPage ?? false;
1293
+ this.projectName = options.projectName ?? "default";
1294
+ this.runTimestamp = new Date;
1166
1295
  }
1167
1296
  async capture(page, options) {
1168
- const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug);
1169
- const filename = generateFilename(options.stepNumber, options.action);
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);
1170
1300
  const filePath = join3(dir, filename);
1171
1301
  ensureDir(dir);
1172
1302
  await page.screenshot({
@@ -1176,16 +1306,32 @@ class Screenshotter {
1176
1306
  quality: this.format === "jpeg" ? this.quality : undefined
1177
1307
  });
1178
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);
1179
1321
  return {
1180
1322
  filePath,
1181
1323
  width: viewport.width,
1182
1324
  height: viewport.height,
1183
- timestamp: new Date().toISOString()
1325
+ timestamp,
1326
+ description: options.description ?? null,
1327
+ pageUrl,
1328
+ thumbnailPath
1184
1329
  };
1185
1330
  }
1186
1331
  async captureFullPage(page, options) {
1187
- const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug);
1188
- const filename = generateFilename(options.stepNumber, options.action);
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);
1189
1335
  const filePath = join3(dir, filename);
1190
1336
  ensureDir(dir);
1191
1337
  await page.screenshot({
@@ -1195,16 +1341,32 @@ class Screenshotter {
1195
1341
  quality: this.format === "jpeg" ? this.quality : undefined
1196
1342
  });
1197
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);
1198
1356
  return {
1199
1357
  filePath,
1200
1358
  width: viewport.width,
1201
1359
  height: viewport.height,
1202
- timestamp: new Date().toISOString()
1360
+ timestamp,
1361
+ description: options.description ?? null,
1362
+ pageUrl,
1363
+ thumbnailPath
1203
1364
  };
1204
1365
  }
1205
1366
  async captureElement(page, selector, options) {
1206
- const dir = getScreenshotDir(this.baseDir, options.runId, options.scenarioSlug);
1207
- const filename = generateFilename(options.stepNumber, options.action);
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);
1208
1370
  const filePath = join3(dir, filename);
1209
1371
  ensureDir(dir);
1210
1372
  await page.locator(selector).screenshot({
@@ -1213,15 +1375,30 @@ class Screenshotter {
1213
1375
  quality: this.format === "jpeg" ? this.quality : undefined
1214
1376
  });
1215
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
+ });
1216
1389
  return {
1217
1390
  filePath,
1218
1391
  width: viewport.width,
1219
1392
  height: viewport.height,
1220
- timestamp: new Date().toISOString()
1393
+ timestamp,
1394
+ description: options.description ?? null,
1395
+ pageUrl,
1396
+ thumbnailPath: null
1221
1397
  };
1222
1398
  }
1223
1399
  }
1224
1400
  // src/lib/ai-client.ts
1401
+ init_types();
1225
1402
  import Anthropic from "@anthropic-ai/sdk";
1226
1403
  function resolveModel2(nameOrPreset) {
1227
1404
  if (nameOrPreset in MODEL_MAP) {
@@ -1899,6 +2076,7 @@ function createClient(apiKey) {
1899
2076
  return new Anthropic({ apiKey: key });
1900
2077
  }
1901
2078
  // src/lib/runner.ts
2079
+ init_runs();
1902
2080
  var eventHandler = null;
1903
2081
  function onRunEvent(handler) {
1904
2082
  eventHandler = handler;
@@ -1946,7 +2124,10 @@ async function runSingleScenario(scenario, runId, options) {
1946
2124
  action: ss.action,
1947
2125
  filePath: ss.filePath,
1948
2126
  width: ss.width,
1949
- height: ss.height
2127
+ height: ss.height,
2128
+ description: ss.description,
2129
+ pageUrl: ss.pageUrl,
2130
+ thumbnailPath: ss.thumbnailPath
1950
2131
  });
1951
2132
  emit({ type: "screenshot:captured", screenshotPath: ss.filePath, scenarioId: scenario.id, runId });
1952
2133
  }
@@ -2044,6 +2225,79 @@ async function runByFilter(options) {
2044
2225
  }
2045
2226
  return runBatch(scenarios, options);
2046
2227
  }
2228
+ function startRunAsync(options) {
2229
+ const config = loadConfig();
2230
+ const model = resolveModel2(options.model ?? config.defaultModel);
2231
+ let scenarios;
2232
+ if (options.scenarioIds && options.scenarioIds.length > 0) {
2233
+ const all = listScenarios({ projectId: options.projectId });
2234
+ scenarios = all.filter((s) => options.scenarioIds.includes(s.id) || options.scenarioIds.includes(s.shortId));
2235
+ } else {
2236
+ scenarios = listScenarios({
2237
+ projectId: options.projectId,
2238
+ tags: options.tags,
2239
+ priority: options.priority
2240
+ });
2241
+ }
2242
+ const parallel = options.parallel ?? 1;
2243
+ const run = createRun({
2244
+ url: options.url,
2245
+ model,
2246
+ headed: options.headed,
2247
+ parallel,
2248
+ projectId: options.projectId
2249
+ });
2250
+ if (scenarios.length === 0) {
2251
+ updateRun(run.id, { status: "passed", total: 0, finished_at: new Date().toISOString() });
2252
+ return { runId: run.id, scenarioCount: 0 };
2253
+ }
2254
+ updateRun(run.id, { status: "running", total: scenarios.length });
2255
+ (async () => {
2256
+ const results = [];
2257
+ try {
2258
+ if (parallel <= 1) {
2259
+ for (const scenario of scenarios) {
2260
+ const result = await runSingleScenario(scenario, run.id, options);
2261
+ results.push(result);
2262
+ }
2263
+ } else {
2264
+ const queue = [...scenarios];
2265
+ const running = [];
2266
+ const processNext = async () => {
2267
+ const scenario = queue.shift();
2268
+ if (!scenario)
2269
+ return;
2270
+ const result = await runSingleScenario(scenario, run.id, options);
2271
+ results.push(result);
2272
+ await processNext();
2273
+ };
2274
+ const workers = Math.min(parallel, scenarios.length);
2275
+ for (let i = 0;i < workers; i++) {
2276
+ running.push(processNext());
2277
+ }
2278
+ await Promise.all(running);
2279
+ }
2280
+ const passed = results.filter((r) => r.status === "passed").length;
2281
+ const failed = results.filter((r) => r.status === "failed" || r.status === "error").length;
2282
+ updateRun(run.id, {
2283
+ status: failed > 0 ? "failed" : "passed",
2284
+ passed,
2285
+ failed,
2286
+ total: scenarios.length,
2287
+ finished_at: new Date().toISOString()
2288
+ });
2289
+ emit({ type: "run:complete", runId: run.id });
2290
+ } catch (error) {
2291
+ const errorMsg = error instanceof Error ? error.message : String(error);
2292
+ updateRun(run.id, {
2293
+ status: "failed",
2294
+ finished_at: new Date().toISOString()
2295
+ });
2296
+ emit({ type: "run:complete", runId: run.id, error: errorMsg });
2297
+ }
2298
+ })();
2299
+ return { runId: run.id, scenarioCount: scenarios.length };
2300
+ }
2047
2301
  function estimateCost(model, tokens) {
2048
2302
  const costs = {
2049
2303
  "claude-haiku-4-5-20251001": 0.1,
@@ -2733,6 +2987,7 @@ import { Database as Database2 } from "bun:sqlite";
2733
2987
  import { existsSync as existsSync4 } from "fs";
2734
2988
  import { join as join4 } from "path";
2735
2989
  import { homedir as homedir4 } from "os";
2990
+ init_types();
2736
2991
  function resolveTodosDbPath() {
2737
2992
  const envPath = process.env["TODOS_DB_PATH"];
2738
2993
  if (envPath)
@@ -2844,6 +3099,7 @@ function markTodoDone(taskId) {
2844
3099
  }
2845
3100
  }
2846
3101
  // src/lib/scheduler.ts
3102
+ init_types();
2847
3103
  function parseCronField(field, min, max) {
2848
3104
  const results = new Set;
2849
3105
  const parts = field.split(",");
@@ -3055,20 +3311,1194 @@ class Scheduler {
3055
3311
  }
3056
3312
  }
3057
3313
  }
3314
+ // src/lib/init.ts
3315
+ import { existsSync as existsSync5, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
3316
+ import { join as join5, basename } from "path";
3317
+ import { homedir as homedir5 } from "os";
3318
+ function detectFramework(dir) {
3319
+ const pkgPath = join5(dir, "package.json");
3320
+ if (!existsSync5(pkgPath))
3321
+ return null;
3322
+ let pkg;
3323
+ try {
3324
+ pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
3325
+ } catch {
3326
+ return null;
3327
+ }
3328
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
3329
+ const depNames = Object.keys(allDeps);
3330
+ const features = [];
3331
+ const hasAuth = depNames.some((d) => d === "next-auth" || d.startsWith("@auth/") || d === "passport" || d === "lucia");
3332
+ if (hasAuth)
3333
+ features.push("hasAuth");
3334
+ const hasForms = depNames.some((d) => d === "react-hook-form" || d === "formik" || d === "zod");
3335
+ if (hasForms)
3336
+ features.push("hasForms");
3337
+ if ("next" in allDeps) {
3338
+ return { name: "Next.js", defaultUrl: "http://localhost:3000", features };
3339
+ }
3340
+ if ("vite" in allDeps) {
3341
+ return { name: "Vite", defaultUrl: "http://localhost:5173", features };
3342
+ }
3343
+ if (depNames.some((d) => d.startsWith("@remix-run"))) {
3344
+ return { name: "Remix", defaultUrl: "http://localhost:3000", features };
3345
+ }
3346
+ if ("nuxt" in allDeps) {
3347
+ return { name: "Nuxt", defaultUrl: "http://localhost:3000", features };
3348
+ }
3349
+ if (depNames.some((d) => d.startsWith("svelte") || d === "@sveltejs/kit")) {
3350
+ return { name: "SvelteKit", defaultUrl: "http://localhost:5173", features };
3351
+ }
3352
+ if (depNames.some((d) => d.startsWith("@angular"))) {
3353
+ return { name: "Angular", defaultUrl: "http://localhost:4200", features };
3354
+ }
3355
+ if ("express" in allDeps) {
3356
+ return { name: "Express", defaultUrl: "http://localhost:3000", features };
3357
+ }
3358
+ return null;
3359
+ }
3360
+ function getStarterScenarios(framework, projectId) {
3361
+ const scenarios = [
3362
+ {
3363
+ name: "Landing page loads",
3364
+ 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.",
3365
+ tags: ["smoke"],
3366
+ priority: "high",
3367
+ projectId
3368
+ },
3369
+ {
3370
+ name: "Navigation works",
3371
+ description: "Click through main navigation links and verify each page loads without errors.",
3372
+ tags: ["smoke"],
3373
+ priority: "medium",
3374
+ projectId
3375
+ },
3376
+ {
3377
+ name: "No console errors",
3378
+ description: "Navigate through the main pages and check the browser console for any JavaScript errors or warnings.",
3379
+ tags: ["smoke"],
3380
+ priority: "high",
3381
+ projectId
3382
+ }
3383
+ ];
3384
+ if (framework.features.includes("hasAuth")) {
3385
+ scenarios.push({
3386
+ name: "Login flow",
3387
+ description: "Navigate to the login page, enter valid credentials, and verify successful authentication and redirect.",
3388
+ tags: ["auth"],
3389
+ priority: "critical",
3390
+ projectId
3391
+ }, {
3392
+ name: "Signup flow",
3393
+ description: "Navigate to the signup page, fill in registration details, and verify account creation succeeds.",
3394
+ tags: ["auth"],
3395
+ priority: "medium",
3396
+ projectId
3397
+ });
3398
+ }
3399
+ if (framework.features.includes("hasForms")) {
3400
+ scenarios.push({
3401
+ name: "Form validation",
3402
+ description: "Submit forms with empty/invalid data and verify validation errors appear correctly.",
3403
+ tags: ["forms"],
3404
+ priority: "medium",
3405
+ projectId
3406
+ });
3407
+ }
3408
+ return scenarios;
3409
+ }
3410
+ function initProject(options) {
3411
+ const dir = options.dir ?? process.cwd();
3412
+ const name = options.name ?? basename(dir);
3413
+ const framework = detectFramework(dir);
3414
+ const url = options.url ?? framework?.defaultUrl ?? "http://localhost:3000";
3415
+ const projectPath = options.path ?? dir;
3416
+ const project = ensureProject(name, projectPath);
3417
+ const starterInputs = getStarterScenarios(framework ?? { name: "Unknown", features: [] }, project.id);
3418
+ const scenarios = starterInputs.map((input) => createScenario(input));
3419
+ const configDir = join5(homedir5(), ".testers");
3420
+ const configPath = join5(configDir, "config.json");
3421
+ if (!existsSync5(configDir)) {
3422
+ mkdirSync3(configDir, { recursive: true });
3423
+ }
3424
+ let config = {};
3425
+ if (existsSync5(configPath)) {
3426
+ try {
3427
+ config = JSON.parse(readFileSync2(configPath, "utf-8"));
3428
+ } catch {}
3429
+ }
3430
+ config.activeProject = project.id;
3431
+ writeFileSync2(configPath, JSON.stringify(config, null, 2), "utf-8");
3432
+ return { project, scenarios, framework, url };
3433
+ }
3434
+ // src/lib/smoke.ts
3435
+ init_runs();
3436
+ 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:
3437
+
3438
+ 1. Start at the given URL and take a screenshot
3439
+ 2. Find all visible navigation links and click through each one
3440
+ 3. On each page: check for visible error messages, broken layouts, missing images
3441
+ 4. Use get_page_html to check for error indicators in the HTML
3442
+ 5. Try clicking the main interactive elements (buttons, links, forms)
3443
+ 6. Keep track of every page you visit
3444
+ 7. After exploring at least 5 different pages (or all available pages), report your findings
3445
+
3446
+ In your report_result, include:
3447
+ - Total pages visited
3448
+ - Any JavaScript errors you noticed
3449
+ - Any broken links (pages that show 404 or error)
3450
+ - Any visual issues (broken layouts, missing images, overlapping text)
3451
+ - Any forms that don't work
3452
+ - Rate each issue as critical/high/medium/low severity`;
3453
+ async function runSmoke(options) {
3454
+ const config = loadConfig();
3455
+ const model = resolveModel2(options.model ?? config.defaultModel);
3456
+ const scenario = createScenario({
3457
+ name: "Smoke Test",
3458
+ description: SMOKE_DESCRIPTION,
3459
+ tags: ["smoke", "auto"],
3460
+ priority: "high",
3461
+ projectId: options.projectId
3462
+ });
3463
+ const run = createRun({
3464
+ url: options.url,
3465
+ model,
3466
+ headed: options.headed,
3467
+ parallel: 1,
3468
+ projectId: options.projectId
3469
+ });
3470
+ updateRun(run.id, { status: "running", total: 1 });
3471
+ let result;
3472
+ try {
3473
+ result = await runSingleScenario(scenario, run.id, {
3474
+ url: options.url,
3475
+ model: options.model,
3476
+ headed: options.headed,
3477
+ timeout: options.timeout,
3478
+ projectId: options.projectId,
3479
+ apiKey: options.apiKey
3480
+ });
3481
+ const finalStatus = result.status === "passed" ? "passed" : "failed";
3482
+ updateRun(run.id, {
3483
+ status: finalStatus,
3484
+ passed: result.status === "passed" ? 1 : 0,
3485
+ failed: result.status === "passed" ? 0 : 1,
3486
+ total: 1,
3487
+ finished_at: new Date().toISOString()
3488
+ });
3489
+ } catch (error) {
3490
+ updateRun(run.id, {
3491
+ status: "failed",
3492
+ failed: 1,
3493
+ total: 1,
3494
+ finished_at: new Date().toISOString()
3495
+ });
3496
+ throw error;
3497
+ } finally {
3498
+ deleteScenario(scenario.id);
3499
+ }
3500
+ const issues = parseSmokeIssues(result.reasoning ?? "");
3501
+ const pagesVisited = extractPagesVisited(result.reasoning ?? "");
3502
+ const { getRun: getRun2 } = await Promise.resolve().then(() => (init_runs(), exports_runs));
3503
+ const finalRun = getRun2(run.id);
3504
+ return {
3505
+ run: finalRun,
3506
+ result,
3507
+ pagesVisited,
3508
+ issuesFound: issues
3509
+ };
3510
+ }
3511
+ var SEVERITY_PATTERN = /\b(CRITICAL|HIGH|MEDIUM|LOW)\b[:\s-]*(.+)/gi;
3512
+ var PAGES_VISITED_PATTERN = /(\d+)\s*(?:pages?\s*visited|pages?\s*explored|pages?\s*checked|total\s*pages?)/i;
3513
+ var URL_PATTERN = /https?:\/\/[^\s,)]+/g;
3514
+ var ISSUE_TYPE_MAP = {
3515
+ javascript: "js-error",
3516
+ "js error": "js-error",
3517
+ "js-error": "js-error",
3518
+ "console error": "js-error",
3519
+ "404": "404",
3520
+ "not found": "404",
3521
+ "broken link": "broken-link",
3522
+ "dead link": "broken-link",
3523
+ "broken image": "broken-image",
3524
+ "missing image": "broken-image",
3525
+ visual: "visual",
3526
+ layout: "visual",
3527
+ overlap: "visual",
3528
+ "broken layout": "visual",
3529
+ performance: "performance",
3530
+ slow: "performance"
3531
+ };
3532
+ function inferIssueType(text) {
3533
+ const lower = text.toLowerCase();
3534
+ for (const [keyword, type] of Object.entries(ISSUE_TYPE_MAP)) {
3535
+ if (lower.includes(keyword))
3536
+ return type;
3537
+ }
3538
+ return "visual";
3539
+ }
3540
+ function extractUrl(text, fallback = "") {
3541
+ const match = text.match(URL_PATTERN);
3542
+ return match ? match[0] : fallback;
3543
+ }
3544
+ function parseSmokeIssues(reasoning) {
3545
+ const issues = [];
3546
+ const seen = new Set;
3547
+ let match;
3548
+ const severityRegex = new RegExp(SEVERITY_PATTERN.source, "gi");
3549
+ while ((match = severityRegex.exec(reasoning)) !== null) {
3550
+ const severity = match[1].toLowerCase();
3551
+ const description = match[2].trim();
3552
+ const key = `${severity}:${description.slice(0, 80)}`;
3553
+ if (seen.has(key))
3554
+ continue;
3555
+ seen.add(key);
3556
+ issues.push({
3557
+ type: inferIssueType(description),
3558
+ severity,
3559
+ description,
3560
+ url: extractUrl(description)
3561
+ });
3562
+ }
3563
+ const bulletLines = reasoning.split(`
3564
+ `).filter((line) => /^\s*[-*]\s/.test(line) && /\b(error|broken|missing|404|fail|issue|bug|problem)\b/i.test(line));
3565
+ for (const line of bulletLines) {
3566
+ const cleaned = line.replace(/^\s*[-*]\s*/, "").trim();
3567
+ const key = `bullet:${cleaned.slice(0, 80)}`;
3568
+ if (seen.has(key))
3569
+ continue;
3570
+ seen.add(key);
3571
+ let severity = "medium";
3572
+ if (/\bcritical\b/i.test(cleaned))
3573
+ severity = "critical";
3574
+ else if (/\bhigh\b/i.test(cleaned))
3575
+ severity = "high";
3576
+ else if (/\blow\b/i.test(cleaned))
3577
+ severity = "low";
3578
+ else if (/\b(error|fail|broken|crash)\b/i.test(cleaned))
3579
+ severity = "high";
3580
+ issues.push({
3581
+ type: inferIssueType(cleaned),
3582
+ severity,
3583
+ description: cleaned,
3584
+ url: extractUrl(cleaned)
3585
+ });
3586
+ }
3587
+ return issues;
3588
+ }
3589
+ function extractPagesVisited(reasoning) {
3590
+ const match = reasoning.match(PAGES_VISITED_PATTERN);
3591
+ if (match)
3592
+ return parseInt(match[1], 10);
3593
+ const urls = reasoning.match(URL_PATTERN);
3594
+ if (urls) {
3595
+ const unique = new Set(urls.map((u) => new URL(u).pathname));
3596
+ return unique.size;
3597
+ }
3598
+ return 0;
3599
+ }
3600
+ var SEVERITY_COLORS = {
3601
+ critical: (t) => `\x1B[41m\x1B[37m ${t} \x1B[0m`,
3602
+ high: (t) => `\x1B[31m${t}\x1B[0m`,
3603
+ medium: (t) => `\x1B[33m${t}\x1B[0m`,
3604
+ low: (t) => `\x1B[36m${t}\x1B[0m`
3605
+ };
3606
+ var SEVERITY_ORDER = ["critical", "high", "medium", "low"];
3607
+ function formatSmokeReport(result) {
3608
+ const lines = [];
3609
+ const url = result.run.url;
3610
+ lines.push("");
3611
+ lines.push(`\x1B[1m Smoke Test Report \x1B[2m- ${url}\x1B[0m`);
3612
+ lines.push(` ${"\u2500".repeat(60)}`);
3613
+ const issueCount = result.issuesFound.length;
3614
+ const criticalCount = result.issuesFound.filter((i) => i.severity === "critical").length;
3615
+ const highCount = result.issuesFound.filter((i) => i.severity === "high").length;
3616
+ lines.push("");
3617
+ lines.push(` Pages visited: \x1B[1m${result.pagesVisited}\x1B[0m`);
3618
+ lines.push(` Issues found: \x1B[1m${issueCount}\x1B[0m`);
3619
+ lines.push(` Duration: ${result.result.durationMs ? `${(result.result.durationMs / 1000).toFixed(1)}s` : "N/A"}`);
3620
+ lines.push(` Model: ${result.run.model}`);
3621
+ lines.push(` Tokens used: ${result.result.tokensUsed}`);
3622
+ if (issueCount > 0) {
3623
+ lines.push("");
3624
+ lines.push(`\x1B[1m Issues\x1B[0m`);
3625
+ lines.push("");
3626
+ for (const severity of SEVERITY_ORDER) {
3627
+ const group = result.issuesFound.filter((i) => i.severity === severity);
3628
+ if (group.length === 0)
3629
+ continue;
3630
+ const badge = SEVERITY_COLORS[severity](severity.toUpperCase());
3631
+ lines.push(` ${badge}`);
3632
+ for (const issue of group) {
3633
+ const urlSuffix = issue.url ? ` \x1B[2m(${issue.url})\x1B[0m` : "";
3634
+ lines.push(` - ${issue.description}${urlSuffix}`);
3635
+ }
3636
+ lines.push("");
3637
+ }
3638
+ }
3639
+ lines.push(` ${"\u2500".repeat(60)}`);
3640
+ const hasCritical = criticalCount > 0 || highCount > 0;
3641
+ if (hasCritical) {
3642
+ lines.push(` Verdict: \x1B[31m\x1B[1mFAIL\x1B[0m \x1B[2m(${criticalCount} critical, ${highCount} high severity issues)\x1B[0m`);
3643
+ } else if (issueCount > 0) {
3644
+ lines.push(` Verdict: \x1B[33m\x1B[1mWARN\x1B[0m \x1B[2m(${issueCount} issues found, none critical/high)\x1B[0m`);
3645
+ } else {
3646
+ lines.push(` Verdict: \x1B[32m\x1B[1mPASS\x1B[0m \x1B[2m(no issues found)\x1B[0m`);
3647
+ }
3648
+ lines.push("");
3649
+ return lines.join(`
3650
+ `);
3651
+ }
3652
+ // src/lib/diff.ts
3653
+ init_runs();
3654
+ function diffRuns(runId1, runId2) {
3655
+ const run1 = getRun(runId1);
3656
+ if (!run1) {
3657
+ throw new Error(`Run not found: ${runId1}`);
3658
+ }
3659
+ const run2 = getRun(runId2);
3660
+ if (!run2) {
3661
+ throw new Error(`Run not found: ${runId2}`);
3662
+ }
3663
+ const results1 = getResultsByRun(run1.id);
3664
+ const results2 = getResultsByRun(run2.id);
3665
+ const map1 = new Map;
3666
+ for (const r of results1) {
3667
+ map1.set(r.scenarioId, r);
3668
+ }
3669
+ const map2 = new Map;
3670
+ for (const r of results2) {
3671
+ map2.set(r.scenarioId, r);
3672
+ }
3673
+ const allScenarioIds = new Set([...map1.keys(), ...map2.keys()]);
3674
+ const regressions = [];
3675
+ const fixes = [];
3676
+ const unchanged = [];
3677
+ const newScenarios = [];
3678
+ const removedScenarios = [];
3679
+ for (const scenarioId of allScenarioIds) {
3680
+ const r1 = map1.get(scenarioId) ?? null;
3681
+ const r2 = map2.get(scenarioId) ?? null;
3682
+ const scenario = getScenario(scenarioId);
3683
+ const diff = {
3684
+ scenarioId,
3685
+ scenarioName: scenario?.name ?? null,
3686
+ scenarioShortId: scenario?.shortId ?? null,
3687
+ status1: r1?.status ?? null,
3688
+ status2: r2?.status ?? null,
3689
+ duration1: r1?.durationMs ?? null,
3690
+ duration2: r2?.durationMs ?? null,
3691
+ tokens1: r1?.tokensUsed ?? null,
3692
+ tokens2: r2?.tokensUsed ?? null
3693
+ };
3694
+ if (!r1 && r2) {
3695
+ newScenarios.push(diff);
3696
+ } else if (r1 && !r2) {
3697
+ removedScenarios.push(diff);
3698
+ } else if (r1 && r2) {
3699
+ const wasPass = r1.status === "passed";
3700
+ const nowPass = r2.status === "passed";
3701
+ const wasFail = r1.status === "failed" || r1.status === "error";
3702
+ const nowFail = r2.status === "failed" || r2.status === "error";
3703
+ if (wasPass && nowFail) {
3704
+ regressions.push(diff);
3705
+ } else if (wasFail && nowPass) {
3706
+ fixes.push(diff);
3707
+ } else {
3708
+ unchanged.push(diff);
3709
+ }
3710
+ }
3711
+ }
3712
+ return { run1, run2, regressions, fixes, unchanged, newScenarios, removedScenarios };
3713
+ }
3714
+ function formatScenarioLabel(diff) {
3715
+ if (diff.scenarioShortId && diff.scenarioName) {
3716
+ return `${diff.scenarioShortId}: ${diff.scenarioName}`;
3717
+ }
3718
+ if (diff.scenarioName) {
3719
+ return diff.scenarioName;
3720
+ }
3721
+ return diff.scenarioId.slice(0, 8);
3722
+ }
3723
+ function formatDuration(ms) {
3724
+ if (ms === null)
3725
+ return "-";
3726
+ if (ms < 1000)
3727
+ return `${ms}ms`;
3728
+ return `${(ms / 1000).toFixed(1)}s`;
3729
+ }
3730
+ function formatDurationComparison(d1, d2) {
3731
+ const s1 = formatDuration(d1);
3732
+ const s2 = formatDuration(d2);
3733
+ if (d1 !== null && d2 !== null) {
3734
+ const delta = d2 - d1;
3735
+ const sign = delta > 0 ? "+" : "";
3736
+ return `${s1} -> ${s2} (${sign}${formatDuration(delta)})`;
3737
+ }
3738
+ return `${s1} -> ${s2}`;
3739
+ }
3740
+ function formatDiffTerminal(diff) {
3741
+ const lines = [];
3742
+ lines.push("");
3743
+ lines.push(source_default.bold(" Run Comparison"));
3744
+ lines.push(` Run 1: ${source_default.dim(diff.run1.id.slice(0, 8))} (${diff.run1.status}) \u2014 ${diff.run1.startedAt}`);
3745
+ lines.push(` Run 2: ${source_default.dim(diff.run2.id.slice(0, 8))} (${diff.run2.status}) \u2014 ${diff.run2.startedAt}`);
3746
+ lines.push("");
3747
+ if (diff.regressions.length > 0) {
3748
+ lines.push(source_default.red.bold(` Regressions (${diff.regressions.length}):`));
3749
+ for (const d of diff.regressions) {
3750
+ const label = formatScenarioLabel(d);
3751
+ const dur = formatDurationComparison(d.duration1, d.duration2);
3752
+ lines.push(source_default.red(` \u2B07 ${label} ${d.status1} -> ${d.status2} ${source_default.dim(dur)}`));
3753
+ }
3754
+ lines.push("");
3755
+ }
3756
+ if (diff.fixes.length > 0) {
3757
+ lines.push(source_default.green.bold(` Fixes (${diff.fixes.length}):`));
3758
+ for (const d of diff.fixes) {
3759
+ const label = formatScenarioLabel(d);
3760
+ const dur = formatDurationComparison(d.duration1, d.duration2);
3761
+ lines.push(source_default.green(` \u2B06 ${label} ${d.status1} -> ${d.status2} ${source_default.dim(dur)}`));
3762
+ }
3763
+ lines.push("");
3764
+ }
3765
+ if (diff.unchanged.length > 0) {
3766
+ lines.push(source_default.dim(` Unchanged (${diff.unchanged.length}):`));
3767
+ for (const d of diff.unchanged) {
3768
+ const label = formatScenarioLabel(d);
3769
+ const dur = formatDurationComparison(d.duration1, d.duration2);
3770
+ lines.push(source_default.dim(` = ${label} ${d.status2} ${dur}`));
3771
+ }
3772
+ lines.push("");
3773
+ }
3774
+ if (diff.newScenarios.length > 0) {
3775
+ lines.push(source_default.cyan(` New in run 2 (${diff.newScenarios.length}):`));
3776
+ for (const d of diff.newScenarios) {
3777
+ const label = formatScenarioLabel(d);
3778
+ lines.push(source_default.cyan(` + ${label} ${d.status2}`));
3779
+ }
3780
+ lines.push("");
3781
+ }
3782
+ if (diff.removedScenarios.length > 0) {
3783
+ lines.push(source_default.yellow(` Removed from run 2 (${diff.removedScenarios.length}):`));
3784
+ for (const d of diff.removedScenarios) {
3785
+ const label = formatScenarioLabel(d);
3786
+ lines.push(source_default.yellow(` - ${label} was ${d.status1}`));
3787
+ }
3788
+ lines.push("");
3789
+ }
3790
+ lines.push(source_default.bold(` Summary: ${diff.regressions.length} regressions, ${diff.fixes.length} fixes, ${diff.unchanged.length} unchanged`));
3791
+ lines.push("");
3792
+ return lines.join(`
3793
+ `);
3794
+ }
3795
+ function formatDiffJSON(diff) {
3796
+ return JSON.stringify(diff, null, 2);
3797
+ }
3798
+ // src/lib/templates.ts
3799
+ var SCENARIO_TEMPLATES = {
3800
+ auth: [
3801
+ { 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"] },
3802
+ { 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"] },
3803
+ { 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"] }
3804
+ ],
3805
+ crud: [
3806
+ { 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"] },
3807
+ { 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"] },
3808
+ { 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"] },
3809
+ { 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"] }
3810
+ ],
3811
+ forms: [
3812
+ { 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"] },
3813
+ { 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"] },
3814
+ { 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"] }
3815
+ ],
3816
+ nav: [
3817
+ { 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"] },
3818
+ { 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"] }
3819
+ ],
3820
+ a11y: [
3821
+ { 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"] },
3822
+ { 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"] }
3823
+ ]
3824
+ };
3825
+ function getTemplate(name) {
3826
+ return SCENARIO_TEMPLATES[name] ?? null;
3827
+ }
3828
+ function listTemplateNames() {
3829
+ return Object.keys(SCENARIO_TEMPLATES);
3830
+ }
3831
+ // src/db/auth-presets.ts
3832
+ init_database();
3833
+ function fromRow(row) {
3834
+ return {
3835
+ id: row.id,
3836
+ name: row.name,
3837
+ email: row.email,
3838
+ password: row.password,
3839
+ loginPath: row.login_path,
3840
+ metadata: JSON.parse(row.metadata),
3841
+ createdAt: row.created_at
3842
+ };
3843
+ }
3844
+ function createAuthPreset(input) {
3845
+ const db2 = getDatabase();
3846
+ const id = uuid();
3847
+ const timestamp = now();
3848
+ db2.query(`
3849
+ INSERT INTO auth_presets (id, name, email, password, login_path, metadata, created_at)
3850
+ VALUES (?, ?, ?, ?, ?, '{}', ?)
3851
+ `).run(id, input.name, input.email, input.password, input.loginPath ?? "/login", timestamp);
3852
+ return getAuthPreset(input.name);
3853
+ }
3854
+ function getAuthPreset(name) {
3855
+ const db2 = getDatabase();
3856
+ const row = db2.query("SELECT * FROM auth_presets WHERE name = ?").get(name);
3857
+ return row ? fromRow(row) : null;
3858
+ }
3859
+ function listAuthPresets() {
3860
+ const db2 = getDatabase();
3861
+ const rows = db2.query("SELECT * FROM auth_presets ORDER BY created_at DESC").all();
3862
+ return rows.map(fromRow);
3863
+ }
3864
+ function deleteAuthPreset(name) {
3865
+ const db2 = getDatabase();
3866
+ const result = db2.query("DELETE FROM auth_presets WHERE name = ?").run(name);
3867
+ return result.changes > 0;
3868
+ }
3869
+ // src/lib/report.ts
3870
+ init_runs();
3871
+ import { readFileSync as readFileSync3, existsSync as existsSync6 } from "fs";
3872
+ function imageToBase64(filePath) {
3873
+ if (!filePath || !existsSync6(filePath))
3874
+ return "";
3875
+ try {
3876
+ const buffer = readFileSync3(filePath);
3877
+ const base64 = buffer.toString("base64");
3878
+ return `data:image/png;base64,${base64}`;
3879
+ } catch {
3880
+ return "";
3881
+ }
3882
+ }
3883
+ function escapeHtml(text) {
3884
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
3885
+ }
3886
+ function formatDuration2(ms) {
3887
+ if (ms < 1000)
3888
+ return `${ms}ms`;
3889
+ if (ms < 60000)
3890
+ return `${(ms / 1000).toFixed(1)}s`;
3891
+ const mins = Math.floor(ms / 60000);
3892
+ const secs = (ms % 60000 / 1000).toFixed(0);
3893
+ return `${mins}m ${secs}s`;
3894
+ }
3895
+ function formatCost(cents) {
3896
+ if (cents < 1)
3897
+ return `$${(cents / 100).toFixed(4)}`;
3898
+ return `$${(cents / 100).toFixed(2)}`;
3899
+ }
3900
+ function statusBadge(status) {
3901
+ const colors = {
3902
+ passed: { bg: "#22c55e", text: "#000" },
3903
+ failed: { bg: "#ef4444", text: "#fff" },
3904
+ error: { bg: "#eab308", text: "#000" },
3905
+ skipped: { bg: "#6b7280", text: "#fff" }
3906
+ };
3907
+ const c = colors[status] ?? { bg: "#6b7280", text: "#fff" };
3908
+ const label = status.toUpperCase();
3909
+ 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>`;
3910
+ }
3911
+ function renderScreenshots(screenshots) {
3912
+ if (screenshots.length === 0)
3913
+ return "";
3914
+ let html = `<div style="display:flex;flex-wrap:wrap;gap:12px;margin-top:12px;">`;
3915
+ for (let i = 0;i < screenshots.length; i++) {
3916
+ const ss = screenshots[i];
3917
+ const dataUri = imageToBase64(ss.filePath);
3918
+ const checkId = `ss-${ss.id}`;
3919
+ if (dataUri) {
3920
+ html += `
3921
+ <div style="flex:0 0 auto;">
3922
+ <input type="checkbox" id="${checkId}" style="display:none;" />
3923
+ <label for="${checkId}" style="cursor:pointer;">
3924
+ <img src="${dataUri}" alt="Step ${ss.stepNumber}: ${escapeHtml(ss.action)}"
3925
+ style="max-width:200px;max-height:150px;border-radius:6px;border:1px solid #262626;display:block;" />
3926
+ </label>
3927
+ <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;">
3928
+ <label for="${checkId}" style="position:absolute;top:0;left:0;width:100%;height:100%;cursor:pointer;"></label>
3929
+ <img src="${dataUri}" alt="Step ${ss.stepNumber}: ${escapeHtml(ss.action)}"
3930
+ style="max-width:600px;max-height:90vh;border-radius:8px;position:relative;z-index:1001;" />
3931
+ </div>
3932
+ <div style="font-size:11px;color:#888;margin-top:4px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
3933
+ ${ss.stepNumber}. ${escapeHtml(ss.action)}
3934
+ </div>
3935
+ </div>`;
3936
+ } else {
3937
+ html += `
3938
+ <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;">
3939
+ Screenshot not found
3940
+ <div style="font-size:11px;color:#888;margin-top:4px;">${ss.stepNumber}. ${escapeHtml(ss.action)}</div>
3941
+ </div>`;
3942
+ }
3943
+ }
3944
+ html += `</div>`;
3945
+ return html;
3946
+ }
3947
+ function generateHtmlReport(runId) {
3948
+ const run = getRun(runId);
3949
+ if (!run)
3950
+ throw new Error(`Run not found: ${runId}`);
3951
+ const results = getResultsByRun(run.id);
3952
+ const resultData = [];
3953
+ for (const result of results) {
3954
+ const screenshots = listScreenshots(result.id);
3955
+ const scenario = getScenario(result.scenarioId);
3956
+ resultData.push({
3957
+ result,
3958
+ scenarioName: scenario?.name ?? "Unknown Scenario",
3959
+ scenarioShortId: scenario?.shortId ?? result.scenarioId.slice(0, 8),
3960
+ screenshots
3961
+ });
3962
+ }
3963
+ const passedCount = results.filter((r) => r.status === "passed").length;
3964
+ const failedCount = results.filter((r) => r.status === "failed").length;
3965
+ const errorCount = results.filter((r) => r.status === "error").length;
3966
+ const totalCount = results.length;
3967
+ const totalTokens = results.reduce((sum, r) => sum + r.tokensUsed, 0);
3968
+ const totalCostCents = results.reduce((sum, r) => sum + r.costCents, 0);
3969
+ const totalDurationMs = run.finishedAt && run.startedAt ? new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime() : results.reduce((sum, r) => sum + r.durationMs, 0);
3970
+ const generatedAt = new Date().toISOString();
3971
+ let resultCards = "";
3972
+ for (const { result, scenarioName, scenarioShortId, screenshots } of resultData) {
3973
+ resultCards += `
3974
+ <div style="background:#141414;border:1px solid #262626;border-radius:8px;padding:20px;margin-bottom:16px;">
3975
+ <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
3976
+ ${statusBadge(result.status)}
3977
+ <span style="font-size:16px;font-weight:600;color:#e5e5e5;">${escapeHtml(scenarioName)}</span>
3978
+ <span style="font-size:12px;color:#666;font-family:monospace;">${escapeHtml(scenarioShortId)}</span>
3979
+ </div>
3980
+
3981
+ ${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>` : ""}
3982
+
3983
+ ${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>` : ""}
3984
+
3985
+ <div style="display:flex;gap:24px;font-size:13px;color:#888;">
3986
+ <span>Duration: <span style="color:#d4d4d4;">${formatDuration2(result.durationMs)}</span></span>
3987
+ <span>Steps: <span style="color:#d4d4d4;">${result.stepsCompleted}/${result.stepsTotal}</span></span>
3988
+ <span>Tokens: <span style="color:#d4d4d4;">${result.tokensUsed.toLocaleString()}</span></span>
3989
+ <span>Cost: <span style="color:#d4d4d4;">${formatCost(result.costCents)}</span></span>
3990
+ <span>Model: <span style="color:#d4d4d4;">${escapeHtml(result.model)}</span></span>
3991
+ </div>
3992
+
3993
+ ${renderScreenshots(screenshots)}
3994
+ </div>`;
3995
+ }
3996
+ return `<!DOCTYPE html>
3997
+ <html lang="en">
3998
+ <head>
3999
+ <meta charset="UTF-8" />
4000
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
4001
+ <title>Test Report - ${escapeHtml(run.id.slice(0, 8))}</title>
4002
+ <style>
4003
+ * { margin: 0; padding: 0; box-sizing: border-box; }
4004
+ body { background: #0a0a0a; color: #e5e5e5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 40px 20px; }
4005
+ .container { max-width: 960px; margin: 0 auto; }
4006
+ input[type="checkbox"]:checked ~ div:last-of-type { display: flex !important; }
4007
+ </style>
4008
+ </head>
4009
+ <body>
4010
+ <div class="container">
4011
+ <!-- Header -->
4012
+ <div style="margin-bottom:32px;">
4013
+ <h1 style="font-size:28px;font-weight:700;margin-bottom:8px;color:#fff;">Test Report</h1>
4014
+ <div style="display:flex;flex-wrap:wrap;gap:24px;font-size:14px;color:#888;">
4015
+ <span>Run: <span style="color:#d4d4d4;font-family:monospace;">${escapeHtml(run.id.slice(0, 8))}</span></span>
4016
+ <span>URL: <a href="${escapeHtml(run.url)}" style="color:#60a5fa;text-decoration:none;">${escapeHtml(run.url)}</a></span>
4017
+ <span>Model: <span style="color:#d4d4d4;">${escapeHtml(run.model)}</span></span>
4018
+ <span>Date: <span style="color:#d4d4d4;">${escapeHtml(run.startedAt)}</span></span>
4019
+ <span>Duration: <span style="color:#d4d4d4;">${formatDuration2(totalDurationMs)}</span></span>
4020
+ <span>Status: ${statusBadge(run.status)}</span>
4021
+ </div>
4022
+ </div>
4023
+
4024
+ <!-- Summary Bar -->
4025
+ <div style="display:flex;gap:16px;margin-bottom:32px;">
4026
+ <div style="flex:1;background:#141414;border:1px solid #262626;border-radius:8px;padding:16px;text-align:center;">
4027
+ <div style="font-size:28px;font-weight:700;color:#e5e5e5;">${totalCount}</div>
4028
+ <div style="font-size:12px;color:#888;margin-top:4px;">TOTAL</div>
4029
+ </div>
4030
+ <div style="flex:1;background:#141414;border:1px solid #262626;border-radius:8px;padding:16px;text-align:center;">
4031
+ <div style="font-size:28px;font-weight:700;color:#22c55e;">${passedCount}</div>
4032
+ <div style="font-size:12px;color:#888;margin-top:4px;">PASSED</div>
4033
+ </div>
4034
+ <div style="flex:1;background:#141414;border:1px solid #262626;border-radius:8px;padding:16px;text-align:center;">
4035
+ <div style="font-size:28px;font-weight:700;color:#ef4444;">${failedCount}</div>
4036
+ <div style="font-size:12px;color:#888;margin-top:4px;">FAILED</div>
4037
+ </div>
4038
+ ${errorCount > 0 ? `
4039
+ <div style="flex:1;background:#141414;border:1px solid #262626;border-radius:8px;padding:16px;text-align:center;">
4040
+ <div style="font-size:28px;font-weight:700;color:#eab308;">${errorCount}</div>
4041
+ <div style="font-size:12px;color:#888;margin-top:4px;">ERRORS</div>
4042
+ </div>` : ""}
4043
+ </div>
4044
+
4045
+ <!-- Results -->
4046
+ ${resultCards}
4047
+
4048
+ <!-- Footer -->
4049
+ <div style="margin-top:32px;padding-top:20px;border-top:1px solid #262626;display:flex;justify-content:space-between;font-size:13px;color:#666;">
4050
+ <div>
4051
+ Total tokens: ${totalTokens.toLocaleString()} | Total cost: ${formatCost(totalCostCents)}
4052
+ </div>
4053
+ <div>
4054
+ Generated: ${escapeHtml(generatedAt)}
4055
+ </div>
4056
+ </div>
4057
+ </div>
4058
+ </body>
4059
+ </html>`;
4060
+ }
4061
+ function generateLatestReport() {
4062
+ const runs = listRuns({ limit: 1 });
4063
+ if (runs.length === 0)
4064
+ throw new Error("No runs found");
4065
+ return generateHtmlReport(runs[0].id);
4066
+ }
4067
+ // src/lib/costs.ts
4068
+ init_database();
4069
+ function getDateFilter(period) {
4070
+ switch (period) {
4071
+ case "day":
4072
+ return "AND r.created_at >= date('now', 'start of day')";
4073
+ case "week":
4074
+ return "AND r.created_at >= date('now', '-7 days')";
4075
+ case "month":
4076
+ return "AND r.created_at >= date('now', '-30 days')";
4077
+ case "all":
4078
+ return "";
4079
+ }
4080
+ }
4081
+ function getPeriodDays(period) {
4082
+ switch (period) {
4083
+ case "day":
4084
+ return 1;
4085
+ case "week":
4086
+ return 7;
4087
+ case "month":
4088
+ return 30;
4089
+ case "all":
4090
+ return 30;
4091
+ }
4092
+ }
4093
+ function loadBudgetConfig() {
4094
+ const config = loadConfig();
4095
+ const budget = config.budget;
4096
+ return {
4097
+ maxPerRunCents: budget?.maxPerRunCents ?? 50,
4098
+ maxPerDayCents: budget?.maxPerDayCents ?? 500,
4099
+ warnAtPercent: budget?.warnAtPercent ?? 0.8
4100
+ };
4101
+ }
4102
+ function getCostSummary(options) {
4103
+ const db2 = getDatabase();
4104
+ const period = options?.period ?? "month";
4105
+ const projectId = options?.projectId;
4106
+ const dateFilter = getDateFilter(period);
4107
+ const projectFilter = projectId ? "AND ru.project_id = ?" : "";
4108
+ const projectParams = projectId ? [projectId] : [];
4109
+ const totalsRow = db2.query(`SELECT
4110
+ COALESCE(SUM(r.cost_cents), 0) as total_cost,
4111
+ COALESCE(SUM(r.tokens_used), 0) as total_tokens,
4112
+ COUNT(DISTINCT r.run_id) as run_count
4113
+ FROM results r
4114
+ JOIN runs ru ON r.run_id = ru.id
4115
+ WHERE 1=1 ${dateFilter} ${projectFilter}`).get(...projectParams);
4116
+ const modelRows = db2.query(`SELECT
4117
+ r.model,
4118
+ COALESCE(SUM(r.cost_cents), 0) as cost_cents,
4119
+ COALESCE(SUM(r.tokens_used), 0) as tokens,
4120
+ COUNT(DISTINCT r.run_id) as runs
4121
+ FROM results r
4122
+ JOIN runs ru ON r.run_id = ru.id
4123
+ WHERE 1=1 ${dateFilter} ${projectFilter}
4124
+ GROUP BY r.model
4125
+ ORDER BY cost_cents DESC`).all(...projectParams);
4126
+ const byModel = {};
4127
+ for (const row of modelRows) {
4128
+ byModel[row.model] = {
4129
+ costCents: row.cost_cents,
4130
+ tokens: row.tokens,
4131
+ runs: row.runs
4132
+ };
4133
+ }
4134
+ const scenarioRows = db2.query(`SELECT
4135
+ r.scenario_id,
4136
+ COALESCE(s.name, r.scenario_id) as name,
4137
+ COALESCE(SUM(r.cost_cents), 0) as cost_cents,
4138
+ COALESCE(SUM(r.tokens_used), 0) as tokens,
4139
+ COUNT(DISTINCT r.run_id) as runs
4140
+ FROM results r
4141
+ JOIN runs ru ON r.run_id = ru.id
4142
+ LEFT JOIN scenarios s ON r.scenario_id = s.id
4143
+ WHERE 1=1 ${dateFilter} ${projectFilter}
4144
+ GROUP BY r.scenario_id
4145
+ ORDER BY cost_cents DESC
4146
+ LIMIT 10`).all(...projectParams);
4147
+ const byScenario = scenarioRows.map((row) => ({
4148
+ scenarioId: row.scenario_id,
4149
+ name: row.name,
4150
+ costCents: row.cost_cents,
4151
+ tokens: row.tokens,
4152
+ runs: row.runs
4153
+ }));
4154
+ const runCount = totalsRow.run_count;
4155
+ const avgCostPerRun = runCount > 0 ? totalsRow.total_cost / runCount : 0;
4156
+ const periodDays = getPeriodDays(period);
4157
+ const estimatedMonthlyCents = periodDays > 0 ? totalsRow.total_cost / periodDays * 30 : 0;
4158
+ return {
4159
+ period,
4160
+ totalCostCents: totalsRow.total_cost,
4161
+ totalTokens: totalsRow.total_tokens,
4162
+ runCount,
4163
+ byModel,
4164
+ byScenario,
4165
+ avgCostPerRun,
4166
+ estimatedMonthlyCents
4167
+ };
4168
+ }
4169
+ function checkBudget(estimatedCostCents) {
4170
+ const budget = loadBudgetConfig();
4171
+ if (estimatedCostCents > budget.maxPerRunCents) {
4172
+ return {
4173
+ allowed: false,
4174
+ warning: `Estimated cost (${formatDollars(estimatedCostCents)}) exceeds per-run limit (${formatDollars(budget.maxPerRunCents)})`
4175
+ };
4176
+ }
4177
+ const todaySummary = getCostSummary({ period: "day" });
4178
+ const projectedDaily = todaySummary.totalCostCents + estimatedCostCents;
4179
+ if (projectedDaily > budget.maxPerDayCents) {
4180
+ return {
4181
+ allowed: false,
4182
+ warning: `Daily spending (${formatDollars(todaySummary.totalCostCents)}) + this run (${formatDollars(estimatedCostCents)}) would exceed daily limit (${formatDollars(budget.maxPerDayCents)})`
4183
+ };
4184
+ }
4185
+ if (projectedDaily > budget.maxPerDayCents * budget.warnAtPercent) {
4186
+ return {
4187
+ allowed: true,
4188
+ warning: `Approaching daily limit: ${formatDollars(projectedDaily)} of ${formatDollars(budget.maxPerDayCents)} (${Math.round(projectedDaily / budget.maxPerDayCents * 100)}%)`
4189
+ };
4190
+ }
4191
+ return { allowed: true };
4192
+ }
4193
+ function formatDollars(cents) {
4194
+ return `$${(cents / 100).toFixed(2)}`;
4195
+ }
4196
+ function formatTokens(tokens) {
4197
+ if (tokens >= 1e6)
4198
+ return `${(tokens / 1e6).toFixed(1)}M`;
4199
+ if (tokens >= 1000)
4200
+ return `${(tokens / 1000).toFixed(1)}K`;
4201
+ return String(tokens);
4202
+ }
4203
+ function formatCostsTerminal(summary) {
4204
+ const lines = [];
4205
+ lines.push("");
4206
+ lines.push(source_default.bold(` Cost Summary (${summary.period})`));
4207
+ lines.push("");
4208
+ lines.push(` Total: ${source_default.yellow(formatDollars(summary.totalCostCents))} (${formatTokens(summary.totalTokens)} tokens across ${summary.runCount} runs)`);
4209
+ lines.push(` Avg/run: ${source_default.yellow(formatDollars(summary.avgCostPerRun))}`);
4210
+ lines.push(` Est/month: ${source_default.yellow(formatDollars(summary.estimatedMonthlyCents))}`);
4211
+ const modelEntries = Object.entries(summary.byModel);
4212
+ if (modelEntries.length > 0) {
4213
+ lines.push("");
4214
+ lines.push(source_default.bold(" By Model"));
4215
+ lines.push(` ${"Model".padEnd(40)} ${"Cost".padEnd(12)} ${"Tokens".padEnd(12)} Runs`);
4216
+ lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(12)} ${"\u2500".repeat(12)} ${"\u2500".repeat(6)}`);
4217
+ for (const [model, data] of modelEntries) {
4218
+ lines.push(` ${model.padEnd(40)} ${formatDollars(data.costCents).padEnd(12)} ${formatTokens(data.tokens).padEnd(12)} ${data.runs}`);
4219
+ }
4220
+ }
4221
+ if (summary.byScenario.length > 0) {
4222
+ lines.push("");
4223
+ lines.push(source_default.bold(" Top Scenarios by Cost"));
4224
+ lines.push(` ${"Scenario".padEnd(40)} ${"Cost".padEnd(12)} ${"Tokens".padEnd(12)} Runs`);
4225
+ lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(12)} ${"\u2500".repeat(12)} ${"\u2500".repeat(6)}`);
4226
+ for (const s of summary.byScenario) {
4227
+ const label = s.name.length > 38 ? s.name.slice(0, 35) + "..." : s.name;
4228
+ lines.push(` ${label.padEnd(40)} ${formatDollars(s.costCents).padEnd(12)} ${formatTokens(s.tokens).padEnd(12)} ${s.runs}`);
4229
+ }
4230
+ }
4231
+ lines.push("");
4232
+ return lines.join(`
4233
+ `);
4234
+ }
4235
+ function formatCostsJSON(summary) {
4236
+ return JSON.stringify(summary, null, 2);
4237
+ }
4238
+ // src/lib/watch.ts
4239
+ import { watch } from "fs";
4240
+ import { resolve } from "path";
4241
+ async function startWatcher(options) {
4242
+ const {
4243
+ dir,
4244
+ url,
4245
+ debounceMs = 2000,
4246
+ tags,
4247
+ priority,
4248
+ ...runOpts
4249
+ } = options;
4250
+ const watchDir = resolve(dir);
4251
+ let debounceTimer = null;
4252
+ let isRunning = false;
4253
+ let lastChange = null;
4254
+ console.log("");
4255
+ console.log(source_default.bold(" Testers Watch Mode"));
4256
+ console.log(source_default.dim(` Watching: ${watchDir}`));
4257
+ console.log(source_default.dim(` Target: ${url}`));
4258
+ console.log(source_default.dim(` Debounce: ${debounceMs}ms`));
4259
+ console.log("");
4260
+ console.log(source_default.dim(" Waiting for file changes... (Ctrl+C to stop)"));
4261
+ console.log("");
4262
+ const runTests = async () => {
4263
+ if (isRunning)
4264
+ return;
4265
+ isRunning = true;
4266
+ console.log(source_default.blue(` [running] Testing against ${url}...`));
4267
+ if (lastChange) {
4268
+ console.log(source_default.dim(` Triggered by: ${lastChange}`));
4269
+ }
4270
+ console.log("");
4271
+ try {
4272
+ const { run, results } = await runByFilter({
4273
+ url,
4274
+ tags,
4275
+ priority,
4276
+ ...runOpts
4277
+ });
4278
+ console.log(formatTerminal(run, results));
4279
+ const exitCode = getExitCode(run);
4280
+ if (exitCode === 0) {
4281
+ console.log(source_default.green(" All tests passed!"));
4282
+ } else {
4283
+ console.log(source_default.red(` ${run.failed} test(s) failed.`));
4284
+ }
4285
+ } catch (error) {
4286
+ console.error(source_default.red(` Error: ${error instanceof Error ? error.message : String(error)}`));
4287
+ } finally {
4288
+ isRunning = false;
4289
+ console.log("");
4290
+ console.log(source_default.dim(" Waiting for file changes..."));
4291
+ console.log("");
4292
+ }
4293
+ };
4294
+ const watcher = watch(watchDir, { recursive: true }, (_event, filename) => {
4295
+ if (!filename)
4296
+ return;
4297
+ const ignored = [
4298
+ "node_modules",
4299
+ ".git",
4300
+ "dist",
4301
+ ".testers",
4302
+ ".next",
4303
+ ".nuxt",
4304
+ ".svelte-kit"
4305
+ ];
4306
+ if (ignored.some((dir2) => filename.includes(dir2)))
4307
+ return;
4308
+ const extensions = [".ts", ".tsx", ".js", ".jsx", ".vue", ".svelte", ".css", ".html"];
4309
+ if (!extensions.some((ext) => filename.endsWith(ext)))
4310
+ return;
4311
+ lastChange = filename;
4312
+ console.log(source_default.yellow(` [change] ${filename}`));
4313
+ if (debounceTimer)
4314
+ clearTimeout(debounceTimer);
4315
+ debounceTimer = setTimeout(() => {
4316
+ runTests();
4317
+ }, debounceMs);
4318
+ });
4319
+ const cleanup = () => {
4320
+ watcher.close();
4321
+ if (debounceTimer)
4322
+ clearTimeout(debounceTimer);
4323
+ console.log("");
4324
+ console.log(source_default.dim(" Watch mode stopped."));
4325
+ process.exit(0);
4326
+ };
4327
+ process.on("SIGINT", cleanup);
4328
+ process.on("SIGTERM", cleanup);
4329
+ await new Promise(() => {});
4330
+ }
4331
+ // src/lib/webhooks.ts
4332
+ init_database();
4333
+ function fromRow2(row) {
4334
+ return {
4335
+ id: row.id,
4336
+ url: row.url,
4337
+ events: JSON.parse(row.events),
4338
+ projectId: row.project_id,
4339
+ secret: row.secret,
4340
+ active: row.active === 1,
4341
+ createdAt: row.created_at
4342
+ };
4343
+ }
4344
+ function createWebhook(input) {
4345
+ const db2 = getDatabase();
4346
+ const id = uuid();
4347
+ const events = input.events ?? ["failed"];
4348
+ const secret = input.secret ?? crypto.randomUUID().replace(/-/g, "");
4349
+ db2.query(`
4350
+ INSERT INTO webhooks (id, url, events, project_id, secret, active, created_at)
4351
+ VALUES (?, ?, ?, ?, ?, 1, ?)
4352
+ `).run(id, input.url, JSON.stringify(events), input.projectId ?? null, secret, now());
4353
+ return getWebhook(id);
4354
+ }
4355
+ function getWebhook(id) {
4356
+ const db2 = getDatabase();
4357
+ const row = db2.query("SELECT * FROM webhooks WHERE id = ?").get(id);
4358
+ if (!row) {
4359
+ const rows = db2.query("SELECT * FROM webhooks WHERE id LIKE ? || '%'").all(id);
4360
+ if (rows.length === 1)
4361
+ return fromRow2(rows[0]);
4362
+ return null;
4363
+ }
4364
+ return fromRow2(row);
4365
+ }
4366
+ function listWebhooks(projectId) {
4367
+ const db2 = getDatabase();
4368
+ let query = "SELECT * FROM webhooks WHERE active = 1";
4369
+ const params = [];
4370
+ if (projectId) {
4371
+ query += " AND (project_id = ? OR project_id IS NULL)";
4372
+ params.push(projectId);
4373
+ }
4374
+ query += " ORDER BY created_at DESC";
4375
+ const rows = db2.query(query).all(...params);
4376
+ return rows.map(fromRow2);
4377
+ }
4378
+ function deleteWebhook(id) {
4379
+ const db2 = getDatabase();
4380
+ const webhook = getWebhook(id);
4381
+ if (!webhook)
4382
+ return false;
4383
+ db2.query("DELETE FROM webhooks WHERE id = ?").run(webhook.id);
4384
+ return true;
4385
+ }
4386
+ function signPayload(body, secret) {
4387
+ const encoder = new TextEncoder;
4388
+ const key = encoder.encode(secret);
4389
+ const data = encoder.encode(body);
4390
+ let hash = 0;
4391
+ for (let i = 0;i < data.length; i++) {
4392
+ hash = (hash << 5) - hash + data[i] + (key[i % key.length] ?? 0) | 0;
4393
+ }
4394
+ return `sha256=${Math.abs(hash).toString(16).padStart(16, "0")}`;
4395
+ }
4396
+ function formatSlackPayload(payload) {
4397
+ const status = payload.run.status === "passed" ? ":white_check_mark:" : ":x:";
4398
+ const color = payload.run.status === "passed" ? "#22c55e" : "#ef4444";
4399
+ return {
4400
+ attachments: [
4401
+ {
4402
+ color,
4403
+ blocks: [
4404
+ {
4405
+ type: "section",
4406
+ text: {
4407
+ type: "mrkdwn",
4408
+ text: `${status} *Test Run ${payload.run.status.toUpperCase()}*
4409
+ ` + `URL: ${payload.run.url}
4410
+ ` + `Results: ${payload.run.passed}/${payload.run.total} passed` + (payload.run.failed > 0 ? ` (${payload.run.failed} failed)` : "") + (payload.schedule ? `
4411
+ Schedule: ${payload.schedule.name}` : "")
4412
+ }
4413
+ }
4414
+ ]
4415
+ }
4416
+ ]
4417
+ };
4418
+ }
4419
+ async function dispatchWebhooks(event, run, schedule) {
4420
+ const webhooks = listWebhooks(run.projectId ?? undefined);
4421
+ const payload = {
4422
+ event,
4423
+ run: {
4424
+ id: run.id,
4425
+ url: run.url,
4426
+ status: run.status,
4427
+ passed: run.passed,
4428
+ failed: run.failed,
4429
+ total: run.total
4430
+ },
4431
+ schedule,
4432
+ timestamp: new Date().toISOString()
4433
+ };
4434
+ for (const webhook of webhooks) {
4435
+ if (!webhook.events.includes(event) && !webhook.events.includes("*"))
4436
+ continue;
4437
+ const isSlack = webhook.url.includes("hooks.slack.com");
4438
+ const body = isSlack ? JSON.stringify(formatSlackPayload(payload)) : JSON.stringify(payload);
4439
+ const headers = {
4440
+ "Content-Type": "application/json"
4441
+ };
4442
+ if (webhook.secret) {
4443
+ headers["X-Testers-Signature"] = signPayload(body, webhook.secret);
4444
+ }
4445
+ try {
4446
+ const response = await fetch(webhook.url, {
4447
+ method: "POST",
4448
+ headers,
4449
+ body
4450
+ });
4451
+ if (!response.ok) {
4452
+ await new Promise((r) => setTimeout(r, 5000));
4453
+ await fetch(webhook.url, { method: "POST", headers, body });
4454
+ }
4455
+ } catch {}
4456
+ }
4457
+ }
4458
+ async function testWebhook(id) {
4459
+ const webhook = getWebhook(id);
4460
+ if (!webhook)
4461
+ return false;
4462
+ const testPayload = {
4463
+ event: "test",
4464
+ run: { id: "test-run", url: "http://localhost:3000", status: "passed", passed: 3, failed: 0, total: 3 },
4465
+ timestamp: new Date().toISOString()
4466
+ };
4467
+ try {
4468
+ const body = JSON.stringify(testPayload);
4469
+ const response = await fetch(webhook.url, {
4470
+ method: "POST",
4471
+ headers: {
4472
+ "Content-Type": "application/json",
4473
+ ...webhook.secret ? { "X-Testers-Signature": signPayload(body, webhook.secret) } : {}
4474
+ },
4475
+ body
4476
+ });
4477
+ return response.ok;
4478
+ } catch {
4479
+ return false;
4480
+ }
4481
+ }
3058
4482
  export {
4483
+ writeScenarioMeta,
4484
+ writeRunMeta,
3059
4485
  uuid,
3060
4486
  updateSchedule,
3061
4487
  updateScenario,
3062
4488
  updateRun,
3063
4489
  updateResult,
3064
4490
  updateLastRun,
4491
+ testWebhook,
3065
4492
  taskToScenarioInput,
4493
+ startWatcher,
4494
+ startRunAsync,
3066
4495
  slugify,
3067
4496
  shouldRunAt,
3068
4497
  shortUuid,
3069
4498
  screenshotFromRow,
3070
4499
  scheduleFromRow,
3071
4500
  scenarioFromRow,
4501
+ runSmoke,
3072
4502
  runSingleScenario,
3073
4503
  runFromRow,
3074
4504
  runByFilter,
@@ -3082,22 +4512,31 @@ export {
3082
4512
  registerAgent,
3083
4513
  pullTasks,
3084
4514
  projectFromRow,
4515
+ parseSmokeIssues,
3085
4516
  parseCronField,
3086
4517
  parseCron,
3087
4518
  onRunEvent,
3088
4519
  now,
3089
4520
  markTodoDone,
3090
4521
  loadConfig,
4522
+ listWebhooks,
4523
+ listTemplateNames,
3091
4524
  listScreenshots,
3092
4525
  listSchedules,
3093
4526
  listScenarios,
3094
4527
  listRuns,
3095
4528
  listResults,
3096
4529
  listProjects,
4530
+ listAuthPresets,
3097
4531
  listAgents,
3098
4532
  launchBrowser,
3099
4533
  installBrowser,
4534
+ initProject,
3100
4535
  importFromTodos,
4536
+ imageToBase64,
4537
+ getWebhook,
4538
+ getTemplate,
4539
+ getStarterScenarios,
3101
4540
  getScreenshotsByResult,
3102
4541
  getScreenshotDir,
3103
4542
  getScreenshot,
@@ -3115,21 +4554,36 @@ export {
3115
4554
  getEnabledSchedules,
3116
4555
  getDefaultConfig,
3117
4556
  getDatabase,
4557
+ getCostSummary,
4558
+ getAuthPreset,
3118
4559
  getAgentByName,
3119
4560
  getAgent,
4561
+ generateLatestReport,
4562
+ generateHtmlReport,
3120
4563
  generateFilename,
3121
4564
  formatTerminal,
3122
4565
  formatSummary,
4566
+ formatSmokeReport,
3123
4567
  formatScenarioList,
3124
4568
  formatRunList,
3125
4569
  formatResultDetail,
3126
4570
  formatJSON,
4571
+ formatDiffTerminal,
4572
+ formatDiffJSON,
4573
+ formatCostsTerminal,
4574
+ formatCostsJSON,
3127
4575
  executeTool,
3128
4576
  ensureProject,
3129
4577
  ensureDir,
4578
+ dispatchWebhooks,
4579
+ diffRuns,
4580
+ detectFramework,
4581
+ deleteWebhook,
3130
4582
  deleteSchedule,
3131
4583
  deleteScenario,
3132
4584
  deleteRun,
4585
+ deleteAuthPreset,
4586
+ createWebhook,
3133
4587
  createScreenshot,
3134
4588
  createSchedule,
3135
4589
  createScenario,
@@ -3137,9 +4591,11 @@ export {
3137
4591
  createResult,
3138
4592
  createProject,
3139
4593
  createClient,
4594
+ createAuthPreset,
3140
4595
  connectToTodos,
3141
4596
  closeDatabase,
3142
4597
  closeBrowser,
4598
+ checkBudget,
3143
4599
  agentFromRow,
3144
4600
  VersionConflictError,
3145
4601
  TodosConnectionError,
@@ -3147,6 +4603,7 @@ export {
3147
4603
  Scheduler,
3148
4604
  ScheduleNotFoundError,
3149
4605
  ScenarioNotFoundError,
4606
+ SCENARIO_TEMPLATES,
3150
4607
  RunNotFoundError,
3151
4608
  ResultNotFoundError,
3152
4609
  ProjectNotFoundError,