@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/dist/mcp/index.js CHANGED
@@ -1,13 +1,17 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
3
  var __defProp = Object.defineProperty;
4
+ var __returnValue = (v) => v;
5
+ function __exportSetter(name, newValue) {
6
+ this[name] = __returnValue.bind(null, newValue);
7
+ }
4
8
  var __export = (target, all) => {
5
9
  for (var name in all)
6
10
  __defProp(target, name, {
7
11
  get: all[name],
8
12
  enumerable: true,
9
13
  configurable: true,
10
- set: (newValue) => all[name] = () => newValue
14
+ set: __exportSetter.bind(all, name)
11
15
  });
12
16
  };
13
17
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
@@ -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/todos-connector.ts
6660
- import { Database as Database2 } from "bun:sqlite";
6661
- import { existsSync as existsSync4 } from "fs";
6662
- import { join as join4 } from "path";
6663
- import { homedir as homedir4 } from "os";
6664
- init_types();
6665
- function resolveTodosDbPath() {
6666
- const envPath = process.env["TODOS_DB_PATH"];
6667
- if (envPath)
6668
- return envPath;
6669
- return join4(homedir4(), ".todos", "todos.db");
6670
- }
6671
- function connectToTodos() {
6672
- const dbPath = resolveTodosDbPath();
6673
- if (!existsSync4(dbPath)) {
6674
- throw new TodosConnectionError(`Todos database not found at ${dbPath}. Install @hasna/todos or set TODOS_DB_PATH.`);
6675
- }
6676
- const db2 = new Database2(dbPath, { readonly: true });
6677
- db2.exec("PRAGMA foreign_keys = ON");
6678
- return db2;
6679
- }
6680
- function pullTasks(options = {}) {
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
- query += " ORDER BY CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END";
6703
- const tasks = db2.query(query).all(...params);
6704
- if (options.tags && options.tags.length > 0) {
6705
- return tasks.filter((task) => {
6706
- const taskTags = JSON.parse(task.tags || "[]");
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
- return tasks;
6711
- } finally {
6712
- db2.close();
6713
- }
6714
- }
6715
- function taskToScenarioInput(task, projectId) {
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
- return {
6730
- name: task.title.replace(/^(OPE\d+-\d+|[A-Z]+-\d+):\s*/, ""),
6731
- description: task.description || task.title,
6732
- steps,
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
- const input = taskToScenarioInput(task, options.projectId);
6755
- createScenario(input);
6756
- imported++;
6937
+ if (matched)
6938
+ matchedIds.add(scenario.id);
6757
6939
  }
6758
- return { imported, skipped };
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("AI model to use"),
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 { content: [{ type: "text", text: `Created scenario ${scenario.shortId}: "${scenario.name}" (id: ${scenario.id})` }] };
7361
+ return json(scenario);
7123
7362
  } catch (error) {
7124
- const e = error instanceof Error ? error : new Error(String(error));
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", "Get a scenario by ID or short ID", {
7129
- id: exports_external.string().describe("Scenario ID or short ID")
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 { content: [{ type: "text", text: `ScenarioNotFoundError: Scenario not found: ${id}` }], isError: true };
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
- const e = error instanceof Error ? error : new Error(String(error));
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
- if (scenarios.length === 0) {
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
- const e = error instanceof Error ? error : new Error(String(error));
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", "Update an existing scenario (requires version for optimistic locking)", {
7180
- id: exports_external.string().describe("Scenario ID or short ID"),
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 { content: [{ type: "text", text: `Updated scenario ${scenario.shortId}: "${scenario.name}" (version: ${scenario.version})` }] };
7403
+ return json(scenario);
7192
7404
  } catch (error) {
7193
- const e = error instanceof Error ? error : new Error(String(error));
7194
- return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
7405
+ return errorResponse(error, {
7406
+ fetchCurrent: () => getScenario(id) ?? getScenarioByShortId(id)
7407
+ });
7195
7408
  }
7196
7409
  });
7197
- server.tool("delete_scenario", "Delete a scenario by ID", {
7198
- id: exports_external.string().describe("Scenario ID or short ID")
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 { content: [{ type: "text", text: `ScenarioNotFoundError: Scenario not found: ${id}` }], isError: true };
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
- const e = error instanceof Error ? error : new Error(String(error));
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("AI model to use"),
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
- const text = [
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
- const e = error instanceof Error ? error : new Error(String(error));
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", "Get details of a test run", {
7238
- id: exports_external.string().describe("Run ID")
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 { content: [{ type: "text", text: `RunNotFoundError: Run not found: ${id}` }], isError: true };
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
- const e = error instanceof Error ? error : new Error(String(error));
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
- if (runs.length === 0) {
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
- const e = error instanceof Error ? error : new Error(String(error));
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", "Get test results for a run", {
7281
- runId: exports_external.string().describe("Run ID")
7282
- }, async ({ runId }) => {
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
- const results = listResults(runId);
7285
- if (results.length === 0) {
7286
- return { content: [{ type: "text", text: `No results found for run: ${runId}` }] };
7287
- }
7288
- const lines = results.map((r) => `[${r.status}] scenario:${r.scenarioId.slice(0, 8)} \u2014 ${r.stepsCompleted}/${r.stepsTotal} steps \u2014 ${r.durationMs}ms \u2014 ${r.model}${r.error ? ` \u2014 error: ${r.error}` : ""}`);
7289
- return { content: [{ type: "text", text: `${results.length} result(s) for run ${runId.slice(0, 8)}:
7290
- ${lines.join(`
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
- const e = error instanceof Error ? error : new Error(String(error));
7294
- return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
7476
+ return errorResponse(error);
7295
7477
  }
7296
7478
  });
7297
- server.tool("get_screenshots", "Get screenshots for a test result", {
7298
- resultId: exports_external.string().describe("Result ID")
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 { content: [{ type: "text", text: `No screenshots found for result: ${resultId}` }] };
7486
+ return json({ items: [], total: 0 });
7304
7487
  }
7305
- const lines = screenshots.map((s) => `Step ${s.stepNumber}: ${s.action} \u2014 ${s.width}x${s.height} \u2014 ${s.filePath}`);
7306
- return { content: [{ type: "text", text: `${screenshots.length} screenshot(s):
7307
- ${lines.join(`
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
- const e = error instanceof Error ? error : new Error(String(error));
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 { content: [{ type: "text", text: `Project "${project.name}" registered (id: ${project.id})` }] };
7520
+ return json(project);
7322
7521
  } catch (error) {
7323
- const e = error instanceof Error ? error : new Error(String(error));
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
- if (projects.length === 0) {
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
- const e = error instanceof Error ? error : new Error(String(error));
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 { content: [{ type: "text", text: `Agent "${agent.name}" registered (id: ${agent.id})` }] };
7540
+ return json(agent);
7350
7541
  } catch (error) {
7351
- const e = error instanceof Error ? error : new Error(String(error));
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
- if (agents.length === 0) {
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
- const e = error instanceof Error ? error : new Error(String(error));
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 { content: [{ type: "text", text: `Imported ${result.imported} scenario(s), skipped ${result.skipped} duplicate(s)` }] };
7560
+ return json(result);
7378
7561
  } catch (error) {
7379
- const e = error instanceof Error ? error : new Error(String(error));
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
- const text = [
7391
- `DB: ${process.env["TESTERS_DB_PATH"] || "~/.testers/testers.db"}`,
7392
- `API key: ${hasApiKey ? "configured" : "not set"}`,
7393
- `Scenarios: ${scenarioCount}`,
7394
- `Runs: ${runCount}`,
7395
- `Default model: ${config.defaultModel}`
7396
- ].join(`
7397
- `);
7398
- return { content: [{ type: "text", text }] };
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
- const e = error instanceof Error ? error : new Error(String(error));
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("AI model"),
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 { content: [{ type: "text", text: `Schedule created: ${schedule.id.slice(0, 8)} | ${schedule.name} | cron: ${schedule.cronExpression} | next: ${nextRun.toISOString()}` }] };
7618
+ return json({ ...schedule, nextRunAt: nextRun.toISOString() });
7428
7619
  } catch (e) {
7429
- return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
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
- const schedules = listSchedules({ projectId: params.projectId, enabled: params.enabled, limit: params.limit });
7438
- if (schedules.length === 0)
7439
- return { content: [{ type: "text", text: "No schedules found." }] };
7440
- const lines = schedules.map((s) => `${s.id.slice(0, 8)} | ${s.name} | ${s.cronExpression} | ${s.url} | ${s.enabled ? "enabled" : "disabled"} | next: ${s.nextRunAt ?? "N/A"}`);
7441
- return { content: [{ type: "text", text: lines.join(`
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 { content: [{ type: "text", text: `Schedule ${schedule.name} enabled.` }] };
7638
+ return json(schedule);
7448
7639
  } catch (e) {
7449
- return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
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 { content: [{ type: "text", text: `Schedule ${schedule.name} disabled.` }] };
7646
+ return json(schedule);
7456
7647
  } catch (e) {
7457
- return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
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
- return { content: [{ type: "text", text: deleted ? "Schedule deleted." : "Schedule not found." }] };
7654
+ if (!deleted)
7655
+ return errorResponse(notFoundErr(params.id, "Schedule"));
7656
+ return json({ deleted: true, id: params.id });
7464
7657
  } catch (e) {
7465
- return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
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() {