@hasna/testers 0.0.12 → 0.0.14

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.
Files changed (36) hide show
  1. package/dashboard/dist/assets/{index-DyXKnBM8.css → index-PT-52SEY.css} +1 -1
  2. package/dashboard/dist/index.html +2 -2
  3. package/dist/cli/index.js +3959 -2496
  4. package/dist/db/agents.d.ts +2 -0
  5. package/dist/db/agents.d.ts.map +1 -1
  6. package/dist/db/database.d.ts.map +1 -1
  7. package/dist/db/scan-issues.d.ts +29 -0
  8. package/dist/db/scan-issues.d.ts.map +1 -0
  9. package/dist/index.js +239 -117
  10. package/dist/lib/affected.d.ts +23 -0
  11. package/dist/lib/affected.d.ts.map +1 -0
  12. package/dist/lib/failure-pipeline.d.ts +20 -0
  13. package/dist/lib/failure-pipeline.d.ts.map +1 -0
  14. package/dist/lib/git-watch.d.ts +16 -0
  15. package/dist/lib/git-watch.d.ts.map +1 -0
  16. package/dist/lib/health-scan.d.ts +22 -0
  17. package/dist/lib/health-scan.d.ts.map +1 -0
  18. package/dist/lib/runner.d.ts.map +1 -1
  19. package/dist/lib/scanners/console.d.ts +12 -0
  20. package/dist/lib/scanners/console.d.ts.map +1 -0
  21. package/dist/lib/scanners/links.d.ts +12 -0
  22. package/dist/lib/scanners/links.d.ts.map +1 -0
  23. package/dist/lib/scanners/network.d.ts +15 -0
  24. package/dist/lib/scanners/network.d.ts.map +1 -0
  25. package/dist/lib/scanners/performance.d.ts +19 -0
  26. package/dist/lib/scanners/performance.d.ts.map +1 -0
  27. package/dist/mcp/index.js +1428 -400
  28. package/dist/server/index.js +161 -13
  29. package/dist/types/index.d.ts +54 -0
  30. package/dist/types/index.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/dist/cli/index.d.ts +0 -3
  33. package/dist/cli/index.d.ts.map +0 -1
  34. package/dist/mcp/index.d.ts +0 -3
  35. package/dist/mcp/index.d.ts.map +0 -1
  36. /package/dashboard/dist/assets/{index-jNG_Nd_Q.js → index-FZ9gzLaz.js} +0 -0
package/dist/mcp/index.js CHANGED
@@ -1,13 +1,17 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
3
  var __defProp = Object.defineProperty;
4
+ var __returnValue = (v) => v;
5
+ function __exportSetter(name, newValue) {
6
+ this[name] = __returnValue.bind(null, newValue);
7
+ }
4
8
  var __export = (target, all) => {
5
9
  for (var name in all)
6
10
  __defProp(target, name, {
7
11
  get: all[name],
8
12
  enumerable: true,
9
13
  configurable: true,
10
- set: (newValue) => all[name] = () => newValue
14
+ set: __exportSetter.bind(all, name)
11
15
  });
12
16
  };
13
17
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
@@ -128,6 +132,24 @@ function scheduleFromRow(row) {
128
132
  updatedAt: row.updated_at
129
133
  };
130
134
  }
