@hasna/testers 0.0.13 → 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.
package/dist/mcp/index.js CHANGED
@@ -132,6 +132,24 @@ function scheduleFromRow(row) {
132
132
  updatedAt: row.updated_at
133
133
  };
134
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
+ }
135
153
  function flowFromRow(row) {
136
154
  return {
137
155
  id: row.id,
@@ -449,10 +467,223 @@ var init_database = __esm(() => {
449
467
  `,
450
468
  `
451
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);
452
493
  `
453
494
  ];
454
495
  });
455
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
+
456
687
  // src/lib/browser-lightpanda.ts
457
688
  var exports_browser_lightpanda = {};
458
689
  __export(exports_browser_lightpanda, {
@@ -615,42 +846,211 @@ var init_browser_lightpanda = __esm(() => {
615
846
  init_types();
616
847
  });
617
848
 
618
- // src/db/flows.ts
619
- var exports_flows = {};
620
- __export(exports_flows, {
621
- topologicalSort: () => topologicalSort,
622
- removeDependency: () => removeDependency,
623
- listFlows: () => listFlows,
624
- getTransitiveDependencies: () => getTransitiveDependencies,
625
- getFlow: () => getFlow,
626
- getDependents: () => getDependents,
627
- getDependencies: () => getDependencies,
628
- deleteFlow: () => deleteFlow,
629
- createFlow: () => createFlow,
630
- addDependency: () => addDependency
631
- });
632
- function addDependency(scenarioId, dependsOn) {
633
- const db2 = getDatabase();
634
- const visited = new Set;
635
- const queue = [dependsOn];
636
- while (queue.length > 0) {
637
- const current = queue.shift();
638
- if (current === scenarioId) {
639
- throw new DependencyCycleError(scenarioId, dependsOn);
640
- }
641
- if (visited.has(current))
642
- continue;
643
- visited.add(current);
644
- const deps = db2.query("SELECT depends_on FROM scenario_dependencies WHERE scenario_id = ?").all(current);
645
- for (const dep of deps) {
646
- if (!visited.has(dep.depends_on)) {
647
- queue.push(dep.depends_on);
648
- }
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");
649
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}`);
650
873
  }
651
- db2.query("INSERT OR IGNORE INTO scenario_dependencies (scenario_id, depends_on) VALUES (?, ?)").run(scenarioId, dependsOn);
652
874
  }
653
- function removeDependency(scenarioId, dependsOn) {
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);
1052
+ }
1053
+ function removeDependency(scenarioId, dependsOn) {
654
1054
  const db2 = getDatabase();
655
1055
  const result = db2.query("DELETE FROM scenario_dependencies WHERE scenario_id = ? AND depends_on = ?").run(scenarioId, dependsOn);
656
1056
  return result.changes > 0;
@@ -760,10 +1160,632 @@ function deleteFlow(id) {
760
1160
  const result = db2.query("DELETE FROM flows WHERE id = ?").run(flow.id);
761
1161
  return result.changes > 0;
762
1162
  }
763
- var init_flows = __esm(() => {
764
- init_database();
765
- init_database();
766
- 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();
767
1789
  });
768
1790
 
769
1791
  // src/mcp/index.ts
@@ -4694,242 +5716,57 @@ var ZodFirstPartyTypeKind;
4694
5716
  })(ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {}));
