@testsmith/api-spector 0.4.9 → 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.
- package/bin/cli.js +6 -0
- package/out/main/chunks/coverage-C54mZEh_.js +146 -0
- package/out/main/chunks/{handle-CF2LjTGV.js → handle-b6bp3fb0.js} +18 -0
- package/out/main/chunks/{import-ChCkdHOB.js → import-BeY7gjWL.js} +2 -2
- package/out/main/chunks/{request-exec-DeXgxps8.js → request-exec-D_xyyt3_.js} +6 -6
- package/out/main/chunks/{snapshots-8wLrfQcq.js → snapshots-BJiZNuRN.js} +1 -1
- package/out/main/chunks/{soap-handler-pOrJ625E.js → soap-handler-BYkE6MyE.js} +2 -2
- package/out/main/compare.js +218 -0
- package/out/main/contract.js +3 -3
- package/out/main/coverage.js +161 -0
- package/out/main/generate-tests.js +317 -0
- package/out/main/index.js +213 -20
- package/out/main/lib.js +2 -2
- package/out/main/runner.js +3 -3
- package/out/main/wsdl.js +3 -3
- package/out/preload/index.js +34 -0
- package/out/renderer/assets/{index-b63nUtxA.js → index-CV7iFJoA.js} +1629 -163
- package/out/renderer/assets/index-feMbYxZb.css +2 -0
- package/out/renderer/index.html +2 -2
- package/package.json +3 -1
- package/out/renderer/assets/index-CFvibipL.css +0 -2
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
const promises = require("fs/promises");
|
|
4
|
+
const jsYaml = require("js-yaml");
|
|
5
|
+
const undici = require("undici");
|
|
6
|
+
const coverage = require("./chunks/coverage-C54mZEh_.js");
|
|
7
|
+
const cliCommon = require("./chunks/cli-common-BKYgsbGB.js");
|
|
8
|
+
require("path");
|
|
9
|
+
require("crypto");
|
|
10
|
+
async function loadObservations(path) {
|
|
11
|
+
const report = JSON.parse(await promises.readFile(path, "utf8"));
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const r of report.results ?? []) {
|
|
14
|
+
if (!r.method || !r.url) continue;
|
|
15
|
+
const status = r.httpStatus ?? r.status;
|
|
16
|
+
if (typeof status !== "number") continue;
|
|
17
|
+
let responsePaths;
|
|
18
|
+
try {
|
|
19
|
+
if (r.response?.body) responsePaths = coverage.flattenValuePaths(JSON.parse(r.response.body));
|
|
20
|
+
} catch {
|
|
21
|
+
}
|
|
22
|
+
out.push({ method: r.method, url: r.url, status, responsePaths });
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
async function loadSpec(source) {
|
|
27
|
+
const isUrl = /^https?:\/\//i.test(source);
|
|
28
|
+
const raw = isUrl ? await (async () => {
|
|
29
|
+
const r = await undici.fetch(source);
|
|
30
|
+
if (!r.ok) throw new Error(`HTTP ${r.status} fetching ${source}`);
|
|
31
|
+
return r.text();
|
|
32
|
+
})() : await promises.readFile(source, "utf8");
|
|
33
|
+
const isYaml = /\.ya?ml$/i.test(source) || !source.trim().startsWith("{") && !isUrl;
|
|
34
|
+
return isYaml ? jsYaml.load(raw) : JSON.parse(raw);
|
|
35
|
+
}
|
|
36
|
+
function collectRequests(collections) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const col of collections) {
|
|
39
|
+
for (const req of Object.values(col.requests ?? {})) {
|
|
40
|
+
if (req.disabled) continue;
|
|
41
|
+
out.push({
|
|
42
|
+
name: `${col.name} / ${req.name}`,
|
|
43
|
+
method: req.method,
|
|
44
|
+
url: req.url,
|
|
45
|
+
expectedStatus: req.contract?.statusCode
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
function bar(pct) {
|
|
52
|
+
const filled = Math.round(pct / 5);
|
|
53
|
+
return "█".repeat(filled) + "░".repeat(20 - filled);
|
|
54
|
+
}
|
|
55
|
+
function pctColor(pct) {
|
|
56
|
+
return pct >= 80 ? cliCommon.C.green : pct >= 50 ? cliCommon.C.yellow : cliCommon.C.red;
|
|
57
|
+
}
|
|
58
|
+
function printReport(report) {
|
|
59
|
+
const t = report.totals;
|
|
60
|
+
const title = report.spec.title ? `${report.spec.title}${report.spec.version ? ` v${report.spec.version}` : ""}` : "API";
|
|
61
|
+
console.log("");
|
|
62
|
+
console.log(cliCommon.color(` ${title} - test coverage`, cliCommon.C.bold, cliCommon.C.white));
|
|
63
|
+
console.log("");
|
|
64
|
+
console.log(` ${cliCommon.color(bar(t.operationPct), pctColor(t.operationPct))} ${cliCommon.color(`${t.operationPct}%`, cliCommon.C.bold)} operations tested (${t.tested}/${t.operations})`);
|
|
65
|
+
console.log(` ${cliCommon.color(`${t.coveredStatuses}/${t.declaredStatuses}`, cliCommon.C.white)} declared response codes covered (${t.statusPct}%)`);
|
|
66
|
+
if (t.declaredProperties > 0) {
|
|
67
|
+
console.log(` ${cliCommon.color(`${t.coveredProperties}/${t.declaredProperties}`, cliCommon.C.white)} response-schema properties seen in runs (${t.propertyPct}%)`);
|
|
68
|
+
}
|
|
69
|
+
console.log(` ${cliCommon.color(String(t.untested), t.untested ? cliCommon.C.yellow : cliCommon.C.green)} operations never tested, ${cliCommon.color(String(t.withoutNegativeTest), t.withoutNegativeTest ? cliCommon.C.yellow : cliCommon.C.green)} without a negative test`);
|
|
70
|
+
console.log("");
|
|
71
|
+
for (const op of report.operations) {
|
|
72
|
+
const mark = op.tested ? cliCommon.color("✓", cliCommon.C.green) : cliCommon.color("✗", cliCommon.C.red);
|
|
73
|
+
const label = `${op.method.padEnd(6)} ${op.path}`;
|
|
74
|
+
const statuses = op.declaredStatuses.length ? cliCommon.color(` [${op.coveredStatuses.length}/${op.declaredStatuses.length} codes]`, cliCommon.C.gray) : "";
|
|
75
|
+
const neg = op.tested && !op.hasNegativeTest ? cliCommon.color(" no negative test", cliCommon.C.yellow) : "";
|
|
76
|
+
console.log(` ${mark} ${op.tested ? cliCommon.color(label, cliCommon.C.white) : cliCommon.color(label, cliCommon.C.dim)}${statuses}${neg}`);
|
|
77
|
+
}
|
|
78
|
+
console.log("");
|
|
79
|
+
}
|
|
80
|
+
function toHtml(report) {
|
|
81
|
+
const t = report.totals;
|
|
82
|
+
const rows = report.operations.map((op) => `
|
|
83
|
+
<tr class="${op.tested ? "ok" : "miss"}">
|
|
84
|
+
<td>${op.tested ? "✓" : "✗"}</td>
|
|
85
|
+
<td><code>${op.method}</code></td>
|
|
86
|
+
<td><code>${op.path}</code></td>
|
|
87
|
+
<td>${op.declaredStatuses.length ? `${op.coveredStatuses.length}/${op.declaredStatuses.length}` : "-"}</td>
|
|
88
|
+
<td>${op.tested ? op.hasNegativeTest ? "yes" : '<span class="warn">missing</span>' : "-"}</td>
|
|
89
|
+
<td>${op.requests.map((r) => r.replace(/</g, "<")).join("<br>") || "-"}</td>
|
|
90
|
+
</tr>`).join("");
|
|
91
|
+
const title = (report.spec.title ?? "API") + (report.spec.version ? ` v${report.spec.version}` : "");
|
|
92
|
+
return `<!doctype html><meta charset="utf-8"><title>${title} coverage</title>
|
|
93
|
+
<style>
|
|
94
|
+
body{font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;margin:2rem;color:#1c1b24}
|
|
95
|
+
h1{font-size:1.4rem} .big{font-size:2rem;font-weight:700}
|
|
96
|
+
.summary{display:flex;gap:2rem;margin:1rem 0 1.5rem}
|
|
97
|
+
table{border-collapse:collapse;width:100%} th,td{text-align:left;padding:.4rem .6rem;border-bottom:1px solid #eee;vertical-align:top}
|
|
98
|
+
code{background:#f4f4f5;padding:.1rem .3rem;border-radius:4px} tr.miss td{color:#b91c1c} .warn{color:#b45309}
|
|
99
|
+
th{color:#666;font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.03em}
|
|
100
|
+
</style>
|
|
101
|
+
<h1>${title} - test coverage</h1>
|
|
102
|
+
<div class="summary">
|
|
103
|
+
<div><div class="big">${t.operationPct}%</div>operations tested (${t.tested}/${t.operations})</div>
|
|
104
|
+
<div><div class="big">${t.statusPct}%</div>response codes (${t.coveredStatuses}/${t.declaredStatuses})</div>
|
|
105
|
+
<div><div class="big">${t.untested}</div>never tested</div>
|
|
106
|
+
<div><div class="big">${t.withoutNegativeTest}</div>without negative test</div>
|
|
107
|
+
</div>
|
|
108
|
+
<table><thead><tr><th></th><th>Method</th><th>Operation</th><th>Codes</th><th>Negative</th><th>Tests</th></tr></thead>
|
|
109
|
+
<tbody>${rows}</tbody></table>`;
|
|
110
|
+
}
|
|
111
|
+
async function main() {
|
|
112
|
+
const args = cliCommon.parseArgs(process.argv.slice(2));
|
|
113
|
+
if (args.help || args.h) {
|
|
114
|
+
console.log("\nUsage:\n api-spector coverage --workspace <path> --spec <file|url> [--json] [--output <file>] [--fail-under <pct>] [--collection <name>]\n");
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
const wsPath = args.workspace;
|
|
118
|
+
if (!wsPath) {
|
|
119
|
+
console.error("Error: --workspace is required.");
|
|
120
|
+
process.exit(2);
|
|
121
|
+
}
|
|
122
|
+
const { workspace, dir } = await cliCommon.loadWorkspace(wsPath);
|
|
123
|
+
const specSource = args.spec || workspace.settings?.coverageSpec;
|
|
124
|
+
if (!specSource) {
|
|
125
|
+
console.error("Error: --spec <file|url> is required (or set settings.coverageSpec in the workspace).");
|
|
126
|
+
process.exit(2);
|
|
127
|
+
}
|
|
128
|
+
let spec;
|
|
129
|
+
try {
|
|
130
|
+
spec = await loadSpec(specSource);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.error(`Error loading spec: ${err instanceof Error ? err.message : String(err)}`);
|
|
133
|
+
process.exit(2);
|
|
134
|
+
}
|
|
135
|
+
const collections = await cliCommon.loadCollections(workspace, dir, { filterName: args.collection });
|
|
136
|
+
const requests = collectRequests(collections);
|
|
137
|
+
const observations = args.runs ? await loadObservations(args.runs) : [];
|
|
138
|
+
const report = coverage.computeCoverage(spec, requests, observations);
|
|
139
|
+
if (args.output) {
|
|
140
|
+
const path = args.output;
|
|
141
|
+
const content = /\.html?$/i.test(path) ? toHtml(report) : JSON.stringify(report, null, 2);
|
|
142
|
+
await promises.writeFile(path, content, "utf8");
|
|
143
|
+
console.error(`Wrote ${path}`);
|
|
144
|
+
}
|
|
145
|
+
if (args.json) {
|
|
146
|
+
console.log(JSON.stringify(report, null, 2));
|
|
147
|
+
} else if (!args.output) {
|
|
148
|
+
printReport(report);
|
|
149
|
+
}
|
|
150
|
+
if (args["fail-under"] !== void 0) {
|
|
151
|
+
const threshold = Number(args["fail-under"]);
|
|
152
|
+
if (!Number.isNaN(threshold) && report.totals.operationPct < threshold) {
|
|
153
|
+
console.error(`Coverage ${report.totals.operationPct}% is below --fail-under ${threshold}%.`);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
main().catch((err) => {
|
|
159
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
160
|
+
process.exit(2);
|
|
161
|
+
});
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
const promises = require("fs/promises");
|
|
4
|
+
const jsYaml = require("js-yaml");
|
|
5
|
+
const undici = require("undici");
|
|
6
|
+
const uuid = require("uuid");
|
|
7
|
+
const coverage = require("./chunks/coverage-C54mZEh_.js");
|
|
8
|
+
const cliCommon = require("./chunks/cli-common-BKYgsbGB.js");
|
|
9
|
+
require("path");
|
|
10
|
+
require("crypto");
|
|
11
|
+
const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
12
|
+
function resolveRef(spec, ref) {
|
|
13
|
+
const parts = ref.replace(/^#\//, "").split("/");
|
|
14
|
+
return parts.reduce((o, k) => o?.[decodeURIComponent(k.replace(/~1/g, "/").replace(/~0/g, "~"))], spec);
|
|
15
|
+
}
|
|
16
|
+
function deref(spec, node, depth = 0, seen = /* @__PURE__ */ new Set()) {
|
|
17
|
+
if (!node || typeof node !== "object" || depth > 8) return node;
|
|
18
|
+
if (Array.isArray(node)) return node.map((n) => deref(spec, n, depth + 1, seen));
|
|
19
|
+
if ("$ref" in node) {
|
|
20
|
+
const target = resolveRef(spec, node.$ref);
|
|
21
|
+
if (!target || seen.has(target)) return {};
|
|
22
|
+
return deref(spec, target, depth + 1, /* @__PURE__ */ new Set([...seen, target]));
|
|
23
|
+
}
|
|
24
|
+
const out = {};
|
|
25
|
+
for (const [k, v] of Object.entries(node)) out[k] = deref(spec, v, depth + 1, seen);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function jsonSchemaFor(spec, responses, code) {
|
|
29
|
+
const resp = responses?.[code];
|
|
30
|
+
const schema = resp?.content?.["application/json"]?.schema ?? resp?.content?.["application/json;charset=utf-8"]?.schema;
|
|
31
|
+
return schema ? deref(spec, schema) : void 0;
|
|
32
|
+
}
|
|
33
|
+
function sampleValue(schema) {
|
|
34
|
+
if (!schema || typeof schema !== "object") return "string";
|
|
35
|
+
if (schema.example !== void 0) return schema.example;
|
|
36
|
+
if (schema.default !== void 0) return schema.default;
|
|
37
|
+
if (Array.isArray(schema.enum) && schema.enum.length) return schema.enum[0];
|
|
38
|
+
const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
|
|
39
|
+
switch (type) {
|
|
40
|
+
case "integer":
|
|
41
|
+
case "number": {
|
|
42
|
+
const min = schema.minimum ?? (schema.exclusiveMinimum != null ? schema.exclusiveMinimum + 1 : void 0);
|
|
43
|
+
const max = schema.maximum ?? (schema.exclusiveMaximum != null ? schema.exclusiveMaximum - 1 : void 0);
|
|
44
|
+
if (min != null) return min;
|
|
45
|
+
if (max != null) return max;
|
|
46
|
+
return type === "integer" ? 1 : 1.5;
|
|
47
|
+
}
|
|
48
|
+
case "boolean":
|
|
49
|
+
return true;
|
|
50
|
+
case "array":
|
|
51
|
+
return [sampleValue(schema.items ?? {})];
|
|
52
|
+
case "object": {
|
|
53
|
+
const out = {};
|
|
54
|
+
const props = schema.properties ?? {};
|
|
55
|
+
const required = schema.required ?? [];
|
|
56
|
+
for (const [name, propSchema] of Object.entries(props)) out[name] = sampleValue(propSchema);
|
|
57
|
+
for (const name of required) if (!(name in out)) out[name] = "string";
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
case "string":
|
|
61
|
+
default:
|
|
62
|
+
return sampleString(schema);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function sampleString(schema) {
|
|
66
|
+
switch (schema.format) {
|
|
67
|
+
case "email":
|
|
68
|
+
return "user@example.com";
|
|
69
|
+
case "uuid":
|
|
70
|
+
return "00000000-0000-0000-0000-000000000000";
|
|
71
|
+
case "date":
|
|
72
|
+
return "2020-01-01";
|
|
73
|
+
case "date-time":
|
|
74
|
+
return "2020-01-01T00:00:00Z";
|
|
75
|
+
case "uri":
|
|
76
|
+
case "url":
|
|
77
|
+
return "https://example.com";
|
|
78
|
+
case "hostname":
|
|
79
|
+
return "example.com";
|
|
80
|
+
case "ipv4":
|
|
81
|
+
return "127.0.0.1";
|
|
82
|
+
default: {
|
|
83
|
+
const min = schema.minLength ?? 0;
|
|
84
|
+
let s = "string";
|
|
85
|
+
if (min > s.length) s = s.padEnd(min, "x");
|
|
86
|
+
if (schema.maxLength != null && s.length > schema.maxLength) s = s.slice(0, schema.maxLength);
|
|
87
|
+
return s;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function collectParams(spec, pathItem, op) {
|
|
92
|
+
const raw = [...pathItem?.parameters ?? [], ...op?.parameters ?? []].map((p) => deref(spec, p));
|
|
93
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
94
|
+
for (const p of raw) if (p?.name && p?.in) byKey.set(`${p.in}:${p.name}`, p);
|
|
95
|
+
return [...byKey.values()];
|
|
96
|
+
}
|
|
97
|
+
function successCode(responses) {
|
|
98
|
+
const codes = Object.keys(responses ?? {}).filter((c) => /^2\d\d$/.test(c)).map(Number).sort((a, b) => a - b);
|
|
99
|
+
return codes[0] ?? 200;
|
|
100
|
+
}
|
|
101
|
+
function negativeCode(responses) {
|
|
102
|
+
const codes = Object.keys(responses ?? {}).filter((c) => /^4\d\d$/.test(c)).map(Number).sort((a, b) => a - b);
|
|
103
|
+
return codes[0] ?? 400;
|
|
104
|
+
}
|
|
105
|
+
function baseTest(method, path, op, params) {
|
|
106
|
+
const pathParams = {};
|
|
107
|
+
for (const p of params.filter((p2) => p2.in === "path")) pathParams[p.name] = sampleValue(p.schema ?? {});
|
|
108
|
+
const query = params.filter((p) => p.in === "query" && p.required).map((p) => ({ key: p.name, value: String(sampleValue(p.schema ?? {})) }));
|
|
109
|
+
const headers = params.filter((p) => p.in === "header" && p.required).map((p) => ({ key: p.name, value: String(sampleValue(p.schema ?? {})) }));
|
|
110
|
+
return { operationId: op?.operationId, method, path, pathParams, query, headers };
|
|
111
|
+
}
|
|
112
|
+
function requestBodySchema(spec, op) {
|
|
113
|
+
const rb = deref(spec, op?.requestBody);
|
|
114
|
+
const schema = rb?.content?.["application/json"]?.schema;
|
|
115
|
+
return schema;
|
|
116
|
+
}
|
|
117
|
+
function generateForOperation(spec, method, path, pathItem, op, opts) {
|
|
118
|
+
const tests = [];
|
|
119
|
+
const params = collectParams(spec, pathItem, op);
|
|
120
|
+
const responses = op?.responses ?? {};
|
|
121
|
+
const okCode = successCode(responses);
|
|
122
|
+
const badCode = negativeCode(responses);
|
|
123
|
+
const bodySchema = requestBodySchema(spec, op);
|
|
124
|
+
const validBody = bodySchema ? sampleValue(bodySchema) : void 0;
|
|
125
|
+
const base = baseTest(method, path, op, params);
|
|
126
|
+
const label = `${method} ${path}`;
|
|
127
|
+
tests.push({
|
|
128
|
+
...base,
|
|
129
|
+
name: `${label} - happy path`,
|
|
130
|
+
category: "happy",
|
|
131
|
+
body: validBody !== void 0 ? JSON.stringify(validBody, null, 2) : void 0,
|
|
132
|
+
expectedStatus: okCode,
|
|
133
|
+
responseSchema: jsonSchemaFor(spec, responses, String(okCode)) ? JSON.stringify(jsonSchemaFor(spec, responses, String(okCode)), null, 2) : void 0
|
|
134
|
+
});
|
|
135
|
+
const props = bodySchema?.properties ?? {};
|
|
136
|
+
const required = bodySchema?.required ?? [];
|
|
137
|
+
if (opts.includeNegative && validBody && typeof validBody === "object") {
|
|
138
|
+
let n = 0;
|
|
139
|
+
for (const field of required) {
|
|
140
|
+
if (n >= opts.maxNegativePerOp) break;
|
|
141
|
+
const mutated = { ...validBody };
|
|
142
|
+
delete mutated[field];
|
|
143
|
+
tests.push({ ...base, name: `${label} - missing ${field}`, category: "negative", body: JSON.stringify(mutated, null, 2), expectedStatus: badCode });
|
|
144
|
+
n++;
|
|
145
|
+
}
|
|
146
|
+
for (const [field, ps] of Object.entries(props)) {
|
|
147
|
+
if (n >= opts.maxNegativePerOp) break;
|
|
148
|
+
const t = Array.isArray(ps.type) ? ps.type[0] : ps.type;
|
|
149
|
+
if (t !== "string" && t !== "integer" && t !== "number" && t !== "boolean") continue;
|
|
150
|
+
const wrong = t === "string" ? 12345 : "not-a-valid-value";
|
|
151
|
+
tests.push({ ...base, name: `${label} - ${field} wrong type`, category: "negative", body: JSON.stringify({ ...validBody, [field]: wrong }, null, 2), expectedStatus: badCode });
|
|
152
|
+
n++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (opts.includeBoundary && validBody && typeof validBody === "object") {
|
|
156
|
+
let n = 0;
|
|
157
|
+
for (const [field, ps] of Object.entries(props)) {
|
|
158
|
+
if (n >= opts.maxBoundaryPerOp) break;
|
|
159
|
+
const t = Array.isArray(ps.type) ? ps.type[0] : ps.type;
|
|
160
|
+
if ((t === "integer" || t === "number") && ps.minimum != null) {
|
|
161
|
+
tests.push({ ...base, name: `${label} - ${field} below minimum`, category: "boundary", body: JSON.stringify({ ...validBody, [field]: ps.minimum - 1 }, null, 2), expectedStatus: badCode });
|
|
162
|
+
n++;
|
|
163
|
+
} else if ((t === "integer" || t === "number") && ps.maximum != null) {
|
|
164
|
+
tests.push({ ...base, name: `${label} - ${field} above maximum`, category: "boundary", body: JSON.stringify({ ...validBody, [field]: ps.maximum + 1 }, null, 2), expectedStatus: badCode });
|
|
165
|
+
n++;
|
|
166
|
+
} else if (t === "string" && ps.maxLength != null) {
|
|
167
|
+
tests.push({ ...base, name: `${label} - ${field} too long`, category: "boundary", body: JSON.stringify({ ...validBody, [field]: "x".repeat(ps.maxLength + 1) }, null, 2), expectedStatus: badCode });
|
|
168
|
+
n++;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return tests;
|
|
173
|
+
}
|
|
174
|
+
function generateTests(spec, options = {}) {
|
|
175
|
+
const opts = {
|
|
176
|
+
only: options.only ?? /* @__PURE__ */ new Set(),
|
|
177
|
+
includeNegative: options.includeNegative ?? true,
|
|
178
|
+
includeBoundary: options.includeBoundary ?? true,
|
|
179
|
+
maxNegativePerOp: options.maxNegativePerOp ?? 4,
|
|
180
|
+
maxBoundaryPerOp: options.maxBoundaryPerOp ?? 4
|
|
181
|
+
};
|
|
182
|
+
const doc = spec ?? {};
|
|
183
|
+
const out = [];
|
|
184
|
+
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
|
185
|
+
if (!item || typeof item !== "object") continue;
|
|
186
|
+
for (const [method, op] of Object.entries(item)) {
|
|
187
|
+
if (!HTTP_METHODS.includes(method.toLowerCase())) continue;
|
|
188
|
+
if (!op || typeof op !== "object") continue;
|
|
189
|
+
if (opts.only.size && !opts.only.has(`${method.toUpperCase()} ${path}`)) continue;
|
|
190
|
+
out.push(...generateForOperation(doc, method.toUpperCase(), path, item, op, opts));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
function testUrl(test, baseVar = "{{baseUrl}}") {
|
|
196
|
+
let path = test.path;
|
|
197
|
+
for (const [name, value] of Object.entries(test.pathParams)) {
|
|
198
|
+
path = path.replace(new RegExp(`\\{${name}\\}`, "g"), encodeURIComponent(String(value)));
|
|
199
|
+
}
|
|
200
|
+
const qs = test.query.filter((q) => q.key).map((q) => `${encodeURIComponent(q.key)}=${encodeURIComponent(q.value)}`).join("&");
|
|
201
|
+
return `${baseVar}${path}${qs ? `?${qs}` : ""}`;
|
|
202
|
+
}
|
|
203
|
+
function toApiRequest(test, id) {
|
|
204
|
+
const headers = test.headers.map((h) => ({ key: h.key, value: h.value, enabled: true }));
|
|
205
|
+
return {
|
|
206
|
+
id,
|
|
207
|
+
name: test.name,
|
|
208
|
+
method: test.method,
|
|
209
|
+
url: testUrl(test),
|
|
210
|
+
headers,
|
|
211
|
+
params: [],
|
|
212
|
+
auth: { type: "none" },
|
|
213
|
+
body: test.body !== void 0 ? { mode: "json", json: test.body } : { mode: "none" },
|
|
214
|
+
contract: {
|
|
215
|
+
statusCode: test.expectedStatus,
|
|
216
|
+
...test.responseSchema ? { bodySchema: test.responseSchema } : {}
|
|
217
|
+
},
|
|
218
|
+
meta: { tags: [test.category] }
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
async function loadSpec(source) {
|
|
222
|
+
const isUrl = /^https?:\/\//i.test(source);
|
|
223
|
+
const raw = isUrl ? await (async () => {
|
|
224
|
+
const r = await undici.fetch(source);
|
|
225
|
+
if (!r.ok) throw new Error(`HTTP ${r.status} fetching ${source}`);
|
|
226
|
+
return r.text();
|
|
227
|
+
})() : await promises.readFile(source, "utf8");
|
|
228
|
+
return /\.ya?ml$/i.test(source) || !isUrl && !raw.trim().startsWith("{") ? jsYaml.load(raw) : JSON.parse(raw);
|
|
229
|
+
}
|
|
230
|
+
async function untestedOperations(specSource, spec, wsPath) {
|
|
231
|
+
const { workspace, dir } = await cliCommon.loadWorkspace(wsPath);
|
|
232
|
+
const collections = await cliCommon.loadCollections(workspace, dir);
|
|
233
|
+
const requests = [];
|
|
234
|
+
for (const col of collections) {
|
|
235
|
+
for (const req of Object.values(col.requests ?? {})) {
|
|
236
|
+
if (req.disabled) continue;
|
|
237
|
+
requests.push({ name: `${col.name} / ${req.name}`, method: req.method, url: req.url, expectedStatus: req.contract?.statusCode });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const report = coverage.computeCoverage(spec, requests);
|
|
241
|
+
return new Set(report.operations.filter((o) => !o.tested).map((o) => `${o.method} ${o.path}`));
|
|
242
|
+
}
|
|
243
|
+
function buildCollection(name, tests) {
|
|
244
|
+
const requests = {};
|
|
245
|
+
const requestIds = [];
|
|
246
|
+
for (const t of tests) {
|
|
247
|
+
const id = uuid.v4();
|
|
248
|
+
requests[id] = toApiRequest(t, id);
|
|
249
|
+
requestIds.push(id);
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
version: "1.0",
|
|
253
|
+
id: uuid.v4(),
|
|
254
|
+
name,
|
|
255
|
+
description: "Generated from an OpenAPI spec by api-spector generate-tests.",
|
|
256
|
+
rootFolder: { id: uuid.v4(), name: "root", description: "", folders: [], requestIds },
|
|
257
|
+
requests
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
async function main() {
|
|
261
|
+
const args = cliCommon.parseArgs(process.argv.slice(2));
|
|
262
|
+
if (args.help || args.h) {
|
|
263
|
+
console.log("\nUsage:\n api-spector generate-tests --spec <file|url> --output <collection.json> [--untested-only --workspace <ws>] [--name <label>] [--no-negative] [--no-boundary]\n");
|
|
264
|
+
process.exit(0);
|
|
265
|
+
}
|
|
266
|
+
const specSource = args.spec;
|
|
267
|
+
const output = args.output;
|
|
268
|
+
if (!specSource) {
|
|
269
|
+
console.error("Error: --spec <file|url> is required.");
|
|
270
|
+
process.exit(2);
|
|
271
|
+
}
|
|
272
|
+
if (!output) {
|
|
273
|
+
console.error("Error: --output <collection.json> is required.");
|
|
274
|
+
process.exit(2);
|
|
275
|
+
}
|
|
276
|
+
let spec;
|
|
277
|
+
try {
|
|
278
|
+
spec = await loadSpec(specSource);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.error(`Error loading spec: ${err instanceof Error ? err.message : String(err)}`);
|
|
281
|
+
process.exit(2);
|
|
282
|
+
}
|
|
283
|
+
let only;
|
|
284
|
+
if (args["untested-only"]) {
|
|
285
|
+
if (!args.workspace) {
|
|
286
|
+
console.error("Error: --untested-only requires --workspace.");
|
|
287
|
+
process.exit(2);
|
|
288
|
+
}
|
|
289
|
+
only = await untestedOperations(specSource, spec, args.workspace);
|
|
290
|
+
if (only.size === 0) {
|
|
291
|
+
console.log(cliCommon.color("\n Every operation already has a test. Nothing to generate.\n", cliCommon.C.green));
|
|
292
|
+
process.exit(0);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const tests = generateTests(spec, {
|
|
296
|
+
only,
|
|
297
|
+
includeNegative: args["no-negative"] !== true,
|
|
298
|
+
includeBoundary: args["no-boundary"] !== true
|
|
299
|
+
});
|
|
300
|
+
const specTitle = spec?.info?.title;
|
|
301
|
+
const name = args.name || `${specTitle ?? "API"} tests`;
|
|
302
|
+
const collection = buildCollection(name, tests);
|
|
303
|
+
await promises.writeFile(output, JSON.stringify(collection, null, 2), "utf8");
|
|
304
|
+
const byCat = tests.reduce((m, t) => {
|
|
305
|
+
m[t.category] = (m[t.category] ?? 0) + 1;
|
|
306
|
+
return m;
|
|
307
|
+
}, {});
|
|
308
|
+
console.log("");
|
|
309
|
+
console.log(cliCommon.color(` Generated ${tests.length} tests`, cliCommon.C.bold, cliCommon.C.white) + cliCommon.color(` -> ${output}`, cliCommon.C.gray));
|
|
310
|
+
console.log(` ${cliCommon.color(String(byCat.happy ?? 0), cliCommon.C.green)} happy path, ${cliCommon.color(String(byCat.negative ?? 0), cliCommon.C.yellow)} negative, ${cliCommon.color(String(byCat.boundary ?? 0), cliCommon.C.yellow)} boundary`);
|
|
311
|
+
console.log(cliCommon.color(` Add it to a workspace, or open the app and import the collection.`, cliCommon.C.gray));
|
|
312
|
+
console.log("");
|
|
313
|
+
}
|
|
314
|
+
main().catch((err) => {
|
|
315
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
316
|
+
process.exit(2);
|
|
317
|
+
});
|