135
+ function scanIssueFromRow(row) {
136
+ return {
137
+ id: row.id,
138
+ fingerprint: row.fingerprint,
139
+ type: row.type,
140
+ severity: row.severity,
141
+ pageUrl: row.page_url,
142
+ message: row.message,
143
+ detail: row.detail ? JSON.parse(row.detail) : null,
144
+ status: row.status,
145
+ occurrenceCount: row.occurrence_count,
146
+ firstSeenAt: row.first_seen_at,
147
+ lastSeenAt: row.last_seen_at,
148
+ resolvedAt: row.resolved_at,
149
+ todoTaskId: row.todo_task_id,
150
+ projectId: row.project_id
151
+ };
152
+ }
131
153
  function flowFromRow(row) {
132
154
  return {
133
155
  id: row.id,
@@ -445,10 +467,223 @@ var init_database = __esm(() => {
445
467
  `,
446
468
  `
447
469
  ALTER TABLE runs ADD COLUMN is_baseline INTEGER NOT NULL DEFAULT 0;
470
+ `,
471
+ `
472
+ CREATE TABLE IF NOT EXISTS scan_issues (
473
+ id TEXT PRIMARY KEY,
474
+ fingerprint TEXT NOT NULL UNIQUE,
475
+ type TEXT NOT NULL,
476
+ severity TEXT NOT NULL DEFAULT 'medium',
477
+ page_url TEXT NOT NULL,
478
+ message TEXT NOT NULL,
479
+ detail TEXT,
480
+ status TEXT NOT NULL DEFAULT 'open',
481
+ occurrence_count INTEGER NOT NULL DEFAULT 1,
482
+ first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
483
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
484
+ resolved_at TEXT,
485
+ todo_task_id TEXT,
486
+ project_id TEXT REFERENCES projects(id) ON DELETE SET NULL
487
+ );
488
+
489
+ CREATE INDEX IF NOT EXISTS idx_scan_issues_fingerprint ON scan_issues(fingerprint);
490
+ CREATE INDEX IF NOT EXISTS idx_scan_issues_status ON scan_issues(status);
491
+ CREATE INDEX IF NOT EXISTS idx_scan_issues_type ON scan_issues(type);
492
+ CREATE INDEX IF NOT EXISTS idx_scan_issues_project ON scan_issues(project_id);
448
493
  `
449
494
  ];
450
495
  });
451
496
 
497
+ // src/db/scenarios.ts
498
+ function nextShortId(projectId) {
499
+ const db2 = getDatabase();
500
+ if (projectId) {
501
+ const project = db2.query("SELECT scenario_prefix, scenario_counter FROM projects WHERE id = ?").get(projectId);
502
+ if (project) {
503
+ const next = project.scenario_counter + 1;
504
+ db2.query("UPDATE projects SET scenario_counter = ? WHERE id = ?").run(next, projectId);
505
+ return `${project.scenario_prefix}-${next}`;
506
+ }
507
+ }
508
+ return shortUuid();
509
+ }
510
+ function createScenario(input) {
511
+ const db2 = getDatabase();
512
+ const id = uuid();
513
+ const short_id = nextShortId(input.projectId);
514
+ const timestamp = now();
515
+ db2.query(`
516
+ 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)
517
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
518
+ `).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);
519
+ return getScenario(id);
520
+ }
521
+ function getScenario(id) {
522
+ const db2 = getDatabase();
523
+ let row = db2.query("SELECT * FROM scenarios WHERE id = ?").get(id);
524
+ if (row)
525
+ return scenarioFromRow(row);
526
+ row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(id);
527
+ if (row)
528
+ return scenarioFromRow(row);
529
+ const fullId = resolvePartialId("scenarios", id);
530
+ if (fullId) {
531
+ row = db2.query("SELECT * FROM scenarios WHERE id = ?").get(fullId);
532
+ if (row)
533
+ return scenarioFromRow(row);
534
+ }
535
+ return null;
536
+ }
537
+ function getScenarioByShortId(shortId) {
538
+ const db2 = getDatabase();
539
+ const row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(shortId);
540
+ return row ? scenarioFromRow(row) : null;
541
+ }
542
+ function listScenarios(filter) {
543
+ const db2 = getDatabase();
544
+ const conditions = [];
545
+ const params = [];
546
+ if (filter?.projectId) {
547
+ conditions.push("project_id = ?");
548
+ params.push(filter.projectId);
549
+ }
550
+ if (filter?.tags && filter.tags.length > 0) {
551
+ for (const tag of filter.tags) {
552
+ conditions.push("tags LIKE ?");
553
+ params.push(`%"${tag}"%`);
554
+ }
555
+ }
556
+ if (filter?.priority) {
557
+ conditions.push("priority = ?");
558
+ params.push(filter.priority);
559
+ }
560
+ if (filter?.search) {
561
+ conditions.push("(name LIKE ? OR description LIKE ?)");
562
+ const term = `%${filter.search}%`;
563
+ params.push(term, term);
564
+ }
565
+ let sql = "SELECT * FROM scenarios";
566
+ if (conditions.length > 0) {
567
+ sql += " WHERE " + conditions.join(" AND ");
568
+ }
569
+ const sortField = filter?.sort ?? "date";
570
+ const sortDir = filter?.desc === false ? "ASC" : "DESC";
571
+ const orderByCol = sortField === "name" ? "name" : sortField === "priority" ? "CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END" : "created_at";
572
+ sql += ` ORDER BY ${orderByCol} ${sortDir}`;
573
+ if (filter?.limit) {
574
+ sql += " LIMIT ?";
575
+ params.push(filter.limit);
576
+ }
577
+ if (filter?.offset) {
578
+ sql += " OFFSET ?";
579
+ params.push(filter.offset);
580
+ }
581
+ const rows = db2.query(sql).all(...params);
582
+ return rows.map(scenarioFromRow);
583
+ }
584
+ function updateScenario(id, input, version) {
585
+ const db2 = getDatabase();
586
+ const existing = getScenario(id);
587
+ if (!existing) {
588
+ throw new Error(`Scenario not found: ${id}`);
589
+ }
590
+ if (existing.version !== version) {
591
+ throw new VersionConflictError("scenario", existing.id);
592
+ }
593
+ const sets = [];
594
+ const params = [];
595
+ if (input.name !== undefined) {
596
+ sets.push("name = ?");
597
+ params.push(input.name);
598
+ }
599
+ if (input.description !== undefined) {
600
+ sets.push("description = ?");
601
+ params.push(input.description);
602
+ }
603
+ if (input.steps !== undefined) {
604
+ sets.push("steps = ?");
605
+ params.push(JSON.stringify(input.steps));
606
+ }
607
+ if (input.tags !== undefined) {
608
+ sets.push("tags = ?");
609
+ params.push(JSON.stringify(input.tags));
610
+ }
611
+ if (input.priority !== undefined) {
612
+ sets.push("priority = ?");
613
+ params.push(input.priority);
614
+ }
615
+ if (input.model !== undefined) {
616
+ sets.push("model = ?");
617
+ params.push(input.model);
618
+ }
619
+ if (input.timeoutMs !== undefined) {
620
+ sets.push("timeout_ms = ?");
621
+ params.push(input.timeoutMs);
622
+ }
623
+ if (input.targetPath !== undefined) {
624
+ sets.push("target_path = ?");
625
+ params.push(input.targetPath);
626
+ }
627
+ if (input.requiresAuth !== undefined) {
628
+ sets.push("requires_auth = ?");
629
+ params.push(input.requiresAuth ? 1 : 0);
630
+ }
631
+ if (input.authConfig !== undefined) {
632
+ sets.push("auth_config = ?");
633
+ params.push(JSON.stringify(input.authConfig));
634
+ }
635
+ if (input.metadata !== undefined) {
636
+ sets.push("metadata = ?");
637
+ params.push(JSON.stringify(input.metadata));
638
+ }
639
+ if (input.assertions !== undefined) {
640
+ sets.push("assertions = ?");
641
+ params.push(JSON.stringify(input.assertions));
642
+ }
643
+ if (sets.length === 0) {
644
+ return existing;
645
+ }
646
+ sets.push("version = ?");
647
+ params.push(version + 1);
648
+ sets.push("updated_at = ?");
649
+ params.push(now());
650
+ params.push(existing.id);
651
+ params.push(version);
652
+ const result = db2.query(`UPDATE scenarios SET ${sets.join(", ")} WHERE id = ? AND version = ?`).run(...params);
653
+ if (result.changes === 0) {
654
+ throw new VersionConflictError("scenario", existing.id);
655
+ }
656
+ return getScenario(existing.id);
657
+ }
658
+ function findStaleScenarios(days) {
659
+ const db2 = getDatabase();
660
+ const rows = db2.query(`
661
+ SELECT s.*, MAX(r.created_at) AS last_run_at
662
+ FROM scenarios s
663
+ LEFT JOIN results r ON r.scenario_id = s.id
664
+ GROUP BY s.id
665
+ HAVING last_run_at IS NULL
666
+ OR last_run_at < datetime('now', ? || ' days')
667
+ ORDER BY last_run_at ASC NULLS FIRST
668
+ `).all(`-${days}`);
669
+ return rows.map((row) => ({
670
+ ...scenarioFromRow(row),
671
+ lastRunAt: row.last_run_at
672
+ }));
673
+ }
674
+ function deleteScenario(id) {
675
+ const db2 = getDatabase();
676
+ const scenario = getScenario(id);
677
+ if (!scenario)
678
+ return false;
679
+ const result = db2.query("DELETE FROM scenarios WHERE id = ?").run(scenario.id);
680
+ return result.changes > 0;
681
+ }
682
+ var init_scenarios = __esm(() => {
683
+ init_types();
684
+ init_database();
685
+ });
686
+
452
687
  // src/lib/browser-lightpanda.ts
453
688
  var exports_browser_lightpanda = {};
454
689
  __export(exports_browser_lightpanda, {
@@ -611,40 +846,209 @@ var init_browser_lightpanda = __esm(() => {
611
846
  init_types();
612
847
  });
613
848
 
614
- // src/db/flows.ts
615
- var exports_flows = {};
616
- __export(exports_flows, {
617
- topologicalSort: () => topologicalSort,
618
- removeDependency: () => removeDependency,
619
- listFlows: () => listFlows,
620
- getTransitiveDependencies: () => getTransitiveDependencies,
621
- getFlow: () => getFlow,
622
- getDependents: () => getDependents,
623
- getDependencies: () => getDependencies,
624
- deleteFlow: () => deleteFlow,
625
- createFlow: () => createFlow,
626
- addDependency: () => addDependency
627
- });
628
- function addDependency(scenarioId, dependsOn) {
629
- const db2 = getDatabase();
630
- const visited = new Set;
631
- const queue = [dependsOn];
632
- while (queue.length > 0) {
633
- const current = queue.shift();
634
- if (current === scenarioId) {
635
- throw new DependencyCycleError(scenarioId, dependsOn);
636
- }
637
- if (visited.has(current))
638
- continue;
639
- visited.add(current);
640
- const deps = db2.query("SELECT depends_on FROM scenario_dependencies WHERE scenario_id = ?").all(current);
641
- for (const dep of deps) {
642
- if (!visited.has(dep.depends_on)) {
643
- queue.push(dep.depends_on);
644
- }
849
+ // src/lib/browser.ts
850
+ import { chromium as chromium2 } from "playwright";
851
+ async function launchBrowser(options) {
852
+ const engine = options?.engine ?? process.env["TESTERS_BROWSER_ENGINE"] ?? "playwright";
853
+ if (engine === "lightpanda") {
854
+ const { launchLightpanda: launchLightpanda2, isLightpandaAvailable: isLightpandaAvailable2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
855
+ if (!isLightpandaAvailable2()) {
856
+ throw new BrowserError("Lightpanda not installed. Run: testers install-browser --engine lightpanda");
645
857
  }
858
+ return launchLightpanda2({ viewport: options?.viewport });
859
+ }
860
+ const headless = options?.headless ?? true;
861
+ const viewport = options?.viewport ?? DEFAULT_VIEWPORT;
862
+ try {
863
+ const browser = await chromium2.launch({
864
+ headless,
865
+ args: [
866
+ `--window-size=${viewport.width},${viewport.height}`
867
+ ]
868
+ });
869
+ return browser;
870
+ } catch (error) {
871
+ const message = error instanceof Error ? error.message : String(error);
872
+ throw new BrowserError(`Failed to launch browser: ${message}`);
646
873
  }
647
- db2.query("INSERT OR IGNORE INTO scenario_dependencies (scenario_id, depends_on) VALUES (?, ?)").run(scenarioId, dependsOn);
874
+ }
875
+ async function getPage(browser, options) {
876
+ const engine = options?.engine ?? "playwright";
877
+ if (engine === "lightpanda") {
878
+ const { getLightpandaPage: getLightpandaPage2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
879
+ return getLightpandaPage2(browser, options);
880
+ }
881
+ const viewport = options?.viewport ?? DEFAULT_VIEWPORT;
882
+ try {
883
+ const context = await browser.newContext({
884
+ viewport,
885
+ userAgent: options?.userAgent,
886
+ locale: options?.locale
887
+ });
888
+ const page = await context.newPage();
889
+ return page;
890
+ } catch (error) {
891
+ const message = error instanceof Error ? error.message : String(error);
892
+ throw new BrowserError(`Failed to create page: ${message}`);
893
+ }
894
+ }
895
+ async function closeBrowser(browser, engine) {
896
+ if (engine === "lightpanda") {
897
+ const { closeLightpanda: closeLightpanda2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
898
+ return closeLightpanda2(browser);
899
+ }
900
+ try {
901
+ await browser.close();
902
+ } catch (error) {
903
+ const message = error instanceof Error ? error.message : String(error);
904
+ throw new BrowserError(`Failed to close browser: ${message}`);
905
+ }
906
+ }
907
+ var DEFAULT_VIEWPORT;
908
+ var init_browser = __esm(() => {
909
+ init_types();
910
+ DEFAULT_VIEWPORT = { width: 1280, height: 720 };
911
+ });
912
+
913
+ // src/lib/todos-connector.ts
914
+ import { Database as Database2 } from "bun:sqlite";
915
+ import { existsSync as existsSync4 } from "fs";
916
+ import { join as join4 } from "path";
917
+ import { homedir as homedir4 } from "os";
918
+ function resolveTodosDbPath() {
919
+ const envPath = process.env["TODOS_DB_PATH"];
920
+ if (envPath)
921
+ return envPath;
922
+ return join4(homedir4(), ".todos", "todos.db");
923
+ }
924
+ function connectToTodos() {
925
+ const dbPath = resolveTodosDbPath();
926
+ if (!existsSync4(dbPath)) {
927
+ throw new TodosConnectionError(`Todos database not found at ${dbPath}. Install @hasna/todos or set TODOS_DB_PATH.`);
928
+ }
929
+ const db2 = new Database2(dbPath, { readonly: true });
930
+ db2.exec("PRAGMA foreign_keys = ON");
931
+ return db2;
932
+ }
933
+ function pullTasks(options = {}) {
934
+ const db2 = connectToTodos();
935
+ try {
936
+ let query = "SELECT id, short_id, title, description, status, priority, tags, project_id FROM tasks WHERE 1=1";
937
+ const params = [];
938
+ if (options.status) {
939
+ query += " AND status = ?";
940
+ params.push(options.status);
941
+ } else {
942
+ query += " AND status IN ('pending', 'in_progress')";
943
+ }
944
+ if (options.priority) {
945
+ query += " AND priority = ?";
946
+ params.push(options.priority);
947
+ }
948
+ if (options.projectName) {
949
+ const project = db2.query("SELECT id FROM projects WHERE name = ?").get(options.projectName);
950
+ if (project) {
951
+ query += " AND project_id = ?";
952
+ params.push(project.id);
953
+ }
954
+ }
955
+ query += " ORDER BY CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END";
956
+ const tasks = db2.query(query).all(...params);
957
+ if (options.tags && options.tags.length > 0) {
958
+ return tasks.filter((task) => {
959
+ const taskTags = JSON.parse(task.tags || "[]");
960
+ return options.tags.some((tag) => taskTags.includes(tag));
961
+ });
962
+ }
963
+ return tasks;
964
+ } finally {
965
+ db2.close();
966
+ }
967
+ }
968
+ function taskToScenarioInput(task, projectId) {
969
+ const tags = JSON.parse(task.tags || "[]");
970
+ const priority = ["low", "medium", "high", "critical"].includes(task.priority) ? task.priority : "medium";
971
+ const steps = [];
972
+ if (task.description) {
973
+ const lines = task.description.split(`
974
+ `);
975
+ for (const line of lines) {
976
+ const match = line.match(/^\s*\d+[\.\)]\s*(.+)/);
977
+ if (match?.[1]) {
978
+ steps.push(match[1].trim());
979
+ }
980
+ }
981
+ }
982
+ return {
983
+ name: task.title.replace(/^(OPE\d+-\d+|[A-Z]+-\d+):\s*/, ""),
984
+ description: task.description || task.title,
985
+ steps,
986
+ tags,
987
+ priority,
988
+ projectId,
989
+ metadata: { todosTaskId: task.id, todosShortId: task.short_id }
990
+ };
991
+ }
992
+ function importFromTodos(options = {}) {
993
+ const tasks = pullTasks({
994
+ projectName: options.projectName,
995
+ tags: options.tags ?? ["qa", "test", "testing"],
996
+ priority: options.priority
997
+ });
998
+ const existing = listScenarios({ projectId: options.projectId });
999
+ const existingTodoIds = new Set(existing.filter((s) => s.metadata?.todosTaskId).map((s) => s.metadata.todosTaskId));
1000
+ let imported = 0;
1001
+ let skipped = 0;
1002
+ for (const task of tasks) {
1003
+ if (existingTodoIds.has(task.id)) {
1004
+ skipped++;
1005
+ continue;
1006
+ }
1007
+ const input = taskToScenarioInput(task, options.projectId);
1008
+ createScenario(input);
1009
+ imported++;
1010
+ }
1011
+ return { imported, skipped };
1012
+ }
1013
+ var init_todos_connector = __esm(() => {
1014
+ init_scenarios();
1015
+ init_types();
1016
+ });
1017
+
1018
+ // src/db/flows.ts
1019
+ var exports_flows = {};
1020
+ __export(exports_flows, {
1021
+ topologicalSort: () => topologicalSort,
1022
+ removeDependency: () => removeDependency,
1023
+ listFlows: () => listFlows,
1024
+ getTransitiveDependencies: () => getTransitiveDependencies,
1025
+ getFlow: () => getFlow,
1026
+ getDependents: () => getDependents,
1027
+ getDependencies: () => getDependencies,
1028
+ deleteFlow: () => deleteFlow,
1029
+ createFlow: () => createFlow,
1030
+ addDependency: () => addDependency
1031
+ });
1032
+ function addDependency(scenarioId, dependsOn) {
1033
+ const db2 = getDatabase();
1034
+ const visited = new Set;
1035
+ const queue = [dependsOn];
1036
+ while (queue.length > 0) {
1037
+ const current = queue.shift();
1038
+ if (current === scenarioId) {
1039
+ throw new DependencyCycleError(scenarioId, dependsOn);
1040
+ }
1041
+ if (visited.has(current))
1042
+ continue;
1043
+ visited.add(current);
1044
+ const deps = db2.query("SELECT depends_on FROM scenario_dependencies WHERE scenario_id = ?").all(current);
1045
+ for (const dep of deps) {
1046
+ if (!visited.has(dep.depends_on)) {
1047
+ queue.push(dep.depends_on);
1048
+ }
1049
+ }
1050
+ }
1051
+ db2.query("INSERT OR IGNORE INTO scenario_dependencies (scenario_id, depends_on) VALUES (?, ?)").run(scenarioId, dependsOn);
648
1052
  }
649
1053
  function removeDependency(scenarioId, dependsOn) {
650
1054
  const db2 = getDatabase();
@@ -756,10 +1160,632 @@ function deleteFlow(id) {
756
1160
  const result = db2.query("DELETE FROM flows WHERE id = ?").run(flow.id);
757
1161
  return result.changes > 0;
758
1162
  }
759
- var init_flows = __esm(() => {
760
- init_database();
761
- init_database();
762
- init_types();
1163
+ var init_flows = __esm(() => {
1164
+ init_database();
1165
+ init_database();
1166
+ init_types();
1167
+ });
1168
+
1169
+ // src/db/scan-issues.ts
1170
+ var exports_scan_issues = {};
1171
+ __export(exports_scan_issues, {
1172
+ upsertScanIssue: () => upsertScanIssue,
1173
+ setScanIssueTodoTaskId: () => setScanIssueTodoTaskId,
1174
+ resolveScanIssue: () => resolveScanIssue,
1175
+ listScanIssues: () => listScanIssues,
1176
+ getScanIssue: () => getScanIssue,
1177
+ fingerprintIssue: () => fingerprintIssue
1178
+ });
1179
+ function fingerprintIssue(issue) {
1180
+ let pagePattern = issue.pageUrl;
1181
+ try {
1182
+ pagePattern = new URL(issue.pageUrl).pathname;
1183
+ } catch {}
1184
+ const raw = `${issue.type}::${issue.message.slice(0, 200)}::${pagePattern}`;
1185
+ let hash = 5381;
1186
+ for (let i = 0;i < raw.length; i++) {
1187
+ hash = (hash << 5) + hash ^ raw.charCodeAt(i);
1188
+ hash = hash >>> 0;
1189
+ }
1190
+ return `${issue.type}-${hash.toString(16).padStart(8, "0")}`;
1191
+ }
1192
+ function upsertScanIssue(issue, projectId) {
1193
+ const db2 = getDatabase();
1194
+ const fingerprint = fingerprintIssue(issue);
1195
+ const timestamp = now();
1196
+ const existing = db2.query("SELECT * FROM scan_issues WHERE fingerprint = ?").get(fingerprint);
1197
+ if (!existing) {
1198
+ const id = uuid();
1199
+ db2.query(`
1200
+ INSERT INTO scan_issues (id, fingerprint, type, severity, page_url, message, detail, status, occurrence_count, first_seen_at, last_seen_at, project_id)
1201
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'open', 1, ?, ?, ?)
1202
+ `).run(id, fingerprint, issue.type, issue.severity, issue.pageUrl, issue.message, issue.detail ? JSON.stringify(issue.detail) : null, timestamp, timestamp, projectId ?? null);
1203
+ const row = db2.query("SELECT * FROM scan_issues WHERE id = ?").get(id);
1204
+ return { issue: scanIssueFromRow(row), outcome: "new" };
1205
+ }
1206
+ const wasResolved = existing.status === "resolved";
1207
+ const newStatus = wasResolved ? "regressed" : "open";
1208
+ db2.query(`
1209
+ UPDATE scan_issues
1210
+ SET occurrence_count = occurrence_count + 1,
1211
+ last_seen_at = ?,
1212
+ status = ?,
1213
+ resolved_at = CASE WHEN ? = 'regressed' THEN NULL ELSE resolved_at END,
1214
+ severity = ?,
1215
+ page_url = ?,
1216
+ message = ?,
1217
+ detail = ?
1218
+ WHERE fingerprint = ?
1219
+ `).run(timestamp, newStatus, newStatus, issue.severity, issue.pageUrl, issue.message, issue.detail ? JSON.stringify(issue.detail) : existing.detail, fingerprint);
1220
+ const updated = db2.query("SELECT * FROM scan_issues WHERE fingerprint = ?").get(fingerprint);
1221
+ return {
1222
+ issue: scanIssueFromRow(updated),
1223
+ outcome: wasResolved ? "regressed" : "existing"
1224
+ };
1225
+ }
1226
+ function resolveScanIssue(id) {
1227
+ const db2 = getDatabase();
1228
+ const result = db2.query("UPDATE scan_issues SET status = 'resolved', resolved_at = ? WHERE id = ?").run(now(), id);
1229
+ return result.changes > 0;
1230
+ }
1231
+ function setScanIssueTodoTaskId(id, todoTaskId) {
1232
+ const db2 = getDatabase();
1233
+ db2.query("UPDATE scan_issues SET todo_task_id = ? WHERE id = ?").run(todoTaskId, id);
1234
+ }
1235
+ function listScanIssues(opts = {}) {
1236
+ const db2 = getDatabase();
1237
+ const conditions = ["1=1"];
1238
+ const params = [];
1239
+ if (opts.status) {
1240
+ conditions.push("status = ?");
1241
+ params.push(opts.status);
1242
+ }
1243
+ if (opts.type) {
1244
+ conditions.push("type = ?");
1245
+ params.push(opts.type);
1246
+ }
1247
+ if (opts.projectId) {
1248
+ conditions.push("project_id = ?");
1249
+ params.push(opts.projectId);
1250
+ }
1251
+ const limitClause = opts.limit ? ` LIMIT ${opts.limit}` : "";
1252
+ const rows = db2.query(`SELECT * FROM scan_issues WHERE ${conditions.join(" AND ")} ORDER BY last_seen_at DESC${limitClause}`).all(...params);
1253
+ return rows.map(scanIssueFromRow);
1254
+ }
1255
+ function getScanIssue(id) {
1256
+ const db2 = getDatabase();
1257
+ const row = db2.query("SELECT * FROM scan_issues WHERE id = ?").get(id);
1258
+ return row ? scanIssueFromRow(row) : null;
1259
+ }
1260
+ var init_scan_issues = __esm(() => {
1261
+ init_types();
1262
+ init_database();
1263
+ });
1264
+
1265
+ // src/lib/scanners/console.ts
1266
+ var exports_console = {};
1267
+ __export(exports_console, {
1268
+ scanConsoleErrors: () => scanConsoleErrors
1269
+ });
1270
+ async function scanConsoleErrors(options) {
1271
+ const { url, pages, headed = false, timeoutMs = 15000 } = options;
1272
+ const start = Date.now();
1273
+ const scannedPages = [];
1274
+ const issues = [];
1275
+ const pageUrls = pages?.length ? pages.map((p) => p.startsWith("http") ? p : `${url.replace(/\/$/, "")}${p}`) : [url];
1276
+ const browser = await launchBrowser({ headless: !headed });
1277
+ try {
1278
+ for (const pageUrl of pageUrls) {
1279
+ const page = await getPage(browser, {});
1280
+ const entries = [];
1281
+ page.on("console", (msg) => {
1282
+ if (msg.type() === "error") {
1283
+ entries.push({ type: "console.error", text: msg.text() });
1284
+ }
1285
+ });
1286
+ page.on("pageerror", (err) => {
1287
+ entries.push({ type: "uncaught", text: err.message, location: err.stack?.split(`
1288
+ `)[1]?.trim() });
1289
+ });
1290
+ try {
1291
+ await page.goto(pageUrl, { timeout: timeoutMs, waitUntil: "domcontentloaded" });
1292
+ await page.waitForTimeout(1500);
1293
+ scannedPages.push(pageUrl);
1294
+ } catch {
1295
+ entries.push({ type: "navigation", text: `Failed to navigate to ${pageUrl}` });
1296
+ scannedPages.push(pageUrl);
1297
+ } finally {
1298
+ await page.close();
1299
+ }
1300
+ for (const entry of entries) {
1301
+ if (isIgnoredConsoleError(entry.text))
1302
+ continue;
1303
+ const severity = classifyConsoleSeverity(entry.text);
1304
+ issues.push({
1305
+ type: "console_error",
1306
+ severity,
1307
+ pageUrl,
1308
+ message: entry.text.slice(0, 500),
1309
+ detail: {
1310
+ errorType: entry.type,
1311
+ location: entry.location ?? null
1312
+ }
1313
+ });
1314
+ }
1315
+ }
1316
+ } finally {
1317
+ await closeBrowser(browser);
1318
+ }
1319
+ return {
1320
+ url,
1321
+ pages: scannedPages,
1322
+ scannedAt: new Date().toISOString(),
1323
+ durationMs: Date.now() - start,
1324
+ issues
1325
+ };
1326
+ }
1327
+ function isIgnoredConsoleError(text) {
1328
+ return IGNORED_PATTERNS.some((p) => p.test(text));
1329
+ }
1330
+ function classifyConsoleSeverity(text) {
1331
+ const lower = text.toLowerCase();
1332
+ if (/uncaught|unhandled|typeerror|referenceerror|cannot read|is not a function|hydrat/i.test(lower))
1333
+ return "critical";
1334
+ if (/error|failed|exception/i.test(lower))
1335
+ return "high";
1336
+ if (/warning|warn|deprecated/i.test(lower))
1337
+ return "medium";
1338
+ return "low";
1339
+ }
1340
+ var IGNORED_PATTERNS;
1341
+ var init_console = __esm(() => {
1342
+ init_browser();
1343
+ IGNORED_PATTERNS = [
1344
+ /Download the React DevTools/i,
1345
+ /\[HMR\]/,
1346
+ /\[vite\]/i,
1347
+ /favicon\.ico/i,
1348
+ /Content Security Policy/i
1349
+ ];
1350
+ });
1351
+
1352
+ // src/lib/scanners/network.ts
1353
+ var exports_network = {};
1354
+ __export(exports_network, {
1355
+ scanNetworkErrors: () => scanNetworkErrors
1356
+ });
1357
+ async function scanNetworkErrors(options) {
1358
+ const { url, pages, headed = false, timeoutMs = 15000 } = options;
1359
+ const start = Date.now();
1360
+ const scannedPages = [];
1361
+ const issues = [];
1362
+ const pageUrls = pages?.length ? pages.map((p) => p.startsWith("http") ? p : `${url.replace(/\/$/, "")}${p}`) : [url];
1363
+ const browser = await launchBrowser({ headless: !headed });
1364
+ try {
1365
+ for (const pageUrl of pageUrls) {
1366
+ const page = await getPage(browser, {});
1367
+ const entries = [];
1368
+ page.on("response", (resp) => {
1369
+ const reqUrl = resp.url();
1370
+ const status = resp.status();
1371
+ if (shouldIgnoreUrl(reqUrl))
1372
+ return;
1373
+ if (status >= 400) {
1374
+ entries.push({ url: reqUrl, status, method: resp.request().method(), type: resp.request().resourceType(), failed: false });
1375
+ }
1376
+ });
1377
+ page.on("requestfailed", (req) => {
1378
+ const reqUrl = req.url();
1379
+ if (shouldIgnoreUrl(reqUrl))
1380
+ return;
1381
+ entries.push({
1382
+ url: reqUrl,
1383
+ status: 0,
1384
+ method: req.method(),
1385
+ type: req.resourceType(),
1386
+ failed: true,
1387
+ failureText: req.failure()?.errorText
1388
+ });
1389
+ });
1390
+ try {
1391
+ await page.goto(pageUrl, { timeout: timeoutMs, waitUntil: "domcontentloaded" });
1392
+ await page.waitForTimeout(1000);
1393
+ scannedPages.push(pageUrl);
1394
+ } catch {
1395
+ scannedPages.push(pageUrl);
1396
+ } finally {
1397
+ await page.close();
1398
+ }
1399
+ for (const entry of entries) {
1400
+ const severity = classifyNetworkSeverity(entry);
1401
+ const message = entry.failed ? `Request failed: ${entry.method} ${entry.url} \u2014 ${entry.failureText ?? "unknown"}` : `HTTP ${entry.status}: ${entry.method} ${entry.url}`;
1402
+ issues.push({
1403
+ type: "network_error",
1404
+ severity,
1405
+ pageUrl,
1406
+ message: message.slice(0, 500),
1407
+ detail: {
1408
+ requestUrl: entry.url,
1409
+ status: entry.status,
1410
+ method: entry.method,
1411
+ resourceType: entry.type,
1412
+ failureText: entry.failureText ?? null
1413
+ }
1414
+ });
1415
+ }
1416
+ }
1417
+ } finally {
1418
+ await closeBrowser(browser);
1419
+ }
1420
+ return { url, pages: scannedPages, scannedAt: new Date().toISOString(), durationMs: Date.now() - start, issues };
1421
+ }
1422
+ function shouldIgnoreUrl(reqUrl) {
1423
+ return IGNORE_URL_PATTERNS.some((p) => p.test(reqUrl));
1424
+ }
1425
+ function classifyNetworkSeverity(entry) {
1426
+ if (entry.failed) {
1427
+ if (entry.failureText?.includes("CORS") || entry.failureText?.includes("blocked"))
1428
+ return "critical";
1429
+ return "high";
1430
+ }
1431
+ if (entry.status >= 500)
1432
+ return "critical";
1433
+ if (entry.status === 401 || entry.status === 403)
1434
+ return "high";
1435
+ if (entry.status >= 400)
1436
+ return "medium";
1437
+ return "low";
1438
+ }
1439
+ var IGNORE_URL_PATTERNS;
1440
+ var init_network = __esm(() => {
1441
+ init_browser();
1442
+ IGNORE_URL_PATTERNS = [
1443
+ /favicon\.ico/i,
1444
+ /\.woff2?$/i,
1445
+ /fonts\.googleapis\.com/i,
1446
+ /analytics\.(google|segment)/i,
1447
+ /hotjar\.com/i,
1448
+ /sentry\.io/i
1449
+ ];
1450
+ });
1451
+
1452
+ // src/lib/scanners/links.ts
1453
+ var exports_links = {};
1454
+ __export(exports_links, {
1455
+ scanBrokenLinks: () => scanBrokenLinks
1456
+ });
1457
+ async function scanBrokenLinks(options) {
1458
+ const { url, maxPages = 30, headed = false, timeoutMs = 12000 } = options;
1459
+ const start = Date.now();
1460
+ const issues = [];
1461
+ const rootOrigin = (() => {
1462
+ try {
1463
+ return new URL(url).origin;
1464
+ } catch {
1465
+ return url;
1466
+ }
1467
+ })();
1468
+ const visited = new Set;
1469
+ const queue = [{ pageUrl: url, sourceUrl: "" }];
1470
+ const browser = await launchBrowser({ headless: !headed });
1471
+ try {
1472
+ while (queue.length > 0 && visited.size < maxPages) {
1473
+ const { pageUrl, sourceUrl } = queue.shift();
1474
+ const normalised = normaliseUrl(pageUrl);
1475
+ if (visited.has(normalised))
1476
+ continue;
1477
+ visited.add(normalised);
1478
+ const page = await getPage(browser, {});
1479
+ let status = 200;
1480
+ let finalUrl = pageUrl;
1481
+ page.on("response", (resp) => {
1482
+ if (resp.url() === pageUrl || resp.url() === normalised) {
1483
+ status = resp.status();
1484
+ }
1485
+ });
1486
+ let hrefs = [];
1487
+ try {
1488
+ const response = await page.goto(pageUrl, { timeout: timeoutMs, waitUntil: "domcontentloaded" });
1489
+ if (response) {
1490
+ status = response.status();
1491
+ finalUrl = response.url();
1492
+ }
1493
+ hrefs = await page.evaluate(() => Array.from(document.querySelectorAll("a[href]")).map((a) => a.href).filter(Boolean));
1494
+ } catch (err) {
1495
+ status = 0;
1496
+ } finally {
1497
+ await page.close();
1498
+ }
1499
+ if (status === 404) {
1500
+ issues.push({
1501
+ type: "broken_link",
1502
+ severity: "high",
1503
+ pageUrl: sourceUrl || url,
1504
+ message: `404 Not Found: ${pageUrl}`,
1505
+ detail: { brokenUrl: pageUrl, sourceUrl, status }
1506
+ });
1507
+ } else if (status === 0) {
1508
+ issues.push({
1509
+ type: "broken_link",
1510
+ severity: "medium",
1511
+ pageUrl: sourceUrl || url,
1512
+ message: `Navigation failed: ${pageUrl}`,
1513
+ detail: { brokenUrl: pageUrl, sourceUrl, status }
1514
+ });
1515
+ }
1516
+ for (const href of hrefs) {
1517
+ try {
1518
+ const linkUrl = new URL(href);
1519
+ if (linkUrl.origin === rootOrigin) {
1520
+ const clean = `${linkUrl.origin}${linkUrl.pathname}`;
1521
+ if (!visited.has(clean)) {
1522
+ queue.push({ pageUrl: clean, sourceUrl: finalUrl });
1523
+ }
1524
+ }
1525
+ } catch {}
1526
+ }
1527
+ }
1528
+ } finally {
1529
+ await closeBrowser(browser);
1530
+ }
1531
+ return {
1532
+ url,
1533
+ pages: Array.from(visited),
1534
+ scannedAt: new Date().toISOString(),
1535
+ durationMs: Date.now() - start,
1536
+ issues
1537
+ };
1538
+ }
1539
+ function normaliseUrl(rawUrl) {
1540
+ try {
1541
+ const u = new URL(rawUrl);
1542
+ return `${u.origin}${u.pathname}`;
1543
+ } catch {
1544
+ return rawUrl;
1545
+ }
1546
+ }
1547
+ var init_links = __esm(() => {
1548
+ init_browser();
1549
+ });
1550
+
1551
+ // src/lib/scanners/performance.ts
1552
+ var exports_performance = {};
1553
+ __export(exports_performance, {
1554
+ scanPerformance: () => scanPerformance
1555
+ });
1556
+ async function scanPerformance(options) {
1557
+ const { url, pages, headed = false, timeoutMs = 20000, thresholds: customThresholds } = options;
1558
+ const thresholds = { ...DEFAULT_THRESHOLDS, ...customThresholds };
1559
+ const start = Date.now();
1560
+ const scannedPages = [];
1561
+ const issues = [];
1562
+ const pageUrls = pages?.length ? pages.map((p) => p.startsWith("http") ? p : `${url.replace(/\/$/, "")}${p}`) : [url];
1563
+ const browser = await launchBrowser({ headless: !headed });
1564
+ try {
1565
+ for (const pageUrl of pageUrls) {
1566
+ const page = await getPage(browser, {});
1567
+ let transferBytes = 0;
1568
+ let requestCount = 0;
1569
+ page.on("response", async (resp) => {
1570
+ requestCount++;
1571
+ try {
1572
+ const headers = resp.headers();
1573
+ const contentLength = parseInt(headers["content-length"] ?? "0", 10);
1574
+ if (!isNaN(contentLength))
1575
+ transferBytes += contentLength;
1576
+ } catch {}
1577
+ });
1578
+ try {
1579
+ await page.goto(pageUrl, { timeout: timeoutMs, waitUntil: "load" });
1580
+ scannedPages.push(pageUrl);
1581
+ const metrics = await page.evaluate((pUrl) => {
1582
+ const nav = performance.getEntriesByType("navigation")[0];
1583
+ const paintEntries = performance.getEntriesByType("paint");
1584
+ const fpEntry = paintEntries.find((e) => e.name === "first-paint");
1585
+ const fcpEntry = paintEntries.find((e) => e.name === "first-contentful-paint");
1586
+ let lcpMs = null;
1587
+ try {
1588
+ const lcpEntries = performance.getEntriesByType("largest-contentful-paint");
1589
+ if (lcpEntries.length > 0) {
1590
+ lcpMs = lcpEntries[lcpEntries.length - 1].startTime;
1591
+ }
1592
+ } catch {}
1593
+ return {
1594
+ pageUrl: pUrl,
1595
+ loadTimeMs: nav ? Math.round(nav.loadEventEnd - nav.startTime) : 0,
1596
+ domContentLoadedMs: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : 0,
1597
+ firstPaintMs: fpEntry ? Math.round(fpEntry.startTime) : fcpEntry ? Math.round(fcpEntry.startTime) : null,
1598
+ lcpMs,
1599
+ requestCount: 0,
1600
+ transferBytes: 0
1601
+ };
1602
+ }, pageUrl);
1603
+ metrics.requestCount = requestCount;
1604
+ metrics.transferBytes = transferBytes;
1605
+ if (metrics.loadTimeMs > thresholds.loadTimeMs) {
1606
+ issues.push({
1607
+ type: "performance",
1608
+ severity: metrics.loadTimeMs > thresholds.loadTimeMs * 2 ? "critical" : "high",
1609
+ pageUrl,
1610
+ message: `Slow page load: ${metrics.loadTimeMs}ms (threshold: ${thresholds.loadTimeMs}ms)`,
1611
+ detail: { ...metrics }
1612
+ });
1613
+ }
1614
+ if (metrics.domContentLoadedMs > thresholds.domContentLoadedMs) {
1615
+ issues.push({
1616
+ type: "performance",
1617
+ severity: "medium",
1618
+ pageUrl,
1619
+ message: `Slow DOMContentLoaded: ${metrics.domContentLoadedMs}ms (threshold: ${thresholds.domContentLoadedMs}ms)`,
1620
+ detail: { ...metrics }
1621
+ });
1622
+ }
1623
+ if (metrics.lcpMs !== null && metrics.lcpMs > thresholds.lcpMs) {
1624
+ issues.push({
1625
+ type: "performance",
1626
+ severity: metrics.lcpMs > thresholds.lcpMs * 2 ? "critical" : "high",
1627
+ pageUrl,
1628
+ message: `Poor LCP: ${Math.round(metrics.lcpMs)}ms (threshold: ${thresholds.lcpMs}ms)`,
1629
+ detail: { ...metrics }
1630
+ });
1631
+ }
1632
+ } catch (err) {
1633
+ issues.push({
1634
+ type: "performance",
1635
+ severity: "medium",
1636
+ pageUrl,
1637
+ message: `Performance scan failed: ${err instanceof Error ? err.message : String(err)}`,
1638
+ detail: {}
1639
+ });
1640
+ } finally {
1641
+ await page.close();
1642
+ }
1643
+ }
1644
+ } finally {
1645
+ await closeBrowser(browser);
1646
+ }
1647
+ return { url, pages: scannedPages, scannedAt: new Date().toISOString(), durationMs: Date.now() - start, issues };
1648
+ }
1649
+ var DEFAULT_THRESHOLDS;
1650
+ var init_performance = __esm(() => {
1651
+ init_browser();
1652
+ DEFAULT_THRESHOLDS = {
1653
+ loadTimeMs: 5000,
1654
+ domContentLoadedMs: 3000,
1655
+ lcpMs: 2500
1656
+ };
1657
+ });
1658
+
1659
+ // src/lib/health-scan.ts
1660
+ var exports_health_scan = {};
1661
+ __export(exports_health_scan, {
1662
+ runHealthScan: () => runHealthScan
1663
+ });
1664
+ async function runHealthScan(options) {
1665
+ const {
1666
+ url,
1667
+ pages,
1668
+ projectId,
1669
+ headed = false,
1670
+ timeoutMs = 15000,
1671
+ scanners = ["console", "network", "links"],
1672
+ maxPages = 20
1673
+ } = options;
1674
+ const start = Date.now();
1675
+ const results = [];
1676
+ if (scanners.includes("console")) {
1677
+ results.push(await scanConsoleErrors({ url, pages, headed, timeoutMs }));
1678
+ }
1679
+ if (scanners.includes("network")) {
1680
+ results.push(await scanNetworkErrors({ url, pages, headed, timeoutMs }));
1681
+ }
1682
+ if (scanners.includes("links")) {
1683
+ results.push(await scanBrokenLinks({ url, maxPages, headed, timeoutMs }));
1684
+ }
1685
+ if (scanners.includes("performance")) {
1686
+ results.push(await scanPerformance({ url, pages, headed, timeoutMs }));
1687
+ }
1688
+ const allIssues = results.flatMap((r) => r.issues);
1689
+ let newCount = 0;
1690
+ let regressedCount = 0;
1691
+ let existingCount = 0;
1692
+ const newAndRegressed = [];
1693
+ for (const issue of allIssues) {
1694
+ const { issue: persisted, outcome } = upsertScanIssue(issue, projectId);
1695
+ if (outcome === "new") {
1696
+ newCount++;
1697
+ newAndRegressed.push({ issue, persistedId: persisted.id });
1698
+ } else if (outcome === "regressed") {
1699
+ regressedCount++;
1700
+ newAndRegressed.push({ issue, persistedId: persisted.id });
1701
+ } else
1702
+ existingCount++;
1703
+ }
1704
+ await createTodoTasksForIssues(newAndRegressed, url, projectId);
1705
+ await notifyHealthScan(url, { new: newCount, regressed: regressedCount, existing: existingCount, total: allIssues.length });
1706
+ return {
1707
+ url,
1708
+ scannedAt: new Date().toISOString(),
1709
+ durationMs: Date.now() - start,
1710
+ totalIssues: allIssues.length,
1711
+ newIssues: newCount,
1712
+ regressedIssues: regressedCount,
1713
+ existingIssues: existingCount,
1714
+ results
1715
+ };
1716
+ }
1717
+ async function createTodoTasksForIssues(items, url, _projectId) {
1718
+ const todosProjectId = process.env["TESTERS_TODOS_PROJECT_ID"];
1719
+ if (!todosProjectId || items.length === 0)
1720
+ return;
1721
+ let db2 = null;
1722
+ try {
1723
+ db2 = connectToTodos();
1724
+ } catch {
1725
+ return;
1726
+ }
1727
+ try {
1728
+ for (const { issue, persistedId } of items) {
1729
+ const title = `BUG: [scan] ${issue.type.replace(/_/g, " ")}: ${issue.message.slice(0, 80)}`;
1730
+ const id = crypto.randomUUID();
1731
+ const now2 = new Date().toISOString();
1732
+ const description = [
1733
+ `Health scan detected a ${issue.type.replace(/_/g, " ")} issue.`,
1734
+ ``,
1735
+ `**URL:** ${url}`,
1736
+ `**Page:** ${issue.pageUrl}`,
1737
+ `**Severity:** ${issue.severity}`,
1738
+ `**Message:** ${issue.message}`,
1739
+ issue.detail ? `**Detail:**
1740
+ \`\`\`json
1741
+ ${JSON.stringify(issue.detail, null, 2)}
1742
+ \`\`\`` : null
1743
+ ].filter(Boolean).join(`
1744
+ `);
1745
+ try {
1746
+ db2.query(`
1747
+ INSERT INTO tasks (id, short_id, title, description, status, priority, tags, project_id, version, created_at, updated_at)
1748
+ VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, 1, ?, ?)
1749
+ `).run(id, `SCAN-${id.slice(0, 6)}`, title, description, issue.severity, JSON.stringify(["bug", "scan", issue.type, "auto-created"]), todosProjectId, now2, now2);
1750
+ setScanIssueTodoTaskId(persistedId, id);
1751
+ } catch {}
1752
+ }
1753
+ } finally {
1754
+ db2.close();
1755
+ }
1756
+ }
1757
+ async function notifyHealthScan(url, counts) {
1758
+ const baseUrl = process.env["TESTERS_CONVERSATIONS_URL"];
1759
+ const space = process.env["TESTERS_CONVERSATIONS_SPACE"];
1760
+ if (!baseUrl || !space)
1761
+ return;
1762
+ if (counts.new === 0 && counts.regressed === 0)
1763
+ return;
1764
+ const icon = counts.new + counts.regressed > 0 ? "\uD83D\uDEA8" : "\u2705";
1765
+ const message = [
1766
+ `${icon} **Health scan** \u2014 ${url}`,
1767
+ ``,
1768
+ `**New issues:** ${counts.new}`,
1769
+ `**Regressed:** ${counts.regressed}`,
1770
+ `**Known (skipped):** ${counts.existing}`,
1771
+ `**Total found:** ${counts.total}`
1772
+ ].join(`
1773
+ `);
1774
+ try {
1775
+ await fetch(`${baseUrl.replace(/\/$/, "")}/api/spaces/${encodeURIComponent(space)}/messages`, {
1776
+ method: "POST",
1777
+ headers: { "Content-Type": "application/json" },
1778
+ body: JSON.stringify({ content: message, from: "testers-health-scan" })
1779
+ });
1780
+ } catch {}
1781
+ }
1782
+ var init_health_scan = __esm(() => {
1783
+ init_console();
1784
+ init_network();
1785
+ init_links();
1786
+ init_performance();
1787
+ init_scan_issues();
1788
+ init_todos_connector();
763
1789
  });
