specproof 0.2.1 → 0.3.0
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/README.md +35 -27
- package/components/CoverageProof.tsx +2 -2
- package/next.config.js +3 -0
- package/package.json +26 -10
- package/scripts/cli.ts +113 -0
- package/scripts/generate-proof.ts +100 -17
- package/.github/workflows/ci.yml +0 -69
- package/.github/workflows/release.yml +0 -49
- package/CHANGELOG.md +0 -52
- package/CLAUDE.md +0 -82
- package/app/proof-contract.test.ts +0 -95
- package/app/proof.generated.json +0 -288
- package/bun.lock +0 -991
- package/eslint.config.mjs +0 -43
- package/example/api/openapi.json +0 -88
- package/example/tests/auth.test.ts +0 -25
- package/example/tests/client.ts +0 -53
- package/example/tests/tasks.test.ts +0 -83
- package/lib/api-test-coverage.test.ts +0 -423
- package/marketing/index.html +0 -1083
- package/marketing/package.json +0 -10
- package/marketing/server.ts +0 -27
- package/public/banner.png +0 -0
- package/public/icon.png +0 -0
- package/public/logo.png +0 -0
- package/vitest.config.ts +0 -19
|
@@ -1,423 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import os from 'os';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
import { afterAll, afterEach, describe, expect, it } from 'vitest';
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
buildCoverageReport,
|
|
8
|
-
collectTestEvidence,
|
|
9
|
-
dedent,
|
|
10
|
-
extractItBlocks,
|
|
11
|
-
operationKey,
|
|
12
|
-
parseTestFile,
|
|
13
|
-
resolveSpecPath
|
|
14
|
-
} from '@/lib/api-test-coverage';
|
|
15
|
-
|
|
16
|
-
// The analyzer scans this repo's own *.test.ts files (as raw text) when
|
|
17
|
-
// auditing the bundled example, so every inline fixture below uses operations
|
|
18
|
-
// (/widgets, /gadgets) that do not exist in example/api/openapi.json — that
|
|
19
|
-
// keeps this file invisible to the generated proof.
|
|
20
|
-
|
|
21
|
-
const tmpRoots: string[] = [];
|
|
22
|
-
|
|
23
|
-
/** Materialize a fixture repo in a temp directory */
|
|
24
|
-
function makeRepo(files: Record<string, string>): string {
|
|
25
|
-
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'specproof-unit-'));
|
|
26
|
-
tmpRoots.push(root);
|
|
27
|
-
for (const [rel, content] of Object.entries(files)) {
|
|
28
|
-
const abs = path.join(root, rel);
|
|
29
|
-
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
30
|
-
fs.writeFileSync(abs, content);
|
|
31
|
-
}
|
|
32
|
-
return root;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const savedSpecEnv = process.env.SPECPROOF_SPEC;
|
|
36
|
-
delete process.env.SPECPROOF_SPEC;
|
|
37
|
-
|
|
38
|
-
afterEach(() => {
|
|
39
|
-
delete process.env.SPECPROOF_SPEC;
|
|
40
|
-
while (tmpRoots.length > 0) {
|
|
41
|
-
fs.rmSync(tmpRoots.pop()!, { recursive: true, force: true });
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
afterAll(() => {
|
|
46
|
-
if (savedSpecEnv !== undefined) process.env.SPECPROOF_SPEC = savedSpecEnv;
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
describe('operationKey', () => {
|
|
50
|
-
it('lowercases the method and keeps literal segments', () => {
|
|
51
|
-
expect(operationKey('GET', '/widgets')).toBe('get /widgets');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('treats {param}, [param], and :param segments as equivalent', () => {
|
|
55
|
-
const key = operationKey('GET', '/widgets/{widgetId}');
|
|
56
|
-
expect(operationKey('get', '/widgets/[widgetId]')).toBe(key);
|
|
57
|
-
expect(operationKey('GET', '/widgets/:widgetId')).toBe(key);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('normalizes params in any segment position', () => {
|
|
61
|
-
expect(operationKey('PUT', '/a/{x}/b/[y]/:z')).toBe('put /a/{}/b/{}/{}');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('ignores a trailing slash', () => {
|
|
65
|
-
expect(operationKey('GET', '/widgets/')).toBe(operationKey('GET', '/widgets'));
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
describe('dedent', () => {
|
|
70
|
-
it('strips the common leading indentation', () => {
|
|
71
|
-
expect(dedent(' a();\n b();\n c();')).toBe('a();\n b();\nc();');
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('ignores blank lines when measuring the indent', () => {
|
|
75
|
-
expect(dedent(' a();\n\n b();')).toBe('a();\n\nb();');
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
describe('extractItBlocks', () => {
|
|
80
|
-
const segment = [
|
|
81
|
-
'describe("GET /widgets", () => {',
|
|
82
|
-
' it("lists widgets", async () => {',
|
|
83
|
-
' const res = await api.get("/widgets");',
|
|
84
|
-
' expect(res.status).toBe(200);',
|
|
85
|
-
' });',
|
|
86
|
-
'',
|
|
87
|
-
" test('rejects anonymous calls', async () => {",
|
|
88
|
-
' const res = await api.get("/widgets", { auth: false });',
|
|
89
|
-
' expect(res.status).toEqual(401);',
|
|
90
|
-
' });',
|
|
91
|
-
'});'
|
|
92
|
-
].join('\n');
|
|
93
|
-
|
|
94
|
-
it('finds it() and test() blocks across quote styles', () => {
|
|
95
|
-
const blocks = extractItBlocks(segment, 0, segment);
|
|
96
|
-
expect(blocks.map((b) => b.title)).toEqual(['lists widgets', 'rejects anonymous calls']);
|
|
97
|
-
|
|
98
|
-
const backtick = 'describe("GET /widgets", () => {\n it(`uses backticks`, () => {\n });\n});';
|
|
99
|
-
expect(extractItBlocks(backtick, 0, backtick)[0].title).toBe('uses backticks');
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('extracts statuses from both toBe and toEqual assertions', () => {
|
|
103
|
-
const blocks = extractItBlocks(segment, 0, segment);
|
|
104
|
-
expect(blocks[0].statuses).toEqual(['200']);
|
|
105
|
-
expect(blocks[1].statuses).toEqual(['401']);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('dedents the extracted source', () => {
|
|
109
|
-
const blocks = extractItBlocks(segment, 0, segment);
|
|
110
|
-
expect(blocks[0].source.startsWith('it("lists widgets"')).toBe(true);
|
|
111
|
-
expect(blocks[0].source.endsWith('\n});')).toBe(true);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('reports 1-indexed start lines, offset into the full source', () => {
|
|
115
|
-
const blocks = extractItBlocks(segment, 0, segment);
|
|
116
|
-
expect(blocks[0].startLine).toBe(2);
|
|
117
|
-
expect(blocks[1].startLine).toBe(7);
|
|
118
|
-
|
|
119
|
-
const prefix = '// header\n\n';
|
|
120
|
-
const fullSource = prefix + segment;
|
|
121
|
-
const shifted = extractItBlocks(segment, prefix.length, fullSource);
|
|
122
|
-
expect(shifted[0].startLine).toBe(4);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('skips nested closers at deeper indentation', () => {
|
|
126
|
-
const nested = [
|
|
127
|
-
'describe("POST /widgets", () => {',
|
|
128
|
-
' it("creates a widget", async () => {',
|
|
129
|
-
' await withRetry(async () => {',
|
|
130
|
-
' await api.post("/widgets");',
|
|
131
|
-
' });',
|
|
132
|
-
' expect(res.status).toBe(201);',
|
|
133
|
-
' });',
|
|
134
|
-
'});'
|
|
135
|
-
].join('\n');
|
|
136
|
-
const blocks = extractItBlocks(nested, 0, nested);
|
|
137
|
-
expect(blocks).toHaveLength(1);
|
|
138
|
-
expect(blocks[0].statuses).toEqual(['201']);
|
|
139
|
-
expect(blocks[0].source.endsWith('\n});')).toBe(true);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it('runs to the segment end when no closer matches', () => {
|
|
143
|
-
const unclosed = 'describe("GET /widgets", () => {\n it("dangles", () => {\n expect(res.status).toBe(200);';
|
|
144
|
-
const blocks = extractItBlocks(unclosed, 0, unclosed);
|
|
145
|
-
expect(blocks).toHaveLength(1);
|
|
146
|
-
expect(blocks[0].statuses).toEqual(['200']);
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
describe('parseTestFile', () => {
|
|
151
|
-
it('keys evidence by operation and ignores non-operation describes', () => {
|
|
152
|
-
const source = [
|
|
153
|
-
'import { api } from "./client";',
|
|
154
|
-
'',
|
|
155
|
-
'describe("GET /widgets", () => {',
|
|
156
|
-
' it("lists widgets", async () => {',
|
|
157
|
-
' expect(res.status).toBe(200);',
|
|
158
|
-
' });',
|
|
159
|
-
'});',
|
|
160
|
-
'',
|
|
161
|
-
'describe("widget helpers", () => {',
|
|
162
|
-
' it("is not an operation", () => {',
|
|
163
|
-
' expect(res.status).toBe(500);',
|
|
164
|
-
' });',
|
|
165
|
-
'});',
|
|
166
|
-
'',
|
|
167
|
-
'describe("POST /widgets", () => {',
|
|
168
|
-
' it("creates twice", async () => {',
|
|
169
|
-
' expect(one.status).toBe(201);',
|
|
170
|
-
' expect(two.status).toBe(201);',
|
|
171
|
-
' });',
|
|
172
|
-
'});'
|
|
173
|
-
].join('\n');
|
|
174
|
-
|
|
175
|
-
const parsed = parseTestFile(source, 'tests/widgets.test.ts');
|
|
176
|
-
expect([...parsed.keys()].sort()).toEqual(['get /widgets', 'post /widgets']);
|
|
177
|
-
|
|
178
|
-
const get = parsed.get('get /widgets')!;
|
|
179
|
-
expect(get.testFile).toBe('tests/widgets.test.ts');
|
|
180
|
-
expect(get.testCount).toBe(1);
|
|
181
|
-
expect([...get.statuses.entries()]).toEqual([['200', 1]]);
|
|
182
|
-
|
|
183
|
-
// The 500 in the non-operation describe is attributed to nothing.
|
|
184
|
-
const post = parsed.get('post /widgets')!;
|
|
185
|
-
expect(post.statuses.has('500')).toBe(false);
|
|
186
|
-
// Two assertions of the same status count twice, but the single it()
|
|
187
|
-
// block yields one snippet.
|
|
188
|
-
expect(post.statuses.get('201')).toBe(2);
|
|
189
|
-
expect(post.snippets.get('201')).toHaveLength(1);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it('merges evidence when two describes name the same operation', () => {
|
|
193
|
-
const source = [
|
|
194
|
-
'describe("GET /gadgets", () => {',
|
|
195
|
-
' it("lists", async () => {',
|
|
196
|
-
' expect(res.status).toBe(200);',
|
|
197
|
-
' });',
|
|
198
|
-
'});',
|
|
199
|
-
'describe("GET /gadgets", () => {',
|
|
200
|
-
' it("paginates", async () => {',
|
|
201
|
-
' expect(res.status).toBe(206);',
|
|
202
|
-
' });',
|
|
203
|
-
'});'
|
|
204
|
-
].join('\n');
|
|
205
|
-
|
|
206
|
-
const parsed = parseTestFile(source, 'tests/gadgets.test.ts');
|
|
207
|
-
const evidence = parsed.get('get /gadgets')!;
|
|
208
|
-
expect(evidence.testCount).toBe(2);
|
|
209
|
-
expect([...evidence.statuses.keys()].sort()).toEqual(['200', '206']);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it('files one it() asserting two statuses as a snippet under each', () => {
|
|
213
|
-
const source = [
|
|
214
|
-
'describe("DELETE /gadgets/{gadgetId}", () => {',
|
|
215
|
-
' it("deletes then 404s", async () => {',
|
|
216
|
-
' expect(first.status).toBe(204);',
|
|
217
|
-
' expect(second.status).toBe(404);',
|
|
218
|
-
' });',
|
|
219
|
-
'});'
|
|
220
|
-
].join('\n');
|
|
221
|
-
|
|
222
|
-
const evidence = parseTestFile(source, 'tests/gadgets.test.ts').get('delete /gadgets/{}')!;
|
|
223
|
-
expect(evidence.snippets.get('204')![0].title).toBe('deletes then 404s');
|
|
224
|
-
expect(evidence.snippets.get('404')![0].title).toBe('deletes then 404s');
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it('counts assertions outside it() blocks but yields no snippet for them', () => {
|
|
228
|
-
const source = [
|
|
229
|
-
'describe("PATCH /gadgets/{gadgetId}", () => {',
|
|
230
|
-
' const conflict = (res) => expect(res.status).toBe(409);',
|
|
231
|
-
' it("renames a gadget", async () => {',
|
|
232
|
-
' expect(res.status).toBe(200);',
|
|
233
|
-
' });',
|
|
234
|
-
'});'
|
|
235
|
-
].join('\n');
|
|
236
|
-
|
|
237
|
-
const evidence = parseTestFile(source, 'tests/gadgets.test.ts').get('patch /gadgets/{}')!;
|
|
238
|
-
expect(evidence.statuses.get('409')).toBe(1);
|
|
239
|
-
expect(evidence.snippets.has('409')).toBe(false);
|
|
240
|
-
expect(evidence.snippets.get('200')).toHaveLength(1);
|
|
241
|
-
});
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
describe('resolveSpecPath', () => {
|
|
245
|
-
it('picks the shallowest spec in the tree', () => {
|
|
246
|
-
const root = makeRepo({
|
|
247
|
-
'docs/openapi.json': '{}',
|
|
248
|
-
'openapi.json': '{}'
|
|
249
|
-
});
|
|
250
|
-
expect(resolveSpecPath(root)).toBe(path.join(root, 'openapi.json'));
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it('breaks equal-depth ties alphabetically', () => {
|
|
254
|
-
const root = makeRepo({
|
|
255
|
-
'b/openapi.json': '{}',
|
|
256
|
-
'a/openapi.json': '{}'
|
|
257
|
-
});
|
|
258
|
-
expect(resolveSpecPath(root)).toBe(path.join(root, 'a/openapi.json'));
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it('matches swagger-prefixed specs', () => {
|
|
262
|
-
const root = makeRepo({ 'swagger-v2.json': '{}' });
|
|
263
|
-
expect(resolveSpecPath(root)).toBe(path.join(root, 'swagger-v2.json'));
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it('never looks inside excluded or dot directories', () => {
|
|
267
|
-
const root = makeRepo({
|
|
268
|
-
'node_modules/dep/openapi.json': '{}',
|
|
269
|
-
'.hidden/openapi.json': '{}'
|
|
270
|
-
});
|
|
271
|
-
expect(resolveSpecPath(root)).toBeNull();
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
it('returns null for a nonexistent root', () => {
|
|
275
|
-
expect(resolveSpecPath(path.join(os.tmpdir(), 'specproof-no-such-dir'))).toBeNull();
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it('SPECPROOF_SPEC overrides discovery', () => {
|
|
279
|
-
const root = makeRepo({
|
|
280
|
-
'api/custom-spec.json': '{}',
|
|
281
|
-
'openapi.json': '{}'
|
|
282
|
-
});
|
|
283
|
-
process.env.SPECPROOF_SPEC = 'api/custom-spec.json';
|
|
284
|
-
expect(resolveSpecPath(root)).toBe(path.join(root, 'api/custom-spec.json'));
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
it('SPECPROOF_SPEC pointing at a missing file yields null, not a fallback', () => {
|
|
288
|
-
const root = makeRepo({ 'openapi.json': '{}' });
|
|
289
|
-
process.env.SPECPROOF_SPEC = 'missing.json';
|
|
290
|
-
expect(resolveSpecPath(root)).toBeNull();
|
|
291
|
-
});
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
const widgetDescribe = (title: string, its: Array<[string, string]>) =>
|
|
295
|
-
[
|
|
296
|
-
`describe("${title}", () => {`,
|
|
297
|
-
...its.flatMap(([name, status]) => [
|
|
298
|
-
` it("${name}", async () => {`,
|
|
299
|
-
` expect(res.status).toBe(${status});`,
|
|
300
|
-
' });'
|
|
301
|
-
]),
|
|
302
|
-
'});'
|
|
303
|
-
].join('\n');
|
|
304
|
-
|
|
305
|
-
describe('collectTestEvidence', () => {
|
|
306
|
-
it('lets the file with the most it() blocks win an operation', () => {
|
|
307
|
-
const root = makeRepo({
|
|
308
|
-
'tests/rich.test.ts': widgetDescribe('GET /widgets', [
|
|
309
|
-
['lists', '200'],
|
|
310
|
-
['rejects', '401']
|
|
311
|
-
]),
|
|
312
|
-
'tests/poor.test.ts': widgetDescribe('GET /widgets', [['lists', '200']])
|
|
313
|
-
});
|
|
314
|
-
const evidence = collectTestEvidence(root).get('get /widgets')!;
|
|
315
|
-
expect(evidence.testFile).toBe('tests/rich.test.ts');
|
|
316
|
-
expect(evidence.testCount).toBe(2);
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it('breaks ties in favor of the alphabetically first file', () => {
|
|
320
|
-
const root = makeRepo({
|
|
321
|
-
'tests/b.test.ts': widgetDescribe('GET /widgets', [['lists', '200']]),
|
|
322
|
-
'tests/a.test.ts': widgetDescribe('GET /widgets', [['lists', '200']])
|
|
323
|
-
});
|
|
324
|
-
expect(collectTestEvidence(root).get('get /widgets')!.testFile).toBe('tests/a.test.ts');
|
|
325
|
-
});
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
describe('buildCoverageReport', () => {
|
|
329
|
-
const spec = {
|
|
330
|
-
tags: [{ name: 'Widgets', description: 'Widget operations' }],
|
|
331
|
-
paths: {
|
|
332
|
-
'/widgets': {
|
|
333
|
-
get: {
|
|
334
|
-
summary: 'List widgets',
|
|
335
|
-
tags: ['Widgets'],
|
|
336
|
-
responses: {
|
|
337
|
-
'200': { description: 'OK' },
|
|
338
|
-
'401': { description: 'Unauthorized' }
|
|
339
|
-
}
|
|
340
|
-
},
|
|
341
|
-
post: {
|
|
342
|
-
tags: ['Widgets'],
|
|
343
|
-
responses: { '201': { description: 'Created' } }
|
|
344
|
-
}
|
|
345
|
-
},
|
|
346
|
-
'/widgets/{widgetId}': {
|
|
347
|
-
delete: { tags: ['Admin'], responses: { '204': { description: 'Deleted' } } }
|
|
348
|
-
},
|
|
349
|
-
'/gadgets': {
|
|
350
|
-
get: { responses: { '200': { description: 'OK' } } }
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
};
|
|
354
|
-
|
|
355
|
-
const fixtureRepo = () =>
|
|
356
|
-
makeRepo({
|
|
357
|
-
'openapi.json': JSON.stringify(spec),
|
|
358
|
-
'tests/widgets.test.ts': [
|
|
359
|
-
widgetDescribe('GET /widgets', [['lists widgets', '200']]),
|
|
360
|
-
widgetDescribe('POST /widgets', [
|
|
361
|
-
['creates a widget', '201'],
|
|
362
|
-
['rejects bad payloads', '422']
|
|
363
|
-
])
|
|
364
|
-
].join('\n'),
|
|
365
|
-
// Trailing slash in the describe title still joins against /gadgets.
|
|
366
|
-
'tests/gadgets.test.ts': widgetDescribe('GET /gadgets/', [['lists gadgets', '200']])
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
it('joins spec operations against test evidence and counts coverage', () => {
|
|
370
|
-
const report = buildCoverageReport(fixtureRepo());
|
|
371
|
-
|
|
372
|
-
expect(report.operationCount).toBe(4);
|
|
373
|
-
expect(report.coveredCount).toBe(3);
|
|
374
|
-
expect(report.totalCount).toBe(5);
|
|
375
|
-
expect(report.untestedOperations).toBe(1);
|
|
376
|
-
|
|
377
|
-
const widgets = report.tags.find((t) => t.tag === 'Widgets')!;
|
|
378
|
-
const get = widgets.operations.find((op) => op.method === 'get')!;
|
|
379
|
-
expect(get.summary).toBe('List widgets');
|
|
380
|
-
expect(get.coveredCount).toBe(1);
|
|
381
|
-
expect(get.gapCount).toBe(1);
|
|
382
|
-
expect(get.testFile).toBe('tests/widgets.test.ts');
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
it('flags statuses the tests assert but the spec omits, sorted by code', () => {
|
|
386
|
-
const report = buildCoverageReport(fixtureRepo());
|
|
387
|
-
const post = report.tags
|
|
388
|
-
.find((t) => t.tag === 'Widgets')!
|
|
389
|
-
.operations.find((op) => op.method === 'post')!;
|
|
390
|
-
|
|
391
|
-
expect(post.statuses.map((s) => s.code)).toEqual(['201', '422']);
|
|
392
|
-
expect(post.statuses[1].documented).toBe(false);
|
|
393
|
-
expect(post.statuses[1].assertions).toBe(1);
|
|
394
|
-
// Undocumented statuses count toward neither coverage nor gaps.
|
|
395
|
-
expect(post.coveredCount).toBe(1);
|
|
396
|
-
expect(post.gapCount).toBe(0);
|
|
397
|
-
expect(post.summary).toBe('');
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
it('joins param-style and trailing-slash variants of spec paths', () => {
|
|
401
|
-
const report = buildCoverageReport(fixtureRepo());
|
|
402
|
-
const gadgets = report.tags.find((t) => t.tag === 'Other')!.operations[0];
|
|
403
|
-
expect(gadgets.testFile).toBe('tests/gadgets.test.ts');
|
|
404
|
-
expect(gadgets.coveredCount).toBe(1);
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
it('orders tags by the spec, unknown tags last, untagged ops under Other', () => {
|
|
408
|
-
const report = buildCoverageReport(fixtureRepo());
|
|
409
|
-
expect(report.tags.map((t) => t.tag)).toEqual(['Widgets', 'Admin', 'Other']);
|
|
410
|
-
expect(report.tags[0].description).toBe('Widget operations');
|
|
411
|
-
expect(report.tags[1].description).toBe('');
|
|
412
|
-
|
|
413
|
-
const del = report.tags[1].operations[0];
|
|
414
|
-
expect(del.testFile).toBeNull();
|
|
415
|
-
expect(del.testCount).toBe(0);
|
|
416
|
-
expect(del.gapCount).toBe(1);
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
it('throws when the repo has no spec to audit', () => {
|
|
420
|
-
const root = makeRepo({ 'readme.md': 'nothing here' });
|
|
421
|
-
expect(() => buildCoverageReport(root)).toThrow(/no OpenAPI spec found/);
|
|
422
|
-
});
|
|
423
|
-
});
|