@hasna/testers 0.0.11 → 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/cli/index.js CHANGED
@@ -3413,7 +3413,7 @@ import chalk5 from "chalk";
3413
3413
  // package.json
3414
3414
  var package_default = {
3415
3415
  name: "@hasna/testers",
3416
- version: "0.0.11",
3416
+ version: "0.0.12",
3417
3417
  description: "AI-powered QA testing CLI \u2014 spawns cheap AI agents to test web apps with headless browsers",
3418
3418
  type: "module",
3419
3419
  main: "dist/index.js",
package/dist/mcp/index.js CHANGED
@@ -4902,6 +4902,22 @@ function updateScenario(id, input, version) {
4902
4902
  }
4903
4903
  return getScenario(existing.id);
4904
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
+ }
4905
4921
  function deleteScenario(id) {
4906
4922
  const db2 = getDatabase();
4907
4923
  const scenario = getScenario(id);
@@ -5106,6 +5122,9 @@ function updateResult(id, updates) {
5106
5122
  db2.query(`UPDATE results SET ${sets.join(", ")} WHERE id = ?`).run(...params);
5107
5123
  return getResult(existing.id);
5108
5124
  }
5125
+ function getResultsByRun(runId) {
5126
+ return listResults(runId);
5127
+ }
5109
5128
 
5110
5129
  // src/db/screenshots.ts
5111
5130
  init_types();
@@ -7103,6 +7122,63 @@ class Scheduler {
7103
7122
 
7104
7123
  // src/mcp/index.ts
7105
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'.";
7106
7182
  var server = new McpServer({
7107
7183
  name: "testers",
7108
7184
  version: "0.0.1"
@@ -7113,47 +7189,27 @@ server.tool("create_scenario", "Create a new test scenario", {
7113
7189
  steps: exports_external.array(exports_external.string()).optional().describe("Ordered test steps"),
7114
7190
  tags: exports_external.array(exports_external.string()).optional().describe("Tags for filtering"),
7115
7191
  priority: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("Scenario priority"),
7116
- model: exports_external.string().optional().describe("AI model to use"),
7192
+ model: exports_external.string().optional().describe(MODEL_DESC),
7117
7193
  targetPath: exports_external.string().optional().describe("URL path to navigate to"),
7118
7194
  requiresAuth: exports_external.boolean().optional().describe("Whether scenario requires authentication")
7119
7195
  }, async ({ name, description, steps, tags, priority, model, targetPath, requiresAuth }) => {
7120
7196
  try {
7121
7197
  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})` }] };
7198
+ return json(scenario);
7123
7199
  } 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 };
7200
+ return errorResponse(error);
7126
7201
  }
7127
7202
  });
7128
- server.tool("get_scenario", "Get a scenario by ID or short ID", {
7129
- 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}`)
7130
7205
  }, async ({ id }) => {
7131
7206
  try {
7132
7207
  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 }] };
7208
+ if (!scenario)
7209
+ return errorResponse(notFoundErr(id, "Scenario"));
7210
+ return json(scenario);
7154
7211
  } 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 };
7212
+ return errorResponse(error);
7157
7213
  }
7158
7214
  });
7159
7215
  server.tool("list_scenarios", "List test scenarios with optional filters", {
@@ -7164,20 +7220,13 @@ server.tool("list_scenarios", "List test scenarios with optional filters", {
7164
7220
  }, async ({ projectId, tags, priority, limit }) => {
7165
7221
  try {
7166
7222
  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
- `)}` }] };
7223
+ return json({ items: scenarios, total: scenarios.length });
7174
7224
  } 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 };
7225
+ return errorResponse(error);
7177
7226
  }
7178
7227
  });
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"),
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}`),
7181
7230
  name: exports_external.string().optional().describe("New name"),
7182
7231
  description: exports_external.string().optional().describe("New description"),
7183
7232
  steps: exports_external.array(exports_external.string()).optional().describe("New steps"),
@@ -7188,24 +7237,23 @@ server.tool("update_scenario", "Update an existing scenario (requires version fo
7188
7237
  }, async ({ id, name, description, steps, tags, priority, model, version }) => {
7189
7238
  try {
7190
7239
  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})` }] };
7240
+ return json(scenario);
7192
7241
  } 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 };
7242
+ return errorResponse(error, {
7243
+ fetchCurrent: () => getScenario(id) ?? getScenarioByShortId(id)
7244
+ });
7195
7245
  }
7196
7246
  });
7197
- server.tool("delete_scenario", "Delete a scenario by ID", {
7198
- 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}`)
7199
7249
  }, async ({ id }) => {
7200
7250
  try {
7201
7251
  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}` }] };