764
1790
 
765
1791
  // src/mcp/index.ts
@@ -4739,193 +5765,8 @@ var coerce = {
4739
5765
  date: (arg) => ZodDate.create({ ...arg, coerce: true })
4740
5766
  };
4741
5767
  var NEVER = INVALID;
4742
- // src/db/scenarios.ts
4743
- init_types();
4744
- init_database();
4745
- function nextShortId(projectId) {
4746
- const db2 = getDatabase();
4747
- if (projectId) {
4748
- const project = db2.query("SELECT scenario_prefix, scenario_counter FROM projects WHERE id = ?").get(projectId);
4749
- if (project) {
4750
- const next = project.scenario_counter + 1;
4751
- db2.query("UPDATE projects SET scenario_counter = ? WHERE id = ?").run(next, projectId);
4752
- return `${project.scenario_prefix}-${next}`;
4753
- }
4754
- }
4755
- return shortUuid();
4756
- }
4757
- function createScenario(input) {
4758
- const db2 = getDatabase();
4759
- const id = uuid();
4760
- const short_id = nextShortId(input.projectId);
4761
- const timestamp = now();
4762
- db2.query(`
4763
- 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)
4764
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
4765
- `).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);
4766
- return getScenario(id);
4767
- }
4768
- function getScenario(id) {
4769
- const db2 = getDatabase();
4770
- let row = db2.query("SELECT * FROM scenarios WHERE id = ?").get(id);
4771
- if (row)
4772
- return scenarioFromRow(row);
4773
- row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(id);
4774
- if (row)
4775
- return scenarioFromRow(row);
4776
- const fullId = resolvePartialId("scenarios", id);
4777
- if (fullId) {
4778
- row = db2.query("SELECT * FROM scenarios WHERE id = ?").get(fullId);
4779
- if (row)
4780
- return scenarioFromRow(row);
4781
- }
4782
- return null;
4783
- }
4784
- function getScenarioByShortId(shortId) {
4785
- const db2 = getDatabase();
4786
- const row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(shortId);
4787
- return row ? scenarioFromRow(row) : null;
4788
- }
4789
- function listScenarios(filter) {
4790
- const db2 = getDatabase();
4791
- const conditions = [];
4792
- const params = [];
4793
- if (filter?.projectId) {
4794
- conditions.push("project_id = ?");
4795
- params.push(filter.projectId);
4796
- }
4797
- if (filter?.tags && filter.tags.length > 0) {
4798
- for (const tag of filter.tags) {
4799
- conditions.push("tags LIKE ?");
4800
- params.push(`%"${tag}"%`);
4801
- }
4802
- }
4803
- if (filter?.priority) {
4804
- conditions.push("priority = ?");
4805
- params.push(filter.priority);
4806
- }
4807
- if (filter?.search) {
4808
- conditions.push("(name LIKE ? OR description LIKE ?)");
4809
- const term = `%${filter.search}%`;
4810
- params.push(term, term);
4811
- }
4812
- let sql = "SELECT * FROM scenarios";
4813
- if (conditions.length > 0) {
4814
- sql += " WHERE " + conditions.join(" AND ");
4815
- }
4816
- const sortField = filter?.sort ?? "date";
4817
- const sortDir = filter?.desc === false ? "ASC" : "DESC";
4818
- const orderByCol = sortField === "name" ? "name" : sortField === "priority" ? "CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END" : "created_at";
4819
- sql += ` ORDER BY ${orderByCol} ${sortDir}`;
4820
- if (filter?.limit) {
4821
- sql += " LIMIT ?";
4822
- params.push(filter.limit);
4823
- }
4824
- if (filter?.offset) {
4825
- sql += " OFFSET ?";
4826
- params.push(filter.offset);
4827
- }
4828
- const rows = db2.query(sql).all(...params);
4829
- return rows.map(scenarioFromRow);
4830
- }
4831
- function updateScenario(id, input, version) {
4832
- const db2 = getDatabase();
4833
- const existing = getScenario(id);
4834
- if (!existing) {
4835
- throw new Error(`Scenario not found: ${id}`);
4836
- }
4837
- if (existing.version !== version) {
4838
- throw new VersionConflictError("scenario", existing.id);
4839
- }
4840
- const sets = [];
4841
- const params = [];
4842
- if (input.name !== undefined) {
4843
- sets.push("name = ?");
4844
- params.push(input.name);
4845
- }
4846
- if (input.description !== undefined) {
4847
- sets.push("description = ?");
4848
- params.push(input.description);
4849
- }
4850
- if (input.steps !== undefined) {
4851
- sets.push("steps = ?");
4852
- params.push(JSON.stringify(input.steps));
4853
- }
4854
- if (input.tags !== undefined) {
4855
- sets.push("tags = ?");
4856
- params.push(JSON.stringify(input.tags));
4857
- }
4858
- if (input.priority !== undefined) {
4859
- sets.push("priority = ?");
4860
- params.push(input.priority);
4861
- }
4862
- if (input.model !== undefined) {
4863
- sets.push("model = ?");
4864
- params.push(input.model);
4865
- }
4866
- if (input.timeoutMs !== undefined) {
4867
- sets.push("timeout_ms = ?");
4868
- params.push(input.timeoutMs);
4869
- }
4870
- if (input.targetPath !== undefined) {
4871
- sets.push("target_path = ?");
4872
- params.push(input.targetPath);
4873
- }
4874
- if (input.requiresAuth !== undefined) {
4875
- sets.push("requires_auth = ?");
4876
- params.push(input.requiresAuth ? 1 : 0);
4877
- }
4878
- if (input.authConfig !== undefined) {
4879
- sets.push("auth_config = ?");
4880
- params.push(JSON.stringify(input.authConfig));
4881
- }
4882
- if (input.metadata !== undefined) {
4883
- sets.push("metadata = ?");
4884
- params.push(JSON.stringify(input.metadata));
4885
- }
4886
- if (input.assertions !== undefined) {
4887
- sets.push("assertions = ?");
4888
- params.push(JSON.stringify(input.assertions));
4889
- }
4890
- if (sets.length === 0) {
4891
- return existing;
4892
- }
4893
- sets.push("version = ?");
4894
- params.push(version + 1);
4895
- sets.push("updated_at = ?");
4896
- params.push(now());
4897
- params.push(existing.id);
4898
- params.push(version);
4899
- const result = db2.query(`UPDATE scenarios SET ${sets.join(", ")} WHERE id = ? AND version = ?`).run(...params);
4900
- if (result.changes === 0) {
4901
- throw new VersionConflictError("scenario", existing.id);
4902
- }
4903
- return getScenario(existing.id);
4904
- }
4905
- function findStaleScenarios(days) {
4906
- const db2 = getDatabase();
4907
- const rows = db2.query(`
4908
- SELECT s.*, MAX(r.created_at) AS last_run_at
4909
- FROM scenarios s
4910
- LEFT JOIN results r ON r.scenario_id = s.id
4911
- GROUP BY s.id
4912
- HAVING last_run_at IS NULL
4913
- OR last_run_at < datetime('now', ? || ' days')
4914
- ORDER BY last_run_at ASC NULLS FIRST
4915
- `).all(`-${days}`);
4916
- return rows.map((row) => ({
4917
- ...scenarioFromRow(row),
4918
- lastRunAt: row.last_run_at
4919
- }));
4920
- }
4921
- function deleteScenario(id) {
4922
- const db2 = getDatabase();
4923
- const scenario = getScenario(id);
4924
- if (!scenario)
4925
- return false;
4926
- const result = db2.query("DELETE FROM scenarios WHERE id = ?").run(scenario.id);
4927
- return result.changes > 0;
4928
- }
5768
+ // src/mcp/index.ts
5769
+ init_scenarios();
4929
5770
 