4695
5717
  var instanceOfType = (cls, params = {
4696
5718
  message: `Input not instance of ${cls.name}`
4697
- }) => custom((data) => data instanceof cls, params);
4698
- var stringType = ZodString.create;
4699
- var numberType = ZodNumber.create;
4700
- var nanType = ZodNaN.create;
4701
- var bigIntType = ZodBigInt.create;
4702
- var booleanType = ZodBoolean.create;
4703
- var dateType = ZodDate.create;
4704
- var symbolType = ZodSymbol.create;
4705
- var undefinedType = ZodUndefined.create;
4706
- var nullType = ZodNull.create;
4707
- var anyType = ZodAny.create;
4708
- var unknownType = ZodUnknown.create;
4709
- var neverType = ZodNever.create;
4710
- var voidType = ZodVoid.create;
4711
- var arrayType = ZodArray.create;
4712
- var objectType = ZodObject.create;
4713
- var strictObjectType = ZodObject.strictCreate;
4714
- var unionType = ZodUnion.create;
4715
- var discriminatedUnionType = ZodDiscriminatedUnion.create;
4716
- var intersectionType = ZodIntersection.create;
4717
- var tupleType = ZodTuple.create;
4718
- var recordType = ZodRecord.create;
4719
- var mapType = ZodMap.create;
4720
- var setType = ZodSet.create;
4721
- var functionType = ZodFunction.create;
4722
- var lazyType = ZodLazy.create;
4723
- var literalType = ZodLiteral.create;
4724
- var enumType = ZodEnum.create;
4725
- var nativeEnumType = ZodNativeEnum.create;
4726
- var promiseType = ZodPromise.create;
4727
- var effectsType = ZodEffects.create;
4728
- var optionalType = ZodOptional.create;
4729
- var nullableType = ZodNullable.create;
4730
- var preprocessType = ZodEffects.createWithPreprocess;
4731
- var pipelineType = ZodPipeline.create;
4732
- var ostring = () => stringType().optional();
4733
- var onumber = () => numberType().optional();
4734
- var oboolean = () => booleanType().optional();
4735
- var coerce = {
4736
- string: (arg) => ZodString.create({ ...arg, coerce: true }),
4737
- number: (arg) => ZodNumber.create({ ...arg, coerce: true }),
4738
- boolean: (arg) => ZodBoolean.create({
4739
- ...arg,
4740
- coerce: true
4741
- }),
4742
- bigint: (arg) => ZodBigInt.create({ ...arg, coerce: true }),
4743
- date: (arg) => ZodDate.create({ ...arg, coerce: true })
4744
- };
4745
- var NEVER = INVALID;
4746
- // src/db/scenarios.ts
4747
- init_types();
4748
- init_database();
4749
- function nextShortId(projectId) {
4750
- const db2 = getDatabase();
4751
- if (projectId) {
4752
- const project = db2.query("SELECT scenario_prefix, scenario_counter FROM projects WHERE id = ?").get(projectId);
4753
- if (project) {
4754
- const next = project.scenario_counter + 1;
4755
- db2.query("UPDATE projects SET scenario_counter = ? WHERE id = ?").run(next, projectId);
4756
- return `${project.scenario_prefix}-${next}`;
4757
- }
4758
- }
4759
- return shortUuid();
4760
- }
4761
- function createScenario(input) {
4762
- const db2 = getDatabase();
4763
- const id = uuid();
4764
- const short_id = nextShortId(input.projectId);
4765
- const timestamp = now();
4766
- db2.query(`
4767
- 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)
4768
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
4769
- `).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);
4770
- return getScenario(id);
4771
- }
4772
- function getScenario(id) {
4773
- const db2 = getDatabase();
4774
- let row = db2.query("SELECT * FROM scenarios WHERE id = ?").get(id);
4775
- if (row)
4776
- return scenarioFromRow(row);
4777
- row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(id);
4778
- if (row)
4779
- return scenarioFromRow(row);
4780
- const fullId = resolvePartialId("scenarios", id);
4781
- if (fullId) {
4782
- row = db2.query("SELECT * FROM scenarios WHERE id = ?").get(fullId);
4783
- if (row)
4784
- return scenarioFromRow(row);
4785
- }
4786
- return null;
4787
- }
4788
- function getScenarioByShortId(shortId) {
4789
- const db2 = getDatabase();
4790
- const row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(shortId);
4791
- return row ? scenarioFromRow(row) : null;
4792
- }
4793
- function listScenarios(filter) {
4794
- const db2 = getDatabase();
4795
- const conditions = [];
4796
- const params = [];
4797
- if (filter?.projectId) {
4798
- conditions.push("project_id = ?");
4799
- params.push(filter.projectId);
4800
- }
4801
- if (filter?.tags && filter.tags.length > 0) {
4802
- for (const tag of filter.tags) {
4803
- conditions.push("tags LIKE ?");
4804
- params.push(`%"${tag}"%`);
4805
- }
4806
- }
4807
- if (filter?.priority) {
4808
- conditions.push("priority = ?");
4809
- params.push(filter.priority);
4810
- }
4811
- if (filter?.search) {
4812
- conditions.push("(name LIKE ? OR description LIKE ?)");
4813
- const term = `%${filter.search}%`;
4814
- params.push(term, term);
4815
- }
4816
- let sql = "SELECT * FROM scenarios";
4817
- if (conditions.length > 0) {
4818
- sql += " WHERE " + conditions.join(" AND ");
4819
- }
4820
- const sortField = filter?.sort ?? "date";
4821
- const sortDir = filter?.desc === false ? "ASC" : "DESC";
4822
- 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";
4823
- sql += ` ORDER BY ${orderByCol} ${sortDir}`;
4824
- if (filter?.limit) {
4825
- sql += " LIMIT ?";
4826
- params.push(filter.limit);
4827
- }
4828
- if (filter?.offset) {
4829
- sql += " OFFSET ?";
4830
- params.push(filter.offset);
4831
- }
4832
- const rows = db2.query(sql).all(...params);
4833
- return rows.map(scenarioFromRow);
4834
- }
4835
- function updateScenario(id, input, version) {
4836
- const db2 = getDatabase();
4837
- const existing = getScenario(id);
4838
- if (!existing) {
4839
- throw new Error(`Scenario not found: ${id}`);
4840
- }
4841
- if (existing.version !== version) {
4842
- throw new VersionConflictError("scenario", existing.id);
4843
- }
4844
- const sets = [];
4845
- const params = [];
4846
- if (input.name !== undefined) {
4847
- sets.push("name = ?");
4848
- params.push(input.name);
4849
- }
4850
- if (input.description !== undefined) {
4851
- sets.push("description = ?");
4852
- params.push(input.description);
4853
- }
4854
- if (input.steps !== undefined) {
4855
- sets.push("steps = ?");
4856
- params.push(JSON.stringify(input.steps));
4857
- }
4858
- if (input.tags !== undefined) {
4859
- sets.push("tags = ?");
4860
- params.push(JSON.stringify(input.tags));
4861
- }
4862
- if (input.priority !== undefined) {
4863
- sets.push("priority = ?");
4864
- params.push(input.priority);
4865
- }
4866
- if (input.model !== undefined) {
4867
- sets.push("model = ?");
4868
- params.push(input.model);
4869
- }
4870
- if (input.timeoutMs !== undefined) {
4871
- sets.push("timeout_ms = ?");
4872
- params.push(input.timeoutMs);
4873
- }
4874
- if (input.targetPath !== undefined) {
4875
- sets.push("target_path = ?");
4876
- params.push(input.targetPath);
4877
- }
4878
- if (input.requiresAuth !== undefined) {
4879
- sets.push("requires_auth = ?");
4880
- params.push(input.requiresAuth ? 1 : 0);
4881
- }
4882
- if (input.authConfig !== undefined) {
4883
- sets.push("auth_config = ?");
4884
- params.push(JSON.stringify(input.authConfig));
4885
- }
4886
- if (input.metadata !== undefined) {
4887
- sets.push("metadata = ?");
4888
- params.push(JSON.stringify(input.metadata));
4889
- }
4890
- if (input.assertions !== undefined) {
4891
- sets.push("assertions = ?");
4892
- params.push(JSON.stringify(input.assertions));
4893
- }
4894
- if (sets.length === 0) {
4895
- return existing;
4896
- }
4897
- sets.push("version = ?");
4898
- params.push(version + 1);
4899
- sets.push("updated_at = ?");
4900
- params.push(now());
4901
- params.push(existing.id);
4902
- params.push(version);
4903
- const result = db2.query(`UPDATE scenarios SET ${sets.join(", ")} WHERE id = ? AND version = ?`).run(...params);
4904
- if (result.changes === 0) {
4905
- throw new VersionConflictError("scenario", existing.id);
4906
- }
4907
- return getScenario(existing.id);
4908
- }
4909
- function findStaleScenarios(days) {
4910
- const db2 = getDatabase();
4911
- const rows = db2.query(`
4912
- SELECT s.*, MAX(r.created_at) AS last_run_at
4913
- FROM scenarios s
4914
- LEFT JOIN results r ON r.scenario_id = s.id
4915
- GROUP BY s.id
4916
- HAVING last_run_at IS NULL
4917
- OR last_run_at < datetime('now', ? || ' days')
4918
- ORDER BY last_run_at ASC NULLS FIRST
4919
- `).all(`-${days}`);
4920
- return rows.map((row) => ({
4921
- ...scenarioFromRow(row),
4922
- lastRunAt: row.last_run_at
4923
- }));
4924
- }
4925
- function deleteScenario(id) {
4926
- const db2 = getDatabase();
4927
- const scenario = getScenario(id);
4928
- if (!scenario)
4929
- return false;
4930
- const result = db2.query("DELETE FROM scenarios WHERE id = ?").run(scenario.id);
4931
- return result.changes > 0;
4932
- }
5719
+ }) => custom((data) => data instanceof cls, params);
5720
+ var stringType = ZodString.create;
5721
+ var numberType = ZodNumber.create;
5722
+ var nanType = ZodNaN.create;
5723
+ var bigIntType = ZodBigInt.create;
5724
+ var booleanType = ZodBoolean.create;
5725
+ var dateType = ZodDate.create;
5726
+ var symbolType = ZodSymbol.create;
5727
+ var undefinedType = ZodUndefined.create;
5728
+ var nullType = ZodNull.create;
5729
+ var anyType = ZodAny.create;
5730
+ var unknownType = ZodUnknown.create;
5731
+ var neverType = ZodNever.create;
5732
+ var voidType = ZodVoid.create;
5733
+ var arrayType = ZodArray.create;
5734
+ var objectType = ZodObject.create;
5735
+ var strictObjectType = ZodObject.strictCreate;
5736
+ var unionType = ZodUnion.create;
5737
+ var discriminatedUnionType = ZodDiscriminatedUnion.create;
5738
+ var intersectionType = ZodIntersection.create;
5739
+ var tupleType = ZodTuple.create;
5740
+ var recordType = ZodRecord.create;
5741
+ var mapType = ZodMap.create;
5742
+ var setType = ZodSet.create;
5743
+ var functionType = ZodFunction.create;
5744
+ var lazyType = ZodLazy.create;
5745
+ var literalType = ZodLiteral.create;
5746
+ var enumType = ZodEnum.create;
5747
+ var nativeEnumType = ZodNativeEnum.create;
5748
+ var promiseType = ZodPromise.create;
5749
+ var effectsType = ZodEffects.create;
5750
+ var optionalType = ZodOptional.create;
5751
+ var nullableType = ZodNullable.create;
5752
+ var preprocessType = ZodEffects.createWithPreprocess;
5753
+ var pipelineType = ZodPipeline.create;
5754
+ var ostring = () => stringType().optional();
5755
+ var onumber = () => numberType().optional();
5756
+ var oboolean = () => booleanType().optional();
5757
+ var coerce = {
5758
+ string: (arg) => ZodString.create({ ...arg, coerce: true }),
5759
+ number: (arg) => ZodNumber.create({ ...arg, coerce: true }),
5760
+ boolean: (arg) => ZodBoolean.create({
5761
+ ...arg,
5762
+ coerce: true
5763
+ }),
5764
+ bigint: (arg) => ZodBigInt.create({ ...arg, coerce: true }),
5765
+ date: (arg) => ZodDate.create({ ...arg, coerce: true })
5766
+ };
5767
+ var NEVER = INVALID;
5768
+ // src/mcp/index.ts
5769
+ init_scenarios();
4933
5770
 
