@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/dashboard/dist/assets/index-DvYdwJK-.css +1 -0
- package/dashboard/dist/assets/index-RV9LMdfY.js +49 -0
- package/dashboard/dist/index.html +2 -2
- package/dist/cli/index.js +1395 -365
- package/dist/db/results.d.ts +1 -0
- package/dist/db/results.d.ts.map +1 -1
- package/dist/db/runs.d.ts +1 -0
- package/dist/db/runs.d.ts.map +1 -1
- package/dist/db/scenarios.d.ts +1 -0
- package/dist/db/scenarios.d.ts.map +1 -1
- package/dist/db/screenshots.d.ts +1 -0
- package/dist/db/screenshots.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +462 -31
- package/dist/lib/browser-lightpanda.d.ts +43 -0
- package/dist/lib/browser-lightpanda.d.ts.map +1 -0
- package/dist/lib/browser.d.ts +9 -3
- package/dist/lib/browser.d.ts.map +1 -1
- package/dist/lib/costs.d.ts +1 -0
- package/dist/lib/costs.d.ts.map +1 -1
- package/dist/lib/init.d.ts.map +1 -1
- package/dist/lib/logs-integration.d.ts +7 -0
- package/dist/lib/logs-integration.d.ts.map +1 -0
- package/dist/lib/reporter.d.ts +7 -0
- package/dist/lib/reporter.d.ts.map +1 -1
- package/dist/lib/runner.d.ts +4 -0
- package/dist/lib/runner.d.ts.map +1 -1
- package/dist/mcp/index.js +230 -7
- package/dist/server/index.js +4461 -125
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/dashboard/dist/assets/index-CDcHt94n.css +0 -1
- package/dashboard/dist/assets/index-DCNDCh61.js +0 -49
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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: "
|
|
3844
|
-
description: "Navigate to the
|
|
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: "
|
|
3851
|
-
description: "
|
|
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: "
|
|
3858
|
-
description: "
|
|
3859
|
-
tags: ["
|
|
3860
|
-
priority: "
|
|
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("
|
|
4704
|
-
lines.push(` ${"Scenario".padEnd(40)} ${"Cost".padEnd(12)} ${"
|
|
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
|
-
|
|
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,
|