@kirrosh/apitool 0.4.3 → 0.5.1

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.
@@ -25,7 +25,7 @@ function makeSkippedResult(stepName: string, reason: string): StepResult {
25
25
  };
26
26
  }
27
27
 
28
- export async function runSuite(suite: TestSuite, env: Environment = {}): Promise<TestRunResult> {
28
+ export async function runSuite(suite: TestSuite, env: Environment = {}, dryRun = false): Promise<TestRunResult> {
29
29
  const startedAt = new Date().toISOString();
30
30
  const steps: StepResult[] = [];
31
31
  const variables: Record<string, unknown> = { ...env };
@@ -71,6 +71,20 @@ export async function runSuite(suite: TestSuite, env: Environment = {}): Promise
71
71
 
72
72
  const request: HttpRequest = { method: resolved.method, url, headers, body };
73
73
 
74
+ if (dryRun) {
75
+ const bodyPreview = body ? ` ${body.slice(0, 200)}` : "";
76
+ steps.push({
77
+ name: step.name,
78
+ status: "pass",
79
+ duration_ms: 0,
80
+ request,
81
+ assertions: [],
82
+ captures: {},
83
+ error: `[DRY RUN] ${resolved.method} ${url}${bodyPreview}`,
84
+ });
85
+ continue;
86
+ }
87
+
74
88
  try {
75
89
  const response = await executeRequest(request, fetchOptions);
76
90
 
@@ -145,6 +159,6 @@ export async function runSuite(suite: TestSuite, env: Environment = {}): Promise
145
159
  };
146
160
  }
147
161
 
148
- export async function runSuites(suites: TestSuite[], env: Environment = {}): Promise<TestRunResult[]> {
149
- return Promise.all(suites.map((suite) => runSuite(suite, env)));
162
+ export async function runSuites(suites: TestSuite[], env: Environment = {}, dryRun = false): Promise<TestRunResult[]> {
163
+ return Promise.all(suites.map((suite) => runSuite(suite, env, dryRun)));
150
164
  }
package/src/db/schema.ts CHANGED
@@ -38,6 +38,12 @@ export function closeDb(): void {
38
38
  }
39
39
  }
40
40
 
41
+ export function resetDb(): void {
42
+ if (_db) { try { _db.close(); } catch {} }
43
+ _db = null;
44
+ _dbPath = null;
45
+ }
46
+
41
47
  // ──────────────────────────────────────────────
42
48
  // Schema
43
49
  // ──────────────────────────────────────────────
package/src/mcp/server.ts CHANGED
@@ -7,12 +7,14 @@ import { registerSendRequestTool } from "./tools/send-request.ts";
7
7
  import { registerExploreApiTool } from "./tools/explore-api.ts";
8
8
  import { registerManageEnvironmentTool } from "./tools/manage-environment.ts";
9
9
  import { registerCoverageAnalysisTool } from "./tools/coverage-analysis.ts";
10
- import { registerSaveTestSuiteTool } from "./tools/save-test-suite.ts";
10
+ import { registerSaveTestSuiteTool, registerSaveTestSuitesTool } from "./tools/save-test-suite.ts";
11
11
  import { registerGenerateTestsGuideTool } from "./tools/generate-tests-guide.ts";
12
12
  import { registerSetupApiTool } from "./tools/setup-api.ts";
13
13
  import { registerGenerateMissingTestsTool } from "./tools/generate-missing-tests.ts";
14
14
  import { registerManageServerTool } from "./tools/manage-server.ts";
15
15
  import { registerCiInitTool } from "./tools/ci-init.ts";
16
+ import { registerSetWorkDirTool } from "./tools/set-work-dir.ts";
17
+ import { registerDescribeEndpointTool } from "./tools/describe-endpoint.ts";
16
18
 
17
19
  export interface McpServerOptions {
18
20
  dbPath?: string;
@@ -35,11 +37,14 @@ export async function startMcpServer(options: McpServerOptions = {}): Promise<vo
35
37
  registerManageEnvironmentTool(server, dbPath);
36
38
  registerCoverageAnalysisTool(server);
37
39
  registerSaveTestSuiteTool(server, dbPath);
40
+ registerSaveTestSuitesTool(server, dbPath);
38
41
  registerGenerateTestsGuideTool(server);
39
42
  registerSetupApiTool(server, dbPath);
40
43
  registerGenerateMissingTestsTool(server);
41
44
  registerManageServerTool(server, dbPath);
42
45
  registerCiInitTool(server);
46
+ registerSetWorkDirTool(server);
47
+ registerDescribeEndpointTool(server);
43
48
 
44
49
  // Connect via stdio transport
45
50
  const transport = new StdioServerTransport();
@@ -9,8 +9,9 @@ export function registerCoverageAnalysisTool(server: McpServer) {
9
9
  inputSchema: {
10
10
  specPath: z.string().describe("Path to OpenAPI spec file (JSON or YAML)"),
11
11
  testsDir: z.string().describe("Path to directory with test YAML files"),
12
+ failThreshold: z.optional(z.number().min(0).max(100)).describe("Return isError when coverage % is below this threshold (0–100)"),
12
13
  },
13
- }, async ({ specPath, testsDir }) => {
14
+ }, async ({ specPath, testsDir, failThreshold }) => {
14
15
  try {
15
16
  const doc = await readOpenApiSpec(specPath);
16
17
  const allEndpoints = extractEndpoints(doc);
@@ -45,8 +46,10 @@ export function registerCoverageAnalysisTool(server: McpServer) {
45
46
  })),
46
47
  };
47
48
 
49
+ const belowThreshold = failThreshold !== undefined && percentage < failThreshold;
48
50
  return {
49
51
  content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
52
+ ...(belowThreshold ? { isError: true } : {}),
50
53
  };
51
54
  } catch (err) {
52
55
  return {
@@ -0,0 +1,159 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import type { OpenAPIV3 } from "openapi-types";
4
+ import { readOpenApiSpec } from "../../core/generator/index.ts";
5
+
6
+ export function registerDescribeEndpointTool(server: McpServer) {
7
+ server.registerTool("describe_endpoint", {
8
+ description:
9
+ "Full details for one endpoint: params grouped by type, request body schema, " +
10
+ "all response schemas + response headers, security, deprecated flag. " +
11
+ "Use when a test fails and you need complete endpoint spec without reading the whole file.",
12
+ inputSchema: {
13
+ specPath: z.string().describe("Path to OpenAPI spec file (JSON or YAML) or HTTP URL"),
14
+ method: z.string().describe('HTTP method, e.g. "GET", "POST", "PUT"'),
15
+ path: z.string().describe('Endpoint path, e.g. "/pets/{petId}"'),
16
+ },
17
+ }, async ({ specPath, method, path: endpointPath }) => {
18
+ try {
19
+ const doc = await readOpenApiSpec(specPath) as OpenAPIV3.Document;
20
+
21
+ // Normalize inputs
22
+ const methodLower = method.toLowerCase() as OpenAPIV3.HttpMethods;
23
+ const normalizedPath = endpointPath.replace(/\/+$/, "") || "/";
24
+
25
+ // Find operation — try exact match first, then case-insensitive path match
26
+ let operation: OpenAPIV3.OperationObject | undefined;
27
+ let resolvedPath = normalizedPath;
28
+
29
+ const paths = doc.paths ?? {};
30
+
31
+ if (paths[normalizedPath]?.[methodLower]) {
32
+ operation = paths[normalizedPath][methodLower] as OpenAPIV3.OperationObject;
33
+ } else {
34
+ // Case-insensitive fallback
35
+ const lowerTarget = normalizedPath.toLowerCase();
36
+ for (const [p, pathItem] of Object.entries(paths)) {
37
+ if (p.toLowerCase() === lowerTarget && pathItem?.[methodLower]) {
38
+ operation = pathItem[methodLower] as OpenAPIV3.OperationObject;
39
+ resolvedPath = p;
40
+ break;
41
+ }
42
+ }
43
+ }
44
+
45
+ if (!operation) {
46
+ const available = Object.entries(paths).flatMap(([p, pathItem]) =>
47
+ Object.keys(pathItem ?? {})
48
+ .filter(k => ["get","post","put","patch","delete","head","options","trace"].includes(k))
49
+ .map(k => `${k.toUpperCase()} ${p}`)
50
+ ).sort();
51
+ return {
52
+ content: [{
53
+ type: "text" as const,
54
+ text: JSON.stringify({
55
+ error: `Endpoint ${method.toUpperCase()} ${endpointPath} not found in spec`,
56
+ availableEndpoints: available,
57
+ }, null, 2),
58
+ }],
59
+ isError: true,
60
+ };
61
+ }
62
+
63
+ const pathItem = paths[resolvedPath] ?? {};
64
+
65
+ // Merge path-level and operation-level parameters (operation overrides by name+in)
66
+ const pathLevelParams = (pathItem.parameters ?? []) as OpenAPIV3.ParameterObject[];
67
+ const opLevelParams = (operation.parameters ?? []) as OpenAPIV3.ParameterObject[];
68
+
69
+ const paramMap = new Map<string, OpenAPIV3.ParameterObject>();
70
+ for (const p of pathLevelParams) paramMap.set(`${p.in}:${p.name}`, p);
71
+ for (const p of opLevelParams) paramMap.set(`${p.in}:${p.name}`, p); // operation overrides
72
+
73
+ // Group by "in"
74
+ const grouped: Record<string, object[]> = { path: [], query: [], header: [], cookie: [] };
75
+ for (const p of paramMap.values()) {
76
+ const loc = p.in in grouped ? p.in : "query";
77
+ const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
78
+ grouped[loc]!.push({
79
+ name: p.name,
80
+ required: p.required ?? false,
81
+ ...(schema?.type ? { type: schema.type } : {}),
82
+ ...(schema?.format ? { format: schema.format } : {}),
83
+ ...(schema?.enum ? { enum: schema.enum } : {}),
84
+ ...(schema?.default !== undefined ? { default: schema.default } : {}),
85
+ ...(p.description ? { description: p.description } : {}),
86
+ });
87
+ }
88
+
89
+ // Request body
90
+ let requestBody: object | undefined;
91
+ if (operation.requestBody) {
92
+ const rb = operation.requestBody as OpenAPIV3.RequestBodyObject;
93
+ const contentTypes = Object.keys(rb.content ?? {});
94
+ const preferredCt = contentTypes.find(ct => ct.includes("application/json")) ?? contentTypes[0];
95
+ const mediaObj = preferredCt ? rb.content[preferredCt] : undefined;
96
+ requestBody = {
97
+ required: rb.required ?? false,
98
+ ...(preferredCt ? { contentType: preferredCt } : {}),
99
+ ...(mediaObj?.schema ? { schema: mediaObj.schema } : {}),
100
+ ...(rb.description ? { description: rb.description } : {}),
101
+ };
102
+ }
103
+
104
+ // Responses
105
+ const responses: Record<string, object> = {};
106
+ for (const [statusCode, respObj] of Object.entries(operation.responses ?? {})) {
107
+ const resp = respObj as OpenAPIV3.ResponseObject;
108
+ const contentTypes = Object.keys(resp.content ?? {});
109
+ const preferredCt = contentTypes.find(ct => ct.includes("application/json")) ?? contentTypes[0];
110
+ const mediaObj = preferredCt ? resp.content?.[preferredCt] : undefined;
111
+
112
+ // Response headers
113
+ const headers: Record<string, object> = {};
114
+ for (const [hName, hObj] of Object.entries(resp.headers ?? {})) {
115
+ const h = hObj as OpenAPIV3.HeaderObject;
116
+ headers[hName] = {
117
+ ...(h.description ? { description: h.description } : {}),
118
+ ...(h.schema ? { schema: h.schema } : {}),
119
+ };
120
+ }
121
+
122
+ responses[statusCode] = {
123
+ description: resp.description,
124
+ headers,
125
+ ...(preferredCt ? { contentType: preferredCt } : {}),
126
+ ...(mediaObj?.schema ? { schema: mediaObj.schema } : {}),
127
+ };
128
+ }
129
+
130
+ // Security — merge doc-level and operation-level
131
+ const docSecurity = (doc.security ?? []) as OpenAPIV3.SecurityRequirementObject[];
132
+ const opSecurity = (operation.security ?? docSecurity) as OpenAPIV3.SecurityRequirementObject[];
133
+ const securityNames = [...new Set(opSecurity.flatMap(req => Object.keys(req)))];
134
+
135
+ const result = {
136
+ method: method.toUpperCase(),
137
+ path: resolvedPath,
138
+ ...(operation.operationId ? { operationId: operation.operationId } : {}),
139
+ ...(operation.summary ? { summary: operation.summary } : {}),
140
+ ...(operation.description ? { description: operation.description } : {}),
141
+ ...(operation.tags?.length ? { tags: operation.tags } : {}),
142
+ deprecated: operation.deprecated ?? false,
143
+ security: securityNames,
144
+ parameters: grouped,
145
+ ...(requestBody ? { requestBody } : {}),
146
+ responses,
147
+ };
148
+
149
+ return {
150
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
151
+ };
152
+ } catch (err) {
153
+ return {
154
+ content: [{ type: "text" as const, text: JSON.stringify({ error: (err as Error).message }, null, 2) }],
155
+ isError: true,
156
+ };
157
+ }
158
+ });
159
+ }
@@ -18,15 +18,22 @@ export function registerGenerateMissingTestsTool(server: McpServer) {
18
18
  specPath: z.string().describe("Path or URL to OpenAPI spec file"),
19
19
  testsDir: z.string().describe("Path to directory with existing test YAML files"),
20
20
  outputDir: z.optional(z.string()).describe("Directory for saving new test files (default: same as testsDir)"),
21
+ methodFilter: z.optional(z.array(z.string())).describe("Only include endpoints with these HTTP methods (e.g. [\"GET\"] for smoke tests)"),
21
22
  },
22
- }, async ({ specPath, testsDir, outputDir }) => {
23
+ }, async ({ specPath, testsDir, outputDir, methodFilter }) => {
23
24
  try {
24
25
  const doc = await readOpenApiSpec(specPath);
25
- const allEndpoints = extractEndpoints(doc);
26
+ let allEndpoints = extractEndpoints(doc);
26
27
  const securitySchemes = extractSecuritySchemes(doc);
27
28
  const baseUrl = ((doc as any).servers?.[0]?.url) as string | undefined;
28
29
  const title = (doc as any).info?.title as string | undefined;
29
30
 
31
+ // Apply method filter before coverage check
32
+ if (methodFilter && methodFilter.length > 0) {
33
+ const methods = methodFilter.map(m => m.toUpperCase());
34
+ allEndpoints = allEndpoints.filter(ep => methods.includes(ep.method.toUpperCase()));
35
+ }
36
+
30
37
  if (allEndpoints.length === 0) {
31
38
  return {
32
39
  content: [{ type: "text" as const, text: JSON.stringify({ error: "No endpoints found in the spec" }, null, 2) }],
@@ -14,11 +14,16 @@ export function registerGenerateTestsGuideTool(server: McpServer) {
14
14
  inputSchema: {
15
15
  specPath: z.string().describe("Path or URL to OpenAPI spec file"),
16
16
  outputDir: z.optional(z.string()).describe("Directory for saving test files (default: ./tests/)"),
17
+ methodFilter: z.optional(z.array(z.string())).describe("Only include endpoints with these HTTP methods (e.g. [\"GET\"] for smoke tests)"),
17
18
  },
18
- }, async ({ specPath, outputDir }) => {
19
+ }, async ({ specPath, outputDir, methodFilter }) => {
19
20
  try {
20
21
  const doc = await readOpenApiSpec(specPath);
21
- const endpoints = extractEndpoints(doc);
22
+ let endpoints = extractEndpoints(doc);
23
+ if (methodFilter && methodFilter.length > 0) {
24
+ const methods = methodFilter.map(m => m.toUpperCase());
25
+ endpoints = endpoints.filter(ep => methods.includes(ep.method.toUpperCase()));
26
+ }
22
27
  const securitySchemes = extractSecuritySchemes(doc);
23
28
  const baseUrl = ((doc as any).servers?.[0]?.url) as string | undefined;
24
29
  const title = (doc as any).info?.title as string | undefined;
@@ -302,6 +307,21 @@ ${hasAuth ? `**Auth suite** (\`auth.yaml\`):
302
307
 
303
308
  ---
304
309
 
310
+ ## Tag Conventions
311
+
312
+ Use standard tags to enable safe filtering:
313
+
314
+ | Tag | HTTP Methods | Safe for |
315
+ |-----|-------------|---------|
316
+ | \`smoke\` | GET only | Production (read-only, zero risk) |
317
+ | \`crud\` | POST/PUT/PATCH | Staging only (state-changing) |
318
+ | \`destructive\` | DELETE | Explicit opt-in, run last |
319
+ | \`auth\` | Any (auth flows) | Run first to capture tokens |
320
+
321
+ Example: \`apitool run --tag smoke --safe\` → reads-only, safe against production.
322
+
323
+ ---
324
+
305
325
  ## Practical Tips
306
326
 
307
327
  - **int64 IDs**: For APIs returning large auto-generated IDs (int64), prefer setting fixed IDs in request bodies rather than capturing auto-generated ones, as JSON number precision may cause mismatches.
@@ -312,6 +332,26 @@ ${hasAuth ? `**Auth suite** (\`auth.yaml\`):
312
332
  - **Error responses**: Assert that error bodies contain useful info (\`message: { exists: true }\`), not just status codes.
313
333
  - **Bulk operations**: After bulk create (createWithArray, createWithList), add GET steps to verify resources were actually created.
314
334
  - **204 No Content**: When an endpoint returns 204, omit \`body:\` assertions entirely — an empty response IS the correct behavior. Adding body assertions on 204 will always fail.
335
+ - **Cleanup pattern**: Always delete test data in the same suite. Use a create → read → delete lifecycle so tests are idempotent:
336
+ \`\`\`yaml
337
+ tests:
338
+ - name: Create test resource
339
+ POST: /users
340
+ json: { name: "apitool-test-{{$randomString}}" }
341
+ expect:
342
+ status: 201
343
+ body:
344
+ id: { capture: user_id }
345
+ - name: Read created resource
346
+ GET: /users/{{user_id}}
347
+ expect:
348
+ status: 200
349
+ - name: Cleanup - delete test resource
350
+ DELETE: /users/{{user_id}}
351
+ expect:
352
+ status: 204
353
+ \`\`\`
354
+ - **Identifiable test data**: Prefix test data with \`apitool-test-\` or use \`{{$uuid}}\` / \`apitool-test-{{$randomString}}\` so you can identify and clean up leftover test data if needed.
315
355
 
316
356
  ---
317
357
 
@@ -8,16 +8,19 @@ export function registerQueryDbTool(server: McpServer, dbPath?: string) {
8
8
  description:
9
9
  "Query the apitool database. Actions: list_collections (all APIs with run stats), " +
10
10
  "list_runs (recent test runs), get_run_results (full detail for a run), " +
11
- "diagnose_failure (only failed/errored steps for a run).",
11
+ "diagnose_failure (only failed/errored steps for a run), " +
12
+ "compare_runs (regressions and fixes between two runs).",
12
13
  inputSchema: {
13
- action: z.enum(["list_collections", "list_runs", "get_run_results", "diagnose_failure"])
14
+ action: z.enum(["list_collections", "list_runs", "get_run_results", "diagnose_failure", "compare_runs"])
14
15
  .describe("Query action to perform"),
15
16
  runId: z.optional(z.number().int())
16
17
  .describe("Run ID (required for get_run_results and diagnose_failure)"),
18
+ runIdB: z.optional(z.number().int())
19
+ .describe("Second run ID (required for compare_runs — this is the newer run)"),
17
20
  limit: z.optional(z.number().int().min(1).max(100))
18
21
  .describe("Max number of runs to return (default: 20, only for list_runs)"),
19
22
  },
20
- }, async ({ action, runId, limit }) => {
23
+ }, async ({ action, runId, runIdB, limit }) => {
21
24
  try {
22
25
  getDb(dbPath);
23
26
 
@@ -130,6 +133,71 @@ export function registerQueryDbTool(server: McpServer, dbPath?: string) {
130
133
  content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
131
134
  };
132
135
  }
136
+
137
+ case "compare_runs": {
138
+ if (runId == null || runIdB == null) {
139
+ return {
140
+ content: [{ type: "text" as const, text: JSON.stringify({ error: "Both runId (run A) and runIdB (run B) are required for compare_runs" }, null, 2) }],
141
+ isError: true,
142
+ };
143
+ }
144
+ const runARecord = getRunById(runId);
145
+ const runBRecord = getRunById(runIdB);
146
+ if (!runARecord) {
147
+ return {
148
+ content: [{ type: "text" as const, text: JSON.stringify({ error: `Run #${runId} not found` }, null, 2) }],
149
+ isError: true,
150
+ };
151
+ }
152
+ if (!runBRecord) {
153
+ return {
154
+ content: [{ type: "text" as const, text: JSON.stringify({ error: `Run #${runIdB} not found` }, null, 2) }],
155
+ isError: true,
156
+ };
157
+ }
158
+
159
+ const resultsA = getResultsByRunId(runId);
160
+ const resultsB = getResultsByRunId(runIdB);
161
+
162
+ const mapA = new Map<string, string>();
163
+ const mapB = new Map<string, string>();
164
+ for (const r of resultsA) mapA.set(`${r.suite_name}::${r.test_name}`, r.status);
165
+ for (const r of resultsB) mapB.set(`${r.suite_name}::${r.test_name}`, r.status);
166
+
167
+ const regressions: Array<{ suite: string; test: string; before: string; after: string }> = [];
168
+ const fixes: Array<{ suite: string; test: string; before: string; after: string }> = [];
169
+ let unchanged = 0;
170
+ let newTests = 0;
171
+ let removedTests = 0;
172
+
173
+ for (const [key, statusB] of mapB) {
174
+ const statusA = mapA.get(key);
175
+ if (statusA === undefined) { newTests++; continue; }
176
+ const [suite, test] = key.split("::") as [string, string];
177
+ const wasPass = statusA === "pass";
178
+ const isPass = statusB === "pass";
179
+ const wasFail = statusA === "fail" || statusA === "error";
180
+ const isFail = statusB === "fail" || statusB === "error";
181
+ if (wasPass && isFail) regressions.push({ suite, test, before: statusA, after: statusB });
182
+ else if (wasFail && isPass) fixes.push({ suite, test, before: statusA, after: statusB });
183
+ else unchanged++;
184
+ }
185
+ for (const key of mapA.keys()) {
186
+ if (!mapB.has(key)) removedTests++;
187
+ }
188
+
189
+ const compareResult = {
190
+ runA: { id: runId, started_at: runARecord.started_at },
191
+ runB: { id: runIdB, started_at: runBRecord.started_at },
192
+ summary: { regressions: regressions.length, fixes: fixes.length, unchanged, newTests, removedTests },
193
+ regressions,
194
+ fixes,
195
+ hasRegressions: regressions.length > 0,
196
+ };
197
+ return {
198
+ content: [{ type: "text" as const, text: JSON.stringify(compareResult, null, 2) }],
199
+ };
200
+ }
133
201
  }
134
202
  } catch (err) {
135
203
  return {
@@ -11,8 +11,10 @@ export function registerRunTestsTool(server: McpServer, dbPath?: string) {
11
11
  envName: z.optional(z.string()).describe("Environment name (loads .env.<name>.yaml)"),
12
12
  safe: z.optional(z.boolean()).describe("Run only GET tests (read-only, safe mode)"),
13
13
  tag: z.optional(z.array(z.string())).describe("Filter suites by tag (OR logic)"),
14
+ envVars: z.optional(z.record(z.string(), z.string())).describe("Environment variables to inject (override env file, e.g. {\"TOKEN\": \"xxx\"})"),
15
+ dryRun: z.optional(z.boolean()).describe("Show requests without sending them (always exits 0)"),
14
16
  },
15
- }, async ({ testPath, envName, safe, tag }) => {
17
+ }, async ({ testPath, envName, safe, tag, envVars, dryRun }) => {
16
18
  const { runId, results } = await executeRun({
17
19
  testPath,
18
20
  envName,
@@ -20,6 +22,8 @@ export function registerRunTestsTool(server: McpServer, dbPath?: string) {
20
22
  dbPath,
21
23
  safe,
22
24
  tag,
25
+ envVars,
26
+ dryRun,
23
27
  });
24
28
 
25
29
  const total = results.reduce((s, r) => s + r.total, 0);