@hasna/testers 0.0.6 → 0.0.8

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
@@ -10,6 +10,7 @@ var __export = (target, all) => {
10
10
  });
11
11
  };
12
12
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
13
+ var __require = import.meta.require;
13
14
 
14
15
  // src/types/index.ts
15
16
  function projectFromRow(row) {
@@ -49,6 +50,7 @@ function scenarioFromRow(row) {
49
50
  requiresAuth: row.requires_auth === 1,
50
51
  authConfig: row.auth_config ? JSON.parse(row.auth_config) : null,
51
52
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
53
+ assertions: JSON.parse(row.assertions || "[]"),
52
54
  version: row.version,
53
55
  createdAt: row.created_at,
54
56
  updatedAt: row.updated_at
@@ -68,7 +70,8 @@ function runFromRow(row) {
68
70
  failed: row.failed,
69
71
  startedAt: row.started_at,
70
72
  finishedAt: row.finished_at,
71
- metadata: row.metadata ? JSON.parse(row.metadata) : null
73
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
74
+ isBaseline: row.is_baseline === 1
72
75
  };
73
76
  }
74
77
  function resultFromRow(row) {
@@ -287,6 +290,7 @@ function resetDatabase() {
287
290
  database.exec("DELETE FROM flows");
288
291
  database.exec("DELETE FROM webhooks");
289
292
  database.exec("DELETE FROM auth_presets");
293
+ database.exec("DELETE FROM environments");
290
294
  database.exec("DELETE FROM schedules");
291
295
  database.exec("DELETE FROM runs");
292
296
  database.exec("DELETE FROM scenarios");
@@ -480,6 +484,24 @@ var init_database = __esm(() => {
480
484
  CREATE INDEX IF NOT EXISTS idx_deps_scenario ON scenario_dependencies(scenario_id);
481
485
  CREATE INDEX IF NOT EXISTS idx_deps_depends ON scenario_dependencies(depends_on);
482
486
  CREATE INDEX IF NOT EXISTS idx_flows_project ON flows(project_id);
487
+ `,
488
+ `
489
+ ALTER TABLE scenarios ADD COLUMN assertions TEXT DEFAULT '[]';
490
+ `,
491
+ `
492
+ CREATE TABLE IF NOT EXISTS environments (
493
+ id TEXT PRIMARY KEY,
494
+ name TEXT NOT NULL UNIQUE,
495
+ url TEXT NOT NULL,
496
+ auth_preset_name TEXT,
497
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
498
+ is_default INTEGER NOT NULL DEFAULT 0,
499
+ metadata TEXT DEFAULT '{}',
500
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
501
+ );
502
+ `,
503
+ `
504
+ ALTER TABLE runs ADD COLUMN is_baseline INTEGER NOT NULL DEFAULT 0;
483
505
  `
484
506
  ];
485
507
  });
@@ -596,6 +618,10 @@ function updateRun(id, updates) {
596
618
  sets.push("metadata = ?");
597
619
  params.push(updates.metadata);
598
620
  }
621
+ if (updates.is_baseline !== undefined) {
622
+ sets.push("is_baseline = ?");
623
+ params.push(updates.is_baseline);
624
+ }
599
625
  if (sets.length === 0) {
600
626
  return existing;
601
627
  }
@@ -767,6 +793,168 @@ var init_flows = __esm(() => {
767
793
  init_types();
768
794
  });
769
795
 
796
+ // src/lib/browser-lightpanda.ts
797
+ var exports_browser_lightpanda = {};
798
+ __export(exports_browser_lightpanda, {
799
+ startLightpandaServer: () => startLightpandaServer,
800
+ launchLightpanda: () => launchLightpanda,
801
+ isLightpandaAvailable: () => isLightpandaAvailable,
802
+ installLightpanda: () => installLightpanda,
803
+ getLightpandaPage: () => getLightpandaPage,
804
+ closeLightpanda: () => closeLightpanda
805
+ });
806
+ import { chromium } from "playwright";
807
+ import { spawn } from "child_process";
808
+ function isLightpandaAvailable() {
809
+ try {
810
+ const possiblePaths = [
811
+ `${process.env["HOME"]}/.cache/lightpanda-node/lightpanda`,
812
+ process.env["LIGHTPANDA_EXECUTABLE_PATH"]
813
+ ];
814
+ for (const p of possiblePaths) {
815
+ if (p) {
816
+ try {
817
+ const { existsSync: existsSync3 } = __require("fs");
818
+ if (existsSync3(p))
819
+ return true;
820
+ } catch {
821
+ continue;
822
+ }
823
+ }
824
+ }
825
+ const { execSync } = __require("child_process");
826
+ execSync("lightpanda --version", { stdio: "ignore", timeout: 5000 });
827
+ return true;
828
+ } catch {
829
+ return false;
830
+ }
831
+ }
832
+ function findLightpandaBinary() {
833
+ const envPath = process.env["LIGHTPANDA_EXECUTABLE_PATH"];
834
+ if (envPath)
835
+ return envPath;
836
+ const cachePath = `${process.env["HOME"]}/.cache/lightpanda-node/lightpanda`;
837
+ try {
838
+ const { existsSync: existsSync3 } = __require("fs");
839
+ if (existsSync3(cachePath))
840
+ return cachePath;
841
+ } catch {}
842
+ return "lightpanda";
843
+ }
844
+ async function startLightpandaServer(port) {
845
+ const binary = findLightpandaBinary();
846
+ const cdpPort = port ?? 9222 + Math.floor(Math.random() * 1000);
847
+ return new Promise((resolve, reject) => {
848
+ const proc = spawn(binary, ["serve", "--port", String(cdpPort)], {
849
+ stdio: ["ignore", "pipe", "pipe"]
850
+ });
851
+ let resolved = false;
852
+ const timeout = setTimeout(() => {
853
+ if (!resolved) {
854
+ resolved = true;
855
+ resolve({
856
+ process: proc,
857
+ wsEndpoint: `ws://127.0.0.1:${cdpPort}`
858
+ });
859
+ }
860
+ }, 5000);
861
+ proc.stdout?.on("data", (data) => {
862
+ const output = data.toString();
863
+ if (output.includes("127.0.0.1") || output.includes("listening") || output.includes("DevTools")) {
864
+ if (!resolved) {
865
+ resolved = true;
866
+ clearTimeout(timeout);
867
+ resolve({
868
+ process: proc,
869
+ wsEndpoint: `ws://127.0.0.1:${cdpPort}`
870
+ });
871
+ }
872
+ }
873
+ });
874
+ proc.stderr?.on("data", (data) => {
875
+ const output = data.toString();
876
+ if (output.includes("127.0.0.1") || output.includes("listening")) {
877
+ if (!resolved) {
878
+ resolved = true;
879
+ clearTimeout(timeout);
880
+ resolve({
881
+ process: proc,
882
+ wsEndpoint: `ws://127.0.0.1:${cdpPort}`
883
+ });
884
+ }
885
+ }
886
+ });
887
+ proc.on("error", (err) => {
888
+ clearTimeout(timeout);
889
+ if (!resolved) {
890
+ resolved = true;
891
+ reject(new BrowserError(`Failed to start Lightpanda: ${err.message}. ` + `Install it with: bun install @lightpanda/browser`));
892
+ }
893
+ });
894
+ proc.on("exit", (code) => {
895
+ if (!resolved) {
896
+ resolved = true;
897
+ clearTimeout(timeout);
898
+ reject(new BrowserError(`Lightpanda exited with code ${code}. ` + `Install it with: bun install @lightpanda/browser`));
899
+ }
900
+ });
901
+ });
902
+ }
903
+ async function launchLightpanda(_options) {
904
+ try {
905
+ const { process: proc, wsEndpoint } = await startLightpandaServer();
906
+ lightpandaProcess = proc;
907
+ const browser = await chromium.connectOverCDP(wsEndpoint);
908
+ return browser;
909
+ } catch (error) {
910
+ const message = error instanceof Error ? error.message : String(error);
911
+ throw new BrowserError(`Failed to launch Lightpanda: ${message}`);
912
+ }
913
+ }
914
+ async function getLightpandaPage(browser, options) {
915
+ try {
916
+ const contexts = browser.contexts();
917
+ const context = contexts.length > 0 ? contexts[0] : await browser.newContext({
918
+ viewport: options?.viewport ?? { width: 1280, height: 720 },
919
+ userAgent: options?.userAgent,
920
+ locale: options?.locale
921
+ });
922
+ const page = await context.newPage();
923
+ return page;
924
+ } catch (error) {
925
+ const message = error instanceof Error ? error.message : String(error);
926
+ throw new BrowserError(`Failed to create Lightpanda page: ${message}`);
927
+ }
928
+ }
929
+ async function closeLightpanda(browser) {
930
+ try {
931
+ await browser.close();
932
+ } catch {}
933
+ if (lightpandaProcess) {
934
+ try {
935
+ lightpandaProcess.kill("SIGTERM");
936
+ lightpandaProcess = null;
937
+ } catch {}
938
+ }
939
+ }
940
+ async function installLightpanda() {
941
+ const { execSync } = __require("child_process");
942
+ try {
943
+ execSync("bun install @lightpanda/browser", {
944
+ stdio: "inherit",
945
+ cwd: process.env["HOME"]
946
+ });
947
+ } catch (error) {
948
+ const message = error instanceof Error ? error.message : String(error);
949
+ throw new BrowserError(`Failed to install Lightpanda: ${message}
950
+ ` + `Try manually: bun install @lightpanda/browser`);
951
+ }
952
+ }
953
+ var lightpandaProcess = null;
954
+ var init_browser_lightpanda = __esm(() => {
955
+ init_types();
956
+ });
957
+
770
958
  // src/index.ts
