@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/dashboard/dist/assets/{index-DvYdwJK-.css → index-DyXKnBM8.css} +1 -1
- package/dashboard/dist/assets/index-jNG_Nd_Q.js +49 -0
- package/dashboard/dist/index.html +2 -2
- package/dist/cli/index.js +254 -15
- package/dist/db/runs.d.ts.map +1 -1
- package/dist/db/scenarios.d.ts +4 -0
- package/dist/db/scenarios.d.ts.map +1 -1
- package/dist/index.js +45 -4
- package/dist/lib/costs.d.ts +12 -0
- package/dist/lib/costs.d.ts.map +1 -1
- package/dist/lib/reporter.d.ts +2 -1
- package/dist/lib/reporter.d.ts.map +1 -1
- package/dist/lib/runner.d.ts +4 -1
- package/dist/lib/runner.d.ts.map +1 -1
- package/dist/mcp/index.js +375 -170
- package/dist/server/index.js +85 -3
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/dashboard/dist/assets/index-RV9LMdfY.js +0 -49
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
7198
|
+
return json(scenario);
|
|
7093
7199
|
} catch (error) {
|
|
7094
|
-
|
|
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",
|
|
7099
|
-
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}`)
|
|
7100
7205
|
}, async ({ id }) => {
|
|
7101
7206
|
try {
|
|
7102
7207
|
const scenario = getScenario(id) ?? getScenarioByShortId(id);
|
|
7103
|
-
if (!scenario)
|
|
7104
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
7150
|
-
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}`),
|
|
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
|
|
7240
|
+
return json(scenario);
|
|
7162
7241
|
} catch (error) {
|
|
7163
|
-
|
|
7164
|
-
|
|
7242
|
+
return errorResponse(error, {
|
|
7243
|
+
fetchCurrent: () => getScenario(id) ?? getScenarioByShortId(id)
|
|
7244
|
+
});
|
|
7165
7245
|
}
|
|
7166
7246
|
});
|
|
7167
|
-
server.tool("delete_scenario",
|
|
7168
|
-
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}`)
|
|
7169
7249
|
}, async ({ id }) => {
|
|
7170
7250
|
try {
|
|
7171
7251
|
const deleted = deleteScenario(id);
|
|
7172
|
-
if (!deleted)
|
|
7173
|
-
return
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
7208
|
-
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}`)
|
|
7209
7277
|
}, async ({ id }) => {
|
|
7210
7278
|
try {
|
|
7211
7279
|
const run = getRun(id);
|
|
7212
|
-
if (!run)
|
|
7213
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
7251
|
-
runId: exports_external.string().describe(
|
|
7252
|
-
|
|
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
|
-
|
|
7255
|
-
if (
|
|
7256
|
-
|
|
7257
|
-
}
|
|
7258
|
-
|
|
7259
|
-
|
|
7260
|
-
|
|
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
|
-
|
|
7264
|
-
return { content: [{ type: "text", text: `${e.name}: ${e.message}` }], isError: true };
|
|
7313
|
+
return errorResponse(error);
|
|
7265
7314
|
}
|
|
7266
7315
|
});
|
|
7267
|
-
|
|
7268
|
-
|
|
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 {
|
|
7323
|
+
return json({ items: [], total: 0 });
|
|
7274
7324
|
}
|
|
7275
|
-
const
|
|
7276
|
-
|
|
7277
|
-
|
|
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
|
-
|
|
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
|
|
7357
|
+
return json(project);
|
|
7292
7358
|
} catch (error) {
|
|
7293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
7377
|
+
return json(agent);
|
|
7320
7378
|
} catch (error) {
|
|
7321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
7397
|
+
return json(result);
|
|
7348
7398
|
} catch (error) {
|
|
7349
|
-
|
|
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
|
-
|
|
7361
|
-
|
|
7362
|
-
|
|
7363
|
-
|
|
7364
|
-
|
|
7365
|
-
|
|
7366
|
-
|
|
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
|
-
|
|
7371
|
-
|
|
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(
|
|
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 {
|
|
7455
|
+
return json({ ...schedule, nextRunAt: nextRun.toISOString() });
|
|
7398
7456
|
} catch (e) {
|
|
7399
|
-
return
|
|
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
|
-
|
|
7408
|
-
|
|
7409
|
-
return {
|
|
7410
|
-
|
|
7411
|
-
|
|
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
|
|
7475
|
+
return json(schedule);
|
|
7418
7476
|
} catch (e) {
|
|
7419
|
-
return
|
|
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
|
|
7483
|
+
return json(schedule);
|
|
7426
7484
|
} catch (e) {
|
|
7427
|
-
return
|
|
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
|
-
|
|
7491
|
+
if (!deleted)
|
|
7492
|
+
return errorResponse(notFoundErr(params.id, "Schedule"));
|
|
7493
|
+
return json({ deleted: true, id: params.id });
|
|
7434
7494
|
} catch (e) {
|
|
7435
|
-
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);
|
|
7436
7641
|
}
|
|
7437
7642
|
});
|
|
7438
7643
|
async function main() {
|