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