771
959
  init_types();
772
960
  init_database();
@@ -792,9 +980,9 @@ function createScenario(input) {
792
980
  const short_id = nextShortId(input.projectId);
793
981
  const timestamp = now();
794
982
  db2.query(`
795
- 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)
796
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
797
- `).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);
983
+ INSERT INTO scenarios (id, short_id, project_id, name, description, steps, tags, priority, model, timeout_ms, target_path, requires_auth, auth_config, metadata, assertions, version, created_at, updated_at)
984
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
985
+ `).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, JSON.stringify(input.assertions ?? []), timestamp, timestamp);
798
986
  return getScenario(id);
799
987
  }
800
988
  function getScenario(id) {
@@ -912,6 +1100,10 @@ function updateScenario(id, input, version) {
912
1100
  sets.push("metadata = ?");
913
1101
  params.push(JSON.stringify(input.metadata));
914
1102
  }
1103
+ if (input.assertions !== undefined) {
1104
+ sets.push("assertions = ?");
1105
+ params.push(JSON.stringify(input.assertions));
1106
+ }
915
1107
  if (sets.length === 0) {
916
1108
  return existing;
917
1109
  }
@@ -1308,14 +1500,22 @@ function resolveModel(nameOrId) {
1308
1500
  }
1309
1501
  // src/lib/browser.ts
1310
1502
  init_types();
1311
- import { chromium } from "playwright";
1503
+ import { chromium as chromium2 } from "playwright";
1312
1504
  import { execSync } from "child_process";
1313
1505
  var DEFAULT_VIEWPORT = { width: 1280, height: 720 };
1314
1506
  async function launchBrowser(options) {
1507
+ const engine = options?.engine ?? process.env["TESTERS_BROWSER_ENGINE"] ?? "playwright";
1508
+ if (engine === "lightpanda") {
1509
+ const { launchLightpanda: launchLightpanda2, isLightpandaAvailable: isLightpandaAvailable2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
1510
+ if (!isLightpandaAvailable2()) {
1511
+ throw new BrowserError("Lightpanda not installed. Run: testers install-browser --engine lightpanda");
1512
+ }
1513
+ return launchLightpanda2({ viewport: options?.viewport });
1514
+ }
1315
1515
  const headless = options?.headless ?? true;
1316
1516
  const viewport = options?.viewport ?? DEFAULT_VIEWPORT;
1317
1517
  try {
1318
- const browser = await chromium.launch({
1518
+ const browser = await chromium2.launch({
1319
1519
  headless,
1320
1520
  args: [
1321
1521
  `--window-size=${viewport.width},${viewport.height}`
@@ -1328,6 +1528,11 @@ async function launchBrowser(options) {
1328
1528
  }
1329
1529
  }
1330
1530
  async function getPage(browser, options) {
1531
+ const engine = options?.engine ?? "playwright";
1532
+ if (engine === "lightpanda") {
1533
+ const { getLightpandaPage: getLightpandaPage2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
1534
+ return getLightpandaPage2(browser, options);
1535
+ }
1331
1536
  const viewport = options?.viewport ?? DEFAULT_VIEWPORT;
1332
1537
  try {
1333
1538
  const context = await browser.newContext({
@@ -1342,7 +1547,11 @@ async function getPage(browser, options) {
1342
1547
  throw new BrowserError(`Failed to create page: ${message}`);
1343
1548
  }
1344
1549
  }
1345
- async function closeBrowser(browser) {
1550
+ async function closeBrowser(browser, engine) {
1551
+ if (engine === "lightpanda") {
1552
+ const { closeLightpanda: closeLightpanda2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
1553
+ return closeLightpanda2(browser);
1554
+ }
1346
1555
  try {
1347
1556
  await browser.close();
1348
1557
  } catch (error) {
@@ -1356,26 +1565,29 @@ class BrowserPool {
1356
1565
  maxSize;
1357
1566
  headless;
1358
1567
  viewport;
1568
+ engine;
1359
1569
  constructor(size, options) {
1360
1570
  this.maxSize = size;
1361
1571
  this.headless = options?.headless ?? true;
1362
1572
  this.viewport = options?.viewport ?? DEFAULT_VIEWPORT;
1573
+ this.engine = options?.engine ?? "playwright";
1363
1574
  }
1364
1575
  async acquire() {
1365
1576
  const idle = this.pool.find((entry) => !entry.inUse);
1366
1577
  if (idle) {
1367
1578
  idle.inUse = true;
1368
- const page = await getPage(idle.browser, { viewport: this.viewport });
1579
+ const page = await getPage(idle.browser, { viewport: this.viewport, engine: this.engine });
1369
1580
  return { browser: idle.browser, page };
1370
1581
  }
1371
1582
  if (this.pool.length < this.maxSize) {
1372
1583
  const browser = await launchBrowser({
1373
1584
  headless: this.headless,
1374
- viewport: this.viewport
1585
+ viewport: this.viewport,
1586
+ engine: this.engine
1375
1587
  });
1376
1588
  const entry = { browser, inUse: true };
1377
1589
  this.pool.push(entry);
1378
- const page = await getPage(browser, { viewport: this.viewport });
1590
+ const page = await getPage(browser, { viewport: this.viewport, engine: this.engine });
1379
1591
  return { browser, page };
1380
1592
  }
1381
1593
  return new Promise((resolve, reject) => {
@@ -1384,7 +1596,7 @@ class BrowserPool {
1384
1596
  if (available) {
1385
1597
  clearInterval(interval);
1386
1598
  available.inUse = true;
1387
- getPage(available.browser, { viewport: this.viewport }).then((page) => resolve({ browser: available.browser, page })).catch(reject);
1599
+ getPage(available.browser, { viewport: this.viewport, engine: this.engine }).then((page) => resolve({ browser: available.browser, page })).catch(reject);
1388
1600
  }
1389
1601
  }, 50);
1390
1602
  });
@@ -1401,7 +1613,11 @@ class BrowserPool {
1401
1613
  this.pool.length = 0;
1402
1614
  }
1403
1615
  }
1404
- async function installBrowser() {
1616
+ async function installBrowser(engine) {
1617
+ if (engine === "lightpanda") {
1618
+ const { installLightpanda: installLightpanda2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
1619
+ return installLightpanda2();
1620
+ }
1405
1621
  try {
1406
1622
  execSync("bunx playwright install chromium", {
1407
1623
  stdio: "inherit"
@@ -1411,6 +1627,10 @@ async function installBrowser() {
1411
1627
  throw new BrowserError(`Failed to install browser: ${message}`);
1412
1628
  }
1413
1629
  }
1630
+
1631
+ // src/index.ts
1632
+ init_browser_lightpanda();
1633
+
1414
1634
  // src/lib/screenshotter.ts
1415
1635
  import { mkdirSync as mkdirSync2, existsSync as existsSync3, writeFileSync } from "fs";
1416
1636
  import { join as join3 } from "path";
@@ -2292,6 +2512,160 @@ function createClient(apiKey) {
2292
2512
  }
2293
2513
  // src/lib/runner.ts
2294
2514
  init_runs();
2515
+
2516
+ // src/lib/webhooks.ts
2517
+ init_database();
2518
+ function fromRow(row) {
2519
+ return {
2520
+ id: row.id,
2521
+ url: row.url,
2522
+ events: JSON.parse(row.events),
2523
+ projectId: row.project_id,
2524
+ secret: row.secret,
2525
+ active: row.active === 1,
2526
+ createdAt: row.created_at
2527
+ };
2528
+ }
2529
+ function createWebhook(input) {
2530
+ const db2 = getDatabase();
2531
+ const id = uuid();
2532
+ const events = input.events ?? ["failed"];
2533
+ const secret = input.secret ?? crypto.randomUUID().replace(/-/g, "");
2534
+ db2.query(`
2535
+ INSERT INTO webhooks (id, url, events, project_id, secret, active, created_at)
2536
+ VALUES (?, ?, ?, ?, ?, 1, ?)
2537
+ `).run(id, input.url, JSON.stringify(events), input.projectId ?? null, secret, now());
2538
+ return getWebhook(id);
2539
+ }
2540
+ function getWebhook(id) {
2541
+ const db2 = getDatabase();
2542
+ const row = db2.query("SELECT * FROM webhooks WHERE id = ?").get(id);
2543
+ if (!row) {
2544
+ const rows = db2.query("SELECT * FROM webhooks WHERE id LIKE ? || '%'").all(id);
2545
+ if (rows.length === 1)
2546
+ return fromRow(rows[0]);
2547
+ return null;
2548
+ }
2549
+ return fromRow(row);
2550
+ }
2551
+ function listWebhooks(projectId) {
2552
+ const db2 = getDatabase();
2553
+ let query = "SELECT * FROM webhooks WHERE active = 1";
2554
+ const params = [];
2555
+ if (projectId) {
2556
+ query += " AND (project_id = ? OR project_id IS NULL)";
2557
+ params.push(projectId);
2558
+ }
2559
+ query += " ORDER BY created_at DESC";
2560
+ const rows = db2.query(query).all(...params);
2561
+ return rows.map(fromRow);
2562
+ }
2563
+ function deleteWebhook(id) {
2564
+ const db2 = getDatabase();
2565
+ const webhook = getWebhook(id);
2566
+ if (!webhook)
2567
+ return false;
2568
+ db2.query("DELETE FROM webhooks WHERE id = ?").run(webhook.id);
2569
+ return true;
2570
+ }
2571
+ function signPayload(body, secret) {
2572
+ const encoder = new TextEncoder;
2573
+ const key = encoder.encode(secret);
2574
+ const data = encoder.encode(body);
2575
+ let hash = 0;
2576
+ for (let i = 0;i < data.length; i++) {
2577
+ hash = (hash << 5) - hash + data[i] + (key[i % key.length] ?? 0) | 0;
2578
+ }
2579
+ return `sha256=${Math.abs(hash).toString(16).padStart(16, "0")}`;
2580
+ }
2581
+ function formatSlackPayload(payload) {
2582
+ const status = payload.run.status === "passed" ? ":white_check_mark:" : ":x:";
2583
+ const color = payload.run.status === "passed" ? "#22c55e" : "#ef4444";
2584
+ return {
2585
+ attachments: [
2586
+ {
2587
+ color,
2588
+ blocks: [
2589
+ {
2590
+ type: "section",
2591
+ text: {
2592
+ type: "mrkdwn",
2593
+ text: `${status} *Test Run ${payload.run.status.toUpperCase()}*
2594
+ ` + `URL: ${payload.run.url}
2595
+ ` + `Results: ${payload.run.passed}/${payload.run.total} passed` + (payload.run.failed > 0 ? ` (${payload.run.failed} failed)` : "") + (payload.schedule ? `
2596
+ Schedule: ${payload.schedule.name}` : "")
2597
+ }
2598
+ }
2599
+ ]
2600
+ }
2601
+ ]
2602
+ };
2603
+ }
2604
+ async function dispatchWebhooks(event, run, schedule) {
2605
+ const webhooks = listWebhooks(run.projectId ?? undefined);
2606
+ const payload = {
2607
+ event,
2608
+ run: {
2609
+ id: run.id,
2610
+ url: run.url,
2611
+ status: run.status,
2612
+ passed: run.passed,
2613
+ failed: run.failed,
2614
+ total: run.total
2615
+ },
2616
+ schedule,
2617
+ timestamp: new Date().toISOString()
2618
+ };
2619
+ for (const webhook of webhooks) {
2620
+ if (!webhook.events.includes(event) && !webhook.events.includes("*"))
2621
+ continue;
2622
+ const isSlack = webhook.url.includes("hooks.slack.com");
2623
+ const body = isSlack ? JSON.stringify(formatSlackPayload(payload)) : JSON.stringify(payload);
2624
+ const headers = {
2625
+ "Content-Type": "application/json"
2626
+ };
2627
+ if (webhook.secret) {
2628
+ headers["X-Testers-Signature"] = signPayload(body, webhook.secret);
2629
+ }
2630
+ try {
2631
+ const response = await fetch(webhook.url, {
2632
+ method: "POST",
2633
+ headers,
2634
+ body
2635
+ });
2636
+ if (!response.ok) {
2637
+ await new Promise((r) => setTimeout(r, 5000));
2638
+ await fetch(webhook.url, { method: "POST", headers, body });
2639
+ }
2640
+ } catch {}
2641
+ }
2642
+ }
2643
+ async function testWebhook(id) {
2644
+ const webhook = getWebhook(id);
2645
+ if (!webhook)
2646
+ return false;
2647
+ const testPayload = {
2648
+ event: "test",
2649
+ run: { id: "test-run", url: "http://localhost:3000", status: "passed", passed: 3, failed: 0, total: 3 },
2650
+ timestamp: new Date().toISOString()
2651
+ };
2652
+ try {
2653
+ const body = JSON.stringify(testPayload);
2654
+ const response = await fetch(webhook.url, {
2655
+ method: "POST",
2656
+ headers: {
2657
+ "Content-Type": "application/json",
2658
+ ...webhook.secret ? { "X-Testers-Signature": signPayload(body, webhook.secret) } : {}
2659
+ },
2660
+ body
2661
+ });
2662
+ return response.ok;
2663
+ } catch {
2664
+ return false;
2665
+ }
2666
+ }
2667
+
2668
+ // src/lib/runner.ts
2295
2669
  var eventHandler = null;
2296
2670
  function onRunEvent(handler) {
2297
2671
  eventHandler = handler;
@@ -2300,6 +2674,20 @@ function emit(event) {
2300
2674
  if (eventHandler)
2301
2675
  eventHandler(event);
2302
2676
  }
2677
+ function withTimeout(promise, ms, label) {
2678
+ return new Promise((resolve, reject) => {
2679
+ const timer = setTimeout(() => {
2680
+ reject(new Error(`Scenario timeout after ${ms}ms: ${label}`));
2681
+ }, ms);
2682
+ promise.then((val) => {
2683
+ clearTimeout(timer);
2684
+ resolve(val);
2685
+ }, (err) => {
2686
+ clearTimeout(timer);
2687
+ reject(err);
2688
+ });
2689
+ });
2690
+ }
2303
2691
  async function runSingleScenario(scenario, runId, options) {
2304
2692
  const config = loadConfig();
2305
2693
  const model = resolveModel2(options.model ?? scenario.model ?? config.defaultModel);
@@ -2317,13 +2705,14 @@ async function runSingleScenario(scenario, runId, options) {
2317
2705
  let browser = null;
2318
2706
  let page = null;
2319
2707
  try {
2320
- browser = await launchBrowser({ headless: !(options.headed ?? false) });
2708
+ browser = await launchBrowser({ headless: !(options.headed ?? false), engine: options.engine });
2321
2709
  page = await getPage(browser, {
2322
2710
  viewport: config.browser.viewport
2323
2711
  });
2324
2712
  const targetUrl = scenario.targetPath ? `${options.url.replace(/\/$/, "")}${scenario.targetPath}` : options.url;
2325
- await page.goto(targetUrl, { timeout: options.timeout ?? config.browser.timeout });
2326
- const agentResult = await runAgentLoop({
2713
+ const scenarioTimeout = scenario.timeoutMs ?? options.timeout ?? config.browser.timeout ?? 60000;
2714
+ await page.goto(targetUrl, { timeout: Math.min(scenarioTimeout, 30000) });
2715
+ const agentResult = await withTimeout(runAgentLoop({
2327
2716
  client,
2328
2717
  page,
2329
2718
  scenario,
@@ -2344,7 +2733,7 @@ async function runSingleScenario(scenario, runId, options) {
2344
2733
  stepNumber: stepEvent.stepNumber
2345
2734
  });
2346
2735
  }
2347
- });
2736
+ }), scenarioTimeout, scenario.name);
2348
2737
  for (const ss of agentResult.screenshots) {
2349
2738
  createScreenshot({
2350
2739
  resultId: result.id,
@@ -2381,7 +2770,7 @@ async function runSingleScenario(scenario, runId, options) {
2381
2770
  return updatedResult;
2382
2771
  } finally {
2383
2772
  if (browser)
2384
- await closeBrowser(browser);
2773
+ await closeBrowser(browser, options.engine);
2385
2774
  }
2386
2775
  }
2387
2776
  async function runBatch(scenarios, options) {
@@ -2476,6 +2865,8 @@ async function runBatch(scenarios, options) {
2476
2865
  finished_at: new Date().toISOString()
2477
2866
  });
2478
2867
  emit({ type: "run:complete", runId: run.id });
2868
+ const eventType = finalRun.status === "failed" ? "failed" : "completed";
2869
+ dispatchWebhooks(eventType, finalRun).catch(() => {});
2479
2870
  return { run: finalRun, results };
2480
2871
  }
2481
2872
  async function runByFilter(options) {
@@ -2561,6 +2952,9 @@ function startRunAsync(options) {
2561
2952
  finished_at: new Date().toISOString()
2562
2953
  });
2563
2954
  emit({ type: "run:complete", runId: run.id });
2955
+ const asyncRun = getRun(run.id);
2956
+ if (asyncRun)
2957
+ dispatchWebhooks(asyncRun.status === "failed" ? "failed" : "completed", asyncRun).catch(() => {});
2564
2958
  } catch (error) {
2565
2959
  const errorMsg = error instanceof Error ? error.message : String(error);
2566
2960
  updateRun(run.id, {
@@ -2568,6 +2962,9 @@ function startRunAsync(options) {
2568
2962
  finished_at: new Date().toISOString()
2569
2963
  });
2570
2964
  emit({ type: "run:complete", runId: run.id, error: errorMsg });
2965
+ const failedRun = getRun(run.id);
2966
+ if (failedRun)
2967
+ dispatchWebhooks("failed", failedRun).catch(() => {});
2571
2968
  }
2572
2969
  })();
2573
2970
  return { runId: run.id, scenarioCount: scenarios.length };
@@ -4104,7 +4501,7 @@ function listTemplateNames() {
4104
4501
  }
4105
4502
  // src/db/auth-presets.ts
4106
4503
  init_database();
4107
- function fromRow(row) {
4504
+ function fromRow2(row) {
4108
4505
  return {
4109
4506
  id: row.id,
4110
4507
  name: row.name,
@@ -4128,12 +4525,12 @@ function createAuthPreset(input) {
4128
4525
  function getAuthPreset(name) {
4129
4526
  const db2 = getDatabase();
4130
4527
  const row = db2.query("SELECT * FROM auth_presets WHERE name = ?").get(name);
4131
- return row ? fromRow(row) : null;
4528
+ return row ? fromRow2(row) : null;
4132
4529
  }
4133
4530
  function listAuthPresets() {
4134
4531
  const db2 = getDatabase();
4135
4532
  const rows = db2.query("SELECT * FROM auth_presets ORDER BY created_at DESC").all();
4136
- return rows.map(fromRow);
4533
+ return rows.map(fromRow2);
4137
4534
  }
4138
4535
  function deleteAuthPreset(name) {
4139
4536
  const db2 = getDatabase();
@@ -4602,157 +4999,6 @@ async function startWatcher(options) {
4602
4999
  process.on("SIGTERM", cleanup);
4603
5000
  await new Promise(() => {});
4604
5001
  }
4605
- // src/lib/webhooks.ts
4606
- init_database();
4607
- function fromRow2(row) {
4608
- return {
4609
- id: row.id,
4610
- url: row.url,
4611
- events: JSON.parse(row.events),
4612
- projectId: row.project_id,
4613
- secret: row.secret,
4614
- active: row.active === 1,
4615
- createdAt: row.created_at
4616
- };
4617
- }
4618
- function createWebhook(input) {
4619
- const db2 = getDatabase();
4620
- const id = uuid();
4621
- const events = input.events ?? ["failed"];
4622
- const secret = input.secret ?? crypto.randomUUID().replace(/-/g, "");
4623
- db2.query(`
4624
- INSERT INTO webhooks (id, url, events, project_id, secret, active, created_at)
4625
- VALUES (?, ?, ?, ?, ?, 1, ?)
4626
- `).run(id, input.url, JSON.stringify(events), input.projectId ?? null, secret, now());
4627
- return getWebhook(id);
4628
- }
4629
- function getWebhook(id) {
4630
- const db2 = getDatabase();
4631
- const row = db2.query("SELECT * FROM webhooks WHERE id = ?").get(id);
4632
- if (!row) {
4633
- const rows = db2.query("SELECT * FROM webhooks WHERE id LIKE ? || '%'").all(id);
4634
- if (rows.length === 1)
4635
- return fromRow2(rows[0]);
4636
- return null;
4637
- }
4638
- return fromRow2(row);
4639
- }
4640
- function listWebhooks(projectId) {
4641
- const db2 = getDatabase();
4642
- let query = "SELECT * FROM webhooks WHERE active = 1";
4643
- const params = [];
4644
- if (projectId) {
4645
- query += " AND (project_id = ? OR project_id IS NULL)";
4646
- params.push(projectId);
4647
- }
4648
- query += " ORDER BY created_at DESC";
4649
- const rows = db2.query(query).all(...params);
4650
- return rows.map(fromRow2);
4651
- }
4652
- function deleteWebhook(id) {
4653
- const db2 = getDatabase();
4654
- const webhook = getWebhook(id);
4655
- if (!webhook)
4656
- return false;
4657
- db2.query("DELETE FROM webhooks WHERE id = ?").run(webhook.id);
4658
- return true;
4659
- }
4660
- function signPayload(body, secret) {
4661
- const encoder = new TextEncoder;
4662
- const key = encoder.encode(secret);
4663
- const data = encoder.encode(body);
4664
- let hash = 0;
4665
- for (let i = 0;i < data.length; i++) {
4666
- hash = (hash << 5) - hash + data[i] + (key[i % key.length] ?? 0) | 0;
4667
- }
4668
- return `sha256=${Math.abs(hash).toString(16).padStart(16, "0")}`;
4669
- }
4670
- function formatSlackPayload(payload) {
4671
- const status = payload.run.status === "passed" ? ":white_check_mark:" : ":x:";
4672
- const color = payload.run.status === "passed" ? "#22c55e" : "#ef4444";
4673
- return {
4674
- attachments: [
4675
- {
4676
- color,
4677
- blocks: [
4678
- {
4679
- type: "section",
4680
- text: {
4681
- type: "mrkdwn",
4682
- text: `${status} *Test Run ${payload.run.status.toUpperCase()}*
4683
- ` + `URL: ${payload.run.url}
4684
- ` + `Results: ${payload.run.passed}/${payload.run.total} passed` + (payload.run.failed > 0 ? ` (${payload.run.failed} failed)` : "") + (payload.schedule ? `
4685
- Schedule: ${payload.schedule.name}` : "")
4686
- }
4687
- }
4688
- ]
4689
- }
4690
- ]
4691
- };
4692
- }
4693
- async function dispatchWebhooks(event, run, schedule) {
4694
- const webhooks = listWebhooks(run.projectId ?? undefined);
4695
- const payload = {
4696
- event,
4697
- run: {
4698
- id: run.id,
4699
- url: run.url,
4700
- status: run.status,
4701
- passed: run.passed,
4702
- failed: run.failed,
4703
- total: run.total
4704
- },
4705
- schedule,
4706
- timestamp: new Date().toISOString()
4707
- };
4708
- for (const webhook of webhooks) {
4709
- if (!webhook.events.includes(event) && !webhook.events.includes("*"))
4710
- continue;
4711
- const isSlack = webhook.url.includes("hooks.slack.com");
4712
- const body = isSlack ? JSON.stringify(formatSlackPayload(payload)) : JSON.stringify(payload);
4713
- const headers = {
4714
- "Content-Type": "application/json"
4715
- };
4716
- if (webhook.secret) {
4717
- headers["X-Testers-Signature"] = signPayload(body, webhook.secret);
4718
- }
4719
- try {
4720
- const response = await fetch(webhook.url, {
4721
- method: "POST",
4722
- headers,
4723
- body
4724
- });
4725
- if (!response.ok) {
4726
- await new Promise((r) => setTimeout(r, 5000));
4727
- await fetch(webhook.url, { method: "POST", headers, body });
4728
- }
4729
- } catch {}
4730
- }
4731
- }
4732
- async function testWebhook(id) {
4733
- const webhook = getWebhook(id);
4734
- if (!webhook)
4735
- return false;
4736
- const testPayload = {
4737
- event: "test",
4738
- run: { id: "test-run", url: "http://localhost:3000", status: "passed", passed: 3, failed: 0, total: 3 },
4739
- timestamp: new Date().toISOString()
4740
- };
4741
- try {
4742
- const body = JSON.stringify(testPayload);
4743
- const response = await fetch(webhook.url, {
4744
- method: "POST",
4745
- headers: {
4746
- "Content-Type": "application/json",
4747
- ...webhook.secret ? { "X-Testers-Signature": signPayload(body, webhook.secret) } : {}
4748
- },
4749
- body
4750
- });
4751
- return response.ok;
4752
- } catch {
4753
- return false;
4754
- }
4755
- }
4756
5002
  export {
4757
5003
  writeScenarioMeta,
4758
5004
  writeRunMeta,
@@ -4806,7 +5052,10 @@ export {
4806
5052
  listFlows,
4807
5053
  listAuthPresets,
4808
5054
  listAgents,
5055
+ launchLightpanda,
4809
5056
  launchBrowser,
5057
+ isLightpandaAvailable,
5058
+ installLightpanda,
4810
5059
  installBrowser,
4811
5060
  initProject,
4812
5061
  importFromTodos,
@@ -4828,6 +5077,7 @@ export {
4828
5077
  getProject,
4829
5078
  getPage,
4830
5079
  getNextRunTime,
5080
+ getLightpandaPage,
4831
5081
  getFlow,
4832
5082
  getExitCode,
4833
5083
  getEnabledSchedules,
@@ -4877,6 +5127,7 @@ export {
4877
5127
  createClient,
4878
5128
  createAuthPreset,
4879
5129
  connectToTodos,
5130
+ closeLightpanda,
4880
5131
  closeDatabase,
4881
5132
  closeBrowser,
4882
5133
  checkBudget,