@hasna/testers 0.0.7 → 0.0.10

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) {
@@ -512,7 +513,8 @@ __export(exports_runs, {
512
513
  listRuns: () => listRuns,
513
514
  getRun: () => getRun,
514
515
  deleteRun: () => deleteRun,
515
- createRun: () => createRun
516
+ createRun: () => createRun,
517
+ countRuns: () => countRuns
516
518
  });
517
519
  function createRun(input) {
518
520
  const db2 = getDatabase();
@@ -565,6 +567,24 @@ function listRuns(filter) {
565
567
  const rows = db2.query(sql).all(...params);
566
568
  return rows.map(runFromRow);
567
569
  }
570
+ function countRuns(filter) {
571
+ const db2 = getDatabase();
572
+ const conditions = [];
573
+ const params = [];
574
+ if (filter?.projectId) {
575
+ conditions.push("project_id = ?");
576
+ params.push(filter.projectId);
577
+ }
578
+ if (filter?.status) {
579
+ conditions.push("status = ?");
580
+ params.push(filter.status);
581
+ }
582
+ let sql = "SELECT COUNT(*) as count FROM runs";
583
+ if (conditions.length > 0)
584
+ sql += " WHERE " + conditions.join(" AND ");
585
+ const row = db2.query(sql).get(...params);
586
+ return row.count;
587
+ }
568
588
  function updateRun(id, updates) {
569
589
  const db2 = getDatabase();
570
590
  const existing = getRun(id);
@@ -792,6 +812,168 @@ var init_flows = __esm(() => {
792
812
  init_types();
793
813
  });
794
814
 
815
+ // src/lib/browser-lightpanda.ts
816
+ var exports_browser_lightpanda = {};
817
+ __export(exports_browser_lightpanda, {
818
+ startLightpandaServer: () => startLightpandaServer,
819
+ launchLightpanda: () => launchLightpanda,
820
+ isLightpandaAvailable: () => isLightpandaAvailable,
821
+ installLightpanda: () => installLightpanda,
822
+ getLightpandaPage: () => getLightpandaPage,
823
+ closeLightpanda: () => closeLightpanda
824
+ });
825
+ import { chromium } from "playwright";
826
+ import { spawn } from "child_process";
827
+ function isLightpandaAvailable() {
828
+ try {
829
+ const possiblePaths = [
830
+ `${process.env["HOME"]}/.cache/lightpanda-node/lightpanda`,
831
+ process.env["LIGHTPANDA_EXECUTABLE_PATH"]
832
+ ];
833
+ for (const p of possiblePaths) {
834
+ if (p) {
835
+ try {
836
+ const { existsSync: existsSync3 } = __require("fs");
837
+ if (existsSync3(p))
838
+ return true;
839
+ } catch {
840
+ continue;
841
+ }
842
+ }
843
+ }
844
+ const { execSync } = __require("child_process");
845
+ execSync("lightpanda --version", { stdio: "ignore", timeout: 5000 });
846
+ return true;
847
+ } catch {
848
+ return false;
849
+ }
850
+ }
851
+ function findLightpandaBinary() {
852
+ const envPath = process.env["LIGHTPANDA_EXECUTABLE_PATH"];
853
+ if (envPath)
854
+ return envPath;
855
+ const cachePath = `${process.env["HOME"]}/.cache/lightpanda-node/lightpanda`;
856
+ try {
857
+ const { existsSync: existsSync3 } = __require("fs");
858
+ if (existsSync3(cachePath))
859
+ return cachePath;
860
+ } catch {}
861
+ return "lightpanda";
862
+ }
863
+ async function startLightpandaServer(port) {
864
+ const binary = findLightpandaBinary();
865
+ const cdpPort = port ?? 9222 + Math.floor(Math.random() * 1000);
866
+ return new Promise((resolve, reject) => {
867
+ const proc = spawn(binary, ["serve", "--port", String(cdpPort)], {
868
+ stdio: ["ignore", "pipe", "pipe"]
869
+ });
870
+ let resolved = false;
871
+ const timeout = setTimeout(() => {
872
+ if (!resolved) {
873
+ resolved = true;
874
+ resolve({
875
+ process: proc,
876
+ wsEndpoint: `ws://127.0.0.1:${cdpPort}`
877
+ });
878
+ }
879
+ }, 5000);
880
+ proc.stdout?.on("data", (data) => {
881
+ const output = data.toString();
882
+ if (output.includes("127.0.0.1") || output.includes("listening") || output.includes("DevTools")) {
883
+ if (!resolved) {
884
+ resolved = true;
885
+ clearTimeout(timeout);
886
+ resolve({
887
+ process: proc,
888
+ wsEndpoint: `ws://127.0.0.1:${cdpPort}`
889
+ });
890
+ }
891
+ }
892
+ });
893
+ proc.stderr?.on("data", (data) => {
894
+ const output = data.toString();
895
+ if (output.includes("127.0.0.1") || output.includes("listening")) {
896
+ if (!resolved) {
897
+ resolved = true;
898
+ clearTimeout(timeout);
899
+ resolve({
900
+ process: proc,
901
+ wsEndpoint: `ws://127.0.0.1:${cdpPort}`
902
+ });
903
+ }
904
+ }
905
+ });
906
+ proc.on("error", (err) => {
907
+ clearTimeout(timeout);
908
+ if (!resolved) {
909
+ resolved = true;
910
+ reject(new BrowserError(`Failed to start Lightpanda: ${err.message}. ` + `Install it with: bun install @lightpanda/browser`));
911
+ }
912
+ });
913
+ proc.on("exit", (code) => {
914
+ if (!resolved) {
915
+ resolved = true;
916
+ clearTimeout(timeout);
917
+ reject(new BrowserError(`Lightpanda exited with code ${code}. ` + `Install it with: bun install @lightpanda/browser`));
918
+ }
919
+ });
920
+ });
921
+ }
922
+ async function launchLightpanda(_options) {
923
+ try {
924
+ const { process: proc, wsEndpoint } = await startLightpandaServer();
925
+ lightpandaProcess = proc;
926
+ const browser = await chromium.connectOverCDP(wsEndpoint);
927
+ return browser;
928
+ } catch (error) {
929
+ const message = error instanceof Error ? error.message : String(error);
930
+ throw new BrowserError(`Failed to launch Lightpanda: ${message}`);
931
+ }
932
+ }
933
+ async function getLightpandaPage(browser, options) {
934
+ try {
935
+ const contexts = browser.contexts();
936
+ const context = contexts.length > 0 ? contexts[0] : await browser.newContext({
937
+ viewport: options?.viewport ?? { width: 1280, height: 720 },
938
+ userAgent: options?.userAgent,
939
+ locale: options?.locale
940
+ });
941
+ const page = await context.newPage();
942
+ return page;
943
+ } catch (error) {
944
+ const message = error instanceof Error ? error.message : String(error);
945
+ throw new BrowserError(`Failed to create Lightpanda page: ${message}`);
946
+ }
947
+ }
948
+ async function closeLightpanda(browser) {
949
+ try {
950
+ await browser.close();
951
+ } catch {}
952
+ if (lightpandaProcess) {
953
+ try {
954
+ lightpandaProcess.kill("SIGTERM");
955
+ lightpandaProcess = null;
956
+ } catch {}
957
+ }
958
+ }
959
+ async function installLightpanda() {
960
+ const { execSync } = __require("child_process");
961
+ try {
962
+ execSync("bun install @lightpanda/browser", {
963
+ stdio: "inherit",
964
+ cwd: process.env["HOME"]
965
+ });
966
+ } catch (error) {
967
+ const message = error instanceof Error ? error.message : String(error);
968
+ throw new BrowserError(`Failed to install Lightpanda: ${message}
969
+ ` + `Try manually: bun install @lightpanda/browser`);
970
+ }
971
+ }
972
+ var lightpandaProcess = null;
973
+ var init_browser_lightpanda = __esm(() => {
974
+ init_types();
975
+ });
976
+
795
977
  // src/index.ts
796
978
  init_types();
797
979
  init_database();
@@ -1337,14 +1519,22 @@ function resolveModel(nameOrId) {
1337
1519
  }
1338
1520
  // src/lib/browser.ts
1339
1521
  init_types();
1340
- import { chromium } from "playwright";
1522
+ import { chromium as chromium2 } from "playwright";
1341
1523
  import { execSync } from "child_process";
1342
1524
  var DEFAULT_VIEWPORT = { width: 1280, height: 720 };
1343
1525
  async function launchBrowser(options) {
1526
+ const engine = options?.engine ?? process.env["TESTERS_BROWSER_ENGINE"] ?? "playwright";
1527
+ if (engine === "lightpanda") {
1528
+ const { launchLightpanda: launchLightpanda2, isLightpandaAvailable: isLightpandaAvailable2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
1529
+ if (!isLightpandaAvailable2()) {
1530
+ throw new BrowserError("Lightpanda not installed. Run: testers install-browser --engine lightpanda");
1531
+ }
1532
+ return launchLightpanda2({ viewport: options?.viewport });
1533
+ }
1344
1534
  const headless = options?.headless ?? true;
1345
1535
  const viewport = options?.viewport ?? DEFAULT_VIEWPORT;
1346
1536
  try {
1347
- const browser = await chromium.launch({
1537
+ const browser = await chromium2.launch({
1348
1538
  headless,
1349
1539
  args: [
1350
1540
  `--window-size=${viewport.width},${viewport.height}`
@@ -1357,6 +1547,11 @@ async function launchBrowser(options) {
1357
1547
  }
1358
1548
  }
1359
1549
  async function getPage(browser, options) {
1550
+ const engine = options?.engine ?? "playwright";
1551
+ if (engine === "lightpanda") {
1552
+ const { getLightpandaPage: getLightpandaPage2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
1553
+ return getLightpandaPage2(browser, options);
1554
+ }
1360
1555
  const viewport = options?.viewport ?? DEFAULT_VIEWPORT;
1361
1556
  try {
1362
1557
  const context = await browser.newContext({
@@ -1371,7 +1566,11 @@ async function getPage(browser, options) {
1371
1566
  throw new BrowserError(`Failed to create page: ${message}`);
1372
1567
  }
1373
1568
  }
1374
- async function closeBrowser(browser) {
1569
+ async function closeBrowser(browser, engine) {
1570
+ if (engine === "lightpanda") {
1571
+ const { closeLightpanda: closeLightpanda2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
1572
+ return closeLightpanda2(browser);
1573
+ }
1375
1574
  try {
1376
1575
  await browser.close();
1377
1576
  } catch (error) {
@@ -1385,26 +1584,29 @@ class BrowserPool {
1385
1584
  maxSize;
1386
1585
  headless;
1387
1586
  viewport;
1587
+ engine;
1388
1588
  constructor(size, options) {
1389
1589
  this.maxSize = size;
1390
1590
  this.headless = options?.headless ?? true;
1391
1591
  this.viewport = options?.viewport ?? DEFAULT_VIEWPORT;
1592
+ this.engine = options?.engine ?? "playwright";
1392
1593
  }
1393
1594
  async acquire() {
1394
1595
  const idle = this.pool.find((entry) => !entry.inUse);
1395
1596
  if (idle) {
1396
1597
  idle.inUse = true;
1397
- const page = await getPage(idle.browser, { viewport: this.viewport });
1598
+ const page = await getPage(idle.browser, { viewport: this.viewport, engine: this.engine });
1398
1599
  return { browser: idle.browser, page };
1399
1600
  }
1400
1601
  if (this.pool.length < this.maxSize) {
1401
1602
  const browser = await launchBrowser({
1402
1603
  headless: this.headless,
1403
- viewport: this.viewport
1604
+ viewport: this.viewport,
1605
+ engine: this.engine
1404
1606
  });
1405
1607
  const entry = { browser, inUse: true };
1406
1608
  this.pool.push(entry);
1407
- const page = await getPage(browser, { viewport: this.viewport });
1609
+ const page = await getPage(browser, { viewport: this.viewport, engine: this.engine });
1408
1610
  return { browser, page };
1409
1611
  }
1410
1612
  return new Promise((resolve, reject) => {
@@ -1413,7 +1615,7 @@ class BrowserPool {
1413
1615
  if (available) {
1414
1616
  clearInterval(interval);
1415
1617
  available.inUse = true;
1416
- getPage(available.browser, { viewport: this.viewport }).then((page) => resolve({ browser: available.browser, page })).catch(reject);
1618
+ getPage(available.browser, { viewport: this.viewport, engine: this.engine }).then((page) => resolve({ browser: available.browser, page })).catch(reject);
1417
1619
  }
1418
1620
  }, 50);
1419
1621
  });
@@ -1430,7 +1632,11 @@ class BrowserPool {
1430
1632
  this.pool.length = 0;
1431
1633
  }
1432
1634
  }
1433
- async function installBrowser() {
1635
+ async function installBrowser(engine) {
1636
+ if (engine === "lightpanda") {
1637
+ const { installLightpanda: installLightpanda2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
1638
+ return installLightpanda2();
1639
+ }
1434
1640
  try {
1435
1641
  execSync("bunx playwright install chromium", {
1436
1642
  stdio: "inherit"
@@ -1440,6 +1646,10 @@ async function installBrowser() {
1440
1646
  throw new BrowserError(`Failed to install browser: ${message}`);
1441
1647
  }
1442
1648
  }
1649
+
1650
+ // src/index.ts
1651
+ init_browser_lightpanda();
1652
+
1443
1653
  // src/lib/screenshotter.ts
1444
1654
  import { mkdirSync as mkdirSync2, existsSync as existsSync3, writeFileSync } from "fs";
1445
1655
  import { join as join3 } from "path";
@@ -2474,6 +2684,38 @@ async function testWebhook(id) {
2474
2684
  }
2475
2685
  }
2476
2686
 
2687
+ // src/lib/logs-integration.ts
2688
+ async function pushFailedRunToLogs(run, failedResults, scenarios) {
2689
+ const logsUrl = process.env.LOGS_URL;
2690
+ if (!logsUrl)
2691
+ return;
2692
+ const scenarioMap = new Map(scenarios.map((s) => [s.id, s]));
2693
+ const entries = failedResults.map((result) => {
2694
+ const scenario = scenarioMap.get(result.scenarioId);
2695
+ return {
2696
+ level: "error",
2697
+ source: "sdk",
2698
+ service: "testers",
2699
+ message: `[testers] Scenario failed: ${scenario?.name ?? result.scenarioId}${result.error ? ` \u2014 ${result.error}` : ""}`,
2700
+ metadata: {
2701
+ run_id: run.id,
2702
+ scenario_id: result.scenarioId,
2703
+ scenario_name: scenario?.name,
2704
+ url: run.url,
2705
+ status: result.status,
2706
+ duration_ms: result.durationMs
2707
+ }
2708
+ };
2709
+ });
2710
+ try {
2711
+ await fetch(`${logsUrl.replace(/\/$/, "")}/api/logs`, {
2712
+ method: "POST",
2713
+ headers: { "Content-Type": "application/json" },
2714
+ body: JSON.stringify(entries)
2715
+ });
2716
+ } catch {}
2717
+ }
2718
+
2477
2719
  // src/lib/runner.ts
2478
2720
  var eventHandler = null;
2479
2721
  function onRunEvent(handler) {
@@ -2486,7 +2728,7 @@ function emit(event) {
2486
2728
  function withTimeout(promise, ms, label) {
2487
2729
  return new Promise((resolve, reject) => {
2488
2730
  const timer = setTimeout(() => {
2489
- reject(new Error(`Scenario timeout after ${ms}ms: ${label}`));
2731
+ reject(new Error(`Scenario '${label}' timed out after ${ms}ms. Try: testers run --timeout ${ms * 2} or simplify the scenario steps.`));
2490
2732
  }, ms);
2491
2733
  promise.then((val) => {
2492
2734
  clearTimeout(timer);
@@ -2514,7 +2756,7 @@ async function runSingleScenario(scenario, runId, options) {
2514
2756
  let browser = null;
2515
2757
  let page = null;
2516
2758
  try {
2517
- browser = await launchBrowser({ headless: !(options.headed ?? false) });
2759
+ browser = await launchBrowser({ headless: !(options.headed ?? false), engine: options.engine });
2518
2760
  page = await getPage(browser, {
2519
2761
  viewport: config.browser.viewport
2520
2762
  });
@@ -2579,7 +2821,7 @@ async function runSingleScenario(scenario, runId, options) {
2579
2821
  return updatedResult;
2580
2822
  } finally {
2581
2823
  if (browser)
2582
- await closeBrowser(browser);
2824
+ await closeBrowser(browser, options.engine);
2583
2825
  }
2584
2826
  }
2585
2827
  async function runBatch(scenarios, options) {
@@ -2619,6 +2861,7 @@ async function runBatch(scenarios, options) {
2619
2861
  } catch {}
2620
2862
  return true;
2621
2863
  };
2864
+ const maxRetries = options.retry ?? 0;
2622
2865
  if (parallel <= 1) {
2623
2866
  for (const scenario of sortedScenarios) {
2624
2867
  if (!await canRun(scenario)) {
@@ -2629,7 +2872,13 @@ async function runBatch(scenarios, options) {
2629
2872
  emit({ type: "scenario:error", scenarioId: scenario.id, scenarioName: scenario.name, error: "Dependency failed \u2014 skipped", runId: run.id });
2630
2873
  continue;
2631
2874
  }
2632
- const result = await runSingleScenario(scenario, run.id, options);
2875
+ let result = await runSingleScenario(scenario, run.id, options);
2876
+ let attempt = 1;
2877
+ while ((result.status === "failed" || result.status === "error") && attempt <= maxRetries) {
2878
+ emit({ type: "scenario:start", scenarioId: scenario.id, scenarioName: scenario.name, runId: run.id, retryAttempt: attempt + 1, maxRetries: maxRetries + 1 });
2879
+ result = await runSingleScenario(scenario, run.id, options);
2880
+ attempt++;
2881
+ }
2633
2882
  results.push(result);
2634
2883
  if (result.status === "failed" || result.status === "error") {
2635
2884
  failedScenarioIds.add(scenario.id);
@@ -2676,6 +2925,10 @@ async function runBatch(scenarios, options) {
2676
2925
  emit({ type: "run:complete", runId: run.id });
2677
2926
  const eventType = finalRun.status === "failed" ? "failed" : "completed";
2678
2927
  dispatchWebhooks(eventType, finalRun).catch(() => {});
2928
+ if (finalRun.status === "failed") {
2929
+ const failedResults = results.filter((r) => r.status === "failed" || r.status === "error");
2930
+ pushFailedRunToLogs(finalRun, failedResults, scenarios).catch(() => {});
2931
+ }
2679
2932
  return { run: finalRun, results };
2680
2933
  }
2681
2934
  async function runByFilter(options) {
@@ -3277,6 +3530,10 @@ var chalkStderr = createChalk({ level: stderrColor ? stderrColor.level : 0 });
3277
3530
  var source_default = chalk;
3278
3531
 
3279
3532
  // src/lib/reporter.ts
3533
+ init_database();
3534
+ function useEmoji() {
3535
+ return !process.env["NO_COLOR"] && process.argv.indexOf("--no-color") === -1;
3536
+ }
3280
3537
  function formatTerminal(run, results) {
3281
3538
  const lines = [];
3282
3539
  lines.push("");
@@ -3291,21 +3548,22 @@ function formatTerminal(run, results) {
3291
3548
  const screenshotCount = screenshots.length;
3292
3549
  let statusIcon;
3293
3550
  let statusColor;
3551
+ const emoji = useEmoji();
3294
3552
  switch (result.status) {
3295
3553
  case "passed":
3296
- statusIcon = source_default.green("PASS");
3554
+ statusIcon = emoji ? "\u2705" : source_default.green("PASS");
3297
3555
  statusColor = source_default.green;
3298
3556
  break;
3299
3557
  case "failed":
3300
- statusIcon = source_default.red("FAIL");
3558
+ statusIcon = emoji ? "\u274C" : source_default.red("FAIL");
3301
3559
  statusColor = source_default.red;
3302
3560
  break;
3303
3561
  case "error":
3304
- statusIcon = source_default.yellow("ERR ");
3562
+ statusIcon = emoji ? "\u26A0\uFE0F " : source_default.yellow("ERR ");
3305
3563
  statusColor = source_default.yellow;
3306
3564
  break;
3307
3565
  default:
3308
- statusIcon = source_default.dim("SKIP");
3566
+ statusIcon = emoji ? "\u23ED\uFE0F " : source_default.dim("SKIP");
3309
3567
  statusColor = source_default.dim;
3310
3568
  break;
3311
3569
  }
@@ -3318,7 +3576,7 @@ function formatTerminal(run, results) {
3318
3576
  }
3319
3577
  }
3320
3578
  lines.push("");
3321
- lines.push(formatSummary(run));
3579
+ lines.push(formatActionableSummary(run, results));
3322
3580
  lines.push("");
3323
3581
  return lines.join(`
3324
3582
  `);
@@ -3330,6 +3588,30 @@ function formatSummary(run) {
3330
3588
  const totalStr = source_default.dim(` (${run.total} total)`);
3331
3589
  return ` ${passedStr}${failedStr}${totalStr} in ${duration}`;
3332
3590
  }
3591
+ function formatActionableSummary(run, results) {
3592
+ const emoji = useEmoji();
3593
+ const passedCount = results.filter((r) => r.status === "passed").length;
3594
+ const failedCount = results.filter((r) => r.status === "failed" || r.status === "error").length;
3595
+ const shortId = run.id.slice(0, 8);
3596
+ const passStr = `${emoji ? "\u2705" : "PASS"} ${passedCount} passed`;
3597
+ const failStr = failedCount > 0 ? ` ${emoji ? "\u274C" : "FAIL"} ${failedCount} failed` : "";
3598
+ const lines = [];
3599
+ lines.push(` ${source_default.bold(passStr)}${failedCount > 0 ? source_default.bold(failStr) : ""}`);
3600
+ if (failedCount > 0) {
3601
+ lines.push(source_default.dim(` retry failed: testers retry ${shortId} | view: testers results ${shortId}`));
3602
+ } else {
3603
+ lines.push(source_default.dim(` view: testers results ${shortId}`));
3604
+ }
3605
+ const totalCostCents = results.reduce((sum, r) => sum + (r.costCents ?? 0), 0);
3606
+ const totalTokens = results.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
3607
+ if (totalTokens > 0) {
3608
+ const costStr = `$${(totalCostCents / 100).toFixed(4)}`;
3609
+ const tokensStr = totalTokens.toLocaleString();
3610
+ lines.push(source_default.dim(` ${emoji ? "\uD83D\uDCB0" : "cost:"} Cost: ${costStr} (${tokensStr} tokens)`));
3611
+ }
3612
+ return lines.join(`
3613
+ `);
3614
+ }
3333
3615
  function formatJSON(run, results) {
3334
3616
  const output = {
3335
3617
  run: {
@@ -3408,6 +3690,15 @@ function formatRunList(runs) {
3408
3690
  return lines.join(`
3409
3691
  `);
3410
3692
  }
3693
+ function getScenarioRunStats(scenarioId) {
3694
+ const db2 = getDatabase();
3695
+ const lastRow = db2.query("SELECT status FROM results WHERE scenario_id = ? ORDER BY created_at DESC LIMIT 1").get(scenarioId);
3696
+ const statsRow = db2.query("SELECT COUNT(*) as total, SUM(CASE WHEN status = 'passed' THEN 1 ELSE 0 END) as passed FROM results WHERE scenario_id = ?").get(scenarioId);
3697
+ return {
3698
+ lastStatus: lastRow ? lastRow.status : null,
3699
+ passRate: statsRow && statsRow.total > 0 ? `${statsRow.passed}/${statsRow.total}` : "\u2014"
3700
+ };
3701
+ }
3411
3702
  function formatScenarioList(scenarios) {
3412
3703
  const lines = [];
3413
3704
  lines.push("");
@@ -3422,7 +3713,21 @@ function formatScenarioList(scenarios) {
3422
3713
  for (const s of scenarios) {
3423
3714
  const priorityColor = s.priority === "critical" ? source_default.red : s.priority === "high" ? source_default.yellow : s.priority === "medium" ? source_default.blue : source_default.dim;
3424
3715
  const tags = s.tags.length > 0 ? source_default.dim(` [${s.tags.join(", ")}]`) : "";
3425
- lines.push(` ${source_default.cyan(s.shortId)} ${s.name} ${priorityColor(s.priority)}${tags}`);
3716
+ let lastStatusIcon = source_default.dim("\u2014");
3717
+ let passRateStr = source_default.dim("\u2014");
3718
+ if (s.id) {
3719
+ const stats = getScenarioRunStats(s.id);
3720
+ if (stats.lastStatus === "passed")
3721
+ lastStatusIcon = source_default.green("\u2713");
3722
+ else if (stats.lastStatus === "failed")
3723
+ lastStatusIcon = source_default.red("\u2717");
3724
+ else if (stats.lastStatus === "error")
3725
+ lastStatusIcon = source_default.yellow("!");
3726
+ else if (stats.lastStatus === "skipped")
3727
+ lastStatusIcon = source_default.dim("~");
3728
+ passRateStr = stats.passRate === "\u2014" ? source_default.dim("\u2014") : source_default.dim(stats.passRate);
3729
+ }
3730
+ lines.push(` ${source_default.cyan(s.shortId)} ${s.name} ${priorityColor(s.priority)}${tags} ${lastStatusIcon} ${passRateStr}`);
3426
3731
  }
3427
3732
  lines.push("");
3428
3733
  return lines.join(`
@@ -3838,26 +4143,146 @@ function detectFramework(dir) {
3838
4143
  return null;
3839
4144
  }
3840
4145
  function getStarterScenarios(framework, projectId) {
4146
+ if (framework.name === "Next.js") {
4147
+ const scenarios2 = [
4148
+ {
4149
+ name: "Homepage loads",
4150
+ description: "Navigate to the homepage and verify it loads correctly. Check that the main heading and content are visible, and there are no console errors.",
4151
+ tags: ["smoke"],
4152
+ priority: "high",
4153
+ projectId
4154
+ },
4155
+ {
4156
+ name: "404 page works",
4157
+ description: "Navigate to a non-existent URL (e.g. /this-page-does-not-exist) and verify the Next.js 404 page renders correctly.",
4158
+ tags: ["smoke"],
4159
+ priority: "medium",
4160
+ projectId
4161
+ },
4162
+ {
4163
+ name: "Navigation links work",
4164
+ description: "Click through the main navigation links and verify each page loads without errors. Check that client-side routing is working correctly.",
4165
+ tags: ["smoke"],
4166
+ priority: "medium",
4167
+ projectId
4168
+ }
4169
+ ];
4170
+ if (framework.features.includes("hasAuth")) {
4171
+ scenarios2.push({
4172
+ name: "Login flow",
4173
+ description: "Navigate to the login page, enter valid credentials, and verify successful authentication and redirect.",
4174
+ tags: ["auth"],
4175
+ priority: "critical",
4176
+ projectId
4177
+ }, {
4178
+ name: "Protected route redirect",
4179
+ description: "Try to access a protected route without authentication and verify you are redirected to the login page.",
4180
+ tags: ["auth"],
4181
+ priority: "high",
4182
+ projectId
4183
+ });
4184
+ }
4185
+ if (framework.features.includes("hasForms")) {
4186
+ scenarios2.push({
4187
+ name: "Form validation",
4188
+ description: "Submit forms with empty/invalid data and verify validation errors appear correctly.",
4189
+ tags: ["forms"],
4190
+ priority: "medium",
4191
+ projectId
4192
+ });
4193
+ }
4194
+ return scenarios2;
4195
+ }
4196
+ if (framework.name === "Vite" || framework.name === "SvelteKit") {
4197
+ const scenarios2 = [
4198
+ {
4199
+ name: "Homepage loads",
4200
+ description: "Navigate to the homepage and verify it loads correctly with no console errors.",
4201
+ tags: ["smoke"],
4202
+ priority: "high",
4203
+ projectId
4204
+ },
4205
+ {
4206
+ name: "Mobile viewport check",
4207
+ description: "Set the viewport to 375x812 (iPhone) and verify the homepage renders correctly without horizontal scrolling or layout issues.",
4208
+ tags: ["responsive"],
4209
+ priority: "medium",
4210
+ projectId
4211
+ },
4212
+ {
4213
+ name: "No console errors",
4214
+ description: "Navigate through the app and verify there are no JavaScript errors or warnings in the browser console.",
4215
+ tags: ["smoke"],
4216
+ priority: "high",
4217
+ projectId
4218
+ }
4219
+ ];
4220
+ if (framework.features.includes("hasAuth")) {
4221
+ scenarios2.push({
4222
+ name: "Login flow",
4223
+ description: "Navigate to the login page, enter valid credentials, and verify successful authentication.",
4224
+ tags: ["auth"],
4225
+ priority: "critical",
4226
+ projectId
4227
+ });
4228
+ }
4229
+ return scenarios2;
4230
+ }
4231
+ if (framework.name === "Nuxt") {
4232
+ const scenarios2 = [
4233
+ {
4234
+ name: "Homepage loads",
4235
+ description: "Navigate to the homepage and verify it loads correctly. Check that the main heading and content are visible.",
4236
+ tags: ["smoke"],
4237
+ priority: "high",
4238
+ projectId
4239
+ },
4240
+ {
4241
+ name: "Navigation works",
4242
+ description: "Click through main navigation links and verify each page loads without errors.",
4243
+ tags: ["smoke"],
4244
+ priority: "medium",
4245
+ projectId
4246
+ },
4247
+ {
4248
+ name: "Mobile viewport check",
4249
+ description: "Set the viewport to 375x812 and verify the homepage renders correctly on mobile.",
4250
+ tags: ["responsive"],
4251
+ priority: "medium",
4252
+ projectId
4253
+ }
4254
+ ];
4255
+ if (framework.features.includes("hasAuth")) {
4256
+ scenarios2.push({
4257
+ name: "Login flow",
4258
+ description: "Navigate to the login page, enter valid credentials, and verify successful authentication.",
4259
+ tags: ["auth"],
4260
+ priority: "critical",
4261
+ projectId
4262
+ });
4263
+ }
4264
+ return scenarios2;
4265
+ }
3841
4266
  const scenarios = [
3842
4267
  {
3843
- name: "Landing page loads",
3844
- 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.",
4268
+ name: "Homepage loads",
4269
+ description: "Navigate to the homepage and verify it loads correctly with no console errors. Check that the main heading, navigation, and primary CTA are visible.",
3845
4270
  tags: ["smoke"],
3846
4271
  priority: "high",
3847
4272
  projectId
3848
4273
  },
3849
4274
  {
3850
- name: "Navigation works",
3851
- description: "Click through main navigation links and verify each page loads without errors.",
4275
+ name: "Form submit works",
4276
+ description: "Find the main form on the page, fill it in with valid test data, submit it, and verify the success state.",
3852
4277
  tags: ["smoke"],
3853
4278
  priority: "medium",
3854
4279
  projectId
3855
4280
  },
3856
4281
  {
3857
- name: "No console errors",
3858
- description: "Navigate through the main pages and check the browser console for any JavaScript errors or warnings.",
3859
- tags: ["smoke"],
3860
- priority: "high",
4282
+ name: "Mobile viewport check",
4283
+ description: "Set the viewport to 375x812 (iPhone) and verify the homepage renders correctly without horizontal scrolling or layout issues.",
4284
+ tags: ["responsive"],
4285
+ priority: "medium",
3861
4286
  projectId
3862
4287
  }
3863
4288
  ];
@@ -4700,12 +5125,13 @@ function formatCostsTerminal(summary) {
4700
5125
  }
4701
5126
  if (summary.byScenario.length > 0) {
4702
5127
  lines.push("");
4703
- lines.push(source_default.bold(" Top Scenarios by Cost"));
4704
- lines.push(` ${"Scenario".padEnd(40)} ${"Cost".padEnd(12)} ${"Tokens".padEnd(12)} Runs`);
4705
- lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(12)} ${"\u2500".repeat(12)} ${"\u2500".repeat(6)}`);
5128
+ lines.push(source_default.bold(" Scenarios by Cost (most expensive first)"));
5129
+ lines.push(` ${"Scenario".padEnd(40)} ${"Total Cost".padEnd(12)} ${"Avg/Run".padEnd(12)} ${"Runs".padEnd(6)} Tokens`);
5130
+ lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(12)} ${"\u2500".repeat(12)} ${"\u2500".repeat(6)} ${"\u2500".repeat(10)}`);
4706
5131
  for (const s of summary.byScenario) {
4707
5132
  const label = s.name.length > 38 ? s.name.slice(0, 35) + "..." : s.name;
4708
- lines.push(` ${label.padEnd(40)} ${formatDollars(s.costCents).padEnd(12)} ${formatTokens(s.tokens).padEnd(12)} ${s.runs}`);
5133
+ const avgPerRun = s.runs > 0 ? s.costCents / s.runs : 0;
5134
+ lines.push(` ${label.padEnd(40)} ${formatDollars(s.costCents).padEnd(12)} ${formatDollars(avgPerRun).padEnd(12)} ${String(s.runs).padEnd(6)} ${formatTokens(s.tokens)}`);
4709
5135
  }
4710
5136
  }
4711
5137
  lines.push("");
@@ -4861,7 +5287,10 @@ export {
4861
5287
  listFlows,
4862
5288
  listAuthPresets,
4863
5289
  listAgents,
5290
+ launchLightpanda,
4864
5291
  launchBrowser,
5292
+ isLightpandaAvailable,
5293
+ installLightpanda,
4865
5294
  installBrowser,
4866
5295
  initProject,
4867
5296
  importFromTodos,
@@ -4883,6 +5312,7 @@ export {
4883
5312
  getProject,
4884
5313
  getPage,
4885
5314
  getNextRunTime,
5315
+ getLightpandaPage,
4886
5316
  getFlow,
4887
5317
  getExitCode,
4888
5318
  getEnabledSchedules,
@@ -4932,6 +5362,7 @@ export {
4932
5362
  createClient,
4933
5363
  createAuthPreset,
4934
5364
  connectToTodos,
5365
+ closeLightpanda,
4935
5366
  closeDatabase,
4936
5367
  closeBrowser,
4937
5368
  checkBudget,