7252
+ if (!deleted)
7253
+ return errorResponse(notFoundErr(id, "Scenario"));
7254
+ return json({ deleted: true, id });
7206
7255
  } 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 };
7256
+ return errorResponse(error);
7209
7257
  }
7210
7258
  });
7211
7259
  server.tool("run_scenarios", "Run test scenarios against a URL", {
@@ -7213,50 +7261,27 @@ server.tool("run_scenarios", "Run test scenarios against a URL", {
7213
7261
  tags: exports_external.array(exports_external.string()).optional().describe("Filter scenarios by tags"),
7214
7262
  scenarioIds: exports_external.array(exports_external.string()).optional().describe("Run specific scenario IDs"),
7215
7263
  priority: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("Filter by priority"),
7216
- model: exports_external.string().optional().describe("AI model to use"),
7264
+ model: exports_external.string().optional().describe(MODEL_DESC),
7217
7265
  headed: exports_external.boolean().optional().describe("Run browser in headed mode"),
7218
7266
  parallel: exports_external.number().optional().describe("Number of parallel workers")
7219
7267
  }, async ({ url, tags, scenarioIds, priority, model, headed, parallel }) => {
7220
7268
  try {
7221
7269
  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 }] };
7270
+ return json({ runId, scenarioCount, url, status: "running", message: "Poll with get_run to check progress." });
7232
7271
  } 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 };
7272
+ return errorResponse(error);
7235
7273
  }
7236
7274
  });
7237
- server.tool("get_run", "Get details of a test run", {
7238
- 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}`)
7239
7277
  }, async ({ id }) => {
7240
7278
  try {
7241
7279
  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 }] };
7280
+ if (!run)
7281
+ return errorResponse(notFoundErr(id, "Run"));
7282
+ return json(run);
7257
7283
  } 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 };
7284
+ return errorResponse(error);
7260
7285
  }
7261
7286
  });
7262
7287
  server.tool("list_runs", "List test runs with optional filters", {
@@ -7265,50 +7290,61 @@ server.tool("list_runs", "List test runs with optional filters", {
7265
7290
  }, async ({ status, limit }) => {
7266
7291
  try {
7267
7292
  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
- `)}` }] };
7293
+ return json({ items: runs, total: runs.length });
7275
7294
  } 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 };
7295
+ return errorResponse(error);
7278
7296
  }
7279
7297
  });
7280
- server.tool("get_results", "Get test results for a run", {
7281
- runId: exports_external.string().describe("Run ID")
7282
- }, 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 }) => {
7283
7303
  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
- `)}` }] };
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 });
7292
7312
  } 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 };
7313
+ return errorResponse(error);
7295
7314
  }
7296
7315
  });
7297
- server.tool("get_screenshots", "Get screenshots for a test result", {
7298
- 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}`)
7299
7319
  }, async ({ resultId }) => {
7300
7320
  try {
7301
7321
  const screenshots = listScreenshots(resultId);
7302
7322
  if (screenshots.length === 0) {
7303
- return { content: [{ type: "text", text: `No screenshots found for result: ${resultId}` }] };
7323
+ return json({ items: [], total: 0 });
7304
7324
  }
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
- `)}` }] };
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 });
7309
7346
  } 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 };
7347
+ return errorResponse(error);
7312
7348
  }
7313
7349
  });
7314
7350
  server.tool("register_project", "Register or ensure a project exists", {
@@ -7318,25 +7354,17 @@ server.tool("register_project", "Register or ensure a project exists", {
7318
7354
  }, async ({ name, path, description }) => {
7319
7355
  try {
7320
7356
  const project = description ? createProject({ name, path, description }) : ensureProject(name, path);
7321
- return { content: [{ type: "text", text: `Project "${project.name}" registered (id: ${project.id})` }] };
7357
+ return json(project);
7322
7358
  } 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 };
7359
+ return errorResponse(error);
7325
7360
  }
7326
7361
  });
7327
7362
  server.tool("list_projects", "List all registered projects", {}, async () => {
7328
7363
  try {
7329
7364
  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
- `)}` }] };
7365
+ return json({ items: projects, total: projects.length });
7337
7366
  } 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 };
7367
+ return errorResponse(error);
7340
7368
  }
7341
7369
  });
7342
7370
  server.tool("register_agent", "Register an agent (idempotent \u2014 returns existing if name matches)", {
@@ -7346,25 +7374,17 @@ server.tool("register_agent", "Register an agent (idempotent \u2014 returns exis
7346
7374
  }, async ({ name, description, role }) => {
7347
7375
  try {
7348
7376
  const agent = registerAgent({ name, description, role });
7349
- return { content: [{ type: "text", text: `Agent "${agent.name}" registered (id: ${agent.id})` }] };
7377
+ return json(agent);
7350
7378
  } 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 };
7379
+ return errorResponse(error);
7353
7380
  }
7354
7381
  });
