@hasna/testers 0.0.10 → 0.0.12

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
@@ -4813,7 +4813,10 @@ function listScenarios(filter) {
4813
4813
  if (conditions.length > 0) {
4814
4814
  sql += " WHERE " + conditions.join(" AND ");
4815
4815
  }
4816
- sql += " ORDER BY created_at DESC";
4816
+ const sortField = filter?.sort ?? "date";
4817
+ const sortDir = filter?.desc === false ? "ASC" : "DESC";
4818
+ const orderByCol = sortField === "name" ? "name" : sortField === "priority" ? "CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END" : "created_at";
4819
+ sql += ` ORDER BY ${orderByCol} ${sortDir}`;
4817
4820
  if (filter?.limit) {
4818
4821
  sql += " LIMIT ?";
4819
4822
  params.push(filter.limit);
@@ -4899,6 +4902,22 @@ function updateScenario(id, input, version) {
4899
4902
  }
4900
4903
  return getScenario(existing.id);
4901
4904
  }
4905
+ function findStaleScenarios(days) {
4906
+ const db2 = getDatabase();
4907
+ const rows = db2.query(`
4908
+ SELECT s.*, MAX(r.created_at) AS last_run_at
4909
+ FROM scenarios s
4910
+ LEFT JOIN results r ON r.scenario_id = s.id
4911
+ GROUP BY s.id
4912
+ HAVING last_run_at IS NULL
4913
+ OR last_run_at < datetime('now', ? || ' days')
4914
+ ORDER BY last_run_at ASC NULLS FIRST
4915
+ `).all(`-${days}`);
4916
+ return rows.map((row) => ({
4917
+ ...scenarioFromRow(row),
4918
+ lastRunAt: row.last_run_at
4919
+ }));
4920
+ }
4902
4921
  function deleteScenario(id) {
4903
4922
  const db2 = getDatabase();
4904
4923
  const scenario = getScenario(id);
@@ -4950,7 +4969,10 @@ function listRuns(filter) {
4950
4969
  if (conditions.length > 0) {
4951
4970
  sql += " WHERE " + conditions.join(" AND ");
4952
4971
  }
4953
- sql += " ORDER BY started_at DESC";
4972
+ const sortField = filter?.sort ?? "date";
4973
+ const sortDir = filter?.desc === false ? "ASC" : "DESC";
4974
+ const orderByCol = sortField === "duration" ? "(CASE WHEN finished_at IS NULL THEN NULL ELSE (julianday(finished_at) - julianday(started_at)) * 86400000 END)" : sortField === "cost" ? "(SELECT COALESCE(SUM(cost_cents), 0) FROM results WHERE run_id = runs.id)" : "started_at";
4975
+ sql += ` ORDER BY ${orderByCol} ${sortDir}`;
4954
4976
  if (filter?.limit) {
4955
4977
  sql += " LIMIT ?";
4956
4978
  params.push(filter.limit);
@@ -5100,6 +5122,9 @@ function updateResult(id, updates) {
5100
5122
  db2.query(`UPDATE results SET ${sets.join(", ")} WHERE id = ?`).run(...params);
5101
5123
  return getResult(existing.id);
5102
5124
  }
5125
+ function getResultsByRun(runId) {
5126
+ return listResults(runId);
5127
+ }
5103
5128
 
5104
5129
  // src/db/screenshots.ts
5105
5130
  init_types();
@@ -6312,14 +6337,26 @@ function emit(event) {
6312
6337
  }
6313
6338
  function withTimeout(promise, ms, label) {
6314
6339
  return new Promise((resolve, reject) => {
6340
+ const warningAt = Math.floor(ms * 0.8);
6341
+ const warningTimer = setTimeout(() => {
6342
+ emit({
6343
+ type: "scenario:timeout_warning",
6344
+ scenarioName: label,
6345
+ timeoutMs: ms,
6346
+ elapsedMs: warningAt
6347
+ });
6348
+ }, warningAt);
6315
6349
  const timer = setTimeout(() => {
6350
+ clearTimeout(warningTimer);
6316
6351
  reject(new Error(`Scenario '${label}' timed out after ${ms}ms. Try: testers run --timeout ${ms * 2} or simplify the scenario steps.`));
6317
6352
  }, ms);
6318
6353
  promise.then((val) => {
6319
6354
  clearTimeout(timer);
6355
+ clearTimeout(warningTimer);
6320
6356
  resolve(val);
6321
6357
  }, (err) => {
6322
6358
  clearTimeout(timer);
6359
+ clearTimeout(warningTimer);
6323
6360
  reject(err);
6324
6361
  });
6325
6362
  });
@@ -6348,6 +6385,7 @@ async function runSingleScenario(scenario, runId, options) {
6348
6385
  const targetUrl = scenario.targetPath ? `${options.url.replace(/\/$/, "")}${scenario.targetPath}` : options.url;
6349
6386
  const scenarioTimeout = scenario.timeoutMs ?? options.timeout ?? config.browser.timeout ?? 60000;
6350
6387
  await page.goto(targetUrl, { timeout: Math.min(scenarioTimeout, 30000) });
6388
+ const stepStartTimes = new Map;
6351
6389
  const agentResult = await withTimeout(runAgentLoop({
6352
6390
  client,
6353
6391
  page,
@@ -6357,6 +6395,16 @@ async function runSingleScenario(scenario, runId, options) {
6357
6395
  runId,
6358
6396
  maxTurns: 30,
6359
6397
  onStep: (stepEvent) => {
6398
+ let stepDurationMs;
6399
+ if (stepEvent.type === "tool_call") {
6400
+ stepStartTimes.set(stepEvent.stepNumber, Date.now());
6401
+ } else if (stepEvent.type === "tool_result") {
6402
+ const startTime = stepStartTimes.get(stepEvent.stepNumber);
6403
+ if (startTime !== undefined) {
6404
+ stepDurationMs = Date.now() - startTime;
6405
+ stepStartTimes.delete(stepEvent.stepNumber);
6406
+ }
6407
+ }
6360
6408
  emit({
6361
6409
  type: `step:${stepEvent.type}`,
6362
6410
  scenarioId: scenario.id,
@@ -6366,7 +6414,8 @@ async function runSingleScenario(scenario, runId, options) {
6366
6414
  toolInput: stepEvent.toolInput,
6367
6415
  toolResult: stepEvent.toolResult,
6368
6416
  thinking: stepEvent.thinking,
6369
- stepNumber: stepEvent.stepNumber
6417
+ stepNumber: stepEvent.stepNumber,
6418
+ stepDurationMs
6370
6419
  });
6371
6420
  }
6372
6421
  }), scenarioTimeout, scenario.name);
@@ -7073,6 +7122,63 @@ class Scheduler {
7073
7122
 
7074
7123
  // src/mcp/index.ts
7075
7124
  init_database();
7125
+ init_types();
7126
+ function json(data) {
7127
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
7128
+ }
7129
+ function notFoundErr(id, label = "Resource") {
7130
+ return Object.assign(new Error(`${label} not found: ${id}`), { name: "NotFoundError" });
7131
+ }
7132
+ function errorResponse(e, context) {
7133
+ const err = e instanceof Error ? e : new Error(String(e));
7134
+ if (e instanceof VersionConflictError) {
7135
+ const payload2 = {
7136
+ error: {
7137
+ code: "VERSION_CONFLICT",
7138
+ message: err.message,
7139
+ retryable: true,
7140
+ hint: "Fetch the scenario with get_scenario to get the current version, then retry.",
7141
+ currentVersion: null
7142
+ }
7143
+ };
7144
+ if (context?.fetchCurrent) {
7145
+ try {
7146
+ const current = context.fetchCurrent();
7147
+ if (current && typeof current.version === "number") {
7148
+ payload2.error.currentVersion = current.version;
7149
+ payload2.error.hint = `Retry with version: ${current.version}`;
7150
+ }
7151
+ } catch {}
7152
+ }
7153
+ return { content: [{ type: "text", text: JSON.stringify(payload2, null, 2) }], isError: true };
7154
+ }
7155
+ const name = err.name ?? "Error";
7156
+ const msg = err.message ?? String(e);
7157
+ let code = "INTERNAL_ERROR";
7158
+ let retryable = false;
7159
+ let hint;
7160
+ if (name === "NotFoundError" || name === "ScenarioNotFoundError" || msg.toLowerCase().includes("not found")) {
7161
+ code = "NOT_FOUND";
7162
+ retryable = false;
7163
+ hint = "Check the ID or short ID and try again.";
7164
+ } else if (msg.toLowerCase().includes("timeout")) {
7165
+ code = "TIMEOUT";
7166
+ retryable = true;
7167
+ hint = "The operation timed out. Try again.";
7168
+ } else if (msg.toLowerCase().includes("unique") || msg.toLowerCase().includes("already exists")) {
7169
+ code = "CONFLICT";
7170
+ retryable = false;
7171
+ hint = "A resource with this identifier already exists.";
7172
+ }
7173
+ const payload = {
7174
+ error: { code, message: msg, retryable }
7175
+ };
7176
+ if (hint)
7177
+ payload.error.hint = hint;
7178
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], isError: true };
7179
+ }
7180
+ var ID_DESC = "Accepts either the full UUID (e.g. 'abc123...') or the short ID (e.g. 'sc-1').";
7181
+ 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'.";
7076
7182
  var server = new McpServer({
7077
7183
  name: "testers",
7078
7184
  version: "0.0.1"
@@ -7083,47 +7189,27 @@ server.tool("create_scenario", "Create a new test scenario", {
7083
7189
  steps: exports_external.array(exports_external.string()).optional().describe("Ordered test steps"),
7084
7190
  tags: exports_external.array(exports_external.string()).optional().describe("Tags for filtering"),
7085
7191
  priority: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("Scenario priority"),
7086
- model: exports_external.string().optional().describe("AI model to use"),
7192
+ model: exports_external.string().optional().describe(MODEL_DESC),
7087
7193
  targetPath: exports_external.string().optional().describe("URL path to navigate to"),
7088
7194
  requiresAuth: exports_external.boolean().optional().describe("Whether scenario requires authentication")
7089
7195
  }, async ({ name, description, steps, tags, priority, model, targetPath, requiresAuth }) => {
7090
7196
  try {
7091
7197
  const scenario = createScenario({ name, description, steps, tags, priority, model, targetPath, requiresAuth });
7092
- return { content: [{ type: "text", text: `Created scenario ${scenario.shortId}: "${scenario.name}" (id: ${scenario.id})` }] };
7198
+ return json(scenario);
7093
7199
  } catch (error) {
7094
- const e = error instanceof Error ? error : new Error(String(error));
7095
- return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
7200
+ return errorResponse(error);
7096
7201
  }
7097
7202
  });
7098
- server.tool("get_scenario", "Get a scenario by ID or short ID", {
7099
- id: exports_external.string().describe("Scenario ID or short ID")
7203
+ server.tool("get_scenario", `Get a scenario by ID or short ID. ${ID_DESC}`, {
7204
+ id: exports_external.string().describe(`Scenario ID or short ID. ${ID_DESC}`)
7100
7205
  }, async ({ id }) => {
7101
7206
  try {
7102
7207
  const scenario = getScenario(id) ?? getScenarioByShortId(id);
7103
- if (!scenario) {
7104
- return { content: [{ type: "text", text: `ScenarioNotFoundError: Scenario not found: ${id}` }], isError: true };
7105
- }
7106
- const text = [
7107
- `Scenario: ${scenario.name} (${scenario.shortId})`,
7108
- `ID: ${scenario.id}`,
7109
- `Description: ${scenario.description}`,
7110
- `Priority: ${scenario.priority}`,
7111
- `Tags: ${scenario.tags.join(", ") || "none"}`,
7112
- `Steps: ${scenario.steps.length > 0 ? `
7113
- ` + scenario.steps.map((s, i) => `${i + 1}. ${s}`).join(`
7114
- `) : "none"}`,
7115
- `Model: ${scenario.model ?? "default"}`,
7116
- `Target path: ${scenario.targetPath ?? "none"}`,
7117
- `Requires auth: ${scenario.requiresAuth}`,
7118
- `Version: ${scenario.version}`,
7119
- `Created: ${scenario.createdAt}`,
7120
- `Updated: ${scenario.updatedAt}`
7121
- ].join(`
7122
- `);
7123
- return { content: [{ type: "text", text }] };
7208
+ if (!scenario)
7209
+ return errorResponse(notFoundErr(id, "Scenario"));
7210
+ return json(scenario);
7124
7211
  } catch (error) {
7125
- const e = error instanceof Error ? error : new Error(String(error));
7126
- return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
7212
+ return errorResponse(error);
7127
7213
  }
7128
7214
  });
7129
7215
  server.tool("list_scenarios", "List test scenarios with optional filters", {
@@ -7134,20 +7220,13 @@ server.tool("list_scenarios", "List test scenarios with optional filters", {
7134
7220
  }, async ({ projectId, tags, priority, limit }) => {
7135
7221
  try {
7136
7222
  const scenarios = listScenarios({ projectId, tags, priority, limit });
7137
- if (scenarios.length === 0) {
7138
- return { content: [{ type: "text", text: "No scenarios found." }] };
7139
- }
7140
- const lines = scenarios.map((s) => `[${s.shortId}] ${s.name} \u2014 ${s.priority} \u2014 tags: ${s.tags.join(", ") || "none"}`);
7141
- return { content: [{ type: "text", text: `${scenarios.length} scenario(s):
7142
- ${lines.join(`
7143
- `)}` }] };
7223
+ return json({ items: scenarios, total: scenarios.length });
7144
7224
  } catch (error) {
7145
- const e = error instanceof Error ? error : new Error(String(error));
7146
- return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
7225
+ return errorResponse(error);
7147
7226
  }
7148
7227
  });
7149
- server.tool("update_scenario", "Update an existing scenario (requires version for optimistic locking)", {
7150
- id: exports_external.string().describe("Scenario ID or short ID"),
7228
+ server.tool("update_scenario", `Update an existing scenario (requires version for optimistic locking). ${ID_DESC}`, {
7229
+ id: exports_external.string().describe(`Scenario ID or short ID. ${ID_DESC}`),
7151
7230
  name: exports_external.string().optional().describe("New name"),
7152
7231
  description: exports_external.string().optional().describe("New description"),
7153
7232
  steps: exports_external.array(exports_external.string()).optional().describe("New steps"),
@@ -7158,24 +7237,23 @@ server.tool("update_scenario", "Update an existing scenario (requires version fo
7158
7237
  }, async ({ id, name, description, steps, tags, priority, model, version }) => {
7159
7238
  try {
7160
7239
  const scenario = updateScenario(id, { name, description, steps, tags, priority, model }, version);
7161
- return { content: [{ type: "text", text: `Updated scenario ${scenario.shortId}: "${scenario.name}" (version: ${scenario.version})` }] };
7240
+ return json(scenario);
7162
7241
  } catch (error) {
7163
- const e = error instanceof Error ? error : new Error(String(error));
7164
- return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
7242
+ return errorResponse(error, {
7243
+ fetchCurrent: () => getScenario(id) ?? getScenarioByShortId(id)
7244
+ });
7165
7245
  }
7166
7246
  });
7167
- server.tool("delete_scenario", "Delete a scenario by ID", {
7168
- id: exports_external.string().describe("Scenario ID or short ID")
7247
+ server.tool("delete_scenario", `Delete a scenario by ID. ${ID_DESC}`, {
7248
+ id: exports_external.string().describe(`Scenario ID or short ID. ${ID_DESC}`)
7169
7249
  }, async ({ id }) => {
7170
7250
  try {
7171
7251
  const deleted = deleteScenario(id);
7172
- if (!deleted) {
7173
- return { content: [{ type: "text", text: `ScenarioNotFoundError: Scenario not found: ${id}` }], isError: true };
7174
- }
7175
- return { content: [{ type: "text", text: `Deleted scenario: ${id}` }] };
7252
+ if (!deleted)
7253
+ return errorResponse(notFoundErr(id, "Scenario"));
7254
+ return json({ deleted: true, id });
7176
7255
  } catch (error) {
7177
- const e = error instanceof Error ? error : new Error(String(error));
7178
- return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
7256
+ return errorResponse(error);
7179
7257
  }
7180
7258
  });
7181
7259
  server.tool("run_scenarios", "Run test scenarios against a URL", {
@@ -7183,50 +7261,27 @@ server.tool("run_scenarios", "Run test scenarios against a URL", {
7183
7261
  tags: exports_external.array(exports_external.string()).optional().describe("Filter scenarios by tags"),
7184
7262
  scenarioIds: exports_external.array(exports_external.string()).optional().describe("Run specific scenario IDs"),
7185
7263
  priority: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("Filter by priority"),
7186
- model: exports_external.string().optional().describe("AI model to use"),
7264
+ model: exports_external.string().optional().describe(MODEL_DESC),
7187
7265
  headed: exports_external.boolean().optional().describe("Run browser in headed mode"),
7188
7266
  parallel: exports_external.number().optional().describe("Number of parallel workers")
7189
7267
  }, async ({ url, tags, scenarioIds, priority, model, headed, parallel }) => {
7190
7268
  try {
7191
7269
  const { runId, scenarioCount } = startRunAsync({ url, tags, scenarioIds, priority, model, headed, parallel });
7192
- const text = [
7193
- `Run started: ${runId}`,
7194
- `Scenarios: ${scenarioCount}`,
7195
- `URL: ${url}`,
7196
- `Status: running (async)`,
7197
- ``,
7198
- `Poll with get_run to check progress.`
7199
- ].join(`
7200
- `);
7201
- return { content: [{ type: "text", text }] };
7270
+ return json({ runId, scenarioCount, url, status: "running", message: "Poll with get_run to check progress." });
7202
7271
  } catch (error) {
7203
- const e = error instanceof Error ? error : new Error(String(error));
7204
- return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
7272
+ return errorResponse(error);
7205
7273
  }
7206
7274
  });
7207
- server.tool("get_run", "Get details of a test run", {
7208
- id: exports_external.string().describe("Run ID")
7275
+ server.tool("get_run", `Get details of a test run. ${ID_DESC}`, {
7276
+ id: exports_external.string().describe(`Run ID. ${ID_DESC}`)
7209
7277
  }, async ({ id }) => {
7210
7278
  try {
7211
7279
  const run = getRun(id);
7212
- if (!run) {
7213
- return { content: [{ type: "text", text: `RunNotFoundError: Run not found: ${id}` }], isError: true };
7214
- }
7215
- const text = [
7216
- `Run: ${run.id}`,
7217
- `Status: ${run.status}`,
7218
- `URL: ${run.url}`,
7219
- `Model: ${run.model}`,
7220
- `Total: ${run.total} | Passed: ${run.passed} | Failed: ${run.failed}`,
7221
- `Parallel: ${run.parallel} | Headed: ${run.headed}`,
7222
- `Started: ${run.startedAt}`,
7223
- run.finishedAt ? `Finished: ${run.finishedAt}` : "Finished: in progress"
7224
- ].join(`
7225
- `);
7226
- return { content: [{ type: "text", text }] };
7280
+ if (!run)
7281
+ return errorResponse(notFoundErr(id, "Run"));
7282
+ return json(run);
7227
7283
  } catch (error) {
7228
- const e = error instanceof Error ? error : new Error(String(error));
7229
- return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
7284
+ return errorResponse(error);
7230
7285
  }
7231
7286
  });
7232
7287
  server.tool("list_runs", "List test runs with optional filters", {
@@ -7235,50 +7290,61 @@ server.tool("list_runs", "List test runs with optional filters", {
7235
7290
  }, async ({ status, limit }) => {
7236
7291
  try {
7237
7292
  const runs = listRuns({ status, limit });
7238
- if (runs.length === 0) {
7239
- return { content: [{ type: "text", text: "No runs found." }] };
7240
- }
7241
- 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}`);
7242
- return { content: [{ type: "text", text: `${runs.length} run(s):
7243
- ${lines.join(`
7244
- `)}` }] };
7293
+ return json({ items: runs, total: runs.length });
7245
7294
  } catch (error) {
7246
- const e = error instanceof Error ? error : new Error(String(error));
7247
- return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
7295
+ return errorResponse(error);
7248
7296
  }
7249
7297
  });
7250
- server.tool("get_results", "Get test results for a run", {
7251
- runId: exports_external.string().describe("Run ID")
7252
- }, async ({ runId }) => {
7298
+ 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}`, {
7299
+ runId: exports_external.string().describe(`Run ID. ${ID_DESC}`),
7300
+ status: exports_external.enum(["passed", "failed", "error", "running"]).optional().describe("Filter by result status"),
7301
+ scenarioId: exports_external.string().optional().describe("Filter by scenario ID (full or partial)")
7302
+ }, async ({ runId, status, scenarioId }) => {
7253
7303
  try {
7254
- const results = listResults(runId);
7255
- if (results.length === 0) {
7256
- return { content: [{ type: "text", text: `No results found for run: ${runId}` }] };
7257
- }
7258
- 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}` : ""}`);
7259
- return { content: [{ type: "text", text: `${results.length} result(s) for run ${runId.slice(0, 8)}:
7260
- ${lines.join(`
7261
- `)}` }] };
7304
+ let results = listResults(runId);
7305
+ if (status) {
7306
+ results = results.filter((r) => r.status === status);
7307
+ }
7308
+ if (scenarioId) {
7309
+ results = results.filter((r) => r.scenarioId === scenarioId || r.scenarioId.startsWith(scenarioId));
7310
+ }
7311
+ return json({ items: results, total: results.length });
7262
7312
  } catch (error) {
7263
- const e = error instanceof Error ? error : new Error(String(error));
7264
- return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
7313
+ return errorResponse(error);
7265
7314
  }
7266
7315
  });
7267
- server.tool("get_screenshots", "Get screenshots for a test result", {
7268
- resultId: exports_external.string().describe("Result ID")
7316
+ var MAX_BASE64_SCREENSHOTS = 5;
7317
+ 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}`, {
7318
+ resultId: exports_external.string().describe(`Result ID. ${ID_DESC}`)
7269
7319
  }, async ({ resultId }) => {
7270
7320
  try {
7271
7321
  const screenshots = listScreenshots(resultId);
7272
7322
  if (screenshots.length === 0) {
7273
- return { content: [{ type: "text", text: `No screenshots found for result: ${resultId}` }] };
7323
+ return json({ items: [], total: 0 });
7274
7324
  }
7275
- const lines = screenshots.map((s) => `Step ${s.stepNumber}: ${s.action} \u2014 ${s.width}x${s.height} \u2014 ${s.filePath}`);
7276
- return { content: [{ type: "text", text: `${screenshots.length} screenshot(s):
7277
- ${lines.join(`
7278
- `)}` }] };
7325
+ const truncated = screenshots.length > MAX_BASE64_SCREENSHOTS;
7326
+ const withBase64 = await Promise.all(screenshots.map(async (s, index) => {
7327
+ let base64 = null;
7328
+ let note;
7329
+ if (!truncated || index < MAX_BASE64_SCREENSHOTS) {
7330
+ try {
7331
+ const file = Bun.file(s.filePath);
7332
+ const exists = await file.exists();
7333
+ if (exists) {
7334
+ const buffer = await file.arrayBuffer();
7335
+ base64 = `data:image/png;base64,${Buffer.from(buffer).toString("base64")}`;
7336
+ } else {
7337
+ note = `File not found on disk: ${s.filePath}`;
7338
+ }
7339
+ } catch {
7340
+ note = `Failed to read file: ${s.filePath}`;
7341
+ }
7342
+ }
7343
+ 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 };
7344
+ }));
7345
+ return json({ truncated, total: screenshots.length, items: withBase64 });
7279
7346
  } catch (error) {
7280
- const e = error instanceof Error ? error : new Error(String(error));
7281
- return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
7347
+ return errorResponse(error);
7282
7348
  }
7283
7349
  });
7284
7350
  server.tool("register_project", "Register or ensure a project exists", {
@@ -7288,25 +7354,17 @@ server.tool("register_project", "Register or ensure a project exists", {
7288
7354
  }, async ({ name, path, description }) => {
7289
7355
  try {
7290
7356
  const project = description ? createProject({ name, path, description }) : ensureProject(name, path);
7291
- return { content: [{ type: "text", text: `Project "${project.name}" registered (id: ${project.id})` }] };
7357
+ return json(project);
7292
7358
  } 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 };
7359
+ return errorResponse(error);
7295
7360
  }
7296
7361
  });
7297
7362
  server.tool("list_projects", "List all registered projects", {}, async () => {
7298
7363
  try {
7299
7364
  const projects = listProjects();
7300
- if (projects.length === 0) {
7301
- return { content: [{ type: "text", text: "No projects registered." }] };
7302
- }
7303
- const lines = projects.map((p) => `[${p.id.slice(0, 8)}] ${p.name}${p.path ? ` \u2014 ${p.path}` : ""}${p.description ? ` \u2014 ${p.description}` : ""}`);
7304
- return { content: [{ type: "text", text: `${projects.length} project(s):
7305
- ${lines.join(`
7306
- `)}` }] };
7365
+ return json({ items: projects, total: projects.length });
7307
7366
  } catch (error) {
7308
- const e = error instanceof Error ? error : new Error(String(error));
7309
- return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
7367
+ return errorResponse(error);
7310
7368
  }
7311
7369
  });
7312
7370
  server.tool("register_agent", "Register an agent (idempotent \u2014 returns existing if name matches)", {
@@ -7316,25 +7374,17 @@ server.tool("register_agent", "Register an agent (idempotent \u2014 returns exis
7316
7374
  }, async ({ name, description, role }) => {
7317
7375
  try {
7318
7376
  const agent = registerAgent({ name, description, role });
7319
- return { content: [{ type: "text", text: `Agent "${agent.name}" registered (id: ${agent.id})` }] };
7377
+ return json(agent);
7320
7378
  } catch (error) {
7321
- const e = error instanceof Error ? error : new Error(String(error));
7322
- return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
7379
+ return errorResponse(error);
7323
7380
  }
7324
7381
  });
7325
7382
  server.tool("list_agents", "List all registered agents", {}, async () => {
7326
7383
  try {
7327
7384
  const agents = listAgents();
7328
- if (agents.length === 0) {
7329
- return { content: [{ type: "text", text: "No agents registered." }] };
7330
- }
7331
- const lines = agents.map((a) => `[${a.id.slice(0, 8)}] ${a.name}${a.role ? ` (${a.role})` : ""} \u2014 last seen: ${a.lastSeenAt}`);
7332
- return { content: [{ type: "text", text: `${agents.length} agent(s):
7333
- ${lines.join(`
7334
- `)}` }] };
7385
+ return json({ items: agents, total: agents.length });
7335
7386
  } catch (error) {
7336
- const e = error instanceof Error ? error : new Error(String(error));
7337
- return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
7387
+ return errorResponse(error);
7338
7388
  }
7339
7389
  });
7340
7390
  server.tool("import_from_todos", "Import test scenarios from the todos database", {
@@ -7344,10 +7394,9 @@ server.tool("import_from_todos", "Import test scenarios from the todos database"
7344
7394
  }, async ({ projectName, tags, projectId }) => {
7345
7395
  try {
7346
7396
  const result = importFromTodos({ projectName, tags, projectId });
7347
- return { content: [{ type: "text", text: `Imported ${result.imported} scenario(s), skipped ${result.skipped} duplicate(s)` }] };
7397
+ return json(result);
7348
7398
  } catch (error) {
7349
- const e = error instanceof Error ? error : new Error(String(error));
7350
- return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
7399
+ return errorResponse(error);
7351
7400
  }
7352
7401
  });
7353
7402
  server.tool("get_status", "Get system status: DB path, API key, scenario and run counts", {}, async () => {
@@ -7357,18 +7406,27 @@ server.tool("get_status", "Get system status: DB path, API key, scenario and run
7357
7406
  const scenarioCount = db2.query("SELECT COUNT(*) as count FROM scenarios").get().count;
7358
7407
  const runCount = db2.query("SELECT COUNT(*) as count FROM runs").get().count;
7359
7408
  const hasApiKey = !!(config.anthropicApiKey || process.env["ANTHROPIC_API_KEY"]);
7360
- const text = [
7361
- `DB: ${process.env["TESTERS_DB_PATH"] || "~/.testers/testers.db"}`,
7362
- `API key: ${hasApiKey ? "configured" : "not set"}`,
7363
- `Scenarios: ${scenarioCount}`,
7364
- `Runs: ${runCount}`,
7365
- `Default model: ${config.defaultModel}`
7366
- ].join(`
7367
- `);
7368
- return { content: [{ type: "text", text }] };
7409
+ return json({
7410
+ dbPath: process.env["TESTERS_DB_PATH"] || "~/.testers/testers.db",
7411
+ apiKey: hasApiKey ? "configured" : "not set",
7412
+ scenarioCount,
7413
+ runCount,
7414
+ defaultModel: config.defaultModel
7415
+ });
7369
7416
  } catch (error) {
7370
- const e = error instanceof Error ? error : new Error(String(error));
7371
- return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
7417
+ return errorResponse(error);
7418
+ }
7419
+ });
7420
+ server.tool("scenario_exists", "Check whether a scenario with the given name exists (exact match). Returns { exists, scenario }.", {
7421
+ name: exports_external.string().describe("Scenario name to look up (exact match)"),
7422
+ projectId: exports_external.string().optional().describe("Restrict search to a specific project ID")
7423
+ }, async ({ name, projectId }) => {
7424
+ try {
7425
+ const scenarios = listScenarios({ projectId });
7426
+ const scenario = scenarios.find((s) => s.name === name) ?? null;
7427
+ return json({ exists: scenario !== null, scenario });
7428
+ } catch (error) {
7429
+ return errorResponse(error);
7372
7430
  }
7373
7431
  });
7374
7432
  server.tool("create_schedule", {
@@ -7377,7 +7435,7 @@ server.tool("create_schedule", {
7377
7435
  url: exports_external.string().describe("Target URL to test"),
7378
7436
  tags: exports_external.array(exports_external.string()).optional().describe("Filter scenarios by tags"),
7379
7437
  priority: exports_external.string().optional().describe("Filter scenarios by priority"),
7380
- model: exports_external.string().optional().describe("AI model"),
7438
+ model: exports_external.string().optional().describe(MODEL_DESC),
7381
7439
  headed: exports_external.boolean().optional().describe("Run headed"),
7382
7440
  parallel: exports_external.number().optional().describe("Parallel count"),
7383
7441
  projectId: exports_external.string().optional().describe("Project ID")
@@ -7394,9 +7452,9 @@ server.tool("create_schedule", {
7394
7452
  projectId: params.projectId
7395
7453
  });
7396
7454
  const nextRun = getNextRunTime(schedule.cronExpression);
7397
- return { content: [{ type: "text", text: `Schedule created: ${schedule.id.slice(0, 8)} | ${schedule.name} | cron: ${schedule.cronExpression} | next: ${nextRun.toISOString()}` }] };
7455
+ return json({ ...schedule, nextRunAt: nextRun.toISOString() });
7398
7456
  } catch (e) {
7399
- return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
7457
+ return errorResponse(e);
7400
7458
  }
7401
7459
  });
7402
7460
  server.tool("list_schedules", {
@@ -7404,35 +7462,182 @@ server.tool("list_schedules", {
7404
7462
  enabled: exports_external.boolean().optional(),
7405
7463
  limit: exports_external.number().optional()
7406
7464
  }, async (params) => {
7407
- const schedules = listSchedules({ projectId: params.projectId, enabled: params.enabled, limit: params.limit });
7408
- if (schedules.length === 0)
7409
- return { content: [{ type: "text", text: "No schedules found." }] };
7410
- 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"}`);
7411
- return { content: [{ type: "text", text: lines.join(`
7412
- `) }] };
7465
+ try {
7466
+ const schedules = listSchedules({ projectId: params.projectId, enabled: params.enabled, limit: params.limit });
7467
+ return json({ items: schedules, total: schedules.length });
7468
+ } catch (e) {
7469
+ return errorResponse(e);
7470
+ }
7413
7471
  });
7414
7472
  server.tool("enable_schedule", { id: exports_external.string().describe("Schedule ID") }, async (params) => {
7415
7473
  try {
7416
7474
  const schedule = updateSchedule(params.id, { enabled: true });
7417
- return { content: [{ type: "text", text: `Schedule ${schedule.name} enabled.` }] };
7475
+ return json(schedule);
7418
7476
  } catch (e) {
7419
- return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
7477
+ return errorResponse(e);
7420
7478
  }
7421
7479
  });
7422
7480
  server.tool("disable_schedule", { id: exports_external.string().describe("Schedule ID") }, async (params) => {
7423
7481
  try {
7424
7482
  const schedule = updateSchedule(params.id, { enabled: false });
7425
- return { content: [{ type: "text", text: `Schedule ${schedule.name} disabled.` }] };
7483
+ return json(schedule);
7426
7484
  } catch (e) {
7427
- return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
7485
+ return errorResponse(e);
7428
7486
  }
7429
7487
  });
7430
7488
  server.tool("delete_schedule", { id: exports_external.string().describe("Schedule ID") }, async (params) => {
7431
7489
  try {
7432
7490
  const deleted = deleteSchedule(params.id);
7433
- return { content: [{ type: "text", text: deleted ? "Schedule deleted." : "Schedule not found." }] };
7491
+ if (!deleted)
7492
+ return errorResponse(notFoundErr(params.id, "Schedule"));
7493
+ return json({ deleted: true, id: params.id });
7434
7494
  } catch (e) {
7435
- return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
7495
+ return errorResponse(e);
7496
+ }
7497
+ });
7498
+ server.tool("wait_for_run", "Poll a run until it reaches a terminal status (passed, failed, error, or cancelled). Blocks until done or timeout.", {
7499
+ runId: exports_external.string().describe("Run ID to wait for"),
7500
+ timeoutMs: exports_external.number().optional().describe("Max wait time in ms (default 300000)"),
7501
+ pollIntervalMs: exports_external.number().optional().describe("Poll interval in ms (default 3000)")
7502
+ }, async ({ runId, timeoutMs = 300000, pollIntervalMs = 3000 }) => {
7503
+ try {
7504
+ const terminalStatuses = new Set(["passed", "failed", "error", "cancelled"]);
7505
+ const deadline = Date.now() + timeoutMs;
7506
+ while (Date.now() < deadline) {
7507
+ const run = getRun(runId);
7508
+ if (!run)
7509
+ return errorResponse(notFoundErr(runId, "Run"));
7510
+ if (terminalStatuses.has(run.status)) {
7511
+ const results = getResultsByRun(runId);
7512
+ const passed = results.filter((r) => r.status === "passed").length;
7513
+ const failed = results.filter((r) => r.status === "failed" || r.status === "error").length;
7514
+ return json({ ...run, passedCount: passed, failedCount: failed });
7515
+ }
7516
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
7517
+ }
7518
+ return errorResponse(Object.assign(new Error(`Run ${runId} did not complete within ${timeoutMs}ms`), { name: "TimeoutError" }));
7519
+ } catch (error) {
7520
+ return errorResponse(error);
7521
+ }
7522
+ });
7523
+ server.tool("get_run_stats", "Get aggregate statistics for a run: pass rate, cost, token usage, duration", {
7524
+ runId: exports_external.string().describe("Run ID")
7525
+ }, async ({ runId }) => {
7526
+ try {
7527
+ const run = getRun(runId);
7528
+ if (!run)
7529
+ return errorResponse(notFoundErr(runId, "Run"));
7530
+ const results = getResultsByRun(runId);
7531
+ const total = results.length;
7532
+ const passed = results.filter((r) => r.status === "passed").length;
7533
+ const failed = results.filter((r) => r.status === "failed").length;
7534
+ const errors2 = results.filter((r) => r.status === "error").length;
7535
+ const passRate = total > 0 ? Math.round(passed / total * 100) : 0;
7536
+ const totalCostCents = results.reduce((sum, r) => sum + (r.costCents ?? 0), 0);
7537
+ const totalTokens = results.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
7538
+ const durations = results.filter((r) => r.durationMs != null && r.durationMs > 0).map((r) => r.durationMs);
7539
+ const avgDurationMs = durations.length > 0 ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) : 0;
7540
+ return json({ runId, status: run.status, total, passed, failed, errors: errors2, passRate, totalCostCents, totalTokens, avgDurationMs, startedAt: run.startedAt, completedAt: run.finishedAt ?? null });
7541
+ } catch (error) {
7542
+ return errorResponse(error);
7543
+ }
7544
+ });
7545
+ server.tool("get_run_costs", "Get cost breakdown for a run, with per-scenario detail", {
7546
+ runId: exports_external.string().describe("Run ID")
7547
+ }, async ({ runId }) => {
7548
+ try {
7549
+ const run = getRun(runId);
7550
+ if (!run)
7551
+ return errorResponse(notFoundErr(runId, "Run"));
7552
+ const results = getResultsByRun(runId);
7553
+ const totalCostCents = results.reduce((sum, r) => sum + (r.costCents ?? 0), 0);
7554
+ const totalTokens = results.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
7555
+ const byScenario = results.map((r) => {
7556
+ const scenario = getScenario(r.scenarioId);
7557
+ return { scenarioId: r.scenarioId, scenarioName: scenario?.name ?? r.scenarioId, costCents: r.costCents ?? 0, tokens: r.tokensUsed ?? 0, status: r.status };
7558
+ });
7559
+ return json({ runId, totalCostCents, totalTokens, byScenario });
7560
+ } catch (error) {
7561
+ return errorResponse(error);
7562
+ }
7563
+ });
7564
+ server.tool("batch_create_scenarios", "Create multiple scenarios in a single call. Returns created scenarios and any failures.", {
7565
+ scenarios: exports_external.array(exports_external.object({
7566
+ name: exports_external.string().describe("Scenario name"),
7567
+ url: exports_external.string().optional().describe("Target URL (stored as targetPath)"),
7568
+ description: exports_external.string().optional().describe("What this scenario tests"),
7569
+ steps: exports_external.array(exports_external.string()).optional().describe("Ordered test steps"),
7570
+ tags: exports_external.array(exports_external.string()).optional().describe("Tags for filtering"),
7571
+ priority: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("Scenario priority")
7572
+ })).describe("Array of scenarios to create")
7573
+ }, async ({ scenarios }) => {
7574
+ const created = [];
7575
+ const failed = [];
7576
+ for (let i = 0;i < scenarios.length; i++) {
7577
+ const input = scenarios[i];
7578
+ try {
7579
+ const scenario = createScenario({
7580
+ name: input.name,
7581
+ description: input.description ?? input.name,
7582
+ steps: input.steps,
7583
+ tags: input.tags,
7584
+ priority: input.priority,
7585
+ targetPath: input.url
7586
+ });
7587
+ created.push(scenario);
7588
+ } catch (error) {
7589
+ const e = error instanceof Error ? error : new Error(String(error));
7590
+ failed.push({ index: i, name: input.name, error: e.message });
7591
+ }
7592
+ }
7593
+ const lines = [
7594
+ `Created: ${created.length} scenario(s)`,
7595
+ ...created.map((s) => ` [${s.shortId}] ${s.name}`)
7596
+ ];
7597
+ return json({ created, failed });
7598
+ });
7599
+ server.tool("cancel_run", "Mark a run as cancelled in the database. In-flight browser processes may still complete but results will be ignored.", {
7600
+ runId: exports_external.string().describe("Run ID to cancel")
7601
+ }, async ({ runId }) => {
7602
+ try {
7603
+ const run = getRun(runId);
7604
+ if (!run)
7605
+ return errorResponse(notFoundErr(runId, "Run"));
7606
+ updateRun(runId, { status: "cancelled", finished_at: new Date().toISOString() });
7607
+ return json({ cancelled: true, runId });
7608
+ } catch (error) {
7609
+ return errorResponse(error);
7610
+ }
7611
+ });
7612
+ server.tool("get_tags", "List all unique tags across scenarios, optionally filtered by project", {
7613
+ projectId: exports_external.string().optional().describe("Filter by project ID")
7614
+ }, async ({ projectId }) => {
7615
+ try {
7616
+ const scenarios = listScenarios({ projectId });
7617
+ const tagSet = new Set;
7618
+ for (const s of scenarios) {
7619
+ for (const tag of s.tags) {
7620
+ tagSet.add(tag);
7621
+ }
7622
+ }
7623
+ const tags = Array.from(tagSet).sort();
7624
+ return json({ tags, total: tags.length });
7625
+ } catch (error) {
7626
+ return errorResponse(error);
7627
+ }
7628
+ });
7629
+ server.tool("get_stale_scenarios", "List scenarios that have not been run recently (or never run)", {
7630
+ days: exports_external.number().optional().describe("Scenarios not run in this many days are considered stale (default 7)"),
7631
+ projectId: exports_external.string().optional().describe("Filter by project ID")
7632
+ }, async ({ days = 7, projectId }) => {
7633
+ try {
7634
+ let scenarios = findStaleScenarios(days);
7635
+ if (projectId) {
7636
+ scenarios = scenarios.filter((s) => s.projectId === projectId);
7637
+ }
7638
+ return json({ items: scenarios, total: scenarios.length });
7639
+ } catch (error) {
7640
+ return errorResponse(error);
7436
7641
  }
7437
7642
  });
7438
7643
  async function main() {