4930
5771
  // src/db/runs.ts
4931
5772
  init_types();
@@ -5186,94 +6027,53 @@ function ensureProject(name, path) {
5186
6027
 
5187
6028
  // src/db/agents.ts
5188
6029
  init_types();
5189
- init_database();
5190
- function registerAgent(input) {
5191
- const db2 = getDatabase();
5192
- const existing = db2.query("SELECT * FROM agents WHERE name = ?").get(input.name);
5193
- if (existing) {
5194
- db2.query("UPDATE agents SET last_seen_at = ? WHERE id = ?").run(now(), existing.id);
5195
- return getAgent(existing.id);
5196
- }
5197
- const id = uuid();
5198
- const timestamp = now();
5199
- db2.query(`
5200
- INSERT INTO agents (id, name, description, role, metadata, created_at, last_seen_at)
5201
- VALUES (?, ?, ?, ?, '{}', ?, ?)
5202
- `).run(id, input.name, input.description ?? null, input.role ?? null, timestamp, timestamp);
5203
- return getAgent(id);
5204
- }
5205
- function getAgent(id) {
5206
- const db2 = getDatabase();
5207
- const row = db2.query("SELECT * FROM agents WHERE id = ?").get(id);
5208
- return row ? agentFromRow(row) : null;
5209
- }
5210
- function listAgents() {
5211
- const db2 = getDatabase();
5212
- const rows = db2.query("SELECT * FROM agents ORDER BY created_at DESC").all();
5213
- return rows.map(agentFromRow);
5214
- }
5215
-
5216
- // src/lib/browser.ts
5217
- import { chromium as chromium2 } from "playwright";
5218
- init_types();
5219
- var DEFAULT_VIEWPORT = { width: 1280, height: 720 };
5220
- async function launchBrowser(options) {
5221
- const engine = options?.engine ?? process.env["TESTERS_BROWSER_ENGINE"] ?? "playwright";
5222
- if (engine === "lightpanda") {
5223
- const { launchLightpanda: launchLightpanda2, isLightpandaAvailable: isLightpandaAvailable2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
5224
- if (!isLightpandaAvailable2()) {
5225
- throw new BrowserError("Lightpanda not installed. Run: testers install-browser --engine lightpanda");
5226
- }
5227
- return launchLightpanda2({ viewport: options?.viewport });
5228
- }
5229
- const headless = options?.headless ?? true;
5230
- const viewport = options?.viewport ?? DEFAULT_VIEWPORT;
5231
- try {
5232
- const browser = await chromium2.launch({
5233
- headless,
5234
- args: [
5235
- `--window-size=${viewport.width},${viewport.height}`
5236
- ]
5237
- });
5238
- return browser;
5239
- } catch (error) {
5240
- const message = error instanceof Error ? error.message : String(error);
5241
- throw new BrowserError(`Failed to launch browser: ${message}`);
6030
+ init_database();
6031
+ function registerAgent(input) {
6032
+ const db2 = getDatabase();
6033
+ const existing = db2.query("SELECT * FROM agents WHERE name = ?").get(input.name);
6034
+ if (existing) {
6035
+ db2.query("UPDATE agents SET last_seen_at = ? WHERE id = ?").run(now(), existing.id);
6036
+ return getAgent(existing.id);
5242
6037
  }
6038
+ const id = uuid();
6039
+ const timestamp = now();
6040
+ db2.query(`
6041
+ INSERT INTO agents (id, name, description, role, metadata, created_at, last_seen_at)
6042
+ VALUES (?, ?, ?, ?, '{}', ?, ?)
6043
+ `).run(id, input.name, input.description ?? null, input.role ?? null, timestamp, timestamp);
6044
+ return getAgent(id);
5243
6045
  }
5244
- async function getPage(browser, options) {
5245
- const engine = options?.engine ?? "playwright";
5246
- if (engine === "lightpanda") {
5247
- const { getLightpandaPage: getLightpandaPage2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
5248
- return getLightpandaPage2(browser, options);
5249
- }
5250
- const viewport = options?.viewport ?? DEFAULT_VIEWPORT;
5251
- try {
5252
- const context = await browser.newContext({
5253
- viewport,
5254
- userAgent: options?.userAgent,
5255
- locale: options?.locale
5256
- });
5257
- const page = await context.newPage();
5258
- return page;
5259
- } catch (error) {
5260
- const message = error instanceof Error ? error.message : String(error);
5261
- throw new BrowserError(`Failed to create page: ${message}`);
5262
- }
6046
+ function getAgent(id) {
6047
+ const db2 = getDatabase();
6048
+ const row = db2.query("SELECT * FROM agents WHERE id = ?").get(id);
6049
+ return row ? agentFromRow(row) : null;
5263
6050
  }
5264
- async function closeBrowser(browser, engine) {
5265
- if (engine === "lightpanda") {
5266
- const { closeLightpanda: closeLightpanda2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
5267
- return closeLightpanda2(browser);
5268
- }
5269
- try {
5270
- await browser.close();
5271
- } catch (error) {
5272
- const message = error instanceof Error ? error.message : String(error);
5273
- throw new BrowserError(`Failed to close browser: ${message}`);
5274
- }
6051
+ function listAgents() {
6052
+ const db2 = getDatabase();
6053
+ const rows = db2.query("SELECT * FROM agents ORDER BY created_at DESC").all();
6054
+ return rows.map(agentFromRow);
6055
+ }
6056
+ function heartbeatAgent(id) {
6057
+ const db2 = getDatabase();
6058
+ const affected = db2.query("UPDATE agents SET last_seen_at = ? WHERE id = ?").run(now(), id);
6059
+ if (affected.changes === 0)
6060
+ return null;
6061
+ return getAgent(id);
6062
+ }
6063
+ function setAgentFocus(id, scenarioId) {
6064
+ const db2 = getDatabase();
6065
+ const agent = getAgent(id);
6066
+ if (!agent)
6067
+ return null;
6068
+ const metadata = { ...agent.metadata ?? {}, focus: scenarioId };
6069
+ db2.query("UPDATE agents SET metadata = ?, last_seen_at = ? WHERE id = ?").run(JSON.stringify(metadata), now(), id);
6070
+ return getAgent(id);
5275
6071
  }
5276
6072
 
6073
+ // src/lib/runner.ts
6074
+ init_scenarios();
6075
+ init_browser();
6076
+
5277
6077
  // src/lib/screenshotter.ts
5278
6078
  import { mkdirSync as mkdirSync2, existsSync as existsSync2, writeFileSync } from "fs";
5279
6079
  import { join as join2 } from "path";
@@ -6329,6 +7129,98 @@ async function pushFailedRunToLogs(run, failedResults, scenarios) {
6329
7129
  } catch {}
6330
7130
  }
6331
7131
 
7132
+ // src/lib/failure-pipeline.ts
7133
+ init_todos_connector();
7134
+ async function createFailureTasks(run, failedResults, scenarios) {
7135
+ if (failedResults.length === 0)
7136
+ return { created: 0, skipped: 0 };
7137
+ const projectId = process.env["TESTERS_TODOS_PROJECT_ID"];
7138
+ if (!projectId)
7139
+ return { created: 0, skipped: 0 };
7140
+ let db2 = null;
7141
+ try {
7142
+ db2 = connectToTodos();
7143
+ } catch {
7144
+ return { created: 0, skipped: 0 };
7145
+ }
7146
+ const scenarioMap = new Map(scenarios.map((s) => [s.id, s]));
7147
+ let created = 0;
7148
+ let skipped = 0;
7149
+ try {
7150
+ for (const result of failedResults) {
7151
+ const scenario = scenarioMap.get(result.scenarioId);
7152
+ const title = `BUG: [testers] ${scenario?.name ?? result.scenarioId} failed`;
7153
+ const existing = db2.query("SELECT id FROM tasks WHERE title = ? AND status NOT IN ('completed', 'cancelled') LIMIT 1").get(title);
7154
+ if (existing) {
7155
+ skipped++;
7156
+ continue;
7157
+ }
7158
+ const id = crypto.randomUUID();
7159
+ const now2 = new Date().toISOString();
7160
+ const description = [
7161
+ `Test failure detected by open-testers.`,
7162
+ ``,
7163
+ `**Run:** ${run.id}`,
7164
+ `**URL:** ${run.url}`,
7165
+ `**Scenario:** ${scenario?.name ?? result.scenarioId}`,
7166
+ `**Status:** ${result.status}`,
7167
+ result.error ? `**Error:** ${result.error}` : null,
7168
+ result.reasoning ? `**Reasoning:** ${result.reasoning.slice(0, 500)}` : null,
7169
+ `**Duration:** ${result.durationMs ? `${(result.durationMs / 1000).toFixed(1)}s` : "N/A"}`,
7170
+ `**Tokens:** ${result.tokensUsed ?? 0}`
7171
+ ].filter(Boolean).join(`
7172
+ `);
7173
+ try {
7174
+ db2.query(`
7175
+ INSERT INTO tasks (id, short_id, title, description, status, priority, tags, project_id, version, created_at, updated_at)
7176
+ VALUES (?, ?, ?, ?, 'pending', 'high', ?, ?, 1, ?, ?)
7177
+ `).run(id, `BUG-${id.slice(0, 6)}`, title, description, JSON.stringify(["bug", "testers", "auto-created"]), projectId, now2, now2);
7178
+ created++;
7179
+ } catch {
7180
+ skipped++;
7181
+ }
7182
+ }
7183
+ } finally {
7184
+ db2.close();
7185
+ }
7186
+ return { created, skipped };
7187
+ }
7188
+ async function notifyFailureToConversations(run, failedResults, scenarios) {
7189
+ const baseUrl = process.env["TESTERS_CONVERSATIONS_URL"];
7190
+ const space = process.env["TESTERS_CONVERSATIONS_SPACE"];
7191
+ if (!baseUrl || !space)
7192
+ return;
7193
+ const scenarioMap = new Map(scenarios.map((s) => [s.id, s]));
7194
+ const total = run.total;
7195
+ const failedCount = failedResults.length;
7196
+ const passedCount = run.passed;
7197
+ const failureLines = failedResults.slice(0, 5).map((r) => {
7198
+ const name = scenarioMap.get(r.scenarioId)?.name ?? r.scenarioId;
7199
+ const err = r.error ? ` \u2014 ${r.error.slice(0, 120)}` : "";
7200
+ return ` \u274C ${name}${err}`;
7201
+ });
7202
+ const extra = failedResults.length > 5 ? ` \u2026 and ${failedResults.length - 5} more` : "";
7203
+ const message = [
7204
+ `\uD83D\uDEA8 **Testers run failed** \u2014 ${failedCount}/${total} scenarios failed`,
7205
+ ``,
7206
+ `**URL:** ${run.url}`,
7207
+ `**Run ID:** \`${run.id}\``,
7208
+ `**Pass rate:** ${passedCount}/${total}`,
7209
+ ``,
7210
+ `**Failures:**`,
7211
+ ...failureLines,
7212
+ extra
7213
+ ].filter((l) => l !== "").join(`
7214
+ `);
7215
+ try {
7216
+ await fetch(`${baseUrl.replace(/\/$/, "")}/api/spaces/${encodeURIComponent(space)}/messages`, {
7217
+ method: "POST",
7218
+ headers: { "Content-Type": "application/json" },
7219
+ body: JSON.stringify({ content: message, from: "testers" })
7220
+ });
7221
+ } catch {}
7222
+ }
7223
+
6332
7224
  // src/lib/runner.ts
6333
7225
  var eventHandler = null;
6334
7226
  function emit(event) {
@@ -6562,6 +7454,8 @@ async function runBatch(scenarios, options) {
6562
7454
  if (finalRun.status === "failed") {
6563
7455
  const failedResults = results.filter((r) => r.status === "failed" || r.status === "error");
6564
7456
  pushFailedRunToLogs(finalRun, failedResults, scenarios).catch(() => {});
7457
+ createFailureTasks(finalRun, failedResults, scenarios).catch(() => {});
7458
+ notifyFailureToConversations(finalRun, failedResults, scenarios).catch(() => {});
6565
7459
  }
6566
7460
  return { run: finalRun, results };
6567
7461
  }
@@ -6675,108 +7569,60 @@ function estimateCost(model, tokens) {
6675
7569
  return tokens / 1e6 * costPer1M * 100;
6676
7570
  }
6677
7571
 
6678
- // src/lib/todos-connector.ts
6679
- import { Database as Database2 } from "bun:sqlite";
6680
- import { existsSync as existsSync4 } from "fs";
6681
- import { join as join4 } from "path";
6682
- import { homedir as homedir4 } from "os";
6683
- init_types();
6684
- function resolveTodosDbPath() {
6685
- const envPath = process.env["TODOS_DB_PATH"];
6686
- if (envPath)
6687
- return envPath;
6688
- return join4(homedir4(), ".todos", "todos.db");
6689
- }
6690
- function connectToTodos() {
6691
- const dbPath = resolveTodosDbPath();
6692
- if (!existsSync4(dbPath)) {
6693
- throw new TodosConnectionError(`Todos database not found at ${dbPath}. Install @hasna/todos or set TODOS_DB_PATH.`);
6694
- }
6695
- const db2 = new Database2(dbPath, { readonly: true });
6696
- db2.exec("PRAGMA foreign_keys = ON");
6697
- return db2;
6698
- }
6699
- function pullTasks(options = {}) {
6700
- const db2 = connectToTodos();
6701
- try {
6702
- let query = "SELECT id, short_id, title, description, status, priority, tags, project_id FROM tasks WHERE 1=1";
6703
- const params = [];
6704
- if (options.status) {
6705
- query += " AND status = ?";
6706
- params.push(options.status);
6707
- } else {
6708
- query += " AND status IN ('pending', 'in_progress')";
6709
- }
6710
- if (options.priority) {
6711
- query += " AND priority = ?";
6712
- params.push(options.priority);
6713
- }
6714
- if (options.projectName) {
6715
- const project = db2.query("SELECT id FROM projects WHERE name = ?").get(options.projectName);
6716
- if (project) {
6717
- query += " AND project_id = ?";
6718
- params.push(project.id);
7572
+ // src/lib/affected.ts
7573
+ function globToRegex(glob) {
7574
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\x00DS\x00").replace(/\*/g, "[^/]*").replace(/\x00DS\x00/g, ".*");
7575
+ return new RegExp(`^${escaped}$`, "i");
7576
+ }
7577
+ function matchFilesToScenarios(filePaths, scenarios, mappings = []) {
7578
+ if (filePaths.length === 0)
7579
+ return scenarios;
7580
+ const compiledMappings = mappings.map((m) => ({
7581
+ regex: globToRegex(m.glob),
7582
+ tags: m.tags
7583
+ }));
7584
+ const normPaths = filePaths.map((p) => p.replace(/\\/g, "/").toLowerCase());
7585
+ const matchedIds = new Set;
7586
+ for (const scenario of scenarios) {
7587
+ let matched = false;
7588
+ if (!matched) {
7589
+ for (const { regex, tags } of compiledMappings) {
7590
+ if (normPaths.some((fp) => regex.test(fp)) && tags.some((tag) => scenario.tags.includes(tag))) {
7591
+ matched = true;
7592
+ break;
7593
+ }
6719
7594
  }
6720
7595
  }
6721
- query += " ORDER BY CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END";
6722
- const tasks = db2.query(query).all(...params);
6723
- if (options.tags && options.tags.length > 0) {
6724
- return tasks.filter((task) => {
6725
- const taskTags = JSON.parse(task.tags || "[]");
6726
- return options.tags.some((tag) => taskTags.includes(tag));
6727
- });
7596
+ if (!matched && scenario.targetPath) {
7597
+ const segments = scenario.targetPath.replace(/^\//, "").split("/").filter((s) => s.length > 2);
7598
+ if (segments.some((seg) => normPaths.some((fp) => fp.includes(seg.toLowerCase())))) {
7599
+ matched = true;
7600
+ }
6728
7601
  }
6729
- return tasks;
6730
- } finally {
6731
- db2.close();
6732
- }
6733
- }
6734
- function taskToScenarioInput(task, projectId) {
6735
- const tags = JSON.parse(task.tags || "[]");
6736
- const priority = ["low", "medium", "high", "critical"].includes(task.priority) ? task.priority : "medium";
6737
- const steps = [];
6738
- if (task.description) {
6739
- const lines = task.description.split(`
6740
- `);
6741
- for (const line of lines) {
6742
- const match = line.match(/^\s*\d+[\.\)]\s*(.+)/);
6743
- if (match?.[1]) {
6744
- steps.push(match[1].trim());
7602
+ if (!matched) {
7603
+ for (const tag of scenario.tags) {
7604
+ if (tag.length > 2 && normPaths.some((fp) => fp.includes(tag.toLowerCase()))) {
7605
+ matched = true;
7606
+ break;
7607
+ }
6745
7608
  }
6746
7609
  }
6747
- }
6748
- return {
6749
- name: task.title.replace(/^(OPE\d+-\d+|[A-Z]+-\d+):\s*/, ""),
6750
- description: task.description || task.title,
6751
- steps,
6752
- tags,
6753
- priority,
6754
- projectId,
6755
- metadata: { todosTaskId: task.id, todosShortId: task.short_id }
6756
- };
6757
- }
6758
- function importFromTodos(options = {}) {
6759
- const tasks = pullTasks({
6760
- projectName: options.projectName,
6761
- tags: options.tags ?? ["qa", "test", "testing"],
6762
- priority: options.priority
6763
- });
6764
- const existing = listScenarios({ projectId: options.projectId });
6765
- const existingTodoIds = new Set(existing.filter((s) => s.metadata?.todosTaskId).map((s) => s.metadata.todosTaskId));
6766
- let imported = 0;
6767
- let skipped = 0;
6768
- for (const task of tasks) {
6769
- if (existingTodoIds.has(task.id)) {
6770
- skipped++;
6771
- continue;
7610
+ if (!matched) {
7611
+ const nameWords = scenario.name.toLowerCase().split(/[\s\-_/]+/).filter((w) => w.length > 3);
7612
+ if (nameWords.some((word) => normPaths.some((fp) => fp.includes(word)))) {
7613
+ matched = true;
7614
+ }
6772
7615
  }
6773
- const input = taskToScenarioInput(task, options.projectId);
6774
- createScenario(input);
6775
- imported++;
7616
+ if (matched)
7617
+ matchedIds.add(scenario.id);
6776
7618
  }
6777
- return { imported, skipped };
7619
+ return scenarios.filter((s) => matchedIds.has(s.id));
6778
7620
  }
6779
7621
 
7622
+ // src/mcp/index.ts
7623
+ init_scan_issues();
7624
+ init_todos_connector();
7625
+
6780
7626
  // src/db/schedules.ts
6781
7627
  init_database();
6782
7628
  init_types();
@@ -7640,6 +8486,188 @@ server.tool("get_stale_scenarios", "List scenarios that have not been run recent
7640
8486
  return errorResponse(error);
7641
8487
  }
7642
8488
  });
8489
+ server.tool("run_affected_scenarios", "Run only the scenarios relevant to a set of changed files. Matches files to scenarios via explicit glob\u2192tag rules, targetPath keywords, and name/tag inference. Returns immediately \u2014 poll with get_run.", {
8490
+ url: exports_external.string().describe("Target URL to test against"),
8491
+ filePaths: exports_external.array(exports_external.string()).describe("Changed file paths (relative or absolute)"),
8492
+ mappings: exports_external.array(exports_external.object({
8493
+ glob: exports_external.string().describe("File glob pattern (supports * and **)"),
8494
+ tags: exports_external.array(exports_external.string()).describe("Run scenarios tagged with these if glob matches")
8495
+ })).optional().describe("Explicit file glob \u2192 scenario tag mappings"),
8496
+ projectId: exports_external.string().optional().describe("Restrict to scenarios in this project"),
8497
+ model: exports_external.string().optional().describe(MODEL_DESC),
8498
+ headed: exports_external.boolean().optional().describe("Run browser in headed mode"),
8499
+ parallel: exports_external.number().optional().describe("Number of parallel workers")
8500
+ }, async ({ url, filePaths, mappings, projectId, model, headed, parallel }) => {
8501
+ try {
8502
+ const allScenarios = listScenarios({ projectId });
8503
+ const matched = matchFilesToScenarios(filePaths, allScenarios, mappings ?? []);
8504
+ if (matched.length === 0) {
8505
+ return json({ runId: null, scenarioCount: 0, matchedScenarios: [], message: "No scenarios matched the provided file paths." });
8506
+ }
8507
+ const scenarioIds = matched.map((s) => s.id);
8508
+ const { runId, scenarioCount } = startRunAsync({ url, scenarioIds, model, headed, parallel, projectId });
8509
+ return json({
8510
+ runId,
8511
+ scenarioCount,
8512
+ url,
8513
+ status: "running",
8514
+ matchedScenarios: matched.map((s) => ({ id: s.id, shortId: s.shortId, name: s.name, tags: s.tags })),
8515
+ message: "Poll with get_run to check progress."
8516
+ });
8517
+ } catch (error) {
8518
+ return errorResponse(error);
8519
+ }
8520
+ });
8521
+ server.tool("heartbeat", "Update an agent's last_seen_at timestamp. Call regularly to signal the agent is alive.", {
8522
+ agentId: exports_external.string().describe("Agent ID to heartbeat")
8523
+ }, async ({ agentId }) => {
8524
+ try {
8525
+ const agent = heartbeatAgent(agentId);
8526
+ if (!agent)
8527
+ return errorResponse(notFoundErr(agentId, "Agent"));
8528
+ return json({ ok: true, agentId: agent.id, lastSeenAt: agent.lastSeenAt });
8529
+ } catch (error) {
8530
+ return errorResponse(error);
8531
+ }
8532
+ });
8533
+ server.tool("set_focus", "Set (or clear) an agent's current focus scenario. Stored in agent metadata.", {
8534
+ agentId: exports_external.string().describe("Agent ID"),
8535
+ scenarioId: exports_external.string().nullable().describe("Scenario ID the agent is working on, or null to clear")
8536
+ }, async ({ agentId, scenarioId }) => {
8537
+ try {
8538
+ const agent = setAgentFocus(agentId, scenarioId);
8539
+ if (!agent)
8540
+ return errorResponse(notFoundErr(agentId, "Agent"));
8541
+ return json({ ok: true, agentId: agent.id, focus: agent.metadata?.focus ?? null });
8542
+ } catch (error) {
8543
+ return errorResponse(error);
8544
+ }
8545
+ });
8546
+ server.tool("scan_console_errors", "Visit pages headlessly and collect JS/React console errors, uncaught exceptions, and unhandled promise rejections.", {
8547
+ url: exports_external.string().describe("Root URL to scan"),
8548
+ pages: exports_external.array(exports_external.string()).optional().describe("Specific paths to visit (e.g. ['/login', '/dashboard']). Defaults to root URL only."),
8549
+ projectId: exports_external.string().optional().describe("Project ID for deduplication tracking"),
8550
+ headed: exports_external.boolean().optional().describe("Run browser in headed mode"),
8551
+ timeoutMs: exports_external.number().optional().describe("Navigation timeout per page (default 15000)")
8552
+ }, async ({ url, pages, projectId, headed, timeoutMs }) => {
8553
+ try {
8554
+ const { scanConsoleErrors: scanConsoleErrors2 } = await Promise.resolve().then(() => (init_console(), exports_console));
8555
+ const { upsertScanIssue: upsertScanIssue2 } = await Promise.resolve().then(() => (init_scan_issues(), exports_scan_issues));
8556
+ const result = await scanConsoleErrors2({ url, pages, headed, timeoutMs });
8557
+ const deduped = result.issues.map((issue) => {
8558
+ const { outcome } = upsertScanIssue2(issue, projectId);
8559
+ return { ...issue, outcome };
8560
+ });
8561
+ return json({ ...result, issues: deduped });
8562
+ } catch (e) {
8563
+ return errorResponse(e);
8564
+ }
8565
+ });
8566
+ server.tool("scan_network_errors", "Visit pages and intercept network requests, flagging 5xx errors, 4xx on API routes, CORS failures, and request timeouts.", {
8567
+ url: exports_external.string().describe("Root URL to scan"),
8568
+ pages: exports_external.array(exports_external.string()).optional().describe("Specific paths to visit"),
8569
+ projectId: exports_external.string().optional().describe("Project ID for deduplication tracking"),
8570
+ headed: exports_external.boolean().optional(),
8571
+ timeoutMs: exports_external.number().optional()
8572
+ }, async ({ url, pages, projectId, headed, timeoutMs }) => {
8573
+ try {
8574
+ const { scanNetworkErrors: scanNetworkErrors2 } = await Promise.resolve().then(() => (init_network(), exports_network));
8575
+ const { upsertScanIssue: upsertScanIssue2 } = await Promise.resolve().then(() => (init_scan_issues(), exports_scan_issues));
8576
+ const result = await scanNetworkErrors2({ url, pages, headed, timeoutMs });
8577
+ const deduped = result.issues.map((issue) => {
8578
+ const { outcome } = upsertScanIssue2(issue, projectId);
8579
+ return { ...issue, outcome };
8580
+ });
8581
+ return json({ ...result, issues: deduped });
8582
+ } catch (e) {
8583
+ return errorResponse(e);
8584
+ }
8585
+ });
8586
+ server.tool("scan_broken_links", "Crawl app from root URL and flag any links that return 404 or fail to load.", {
8587
+ url: exports_external.string().describe("Root URL to crawl from"),
8588
+ maxPages: exports_external.number().optional().describe("Max pages to crawl (default 30)"),
8589
+ projectId: exports_external.string().optional().describe("Project ID for deduplication tracking"),
8590
+ headed: exports_external.boolean().optional(),
8591
+ timeoutMs: exports_external.number().optional()
8592
+ }, async ({ url, maxPages, projectId, headed, timeoutMs }) => {
8593
+ try {
8594
+ const { scanBrokenLinks: scanBrokenLinks2 } = await Promise.resolve().then(() => (init_links(), exports_links));
8595
+ const { upsertScanIssue: upsertScanIssue2 } = await Promise.resolve().then(() => (init_scan_issues(), exports_scan_issues));
8596
+ const result = await scanBrokenLinks2({ url, maxPages, headed, timeoutMs });
8597
+ const deduped = result.issues.map((issue) => {
8598
+ const { outcome } = upsertScanIssue2(issue, projectId);
8599
+ return { ...issue, outcome };
8600
+ });
8601
+ return json({ ...result, issues: deduped });
8602
+ } catch (e) {
8603
+ return errorResponse(e);
8604
+ }
8605
+ });
8606
+ server.tool("scan_performance", "Visit pages and measure load time, DOMContentLoaded, and LCP using the Web Performance API. Flags slow pages.", {
8607
+ url: exports_external.string().describe("Root URL to scan"),
8608
+ pages: exports_external.array(exports_external.string()).optional().describe("Specific paths to visit"),
8609
+ projectId: exports_external.string().optional().describe("Project ID for deduplication tracking"),
8610
+ headed: exports_external.boolean().optional(),
8611
+ timeoutMs: exports_external.number().optional(),
8612
+ thresholds: exports_external.object({
8613
+ loadTimeMs: exports_external.number().optional(),
8614
+ domContentLoadedMs: exports_external.number().optional(),
8615
+ lcpMs: exports_external.number().optional()
8616
+ }).optional().describe("Override default thresholds")
8617
+ }, async ({ url, pages, projectId, headed, timeoutMs, thresholds }) => {
8618
+ try {
8619
+ const { scanPerformance: scanPerformance2 } = await Promise.resolve().then(() => (init_performance(), exports_performance));
8620
+ const { upsertScanIssue: upsertScanIssue2 } = await Promise.resolve().then(() => (init_scan_issues(), exports_scan_issues));
8621
+ const result = await scanPerformance2({ url, pages, headed, timeoutMs, thresholds });
8622
+ const deduped = result.issues.map((issue) => {
8623
+ const { outcome } = upsertScanIssue2(issue, projectId);
8624
+ return { ...issue, outcome };
8625
+ });
8626
+ return json({ ...result, issues: deduped });
8627
+ } catch (e) {
8628
+ return errorResponse(e);
8629
+ }
8630
+ });
8631
+ server.tool("run_health_scan", "Run all scanners (console, network, links, performance) against a URL. Deduplicates issues, creates todo tasks for new/regressed issues, and posts to conversations space.", {
8632
+ url: exports_external.string().describe("URL to scan"),
8633
+ pages: exports_external.array(exports_external.string()).optional().describe("Specific paths to include"),
8634
+ projectId: exports_external.string().optional().describe("Project ID"),
8635
+ scanners: exports_external.array(exports_external.enum(["console", "network", "links", "performance"])).optional().describe("Which scanners to run (default: console, network, links)"),
8636
+ maxPages: exports_external.number().optional().describe("Max pages for link crawl (default 20)"),
8637
+ headed: exports_external.boolean().optional(),
8638
+ timeoutMs: exports_external.number().optional()
8639
+ }, async ({ url, pages, projectId, scanners, maxPages, headed, timeoutMs }) => {
8640
+ try {
8641
+ const { runHealthScan: runHealthScan2 } = await Promise.resolve().then(() => (init_health_scan(), exports_health_scan));
8642
+ const summary = await runHealthScan2({ url, pages, projectId, scanners, maxPages, headed, timeoutMs });
8643
+ return json(summary);
8644
+ } catch (e) {
8645
+ return errorResponse(e);
8646
+ }
8647
+ });
8648
+ server.tool("list_scan_issues", "List persisted scan issues with optional filters.", {
8649
+ status: exports_external.enum(["open", "resolved", "regressed"]).optional(),
8650
+ type: exports_external.enum(["console_error", "network_error", "broken_link", "performance"]).optional(),
8651
+ projectId: exports_external.string().optional(),
8652
+ limit: exports_external.number().optional()
8653
+ }, async ({ status, type, projectId, limit }) => {
8654
+ try {
8655
+ const issues = listScanIssues({ status, type, projectId, limit });
8656
+ return json({ items: issues, total: issues.length });
8657
+ } catch (e) {
8658
+ return errorResponse(e);
8659
+ }
8660
+ });
8661
+ server.tool("resolve_scan_issue", "Mark a scan issue as resolved.", { id: exports_external.string().describe("Scan issue ID") }, async ({ id }) => {
8662
+ try {
8663
+ const ok = resolveScanIssue(id);
8664
+ if (!ok)
8665
+ return errorResponse(notFoundErr(id, "ScanIssue"));
8666
+ return json({ resolved: true, id });
8667
+ } catch (e) {
8668
+ return errorResponse(e);
8669
+ }
8670
+ });
7643
8671
  async function main() {
7644
8672
  const transport = new StdioServerTransport;
7645
8673
  await server.connect(transport);