@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 +1 -1
- package/dist/mcp/index.js +342 -167
- package/package.json +1 -1
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.
|
|
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(
|
|
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
|
|
7198
|
+
return json(scenario);
|
|
7123
7199
|
} catch (error) {
|
|
7124
|
-
|
|
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",
|
|
7129
|
-
id: exports_external.string().describe(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
7180
|
-
id: exports_external.string().describe(
|
|
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
|
|
7240
|
+
return json(scenario);
|
|
7192
7241
|
} catch (error) {
|
|
7193
|
-
|
|
7194
|
-
|
|
7242
|
+
return errorResponse(error, {
|
|
7243
|
+
fetchCurrent: () => getScenario(id) ?? getScenarioByShortId(id)
|
|
7244
|
+
});
|
|
7195
7245
|
}
|
|
7196
7246
|
});
|
|
7197
|
-
server.tool("delete_scenario",
|
|
7198
|
-
id: exports_external.string().describe(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
7238
|
-
id: exports_external.string().describe(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
7281
|
-
runId: exports_external.string().describe(
|
|
7282
|
-
|
|
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
|
-
|
|
7285
|
-
if (
|
|
7286
|
-
|
|
7287
|
-
}
|
|
7288
|
-
|
|
7289
|
-
|
|
7290
|
-
|
|
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
|
-
|
|
7294
|
-
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
7313
|
+
return errorResponse(error);
|
|
7295
7314
|
}
|
|
7296
7315
|
});
|
|
7297
|
-
|
|
7298
|
-
|
|
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 {
|
|
7323
|
+
return json({ items: [], total: 0 });
|
|
7304
7324
|
}
|
|
7305
|
-
const
|
|
7306
|
-
|
|
7307
|
-
|
|
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
|
-
|
|
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
|
|
7357
|
+
return json(project);
|
|
7322
7358
|
} catch (error) {
|
|
7323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
7377
|
+
return json(agent);
|
|
7350
7378
|
} catch (error) {
|
|
7351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
7397
|
+
return json(result);
|
|
7378
7398
|
} catch (error) {
|
|
7379
|
-
|
|
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
|
-
|
|
7391
|
-
|
|
7392
|
-
|
|
7393
|
-
|
|
7394
|
-
|
|
7395
|
-
|
|
7396
|
-
|
|
7397
|
-
|
|
7398
|
-
return
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
7455
|
+
return json({ ...schedule, nextRunAt: nextRun.toISOString() });
|
|
7428
7456
|
} catch (e) {
|
|
7429
|
-
return
|
|
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
|
-
|
|
7438
|
-
|
|
7439
|
-
return {
|
|
7440
|
-
|
|
7441
|
-
|
|
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
|
|
7475
|
+
return json(schedule);
|
|
7448
7476
|
} catch (e) {
|
|
7449
|
-
return
|
|
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
|
|
7483
|
+
return json(schedule);
|
|
7456
7484
|
} catch (e) {
|
|
7457
|
-
return
|
|
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
|
-
|
|
7491
|
+
if (!deleted)
|
|
7492
|
+
return errorResponse(notFoundErr(params.id, "Schedule"));
|
|
7493
|
+
return json({ deleted: true, id: params.id });
|
|
7464
7494
|
} catch (e) {
|
|
7465
|
-
return
|
|
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() {
|