7355
7382
  server.tool("list_agents", "List all registered agents", {}, async () => {
7356
7383
  try {
7357
7384
  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
- `)}` }] };
7385
+ return json({ items: agents, total: agents.length });
7365
7386
  } 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 };
7387
+ return errorResponse(error);
7368
7388
  }
7369
7389
  });
7370
7390
  server.tool("import_from_todos", "Import test scenarios from the todos database", {
@@ -7374,10 +7394,9 @@ server.tool("import_from_todos", "Import test scenarios from the todos database"
7374
7394
  }, async ({ projectName, tags, projectId }) => {
7375
7395
  try {
7376
7396
  const result = importFromTodos({ projectName, tags, projectId });
7377
- return { content: [{ type: "text", text: `Imported ${result.imported} scenario(s), skipped ${result.skipped} duplicate(s)` }] };
7397
+ return json(result);
7378
7398
  } 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 };
7399
+ return errorResponse(error);
7381
7400
  }
7382
7401
  });
7383
7402
  server.tool("get_status", "Get system status: DB path, API key, scenario and run counts", {}, async () => {
@@ -7387,18 +7406,27 @@ server.tool("get_status", "Get system status: DB path, API key, scenario and run
7387
7406
  const scenarioCount = db2.query("SELECT COUNT(*) as count FROM scenarios").get().count;
7388
7407
  const runCount = db2.query("SELECT COUNT(*) as count FROM runs").get().count;
7389
7408
  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 }] };
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
+ });
7416
+ } catch (error) {
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 });
7399
7428
  } 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 };
7429
+ return errorResponse(error);
7402
7430
  }
7403
7431
  });
7404
7432
  server.tool("create_schedule", {
@@ -7407,7 +7435,7 @@ server.tool("create_schedule", {
7407
7435
  url: exports_external.string().describe("Target URL to test"),
7408
7436
  tags: exports_external.array(exports_external.string()).optional().describe("Filter scenarios by tags"),
7409
7437
  priority: exports_external.string().optional().describe("Filter scenarios by priority"),
7410
- model: exports_external.string().optional().describe("AI model"),
7438
+ model: exports_external.string().optional().describe(MODEL_DESC),
7411
7439
  headed: exports_external.boolean().optional().describe("Run headed"),
7412
7440
  parallel: exports_external.number().optional().describe("Parallel count"),
7413
7441
  projectId: exports_external.string().optional().describe("Project ID")
@@ -7424,9 +7452,9 @@ server.tool("create_schedule", {
7424
7452
  projectId: params.projectId
7425
7453
  });
7426
7454
  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()}` }] };
7455
+ return json({ ...schedule, nextRunAt: nextRun.toISOString() });
7428
7456
  } catch (e) {
7429
- return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
7457
+ return errorResponse(e);
7430
7458
  }
7431
7459
  });
7432
7460
  server.tool("list_schedules", {
@@ -7434,35 +7462,182 @@ server.tool("list_schedules", {
7434
7462
  enabled: exports_external.boolean().optional(),
7435
7463
  limit: exports_external.number().optional()
7436
7464
  }, 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
- `) }] };
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
+ }
7443
7471
  });
7444
7472
  server.tool("enable_schedule", { id: exports_external.string().describe("Schedule ID") }, async (params) => {
7445
7473
  try {
7446
7474
  const schedule = updateSchedule(params.id, { enabled: true });
7447
- return { content: [{ type: "text", text: `Schedule ${schedule.name} enabled.` }] };
7475
+ return json(schedule);
7448
7476
  } catch (e) {
7449
- return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
7477
+ return errorResponse(e);
7450
7478
  }
7451
7479
  });
7452
7480
  server.tool("disable_schedule", { id: exports_external.string().describe("Schedule ID") }, async (params) => {
7453
7481
  try {
7454
7482
  const schedule = updateSchedule(params.id, { enabled: false });
7455
- return { content: [{ type: "text", text: `Schedule ${schedule.name} disabled.` }] };
7483
+ return json(schedule);
7456
7484
  } catch (e) {
7457
- return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
7485
+ return errorResponse(e);
7458
7486
  }
7459
7487
  });
7460
7488
  server.tool("delete_schedule", { id: exports_external.string().describe("Schedule ID") }, async (params) => {
7461
7489
  try {
7462
7490
  const deleted = deleteSchedule(params.id);
7463
- 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 });
7464
7494
  } catch (e) {
7465
- 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);
7466
7641
  }
7467
7642
  });
7468
7643
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/testers",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "description": "AI-powered QA testing CLI — spawns cheap AI agents to test web apps with headless browsers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",