4934
5771
  // src/db/runs.ts
4935
5772
  init_types();
@@ -5233,66 +6070,9 @@ function setAgentFocus(id, scenarioId) {
5233
6070
  return getAgent(id);
5234
6071
  }
5235
6072
 
5236
- // src/lib/browser.ts
5237
- import { chromium as chromium2 } from "playwright";
5238
- init_types();
5239
- var DEFAULT_VIEWPORT = { width: 1280, height: 720 };
5240
- async function launchBrowser(options) {
5241
- const engine = options?.engine ?? process.env["TESTERS_BROWSER_ENGINE"] ?? "playwright";
5242
- if (engine === "lightpanda") {
5243
- const { launchLightpanda: launchLightpanda2, isLightpandaAvailable: isLightpandaAvailable2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
5244
- if (!isLightpandaAvailable2()) {
5245
- throw new BrowserError("Lightpanda not installed. Run: testers install-browser --engine lightpanda");
5246
- }
5247
- return launchLightpanda2({ viewport: options?.viewport });
5248
- }
5249
- const headless = options?.headless ?? true;
5250
- const viewport = options?.viewport ?? DEFAULT_VIEWPORT;
5251
- try {
5252
- const browser = await chromium2.launch({
5253
- headless,
5254
- args: [
5255
- `--window-size=${viewport.width},${viewport.height}`
5256
- ]
5257
- });
5258
- return browser;
5259
- } catch (error) {
5260
- const message = error instanceof Error ? error.message : String(error);
5261
- throw new BrowserError(`Failed to launch browser: ${message}`);
5262
- }
5263
- }
5264
- async function getPage(browser, options) {
5265
- const engine = options?.engine ?? "playwright";
5266
- if (engine === "lightpanda") {
5267
- const { getLightpandaPage: getLightpandaPage2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
5268
- return getLightpandaPage2(browser, options);
5269
- }
5270
- const viewport = options?.viewport ?? DEFAULT_VIEWPORT;
5271
- try {
5272
- const context = await browser.newContext({
5273
- viewport,
5274
- userAgent: options?.userAgent,
5275
- locale: options?.locale
5276
- });
5277
- const page = await context.newPage();
5278
- return page;
5279
- } catch (error) {
5280
- const message = error instanceof Error ? error.message : String(error);
5281
- throw new BrowserError(`Failed to create page: ${message}`);
5282
- }
5283
- }
5284
- async function closeBrowser(browser, engine) {
5285
- if (engine === "lightpanda") {
5286
- const { closeLightpanda: closeLightpanda2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
5287
- return closeLightpanda2(browser);
5288
- }
5289
- try {
5290
- await browser.close();
5291
- } catch (error) {
5292
- const message = error instanceof Error ? error.message : String(error);
5293
- throw new BrowserError(`Failed to close browser: ${message}`);
5294
- }
5295
- }
6073
+ // src/lib/runner.ts
6074
+ init_scenarios();
6075
+ init_browser();
5296
6076
 
5297
6077
  // src/lib/screenshotter.ts
5298
6078
  import { mkdirSync as mkdirSync2, existsSync as existsSync2, writeFileSync } from "fs";
@@ -6349,109 +7129,8 @@ async function pushFailedRunToLogs(run, failedResults, scenarios) {
6349
7129
  } catch {}
6350
7130
  }
6351
7131
 
6352
- // src/lib/todos-connector.ts
6353
- import { Database as Database2 } from "bun:sqlite";
6354
- import { existsSync as existsSync4 } from "fs";
6355
- import { join as join4 } from "path";
6356
- import { homedir as homedir4 } from "os";
6357
- init_types();
6358
- function resolveTodosDbPath() {
6359
- const envPath = process.env["TODOS_DB_PATH"];
6360
- if (envPath)
6361
- return envPath;
6362
- return join4(homedir4(), ".todos", "todos.db");
6363
- }
6364
- function connectToTodos() {
6365
- const dbPath = resolveTodosDbPath();
6366
- if (!existsSync4(dbPath)) {
6367
- throw new TodosConnectionError(`Todos database not found at ${dbPath}. Install @hasna/todos or set TODOS_DB_PATH.`);
6368
- }
6369
- const db2 = new Database2(dbPath, { readonly: true });
6370
- db2.exec("PRAGMA foreign_keys = ON");
6371
- return db2;
6372
- }
6373
- function pullTasks(options = {}) {
6374
- const db2 = connectToTodos();
6375
- try {
6376
- let query = "SELECT id, short_id, title, description, status, priority, tags, project_id FROM tasks WHERE 1=1";
6377
- const params = [];
6378
- if (options.status) {
6379
- query += " AND status = ?";
6380
- params.push(options.status);
6381
- } else {
6382
- query += " AND status IN ('pending', 'in_progress')";
6383
- }
6384
- if (options.priority) {
6385
- query += " AND priority = ?";
6386
- params.push(options.priority);
6387
- }
6388
- if (options.projectName) {
6389
- const project = db2.query("SELECT id FROM projects WHERE name = ?").get(options.projectName);
6390
- if (project) {
6391
- query += " AND project_id = ?";
6392
- params.push(project.id);
6393
- }
6394
- }
6395
- query += " ORDER BY CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END";
6396
- const tasks = db2.query(query).all(...params);
6397
- if (options.tags && options.tags.length > 0) {
6398
- return tasks.filter((task) => {
6399
- const taskTags = JSON.parse(task.tags || "[]");
6400
- return options.tags.some((tag) => taskTags.includes(tag));
6401
- });
6402
- }
6403
- return tasks;
6404
- } finally {
6405
- db2.close();
6406
- }
6407
- }
6408
- function taskToScenarioInput(task, projectId) {
6409
- const tags = JSON.parse(task.tags || "[]");
6410
- const priority = ["low", "medium", "high", "critical"].includes(task.priority) ? task.priority : "medium";
6411
- const steps = [];
6412
- if (task.description) {
6413
- const lines = task.description.split(`
6414
- `);
6415
- for (const line of lines) {
6416
- const match = line.match(/^\s*\d+[\.\)]\s*(.+)/);
6417
- if (match?.[1]) {
6418
- steps.push(match[1].trim());
6419
- }
6420
- }
6421
- }
6422
- return {
6423
- name: task.title.replace(/^(OPE\d+-\d+|[A-Z]+-\d+):\s*/, ""),
6424
- description: task.description || task.title,
6425
- steps,
6426
- tags,
6427
- priority,
6428
- projectId,
6429
- metadata: { todosTaskId: task.id, todosShortId: task.short_id }
6430
- };
6431
- }
6432
- function importFromTodos(options = {}) {
6433
- const tasks = pullTasks({
6434
- projectName: options.projectName,
6435
- tags: options.tags ?? ["qa", "test", "testing"],
6436
- priority: options.priority
6437
- });
6438
- const existing = listScenarios({ projectId: options.projectId });
6439
- const existingTodoIds = new Set(existing.filter((s) => s.metadata?.todosTaskId).map((s) => s.metadata.todosTaskId));
6440
- let imported = 0;
6441
- let skipped = 0;
6442
- for (const task of tasks) {
6443
- if (existingTodoIds.has(task.id)) {
6444
- skipped++;
6445
- continue;
6446
- }
6447
- const input = taskToScenarioInput(task, options.projectId);
6448
- createScenario(input);
6449
- imported++;
6450
- }
6451
- return { imported, skipped };
6452
- }
6453
-
6454
7132
  // src/lib/failure-pipeline.ts
7133
+ init_todos_connector();
6455
7134
  async function createFailureTasks(run, failedResults, scenarios) {
6456
7135
  if (failedResults.length === 0)
6457
7136
  return { created: 0, skipped: 0 };
@@ -6940,6 +7619,10 @@ function matchFilesToScenarios(filePaths, scenarios, mappings = []) {
6940
7619
  return scenarios.filter((s) => matchedIds.has(s.id));
6941
7620
  }
6942
7621
 
7622
+ // src/mcp/index.ts
7623
+ init_scan_issues();
7624
+ init_todos_connector();
7625
+
6943
7626
  // src/db/schedules.ts
6944
7627
  init_database();
6945
7628
  init_types();
@@ -7860,6 +8543,131 @@ server.tool("set_focus", "Set (or clear) an agent's current focus scenario. Stor
7860
8543
  return errorResponse(error);
7861
8544
  }
7862
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
+ });
7863
8671
  async function main() {
7864
8672
  const transport = new StdioServerTransport;
7865
8673
  await server.connect(transport);