@hasna/testers 0.0.11 → 0.0.13
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 +2110 -1469
- package/dist/db/agents.d.ts +2 -0
- package/dist/db/agents.d.ts.map +1 -1
- package/dist/index.js +215 -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/runner.d.ts.map +1 -1
- package/dist/mcp/index.js +656 -261
- package/dist/server/index.js +138 -13
- 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);
|
|
@@ -4902,6 +4906,22 @@ function updateScenario(id, input, version) {
|
|
|
4902
4906
|
}
|
|
4903
4907
|
return getScenario(existing.id);
|
|
4904
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
|
+
}
|
|
4905
4925
|
function deleteScenario(id) {
|
|
4906
4926
|
const db2 = getDatabase();
|
|
4907
4927
|
const scenario = getScenario(id);
|
|
@@ -5106,6 +5126,9 @@ function updateResult(id, updates) {
|
|
|
5106
5126
|
db2.query(`UPDATE results SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
5107
5127
|
return getResult(existing.id);
|
|
5108
5128
|
}
|
|
5129
|
+
function getResultsByRun(runId) {
|
|
5130
|
+
return listResults(runId);
|
|
5131
|
+
}
|
|
5109
5132
|
|
|
5110
5133
|
// src/db/screenshots.ts
|
|
5111
5134
|
init_types();
|
|
@@ -5193,6 +5216,22 @@ function listAgents() {
|
|
|
5193
5216
|
const rows = db2.query("SELECT * FROM agents ORDER BY created_at DESC").all();
|
|
5194
5217
|
return rows.map(agentFromRow);
|
|
5195
5218
|
}
|
|
5219
|
+
function heartbeatAgent(id) {
|
|
5220
|
+
const db2 = getDatabase();
|
|
5221
|
+
const affected = db2.query("UPDATE agents SET last_seen_at = ? WHERE id = ?").run(now(), id);
|
|
5222
|
+
if (affected.changes === 0)
|
|
5223
|
+
return null;
|
|
5224
|
+
return getAgent(id);
|
|
5225
|
+
}
|
|
5226
|
+
function setAgentFocus(id, scenarioId) {
|
|
5227
|
+
const db2 = getDatabase();
|
|
5228
|
+
const agent = getAgent(id);
|
|
5229
|
+
if (!agent)
|
|
5230
|
+
return null;
|
|
5231
|
+
const metadata = { ...agent.metadata ?? {}, focus: scenarioId };
|
|
5232
|
+
db2.query("UPDATE agents SET metadata = ?, last_seen_at = ? WHERE id = ?").run(JSON.stringify(metadata), now(), id);
|
|
5233
|
+
return getAgent(id);
|
|
5234
|
+
}
|
|
5196
5235
|
|
|
5197
5236
|
// src/lib/browser.ts
|
|
5198
5237
|
import { chromium as chromium2 } from "playwright";
|
|
@@ -6310,6 +6349,199 @@ async function pushFailedRunToLogs(run, failedResults, scenarios) {
|
|
|
6310
6349
|
} catch {}
|
|
6311
6350
|
}
|
|
6312
6351
|
|
|
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
|
+
// src/lib/failure-pipeline.ts
|
|
6455
|
+
async function createFailureTasks(run, failedResults, scenarios) {
|
|
6456
|
+
if (failedResults.length === 0)
|
|
6457
|
+
return { created: 0, skipped: 0 };
|
|
6458
|
+
const projectId = process.env["TESTERS_TODOS_PROJECT_ID"];
|
|
6459
|
+
if (!projectId)
|
|
6460
|
+
return { created: 0, skipped: 0 };
|
|
6461
|
+
let db2 = null;
|
|
6462
|
+
try {
|
|
6463
|
+
db2 = connectToTodos();
|
|
6464
|
+
} catch {
|
|
6465
|
+
return { created: 0, skipped: 0 };
|
|
6466
|
+
}
|
|
6467
|
+
const scenarioMap = new Map(scenarios.map((s) => [s.id, s]));
|
|
6468
|
+
let created = 0;
|
|
6469
|
+
let skipped = 0;
|
|
6470
|
+
try {
|
|
6471
|
+
for (const result of failedResults) {
|
|
6472
|
+
const scenario = scenarioMap.get(result.scenarioId);
|
|
6473
|
+
const title = `BUG: [testers] ${scenario?.name ?? result.scenarioId} failed`;
|
|
6474
|
+
const existing = db2.query("SELECT id FROM tasks WHERE title = ? AND status NOT IN ('completed', 'cancelled') LIMIT 1").get(title);
|
|
6475
|
+
if (existing) {
|
|
6476
|
+
skipped++;
|
|
6477
|
+
continue;
|
|
6478
|
+
}
|
|
6479
|
+
const id = crypto.randomUUID();
|
|
6480
|
+
const now2 = new Date().toISOString();
|
|
6481
|
+
const description = [
|
|
6482
|
+
`Test failure detected by open-testers.`,
|
|
6483
|
+
``,
|
|
6484
|
+
`**Run:** ${run.id}`,
|
|
6485
|
+
`**URL:** ${run.url}`,
|
|
6486
|
+
`**Scenario:** ${scenario?.name ?? result.scenarioId}`,
|
|
6487
|
+
`**Status:** ${result.status}`,
|
|
6488
|
+
result.error ? `**Error:** ${result.error}` : null,
|
|
6489
|
+
result.reasoning ? `**Reasoning:** ${result.reasoning.slice(0, 500)}` : null,
|
|
6490
|
+
`**Duration:** ${result.durationMs ? `${(result.durationMs / 1000).toFixed(1)}s` : "N/A"}`,
|
|
6491
|
+
`**Tokens:** ${result.tokensUsed ?? 0}`
|
|
6492
|
+
].filter(Boolean).join(`
|
|
6493
|
+
`);
|
|
6494
|
+
try {
|
|
6495
|
+
db2.query(`
|
|
6496
|
+
INSERT INTO tasks (id, short_id, title, description, status, priority, tags, project_id, version, created_at, updated_at)
|
|
6497
|
+
VALUES (?, ?, ?, ?, 'pending', 'high', ?, ?, 1, ?, ?)
|
|
6498
|
+
`).run(id, `BUG-${id.slice(0, 6)}`, title, description, JSON.stringify(["bug", "testers", "auto-created"]), projectId, now2, now2);
|
|
6499
|
+
created++;
|
|
6500
|
+
} catch {
|
|
6501
|
+
skipped++;
|
|
6502
|
+
}
|
|
6503
|
+
}
|
|
6504
|
+
} finally {
|
|
6505
|
+
db2.close();
|
|
6506
|
+
}
|
|
6507
|
+
return { created, skipped };
|
|
6508
|
+
}
|
|
6509
|
+
async function notifyFailureToConversations(run, failedResults, scenarios) {
|
|
6510
|
+
const baseUrl = process.env["TESTERS_CONVERSATIONS_URL"];
|
|
6511
|
+
const space = process.env["TESTERS_CONVERSATIONS_SPACE"];
|
|
6512
|
+
if (!baseUrl || !space)
|
|
6513
|
+
return;
|
|
6514
|
+
const scenarioMap = new Map(scenarios.map((s) => [s.id, s]));
|
|
6515
|
+
const total = run.total;
|
|
6516
|
+
const failedCount = failedResults.length;
|
|
6517
|
+
const passedCount = run.passed;
|
|
6518
|
+
const failureLines = failedResults.slice(0, 5).map((r) => {
|
|
6519
|
+
const name = scenarioMap.get(r.scenarioId)?.name ?? r.scenarioId;
|
|
6520
|
+
const err = r.error ? ` \u2014 ${r.error.slice(0, 120)}` : "";
|
|
6521
|
+
return ` \u274C ${name}${err}`;
|
|
6522
|
+
});
|
|
6523
|
+
const extra = failedResults.length > 5 ? ` \u2026 and ${failedResults.length - 5} more` : "";
|
|
6524
|
+
const message = [
|
|
6525
|
+
`\uD83D\uDEA8 **Testers run failed** \u2014 ${failedCount}/${total} scenarios failed`,
|
|
6526
|
+
``,
|
|
6527
|
+
`**URL:** ${run.url}`,
|
|
6528
|
+
`**Run ID:** \`${run.id}\``,
|
|
6529
|
+
`**Pass rate:** ${passedCount}/${total}`,
|
|
6530
|
+
``,
|
|
6531
|
+
`**Failures:**`,
|
|
6532
|
+
...failureLines,
|
|
6533
|
+
extra
|
|
6534
|
+
].filter((l) => l !== "").join(`
|
|
6535
|
+
`);
|
|
6536
|
+
try {
|
|
6537
|
+
await fetch(`${baseUrl.replace(/\/$/, "")}/api/spaces/${encodeURIComponent(space)}/messages`, {
|
|
6538
|
+
method: "POST",
|
|
6539
|
+
headers: { "Content-Type": "application/json" },
|
|
6540
|
+
body: JSON.stringify({ content: message, from: "testers" })
|
|
6541
|
+
});
|
|
6542
|
+
} catch {}
|
|
6543
|
+
}
|
|
6544
|
+
|
|
6313
6545
|
// src/lib/runner.ts
|
|
6314
6546
|
var eventHandler = null;
|
|
6315
6547
|
function emit(event) {
|
|
@@ -6543,6 +6775,8 @@ async function runBatch(scenarios, options) {
|
|
|
6543
6775
|
if (finalRun.status === "failed") {
|
|
6544
6776
|
const failedResults = results.filter((r) => r.status === "failed" || r.status === "error");
|
|
6545
6777
|
pushFailedRunToLogs(finalRun, failedResults, scenarios).catch(() => {});
|
|
6778
|
+
createFailureTasks(finalRun, failedResults, scenarios).catch(() => {});
|
|
6779
|
+
notifyFailureToConversations(finalRun, failedResults, scenarios).catch(() => {});
|
|
6546
6780
|
}
|
|
6547
6781
|
return { run: finalRun, results };
|
|
6548
6782
|
}
|
|
@@ -6656,106 +6890,54 @@ function estimateCost(model, tokens) {
|
|
|
6656
6890
|
return tokens / 1e6 * costPer1M * 100;
|
|
6657
6891
|
}
|
|
6658
6892
|
|
|
6659
|
-
// src/lib/
|
|
6660
|
-
|
|
6661
|
-
|
|
6662
|
-
|
|
6663
|
-
|
|
6664
|
-
|
|
6665
|
-
|
|
6666
|
-
|
|
6667
|
-
|
|
6668
|
-
|
|
6669
|
-
|
|
6670
|
-
}
|
|
6671
|
-
|
|
6672
|
-
const
|
|
6673
|
-
|
|
6674
|
-
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
|
|
6680
|
-
|
|
6681
|
-
const db2 = connectToTodos();
|
|
6682
|
-
try {
|
|
6683
|
-
let query = "SELECT id, short_id, title, description, status, priority, tags, project_id FROM tasks WHERE 1=1";
|
|
6684
|
-
const params = [];
|
|
6685
|
-
if (options.status) {
|
|
6686
|
-
query += " AND status = ?";
|
|
6687
|
-
params.push(options.status);
|
|
6688
|
-
} else {
|
|
6689
|
-
query += " AND status IN ('pending', 'in_progress')";
|
|
6690
|
-
}
|
|
6691
|
-
if (options.priority) {
|
|
6692
|
-
query += " AND priority = ?";
|
|
6693
|
-
params.push(options.priority);
|
|
6694
|
-
}
|
|
6695
|
-
if (options.projectName) {
|
|
6696
|
-
const project = db2.query("SELECT id FROM projects WHERE name = ?").get(options.projectName);
|
|
6697
|
-
if (project) {
|
|
6698
|
-
query += " AND project_id = ?";
|
|
6699
|
-
params.push(project.id);
|
|
6893
|
+
// src/lib/affected.ts
|
|
6894
|
+
function globToRegex(glob) {
|
|
6895
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\x00DS\x00").replace(/\*/g, "[^/]*").replace(/\x00DS\x00/g, ".*");
|
|
6896
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
6897
|
+
}
|
|
6898
|
+
function matchFilesToScenarios(filePaths, scenarios, mappings = []) {
|
|
6899
|
+
if (filePaths.length === 0)
|
|
6900
|
+
return scenarios;
|
|
6901
|
+
const compiledMappings = mappings.map((m) => ({
|
|
6902
|
+
regex: globToRegex(m.glob),
|
|
6903
|
+
tags: m.tags
|
|
6904
|
+
}));
|
|
6905
|
+
const normPaths = filePaths.map((p) => p.replace(/\\/g, "/").toLowerCase());
|
|
6906
|
+
const matchedIds = new Set;
|
|
6907
|
+
for (const scenario of scenarios) {
|
|
6908
|
+
let matched = false;
|
|
6909
|
+
if (!matched) {
|
|
6910
|
+
for (const { regex, tags } of compiledMappings) {
|
|
6911
|
+
if (normPaths.some((fp) => regex.test(fp)) && tags.some((tag) => scenario.tags.includes(tag))) {
|
|
6912
|
+
matched = true;
|
|
6913
|
+
break;
|
|
6914
|
+
}
|
|
6700
6915
|
}
|
|
6701
6916
|
}
|
|
6702
|
-
|
|
6703
|
-
|
|
6704
|
-
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
return options.tags.some((tag) => taskTags.includes(tag));
|
|
6708
|
-
});
|
|
6917
|
+
if (!matched && scenario.targetPath) {
|
|
6918
|
+
const segments = scenario.targetPath.replace(/^\//, "").split("/").filter((s) => s.length > 2);
|
|
6919
|
+
if (segments.some((seg) => normPaths.some((fp) => fp.includes(seg.toLowerCase())))) {
|
|
6920
|
+
matched = true;
|
|
6921
|
+
}
|
|
6709
6922
|
}
|
|
6710
|
-
|
|
6711
|
-
|
|
6712
|
-
|
|
6713
|
-
|
|
6714
|
-
|
|
6715
|
-
|
|
6716
|
-
const tags = JSON.parse(task.tags || "[]");
|
|
6717
|
-
const priority = ["low", "medium", "high", "critical"].includes(task.priority) ? task.priority : "medium";
|
|
6718
|
-
const steps = [];
|
|
6719
|
-
if (task.description) {
|
|
6720
|
-
const lines = task.description.split(`
|
|
6721
|
-
`);
|
|
6722
|
-
for (const line of lines) {
|
|
6723
|
-
const match = line.match(/^\s*\d+[\.\)]\s*(.+)/);
|
|
6724
|
-
if (match?.[1]) {
|
|
6725
|
-
steps.push(match[1].trim());
|
|
6923
|
+
if (!matched) {
|
|
6924
|
+
for (const tag of scenario.tags) {
|
|
6925
|
+
if (tag.length > 2 && normPaths.some((fp) => fp.includes(tag.toLowerCase()))) {
|
|
6926
|
+
matched = true;
|
|
6927
|
+
break;
|
|
6928
|
+
}
|
|
6726
6929
|
}
|
|
6727
6930
|
}
|
|
6728
|
-
|
|
6729
|
-
|
|
6730
|
-
|
|
6731
|
-
|
|
6732
|
-
|
|
6733
|
-
tags,
|
|
6734
|
-
priority,
|
|
6735
|
-
projectId,
|
|
6736
|
-
metadata: { todosTaskId: task.id, todosShortId: task.short_id }
|
|
6737
|
-
};
|
|
6738
|
-
}
|
|
6739
|
-
function importFromTodos(options = {}) {
|
|
6740
|
-
const tasks = pullTasks({
|
|
6741
|
-
projectName: options.projectName,
|
|
6742
|
-
tags: options.tags ?? ["qa", "test", "testing"],
|
|
6743
|
-
priority: options.priority
|
|
6744
|
-
});
|
|
6745
|
-
const existing = listScenarios({ projectId: options.projectId });
|
|
6746
|
-
const existingTodoIds = new Set(existing.filter((s) => s.metadata?.todosTaskId).map((s) => s.metadata.todosTaskId));
|
|
6747
|
-
let imported = 0;
|
|
6748
|
-
let skipped = 0;
|
|
6749
|
-
for (const task of tasks) {
|
|
6750
|
-
if (existingTodoIds.has(task.id)) {
|
|
6751
|
-
skipped++;
|
|
6752
|
-
continue;
|
|
6931
|
+
if (!matched) {
|
|
6932
|
+
const nameWords = scenario.name.toLowerCase().split(/[\s\-_/]+/).filter((w) => w.length > 3);
|
|
6933
|
+
if (nameWords.some((word) => normPaths.some((fp) => fp.includes(word)))) {
|
|
6934
|
+
matched = true;
|
|
6935
|
+
}
|
|
6753
6936
|
}
|
|
6754
|
-
|
|
6755
|
-
|
|
6756
|
-
imported++;
|
|
6937
|
+
if (matched)
|
|
6938
|
+
matchedIds.add(scenario.id);
|
|
6757
6939
|
}
|
|
6758
|
-
return
|
|
6940
|
+
return scenarios.filter((s) => matchedIds.has(s.id));
|
|
6759
6941
|
}
|
|
6760
6942
|
|
|
6761
6943
|
// src/db/schedules.ts
|
|
@@ -7103,6 +7285,63 @@ class Scheduler {
|
|
|
7103
7285
|
|
|
7104
7286
|
// src/mcp/index.ts
|
|
7105
7287
|
init_database();
|
|
7288
|
+
init_types();
|
|
7289
|
+
function json(data) {
|
|
7290
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
7291
|
+
}
|
|
7292
|
+
function notFoundErr(id, label = "Resource") {
|
|
7293
|
+
return Object.assign(new Error(`${label} not found: ${id}`), { name: "NotFoundError" });
|
|
7294
|
+
}
|
|
7295
|
+
function errorResponse(e, context) {
|
|
7296
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
7297
|
+
if (e instanceof VersionConflictError) {
|
|
7298
|
+
const payload2 = {
|
|
7299
|
+
error: {
|
|
7300
|
+
code: "VERSION_CONFLICT",
|
|
7301
|
+
message: err.message,
|
|
7302
|
+
retryable: true,
|
|
7303
|
+
hint: "Fetch the scenario with get_scenario to get the current version, then retry.",
|
|
7304
|
+
currentVersion: null
|
|
7305
|
+
}
|
|
7306
|
+
};
|
|
7307
|
+
if (context?.fetchCurrent) {
|
|
7308
|
+
try {
|
|
7309
|
+
const current = context.fetchCurrent();
|
|
7310
|
+
if (current && typeof current.version === "number") {
|
|
7311
|
+
payload2.error.currentVersion = current.version;
|
|
7312
|
+
payload2.error.hint = `Retry with version: ${current.version}`;
|
|
7313
|
+
}
|
|
7314
|
+
} catch {}
|
|
7315
|
+
}
|
|
7316
|
+
return { content: [{ type: "text", text: JSON.stringify(payload2, null, 2) }], isError: true };
|
|
7317
|
+
}
|
|
7318
|
+
const name = err.name ?? "Error";
|
|
7319
|
+
const msg = err.message ?? String(e);
|
|
7320
|
+
let code = "INTERNAL_ERROR";
|
|
7321
|
+
let retryable = false;
|
|
7322
|
+
let hint;
|
|
7323
|
+
if (name === "NotFoundError" || name === "ScenarioNotFoundError" || msg.toLowerCase().includes("not found")) {
|
|
7324
|
+
code = "NOT_FOUND";
|
|
7325
|
+
retryable = false;
|
|
7326
|
+
hint = "Check the ID or short ID and try again.";
|
|
7327
|
+
} else if (msg.toLowerCase().includes("timeout")) {
|
|
7328
|
+
code = "TIMEOUT";
|
|
7329
|
+
retryable = true;
|
|
7330
|
+
hint = "The operation timed out. Try again.";
|
|
7331
|
+
} else if (msg.toLowerCase().includes("unique") || msg.toLowerCase().includes("already exists")) {
|
|
7332
|
+
code = "CONFLICT";
|
|
7333
|
+
retryable = false;
|
|
7334
|
+
hint = "A resource with this identifier already exists.";
|
|
7335
|
+
}
|
|
7336
|
+
const payload = {
|
|
7337
|
+
error: { code, message: msg, retryable }
|
|
7338
|
+
};
|
|
7339
|
+
if (hint)
|
|
7340
|
+
payload.error.hint = hint;
|
|
7341
|
+
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
7342
|
+
}
|
|
7343
|
+
var ID_DESC = "Accepts either the full UUID (e.g. 'abc123...') or the short ID (e.g. 'sc-1').";
|
|
7344
|
+
var MODEL_DESC = "Model to use. Values: 'quick' (claude-haiku-4-5, cheapest), 'thorough' (claude-sonnet-4-6, balanced), 'deep' (claude-opus-4-6, most capable). Default: 'quick'.";
|
|
7106
7345
|
var server = new McpServer({
|
|
7107
7346
|
name: "testers",
|
|
7108
7347
|
version: "0.0.1"
|
|
@@ -7113,47 +7352,27 @@ server.tool("create_scenario", "Create a new test scenario", {
|
|
|
7113
7352
|
steps: exports_external.array(exports_external.string()).optional().describe("Ordered test steps"),
|
|
7114
7353
|
tags: exports_external.array(exports_external.string()).optional().describe("Tags for filtering"),
|
|
7115
7354
|
priority: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("Scenario priority"),
|
|
7116
|
-
model: exports_external.string().optional().describe(
|
|
7355
|
+
model: exports_external.string().optional().describe(MODEL_DESC),
|
|
7117
7356
|
targetPath: exports_external.string().optional().describe("URL path to navigate to"),
|
|
7118
7357
|
requiresAuth: exports_external.boolean().optional().describe("Whether scenario requires authentication")
|
|
7119
7358
|
}, async ({ name, description, steps, tags, priority, model, targetPath, requiresAuth }) => {
|
|
7120
7359
|
try {
|
|
7121
7360
|
const scenario = createScenario({ name, description, steps, tags, priority, model, targetPath, requiresAuth });
|
|
7122
|
-
return
|
|
7361
|
+
return json(scenario);
|
|
7123
7362
|
} catch (error) {
|
|
7124
|
-
|
|
7125
|
-
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
7363
|
+
return errorResponse(error);
|
|
7126
7364
|
}
|
|
7127
7365
|
});
|
|
7128
|
-
server.tool("get_scenario",
|
|
7129
|
-
id: exports_external.string().describe(
|
|
7366
|
+
server.tool("get_scenario", `Get a scenario by ID or short ID. ${ID_DESC}`, {
|
|
7367
|
+
id: exports_external.string().describe(`Scenario ID or short ID. ${ID_DESC}`)
|
|
7130
7368
|
}, async ({ id }) => {
|
|
7131
7369
|
try {
|
|
7132
7370
|
const scenario = getScenario(id) ?? getScenarioByShortId(id);
|
|
7133
|
-
if (!scenario)
|
|
7134
|
-
return
|
|
7135
|
-
|
|
7136
|
-
const text = [
|
|
7137
|
-
`Scenario: ${scenario.name} (${scenario.shortId})`,
|
|
7138
|
-
`ID: ${scenario.id}`,
|
|
7139
|
-
`Description: ${scenario.description}`,
|
|
7140
|
-
`Priority: ${scenario.priority}`,
|
|
7141
|
-
`Tags: ${scenario.tags.join(", ") || "none"}`,
|
|
7142
|
-
`Steps: ${scenario.steps.length > 0 ? `
|
|
7143
|
-
` + scenario.steps.map((s, i) => `${i + 1}. ${s}`).join(`
|
|
7144
|
-
`) : "none"}`,
|
|
7145
|
-
`Model: ${scenario.model ?? "default"}`,
|
|
7146
|
-
`Target path: ${scenario.targetPath ?? "none"}`,
|
|
7147
|
-
`Requires auth: ${scenario.requiresAuth}`,
|
|
7148
|
-
`Version: ${scenario.version}`,
|
|
7149
|
-
`Created: ${scenario.createdAt}`,
|
|
7150
|
-
`Updated: ${scenario.updatedAt}`
|
|
7151
|
-
].join(`
|
|
7152
|
-
`);
|
|
7153
|
-
return { content: [{ type: "text", text }] };
|
|
7371
|
+
if (!scenario)
|
|
7372
|
+
return errorResponse(notFoundErr(id, "Scenario"));
|
|
7373
|
+
return json(scenario);
|
|
7154
7374
|
} catch (error) {
|
|
7155
|
-
|
|
7156
|
-
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
7375
|
+
return errorResponse(error);
|
|
7157
7376
|
}
|
|
7158
7377
|
});
|
|
7159
7378
|
server.tool("list_scenarios", "List test scenarios with optional filters", {
|
|
@@ -7164,20 +7383,13 @@ server.tool("list_scenarios", "List test scenarios with optional filters", {
|
|
|
7164
7383
|
}, async ({ projectId, tags, priority, limit }) => {
|
|
7165
7384
|
try {
|
|
7166
7385
|
const scenarios = listScenarios({ projectId, tags, priority, limit });
|
|
7167
|
-
|
|
7168
|
-
return { content: [{ type: "text", text: "No scenarios found." }] };
|
|
7169
|
-
}
|
|
7170
|
-
const lines = scenarios.map((s) => `[${s.shortId}] ${s.name} \u2014 ${s.priority} \u2014 tags: ${s.tags.join(", ") || "none"}`);
|
|
7171
|
-
return { content: [{ type: "text", text: `${scenarios.length} scenario(s):
|
|
7172
|
-
${lines.join(`
|
|
7173
|
-
`)}` }] };
|
|
7386
|
+
return json({ items: scenarios, total: scenarios.length });
|
|
7174
7387
|
} catch (error) {
|
|
7175
|
-
|
|
7176
|
-
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
7388
|
+
return errorResponse(error);
|
|
7177
7389
|
}
|
|
7178
7390
|
});
|
|
7179
|
-
server.tool("update_scenario",
|
|
7180
|
-
id: exports_external.string().describe(
|
|
7391
|
+
server.tool("update_scenario", `Update an existing scenario (requires version for optimistic locking). ${ID_DESC}`, {
|
|
7392
|
+
id: exports_external.string().describe(`Scenario ID or short ID. ${ID_DESC}`),
|
|
7181
7393
|
name: exports_external.string().optional().describe("New name"),
|
|
7182
7394
|
description: exports_external.string().optional().describe("New description"),
|
|
7183
7395
|
steps: exports_external.array(exports_external.string()).optional().describe("New steps"),
|
|
@@ -7188,24 +7400,23 @@ server.tool("update_scenario", "Update an existing scenario (requires version fo
|
|
|
7188
7400
|
}, async ({ id, name, description, steps, tags, priority, model, version }) => {
|
|
7189
7401
|
try {
|
|
7190
7402
|
const scenario = updateScenario(id, { name, description, steps, tags, priority, model }, version);
|
|
7191
|
-
return
|
|
7403
|
+
return json(scenario);
|
|
7192
7404
|
} catch (error) {
|
|
7193
|
-
|
|
7194
|
-
|
|
7405
|
+
return errorResponse(error, {
|
|
7406
|
+
fetchCurrent: () => getScenario(id) ?? getScenarioByShortId(id)
|
|
7407
|
+
});
|
|
7195
7408
|
}
|
|
7196
7409
|
});
|
|
7197
|
-
server.tool("delete_scenario",
|
|
7198
|
-
id: exports_external.string().describe(
|
|
7410
|
+
server.tool("delete_scenario", `Delete a scenario by ID. ${ID_DESC}`, {
|
|
7411
|
+
id: exports_external.string().describe(`Scenario ID or short ID. ${ID_DESC}`)
|
|
7199
7412
|
}, async ({ id }) => {
|
|
7200
7413
|
try {
|
|
7201
7414
|
const deleted = deleteScenario(id);
|
|
7202
|
-
if (!deleted)
|
|
7203
|
-
return
|
|
7204
|
-
}
|
|
7205
|
-
return { content: [{ type: "text", text: `Deleted scenario: ${id}` }] };
|
|
7415
|
+
if (!deleted)
|
|
7416
|
+
return errorResponse(notFoundErr(id, "Scenario"));
|
|
7417
|
+
return json({ deleted: true, id });
|
|
7206
7418
|
} catch (error) {
|
|
7207
|
-
|
|
7208
|
-
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
7419
|
+
return errorResponse(error);
|
|
7209
7420
|
}
|
|
7210
7421
|
});
|
|
7211
7422
|
server.tool("run_scenarios", "Run test scenarios against a URL", {
|
|
@@ -7213,50 +7424,27 @@ server.tool("run_scenarios", "Run test scenarios against a URL", {
|
|
|
7213
7424
|
tags: exports_external.array(exports_external.string()).optional().describe("Filter scenarios by tags"),
|
|
7214
7425
|
scenarioIds: exports_external.array(exports_external.string()).optional().describe("Run specific scenario IDs"),
|
|
7215
7426
|
priority: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("Filter by priority"),
|
|
7216
|
-
model: exports_external.string().optional().describe(
|
|
7427
|
+
model: exports_external.string().optional().describe(MODEL_DESC),
|
|
7217
7428
|
headed: exports_external.boolean().optional().describe("Run browser in headed mode"),
|
|
7218
7429
|
parallel: exports_external.number().optional().describe("Number of parallel workers")
|
|
7219
7430
|
}, async ({ url, tags, scenarioIds, priority, model, headed, parallel }) => {
|
|
7220
7431
|
try {
|
|
7221
7432
|
const { runId, scenarioCount } = startRunAsync({ url, tags, scenarioIds, priority, model, headed, parallel });
|
|
7222
|
-
|
|
7223
|
-
`Run started: ${runId}`,
|
|
7224
|
-
`Scenarios: ${scenarioCount}`,
|
|
7225
|
-
`URL: ${url}`,
|
|
7226
|
-
`Status: running (async)`,
|
|
7227
|
-
``,
|
|
7228
|
-
`Poll with get_run to check progress.`
|
|
7229
|
-
].join(`
|
|
7230
|
-
`);
|
|
7231
|
-
return { content: [{ type: "text", text }] };
|
|
7433
|
+
return json({ runId, scenarioCount, url, status: "running", message: "Poll with get_run to check progress." });
|
|
7232
7434
|
} catch (error) {
|
|
7233
|
-
|
|
7234
|
-
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
7435
|
+
return errorResponse(error);
|
|
7235
7436
|
}
|
|
7236
7437
|
});
|
|
7237
|
-
server.tool("get_run",
|
|
7238
|
-
id: exports_external.string().describe(
|
|
7438
|
+
server.tool("get_run", `Get details of a test run. ${ID_DESC}`, {
|
|
7439
|
+
id: exports_external.string().describe(`Run ID. ${ID_DESC}`)
|
|
7239
7440
|
}, async ({ id }) => {
|
|
7240
7441
|
try {
|
|
7241
7442
|
const run = getRun(id);
|
|
7242
|
-
if (!run)
|
|
7243
|
-
return
|
|
7244
|
-
|
|
7245
|
-
const text = [
|
|
7246
|
-
`Run: ${run.id}`,
|
|
7247
|
-
`Status: ${run.status}`,
|
|
7248
|
-
`URL: ${run.url}`,
|
|
7249
|
-
`Model: ${run.model}`,
|
|
7250
|
-
`Total: ${run.total} | Passed: ${run.passed} | Failed: ${run.failed}`,
|
|
7251
|
-
`Parallel: ${run.parallel} | Headed: ${run.headed}`,
|
|
7252
|
-
`Started: ${run.startedAt}`,
|
|
7253
|
-
run.finishedAt ? `Finished: ${run.finishedAt}` : "Finished: in progress"
|
|
7254
|
-
].join(`
|
|
7255
|
-
`);
|
|
7256
|
-
return { content: [{ type: "text", text }] };
|
|
7443
|
+
if (!run)
|
|
7444
|
+
return errorResponse(notFoundErr(id, "Run"));
|
|
7445
|
+
return json(run);
|
|
7257
7446
|
} catch (error) {
|
|
7258
|
-
|
|
7259
|
-
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
7447
|
+
return errorResponse(error);
|
|
7260
7448
|
}
|
|
7261
7449
|
});
|
|
7262
7450
|
server.tool("list_runs", "List test runs with optional filters", {
|
|
@@ -7265,50 +7453,61 @@ server.tool("list_runs", "List test runs with optional filters", {
|
|
|
7265
7453
|
}, async ({ status, limit }) => {
|
|
7266
7454
|
try {
|
|
7267
7455
|
const runs = listRuns({ status, limit });
|
|
7268
|
-
|
|
7269
|
-
return { content: [{ type: "text", text: "No runs found." }] };
|
|
7270
|
-
}
|
|
7271
|
-
const lines = runs.map((r) => `[${r.id.slice(0, 8)}] ${r.status} \u2014 ${r.total} scenarios \u2014 ${r.passed} passed, ${r.failed} failed \u2014 ${r.startedAt}`);
|
|
7272
|
-
return { content: [{ type: "text", text: `${runs.length} run(s):
|
|
7273
|
-
${lines.join(`
|
|
7274
|
-
`)}` }] };
|
|
7456
|
+
return json({ items: runs, total: runs.length });
|
|
7275
7457
|
} catch (error) {
|
|
7276
|
-
|
|
7277
|
-
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
7458
|
+
return errorResponse(error);
|
|
7278
7459
|
}
|
|
7279
7460
|
});
|
|
7280
|
-
server.tool("get_results",
|
|
7281
|
-
runId: exports_external.string().describe(
|
|
7282
|
-
|
|
7461
|
+
server.tool("get_results", `Get test results for a run. Optionally filter by status and/or scenarioId. Each result includes AI reasoning when available. ${ID_DESC}`, {
|
|
7462
|
+
runId: exports_external.string().describe(`Run ID. ${ID_DESC}`),
|
|
7463
|
+
status: exports_external.enum(["passed", "failed", "error", "running"]).optional().describe("Filter by result status"),
|
|
7464
|
+
scenarioId: exports_external.string().optional().describe("Filter by scenario ID (full or partial)")
|
|
7465
|
+
}, async ({ runId, status, scenarioId }) => {
|
|
7283
7466
|
try {
|
|
7284
|
-
|
|
7285
|
-
if (
|
|
7286
|
-
|
|
7287
|
-
}
|
|
7288
|
-
|
|
7289
|
-
|
|
7290
|
-
|
|
7291
|
-
|
|
7467
|
+
let results = listResults(runId);
|
|
7468
|
+
if (status) {
|
|
7469
|
+
results = results.filter((r) => r.status === status);
|
|
7470
|
+
}
|
|
7471
|
+
if (scenarioId) {
|
|
7472
|
+
results = results.filter((r) => r.scenarioId === scenarioId || r.scenarioId.startsWith(scenarioId));
|
|
7473
|
+
}
|
|
7474
|
+
return json({ items: results, total: results.length });
|
|
7292
7475
|
} catch (error) {
|
|
7293
|
-
|
|
7294
|
-
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
7476
|
+
return errorResponse(error);
|
|
7295
7477
|
}
|
|
7296
7478
|
});
|
|
7297
|
-
|
|
7298
|
-
|
|
7479
|
+
var MAX_BASE64_SCREENSHOTS = 5;
|
|
7480
|
+
server.tool("get_screenshots", `Get screenshots for a test result. Returns base64-encoded image data for up to 5 screenshots. If more than 5 exist, only metadata is returned with truncated:true. ${ID_DESC}`, {
|
|
7481
|
+
resultId: exports_external.string().describe(`Result ID. ${ID_DESC}`)
|
|
7299
7482
|
}, async ({ resultId }) => {
|
|
7300
7483
|
try {
|
|
7301
7484
|
const screenshots = listScreenshots(resultId);
|
|
7302
7485
|
if (screenshots.length === 0) {
|
|
7303
|
-
return {
|
|
7486
|
+
return json({ items: [], total: 0 });
|
|
7304
7487
|
}
|
|
7305
|
-
const
|
|
7306
|
-
|
|
7307
|
-
|
|
7308
|
-
|
|
7488
|
+
const truncated = screenshots.length > MAX_BASE64_SCREENSHOTS;
|
|
7489
|
+
const withBase64 = await Promise.all(screenshots.map(async (s, index) => {
|
|
7490
|
+
let base64 = null;
|
|
7491
|
+
let note;
|
|
7492
|
+
if (!truncated || index < MAX_BASE64_SCREENSHOTS) {
|
|
7493
|
+
try {
|
|
7494
|
+
const file = Bun.file(s.filePath);
|
|
7495
|
+
const exists = await file.exists();
|
|
7496
|
+
if (exists) {
|
|
7497
|
+
const buffer = await file.arrayBuffer();
|
|
7498
|
+
base64 = `data:image/png;base64,${Buffer.from(buffer).toString("base64")}`;
|
|
7499
|
+
} else {
|
|
7500
|
+
note = `File not found on disk: ${s.filePath}`;
|
|
7501
|
+
}
|
|
7502
|
+
} catch {
|
|
7503
|
+
note = `Failed to read file: ${s.filePath}`;
|
|
7504
|
+
}
|
|
7505
|
+
}
|
|
7506
|
+
return { id: s.id, stepNumber: s.stepNumber, description: s.description, pageUrl: s.pageUrl, filePath: s.filePath, base64, note, width: s.width, height: s.height, createdAt: s.timestamp };
|
|
7507
|
+
}));
|
|
7508
|
+
return json({ truncated, total: screenshots.length, items: withBase64 });
|
|
7309
7509
|
} catch (error) {
|
|
7310
|
-
|
|
7311
|
-
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
7510
|
+
return errorResponse(error);
|
|
7312
7511
|
}
|
|
7313
7512
|
});
|
|
7314
7513
|
server.tool("register_project", "Register or ensure a project exists", {
|
|
@@ -7318,25 +7517,17 @@ server.tool("register_project", "Register or ensure a project exists", {
|
|
|
7318
7517
|
}, async ({ name, path, description }) => {
|
|
7319
7518
|
try {
|
|
7320
7519
|
const project = description ? createProject({ name, path, description }) : ensureProject(name, path);
|
|
7321
|
-
return
|
|
7520
|
+
return json(project);
|
|
7322
7521
|
} catch (error) {
|
|
7323
|
-
|
|
7324
|
-
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
7522
|
+
return errorResponse(error);
|
|
7325
7523
|
}
|
|
7326
7524
|
});
|
|
7327
7525
|
server.tool("list_projects", "List all registered projects", {}, async () => {
|
|
7328
7526
|
try {
|
|
7329
7527
|
const projects = listProjects();
|
|
7330
|
-
|
|
7331
|
-
return { content: [{ type: "text", text: "No projects registered." }] };
|
|
7332
|
-
}
|
|
7333
|
-
const lines = projects.map((p) => `[${p.id.slice(0, 8)}] ${p.name}${p.path ? ` \u2014 ${p.path}` : ""}${p.description ? ` \u2014 ${p.description}` : ""}`);
|
|
7334
|
-
return { content: [{ type: "text", text: `${projects.length} project(s):
|
|
7335
|
-
${lines.join(`
|
|
7336
|
-
`)}` }] };
|
|
7528
|
+
return json({ items: projects, total: projects.length });
|
|
7337
7529
|
} catch (error) {
|
|
7338
|
-
|
|
7339
|
-
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
7530
|
+
return errorResponse(error);
|
|
7340
7531
|
}
|
|
7341
7532
|
});
|
|
7342
7533
|
server.tool("register_agent", "Register an agent (idempotent \u2014 returns existing if name matches)", {
|
|
@@ -7346,25 +7537,17 @@ server.tool("register_agent", "Register an agent (idempotent \u2014 returns exis
|
|
|
7346
7537
|
}, async ({ name, description, role }) => {
|
|
7347
7538
|
try {
|
|
7348
7539
|
const agent = registerAgent({ name, description, role });
|
|
7349
|
-
return
|
|
7540
|
+
return json(agent);
|
|
7350
7541
|
} catch (error) {
|
|
7351
|
-
|
|
7352
|
-
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
7542
|
+
return errorResponse(error);
|
|
7353
7543
|
}
|
|
7354
7544
|
});
|
|
7355
7545
|
server.tool("list_agents", "List all registered agents", {}, async () => {
|
|
7356
7546
|
try {
|
|
7357
7547
|
const agents = listAgents();
|
|
7358
|
-
|
|
7359
|
-
return { content: [{ type: "text", text: "No agents registered." }] };
|
|
7360
|
-
}
|
|
7361
|
-
const lines = agents.map((a) => `[${a.id.slice(0, 8)}] ${a.name}${a.role ? ` (${a.role})` : ""} \u2014 last seen: ${a.lastSeenAt}`);
|
|
7362
|
-
return { content: [{ type: "text", text: `${agents.length} agent(s):
|
|
7363
|
-
${lines.join(`
|
|
7364
|
-
`)}` }] };
|
|
7548
|
+
return json({ items: agents, total: agents.length });
|
|
7365
7549
|
} catch (error) {
|
|
7366
|
-
|
|
7367
|
-
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
7550
|
+
return errorResponse(error);
|
|
7368
7551
|
}
|
|
7369
7552
|
});
|
|
7370
7553
|
server.tool("import_from_todos", "Import test scenarios from the todos database", {
|
|
@@ -7374,10 +7557,9 @@ server.tool("import_from_todos", "Import test scenarios from the todos database"
|
|
|
7374
7557
|
}, async ({ projectName, tags, projectId }) => {
|
|
7375
7558
|
try {
|
|
7376
7559
|
const result = importFromTodos({ projectName, tags, projectId });
|
|
7377
|
-
return
|
|
7560
|
+
return json(result);
|
|
7378
7561
|
} catch (error) {
|
|
7379
|
-
|
|
7380
|
-
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
7562
|
+
return errorResponse(error);
|
|
7381
7563
|
}
|
|
7382
7564
|
});
|
|
7383
7565
|
server.tool("get_status", "Get system status: DB path, API key, scenario and run counts", {}, async () => {
|
|
@@ -7387,18 +7569,27 @@ server.tool("get_status", "Get system status: DB path, API key, scenario and run
|
|
|
7387
7569
|
const scenarioCount = db2.query("SELECT COUNT(*) as count FROM scenarios").get().count;
|
|
7388
7570
|
const runCount = db2.query("SELECT COUNT(*) as count FROM runs").get().count;
|
|
7389
7571
|
const hasApiKey = !!(config.anthropicApiKey || process.env["ANTHROPIC_API_KEY"]);
|
|
7390
|
-
|
|
7391
|
-
|
|
7392
|
-
|
|
7393
|
-
|
|
7394
|
-
|
|
7395
|
-
|
|
7396
|
-
|
|
7397
|
-
|
|
7398
|
-
return
|
|
7572
|
+
return json({
|
|
7573
|
+
dbPath: process.env["TESTERS_DB_PATH"] || "~/.testers/testers.db",
|
|
7574
|
+
apiKey: hasApiKey ? "configured" : "not set",
|
|
7575
|
+
scenarioCount,
|
|
7576
|
+
runCount,
|
|
7577
|
+
defaultModel: config.defaultModel
|
|
7578
|
+
});
|
|
7579
|
+
} catch (error) {
|
|
7580
|
+
return errorResponse(error);
|
|
7581
|
+
}
|
|
7582
|
+
});
|
|
7583
|
+
server.tool("scenario_exists", "Check whether a scenario with the given name exists (exact match). Returns { exists, scenario }.", {
|
|
7584
|
+
name: exports_external.string().describe("Scenario name to look up (exact match)"),
|
|
7585
|
+
projectId: exports_external.string().optional().describe("Restrict search to a specific project ID")
|
|
7586
|
+
}, async ({ name, projectId }) => {
|
|
7587
|
+
try {
|
|
7588
|
+
const scenarios = listScenarios({ projectId });
|
|
7589
|
+
const scenario = scenarios.find((s) => s.name === name) ?? null;
|
|
7590
|
+
return json({ exists: scenario !== null, scenario });
|
|
7399
7591
|
} catch (error) {
|
|
7400
|
-
|
|
7401
|
-
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
7592
|
+
return errorResponse(error);
|
|
7402
7593
|
}
|
|
7403
7594
|
});
|
|
7404
7595
|
server.tool("create_schedule", {
|
|
@@ -7407,7 +7598,7 @@ server.tool("create_schedule", {
|
|
|
7407
7598
|
url: exports_external.string().describe("Target URL to test"),
|
|
7408
7599
|
tags: exports_external.array(exports_external.string()).optional().describe("Filter scenarios by tags"),
|
|
7409
7600
|
priority: exports_external.string().optional().describe("Filter scenarios by priority"),
|
|
7410
|
-
model: exports_external.string().optional().describe(
|
|
7601
|
+
model: exports_external.string().optional().describe(MODEL_DESC),
|
|
7411
7602
|
headed: exports_external.boolean().optional().describe("Run headed"),
|
|
7412
7603
|
parallel: exports_external.number().optional().describe("Parallel count"),
|
|
7413
7604
|
projectId: exports_external.string().optional().describe("Project ID")
|
|
@@ -7424,9 +7615,9 @@ server.tool("create_schedule", {
|
|
|
7424
7615
|
projectId: params.projectId
|
|
7425
7616
|
});
|
|
7426
7617
|
const nextRun = getNextRunTime(schedule.cronExpression);
|
|
7427
|
-
return {
|
|
7618
|
+
return json({ ...schedule, nextRunAt: nextRun.toISOString() });
|
|
7428
7619
|
} catch (e) {
|
|
7429
|
-
return
|
|
7620
|
+
return errorResponse(e);
|
|
7430
7621
|
}
|
|
7431
7622
|
});
|
|
7432
7623
|
server.tool("list_schedules", {
|
|
@@ -7434,35 +7625,239 @@ server.tool("list_schedules", {
|
|
|
7434
7625
|
enabled: exports_external.boolean().optional(),
|
|
7435
7626
|
limit: exports_external.number().optional()
|
|
7436
7627
|
}, async (params) => {
|
|
7437
|
-
|
|
7438
|
-
|
|
7439
|
-
return {
|
|
7440
|
-
|
|
7441
|
-
|
|
7442
|
-
|
|
7628
|
+
try {
|
|
7629
|
+
const schedules = listSchedules({ projectId: params.projectId, enabled: params.enabled, limit: params.limit });
|
|
7630
|
+
return json({ items: schedules, total: schedules.length });
|
|
7631
|
+
} catch (e) {
|
|
7632
|
+
return errorResponse(e);
|
|
7633
|
+
}
|
|
7443
7634
|
});
|
|
7444
7635
|
server.tool("enable_schedule", { id: exports_external.string().describe("Schedule ID") }, async (params) => {
|
|
7445
7636
|
try {
|
|
7446
7637
|
const schedule = updateSchedule(params.id, { enabled: true });
|
|
7447
|
-
return
|
|
7638
|
+
return json(schedule);
|
|
7448
7639
|
} catch (e) {
|
|
7449
|
-
return
|
|
7640
|
+
return errorResponse(e);
|
|
7450
7641
|
}
|
|
7451
7642
|
});
|
|
7452
7643
|
server.tool("disable_schedule", { id: exports_external.string().describe("Schedule ID") }, async (params) => {
|
|
7453
7644
|
try {
|
|
7454
7645
|
const schedule = updateSchedule(params.id, { enabled: false });
|
|
7455
|
-
return
|
|
7646
|
+
return json(schedule);
|
|
7456
7647
|
} catch (e) {
|
|
7457
|
-
return
|
|
7648
|
+
return errorResponse(e);
|
|
7458
7649
|
}
|
|
7459
7650
|
});
|
|
7460
7651
|
server.tool("delete_schedule", { id: exports_external.string().describe("Schedule ID") }, async (params) => {
|
|
7461
7652
|
try {
|
|
7462
7653
|
const deleted = deleteSchedule(params.id);
|
|
7463
|
-
|
|
7654
|
+
if (!deleted)
|
|
7655
|
+
return errorResponse(notFoundErr(params.id, "Schedule"));
|
|
7656
|
+
return json({ deleted: true, id: params.id });
|
|
7464
7657
|
} catch (e) {
|
|
7465
|
-
return
|
|
7658
|
+
return errorResponse(e);
|
|
7659
|
+
}
|
|
7660
|
+
});
|
|
7661
|
+
server.tool("wait_for_run", "Poll a run until it reaches a terminal status (passed, failed, error, or cancelled). Blocks until done or timeout.", {
|
|
7662
|
+
runId: exports_external.string().describe("Run ID to wait for"),
|
|
7663
|
+
timeoutMs: exports_external.number().optional().describe("Max wait time in ms (default 300000)"),
|
|
7664
|
+
pollIntervalMs: exports_external.number().optional().describe("Poll interval in ms (default 3000)")
|
|
7665
|
+
}, async ({ runId, timeoutMs = 300000, pollIntervalMs = 3000 }) => {
|
|
7666
|
+
try {
|
|
7667
|
+
const terminalStatuses = new Set(["passed", "failed", "error", "cancelled"]);
|
|
7668
|
+
const deadline = Date.now() + timeoutMs;
|
|
7669
|
+
while (Date.now() < deadline) {
|
|
7670
|
+
const run = getRun(runId);
|
|
7671
|
+
if (!run)
|
|
7672
|
+
return errorResponse(notFoundErr(runId, "Run"));
|
|
7673
|
+
if (terminalStatuses.has(run.status)) {
|
|
7674
|
+
const results = getResultsByRun(runId);
|
|
7675
|
+
const passed = results.filter((r) => r.status === "passed").length;
|
|
7676
|
+
const failed = results.filter((r) => r.status === "failed" || r.status === "error").length;
|
|
7677
|
+
return json({ ...run, passedCount: passed, failedCount: failed });
|
|
7678
|
+
}
|
|
7679
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
7680
|
+
}
|
|
7681
|
+
return errorResponse(Object.assign(new Error(`Run ${runId} did not complete within ${timeoutMs}ms`), { name: "TimeoutError" }));
|
|
7682
|
+
} catch (error) {
|
|
7683
|
+
return errorResponse(error);
|
|
7684
|
+
}
|
|
7685
|
+
});
|
|
7686
|
+
server.tool("get_run_stats", "Get aggregate statistics for a run: pass rate, cost, token usage, duration", {
|
|
7687
|
+
runId: exports_external.string().describe("Run ID")
|
|
7688
|
+
}, async ({ runId }) => {
|
|
7689
|
+
try {
|
|
7690
|
+
const run = getRun(runId);
|
|
7691
|
+
if (!run)
|
|
7692
|
+
return errorResponse(notFoundErr(runId, "Run"));
|
|
7693
|
+
const results = getResultsByRun(runId);
|
|
7694
|
+
const total = results.length;
|
|
7695
|
+
const passed = results.filter((r) => r.status === "passed").length;
|
|
7696
|
+
const failed = results.filter((r) => r.status === "failed").length;
|
|
7697
|
+
const errors2 = results.filter((r) => r.status === "error").length;
|
|
7698
|
+
const passRate = total > 0 ? Math.round(passed / total * 100) : 0;
|
|
7699
|
+
const totalCostCents = results.reduce((sum, r) => sum + (r.costCents ?? 0), 0);
|
|
7700
|
+
const totalTokens = results.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
|
|
7701
|
+
const durations = results.filter((r) => r.durationMs != null && r.durationMs > 0).map((r) => r.durationMs);
|
|
7702
|
+
const avgDurationMs = durations.length > 0 ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) : 0;
|
|
7703
|
+
return json({ runId, status: run.status, total, passed, failed, errors: errors2, passRate, totalCostCents, totalTokens, avgDurationMs, startedAt: run.startedAt, completedAt: run.finishedAt ?? null });
|
|
7704
|
+
} catch (error) {
|
|
7705
|
+
return errorResponse(error);
|
|
7706
|
+
}
|
|
7707
|
+
});
|
|
7708
|
+
server.tool("get_run_costs", "Get cost breakdown for a run, with per-scenario detail", {
|
|
7709
|
+
runId: exports_external.string().describe("Run ID")
|
|
7710
|
+
}, async ({ runId }) => {
|
|
7711
|
+
try {
|
|
7712
|
+
const run = getRun(runId);
|
|
7713
|
+
if (!run)
|
|
7714
|
+
return errorResponse(notFoundErr(runId, "Run"));
|
|
7715
|
+
const results = getResultsByRun(runId);
|
|
7716
|
+
const totalCostCents = results.reduce((sum, r) => sum + (r.costCents ?? 0), 0);
|
|
7717
|
+
const totalTokens = results.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
|
|
7718
|
+
const byScenario = results.map((r) => {
|
|
7719
|
+
const scenario = getScenario(r.scenarioId);
|
|
7720
|
+
return { scenarioId: r.scenarioId, scenarioName: scenario?.name ?? r.scenarioId, costCents: r.costCents ?? 0, tokens: r.tokensUsed ?? 0, status: r.status };
|
|
7721
|
+
});
|
|
7722
|
+
return json({ runId, totalCostCents, totalTokens, byScenario });
|
|
7723
|
+
} catch (error) {
|
|
7724
|
+
return errorResponse(error);
|
|
7725
|
+
}
|
|
7726
|
+
});
|
|
7727
|
+
server.tool("batch_create_scenarios", "Create multiple scenarios in a single call. Returns created scenarios and any failures.", {
|
|
7728
|
+
scenarios: exports_external.array(exports_external.object({
|
|
7729
|
+
name: exports_external.string().describe("Scenario name"),
|
|
7730
|
+
url: exports_external.string().optional().describe("Target URL (stored as targetPath)"),
|
|
7731
|
+
description: exports_external.string().optional().describe("What this scenario tests"),
|
|
7732
|
+
steps: exports_external.array(exports_external.string()).optional().describe("Ordered test steps"),
|
|
7733
|
+
tags: exports_external.array(exports_external.string()).optional().describe("Tags for filtering"),
|
|
7734
|
+
priority: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("Scenario priority")
|
|
7735
|
+
})).describe("Array of scenarios to create")
|
|
7736
|
+
}, async ({ scenarios }) => {
|
|
7737
|
+
const created = [];
|
|
7738
|
+
const failed = [];
|
|
7739
|
+
for (let i = 0;i < scenarios.length; i++) {
|
|
7740
|
+
const input = scenarios[i];
|
|
7741
|
+
try {
|
|
7742
|
+
const scenario = createScenario({
|
|
7743
|
+
name: input.name,
|
|
7744
|
+
description: input.description ?? input.name,
|
|
7745
|
+
steps: input.steps,
|
|
7746
|
+
tags: input.tags,
|
|
7747
|
+
priority: input.priority,
|
|
7748
|
+
targetPath: input.url
|
|
7749
|
+
});
|
|
7750
|
+
created.push(scenario);
|
|
7751
|
+
} catch (error) {
|
|
7752
|
+
const e = error instanceof Error ? error : new Error(String(error));
|
|
7753
|
+
failed.push({ index: i, name: input.name, error: e.message });
|
|
7754
|
+
}
|
|
7755
|
+
}
|
|
7756
|
+
const lines = [
|
|
7757
|
+
`Created: ${created.length} scenario(s)`,
|
|
7758
|
+
...created.map((s) => ` [${s.shortId}] ${s.name}`)
|
|
7759
|
+
];
|
|
7760
|
+
return json({ created, failed });
|
|
7761
|
+
});
|
|
7762
|
+
server.tool("cancel_run", "Mark a run as cancelled in the database. In-flight browser processes may still complete but results will be ignored.", {
|
|
7763
|
+
runId: exports_external.string().describe("Run ID to cancel")
|
|
7764
|
+
}, async ({ runId }) => {
|
|
7765
|
+
try {
|
|
7766
|
+
const run = getRun(runId);
|
|
7767
|
+
if (!run)
|
|
7768
|
+
return errorResponse(notFoundErr(runId, "Run"));
|
|
7769
|
+
updateRun(runId, { status: "cancelled", finished_at: new Date().toISOString() });
|
|
7770
|
+
return json({ cancelled: true, runId });
|
|
7771
|
+
} catch (error) {
|
|
7772
|
+
return errorResponse(error);
|
|
7773
|
+
}
|
|
7774
|
+
});
|
|
7775
|
+
server.tool("get_tags", "List all unique tags across scenarios, optionally filtered by project", {
|
|
7776
|
+
projectId: exports_external.string().optional().describe("Filter by project ID")
|
|
7777
|
+
}, async ({ projectId }) => {
|
|
7778
|
+
try {
|
|
7779
|
+
const scenarios = listScenarios({ projectId });
|
|
7780
|
+
const tagSet = new Set;
|
|
7781
|
+
for (const s of scenarios) {
|
|
7782
|
+
for (const tag of s.tags) {
|
|
7783
|
+
tagSet.add(tag);
|
|
7784
|
+
}
|
|
7785
|
+
}
|
|
7786
|
+
const tags = Array.from(tagSet).sort();
|
|
7787
|
+
return json({ tags, total: tags.length });
|
|
7788
|
+
} catch (error) {
|
|
7789
|
+
return errorResponse(error);
|
|
7790
|
+
}
|
|
7791
|
+
});
|
|
7792
|
+
server.tool("get_stale_scenarios", "List scenarios that have not been run recently (or never run)", {
|
|
7793
|
+
days: exports_external.number().optional().describe("Scenarios not run in this many days are considered stale (default 7)"),
|
|
7794
|
+
projectId: exports_external.string().optional().describe("Filter by project ID")
|
|
7795
|
+
}, async ({ days = 7, projectId }) => {
|
|
7796
|
+
try {
|
|
7797
|
+
let scenarios = findStaleScenarios(days);
|
|
7798
|
+
if (projectId) {
|
|
7799
|
+
scenarios = scenarios.filter((s) => s.projectId === projectId);
|
|
7800
|
+
}
|
|
7801
|
+
return json({ items: scenarios, total: scenarios.length });
|
|
7802
|
+
} catch (error) {
|
|
7803
|
+
return errorResponse(error);
|
|
7804
|
+
}
|
|
7805
|
+
});
|
|
7806
|
+
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.", {
|
|
7807
|
+
url: exports_external.string().describe("Target URL to test against"),
|
|
7808
|
+
filePaths: exports_external.array(exports_external.string()).describe("Changed file paths (relative or absolute)"),
|
|
7809
|
+
mappings: exports_external.array(exports_external.object({
|
|
7810
|
+
glob: exports_external.string().describe("File glob pattern (supports * and **)"),
|
|
7811
|
+
tags: exports_external.array(exports_external.string()).describe("Run scenarios tagged with these if glob matches")
|
|
7812
|
+
})).optional().describe("Explicit file glob \u2192 scenario tag mappings"),
|
|
7813
|
+
projectId: exports_external.string().optional().describe("Restrict to scenarios in this project"),
|
|
7814
|
+
model: exports_external.string().optional().describe(MODEL_DESC),
|
|
7815
|
+
headed: exports_external.boolean().optional().describe("Run browser in headed mode"),
|
|
7816
|
+
parallel: exports_external.number().optional().describe("Number of parallel workers")
|
|
7817
|
+
}, async ({ url, filePaths, mappings, projectId, model, headed, parallel }) => {
|
|
7818
|
+
try {
|
|
7819
|
+
const allScenarios = listScenarios({ projectId });
|
|
7820
|
+
const matched = matchFilesToScenarios(filePaths, allScenarios, mappings ?? []);
|
|
7821
|
+
if (matched.length === 0) {
|
|
7822
|
+
return json({ runId: null, scenarioCount: 0, matchedScenarios: [], message: "No scenarios matched the provided file paths." });
|
|
7823
|
+
}
|
|
7824
|
+
const scenarioIds = matched.map((s) => s.id);
|
|
7825
|
+
const { runId, scenarioCount } = startRunAsync({ url, scenarioIds, model, headed, parallel, projectId });
|
|
7826
|
+
return json({
|
|
7827
|
+
runId,
|
|
7828
|
+
scenarioCount,
|
|
7829
|
+
url,
|
|
7830
|
+
status: "running",
|
|
7831
|
+
matchedScenarios: matched.map((s) => ({ id: s.id, shortId: s.shortId, name: s.name, tags: s.tags })),
|
|
7832
|
+
message: "Poll with get_run to check progress."
|
|
7833
|
+
});
|
|
7834
|
+
} catch (error) {
|
|
7835
|
+
return errorResponse(error);
|
|
7836
|
+
}
|
|
7837
|
+
});
|
|
7838
|
+
server.tool("heartbeat", "Update an agent's last_seen_at timestamp. Call regularly to signal the agent is alive.", {
|
|
7839
|
+
agentId: exports_external.string().describe("Agent ID to heartbeat")
|
|
7840
|
+
}, async ({ agentId }) => {
|
|
7841
|
+
try {
|
|
7842
|
+
const agent = heartbeatAgent(agentId);
|
|
7843
|
+
if (!agent)
|
|
7844
|
+
return errorResponse(notFoundErr(agentId, "Agent"));
|
|
7845
|
+
return json({ ok: true, agentId: agent.id, lastSeenAt: agent.lastSeenAt });
|
|
7846
|
+
} catch (error) {
|
|
7847
|
+
return errorResponse(error);
|
|
7848
|
+
}
|
|
7849
|
+
});
|
|
7850
|
+
server.tool("set_focus", "Set (or clear) an agent's current focus scenario. Stored in agent metadata.", {
|
|
7851
|
+
agentId: exports_external.string().describe("Agent ID"),
|
|
7852
|
+
scenarioId: exports_external.string().nullable().describe("Scenario ID the agent is working on, or null to clear")
|
|
7853
|
+
}, async ({ agentId, scenarioId }) => {
|
|
7854
|
+
try {
|
|
7855
|
+
const agent = setAgentFocus(agentId, scenarioId);
|
|
7856
|
+
if (!agent)
|
|
7857
|
+
return errorResponse(notFoundErr(agentId, "Agent"));
|
|
7858
|
+
return json({ ok: true, agentId: agent.id, focus: agent.metadata?.focus ?? null });
|
|
7859
|
+
} catch (error) {
|
|
7860
|
+
return errorResponse(error);
|
|
7466
7861
|
}
|
|
7467
7862
|
});
|
|
7468
7863
|
async function main() {
|