@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/cli/index.js +823 -1
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/scan-issues.d.ts +29 -0
- package/dist/db/scan-issues.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/lib/health-scan.d.ts +22 -0
- package/dist/lib/health-scan.d.ts.map +1 -0
- package/dist/lib/scanners/console.d.ts +12 -0
- package/dist/lib/scanners/console.d.ts.map +1 -0
- package/dist/lib/scanners/links.d.ts +12 -0
- package/dist/lib/scanners/links.d.ts.map +1 -0
- package/dist/lib/scanners/network.d.ts +15 -0
- package/dist/lib/scanners/network.d.ts.map +1 -0
- package/dist/lib/scanners/performance.d.ts +19 -0
- package/dist/lib/scanners/performance.d.ts.map +1 -0
- package/dist/mcp/index.js +1243 -435
- package/dist/server/index.js +23 -0
- package/dist/types/index.d.ts +54 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
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/
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
|
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/
|
|
4747
|
-
|
|
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/
|
|
5237
|
-
|
|
5238
|
-
|
|
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);
|