norn-cli 1.3.17 → 1.3.19
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/AGENTS.md +72 -0
- package/CHANGELOG.md +39 -1
- package/README.md +7 -3
- package/dist/cli.js +113 -54
- package/out/assertionRunner.js +537 -0
- package/out/cli/colors.js +129 -0
- package/out/cli/formatters/assertion.js +75 -0
- package/out/cli/formatters/index.js +23 -0
- package/out/cli/formatters/response.js +106 -0
- package/out/cli/formatters/summary.js +187 -0
- package/out/cli/redaction.js +237 -0
- package/out/cli/reporters/html.js +634 -0
- package/out/cli/reporters/index.js +22 -0
- package/out/cli/reporters/junit.js +211 -0
- package/out/cli.js +926 -0
- package/out/codeLensProvider.js +254 -0
- package/out/compareContentProvider.js +85 -0
- package/out/completionProvider.js +1886 -0
- package/out/contractDecorationProvider.js +243 -0
- package/out/coverageCalculator.js +756 -0
- package/out/coveragePanel.js +542 -0
- package/out/diagnosticProvider.js +980 -0
- package/out/environmentProvider.js +373 -0
- package/out/extension.js +1025 -0
- package/out/httpClient.js +269 -0
- package/out/jsonFileReader.js +320 -0
- package/out/nornapiParser.js +326 -0
- package/out/parser.js +725 -0
- package/out/responsePanel.js +4674 -0
- package/out/schemaGenerator.js +393 -0
- package/out/scriptRunner.js +419 -0
- package/out/sequenceRunner.js +3046 -0
- package/out/swaggerParser.js +339 -0
- package/out/test/extension.test.js +48 -0
- package/out/testProvider.js +658 -0
- package/out/validationCache.js +245 -0
- package/package.json +1 -1
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Coverage Calculator for Swagger/OpenAPI specs
|
|
4
|
+
*
|
|
5
|
+
* Calculates what percentage of swagger-defined endpoints and their
|
|
6
|
+
* response codes are covered by test sequences.
|
|
7
|
+
*/
|
|
8
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
|
+
if (k2 === undefined) k2 = k;
|
|
10
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
11
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
12
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
13
|
+
}
|
|
14
|
+
Object.defineProperty(o, k2, desc);
|
|
15
|
+
}) : (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
o[k2] = m[k];
|
|
18
|
+
}));
|
|
19
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
20
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
21
|
+
}) : function(o, v) {
|
|
22
|
+
o["default"] = v;
|
|
23
|
+
});
|
|
24
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
25
|
+
var ownKeys = function(o) {
|
|
26
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
27
|
+
var ar = [];
|
|
28
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
29
|
+
return ar;
|
|
30
|
+
};
|
|
31
|
+
return ownKeys(o);
|
|
32
|
+
};
|
|
33
|
+
return function (mod) {
|
|
34
|
+
if (mod && mod.__esModule) return mod;
|
|
35
|
+
var result = {};
|
|
36
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
37
|
+
__setModuleDefault(result, mod);
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
})();
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.calculateWorkspaceCoverage = calculateWorkspaceCoverage;
|
|
43
|
+
exports.getCoverage = getCoverage;
|
|
44
|
+
exports.refreshCoverage = refreshCoverage;
|
|
45
|
+
exports.recalculateCoverageAfterExecution = recalculateCoverageAfterExecution;
|
|
46
|
+
exports.clearCoverageCache = clearCoverageCache;
|
|
47
|
+
exports.onCoverageUpdate = onCoverageUpdate;
|
|
48
|
+
const vscode = __importStar(require("vscode"));
|
|
49
|
+
const fs = __importStar(require("fs"));
|
|
50
|
+
const swaggerParser_1 = require("./swaggerParser");
|
|
51
|
+
const nornapiParser_1 = require("./nornapiParser");
|
|
52
|
+
/**
|
|
53
|
+
* Extract the path portion from a full URL and normalize it for swagger matching.
|
|
54
|
+
* e.g., "https://petstore.swagger.io/v2/store/inventory" -> "/store/inventory"
|
|
55
|
+
*
|
|
56
|
+
* We need to strip the base URL (which typically includes version like /v2) to match swagger paths.
|
|
57
|
+
*/
|
|
58
|
+
function extractSwaggerPath(fullUrl, swaggerBaseUrl) {
|
|
59
|
+
try {
|
|
60
|
+
// If it's already just a path (starts with /), return as-is
|
|
61
|
+
if (fullUrl.startsWith('/')) {
|
|
62
|
+
return fullUrl;
|
|
63
|
+
}
|
|
64
|
+
const url = new URL(fullUrl);
|
|
65
|
+
// Decode the pathname to convert %7B to { and %7D to }
|
|
66
|
+
let path = decodeURIComponent(url.pathname);
|
|
67
|
+
// If we have a swagger base URL, try to strip it from the path
|
|
68
|
+
if (swaggerBaseUrl) {
|
|
69
|
+
try {
|
|
70
|
+
const baseUrl = new URL(swaggerBaseUrl);
|
|
71
|
+
const basePath = decodeURIComponent(baseUrl.pathname);
|
|
72
|
+
// If the path starts with the base path (e.g., /v2), strip it
|
|
73
|
+
if (basePath && basePath !== '/' && path.startsWith(basePath)) {
|
|
74
|
+
path = path.substring(basePath.length);
|
|
75
|
+
// Ensure path starts with /
|
|
76
|
+
if (!path.startsWith('/')) {
|
|
77
|
+
path = '/' + path;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Ignore base URL parsing errors
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return path || '/';
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// If URL parsing fails, try to extract path manually
|
|
89
|
+
const match = fullUrl.match(/https?:\/\/[^\/]+(\/.*)/);
|
|
90
|
+
return match ? match[1] : fullUrl;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Extract swagger URL from .nornapi file content
|
|
95
|
+
*/
|
|
96
|
+
function extractSwaggerUrl(content) {
|
|
97
|
+
const match = content.match(/^swagger\s+["']?(https?:\/\/[^\s"']+)["']?\s*$/m);
|
|
98
|
+
return match?.[1];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Parse assert status lines from sequence content.
|
|
102
|
+
* Returns array of status codes/patterns (e.g., ["200", "2xx", "404"])
|
|
103
|
+
*/
|
|
104
|
+
function extractAssertedStatusCodes(sequenceContent) {
|
|
105
|
+
const codes = [];
|
|
106
|
+
const lines = sequenceContent.split('\n');
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
const trimmed = line.trim();
|
|
109
|
+
// Match various assert status patterns:
|
|
110
|
+
// - assert status == 200
|
|
111
|
+
// - assert $1.status == 200
|
|
112
|
+
// - assert response.status == 200
|
|
113
|
+
// - assert inventory.status == 200
|
|
114
|
+
// - assert variableName.status >= 400
|
|
115
|
+
const statusMatch = trimmed.match(/^assert\s+(?:(?:\$\d+|[a-zA-Z_][a-zA-Z0-9_]*)\.)?status\s*(?:==|>=|<=|>|<|!=)?\s*(\d{3}|[1-5]xx)$/i);
|
|
116
|
+
if (statusMatch) {
|
|
117
|
+
codes.push(statusMatch[1].toLowerCase());
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// Also match simpler patterns like: assert status 200
|
|
121
|
+
const simpleMatch = trimmed.match(/^assert\s+status\s+(\d{3}|[1-5]xx)$/i);
|
|
122
|
+
if (simpleMatch) {
|
|
123
|
+
codes.push(simpleMatch[1].toLowerCase());
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return codes;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Check if a wildcard pattern matches a status code
|
|
130
|
+
* e.g., "2xx" matches "200", "201", "204"
|
|
131
|
+
*/
|
|
132
|
+
function wildcardMatches(pattern, code) {
|
|
133
|
+
const lowerPattern = pattern.toLowerCase();
|
|
134
|
+
// Exact match
|
|
135
|
+
if (lowerPattern === code) {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
// Wildcard match (2xx, 4xx, 5xx)
|
|
139
|
+
if (/^[1-5]xx$/.test(lowerPattern)) {
|
|
140
|
+
const patternPrefix = lowerPattern[0];
|
|
141
|
+
return code.startsWith(patternPrefix);
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Check if a concrete URL path matches a swagger endpoint path template
|
|
147
|
+
* e.g., "/pet/1" matches "/pet/{petId}"
|
|
148
|
+
* "/v2/pet/123" matches "/pet/{petId}" (with base path /v2)
|
|
149
|
+
*/
|
|
150
|
+
function pathMatchesSwaggerEndpoint(urlPath, swaggerPath) {
|
|
151
|
+
// Normalize paths - remove leading/trailing slashes for comparison
|
|
152
|
+
const normalizedUrl = urlPath.replace(/^\/+|\/+$/g, '');
|
|
153
|
+
const normalizedSwagger = swaggerPath.replace(/^\/+|\/+$/g, '');
|
|
154
|
+
const urlParts = normalizedUrl.split('/');
|
|
155
|
+
const swaggerParts = normalizedSwagger.split('/');
|
|
156
|
+
// Try matching from different starting positions to handle base paths
|
|
157
|
+
// e.g., URL /v2/pet/1 should match swagger /pet/{petId} if base is /v2
|
|
158
|
+
for (let offset = 0; offset <= urlParts.length - swaggerParts.length; offset++) {
|
|
159
|
+
let matches = true;
|
|
160
|
+
for (let i = 0; i < swaggerParts.length; i++) {
|
|
161
|
+
const urlPart = urlParts[offset + i];
|
|
162
|
+
const swaggerPart = swaggerParts[i];
|
|
163
|
+
// Path parameter in swagger (e.g., {petId}) matches any value
|
|
164
|
+
if (swaggerPart.startsWith('{') && swaggerPart.endsWith('}')) {
|
|
165
|
+
// Path parameter matches any non-empty value
|
|
166
|
+
if (!urlPart || urlPart.length === 0) {
|
|
167
|
+
matches = false;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else if (urlPart !== swaggerPart) {
|
|
172
|
+
// Static segments must match exactly
|
|
173
|
+
matches = false;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (matches) {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Extract API calls from sequence content.
|
|
185
|
+
* Returns array of { endpointName, assertedCodes, method?, url? }
|
|
186
|
+
*
|
|
187
|
+
* @param sequenceContent - The content of the sequence to analyze
|
|
188
|
+
* @param allSequences - Map of all sequences by name (for following run SequenceName calls)
|
|
189
|
+
* @param allNamedRequests - Map of named request blocks by name (for following run NamedRequest calls)
|
|
190
|
+
* @param visited - Set of already visited sequence names (to prevent infinite recursion)
|
|
191
|
+
*/
|
|
192
|
+
function extractApiCalls(sequenceContent, allSequences, allNamedRequests, visited) {
|
|
193
|
+
const calls = [];
|
|
194
|
+
const lines = sequenceContent.split('\n');
|
|
195
|
+
const visitedSet = visited || new Set();
|
|
196
|
+
for (let i = 0; i < lines.length; i++) {
|
|
197
|
+
const line = lines[i].trim();
|
|
198
|
+
// Check for "run Name" or "var x = run Name" calls
|
|
199
|
+
const runMatch = line.match(/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s*\(.*?\))?$/i);
|
|
200
|
+
if (runMatch) {
|
|
201
|
+
const targetName = runMatch[1];
|
|
202
|
+
// Avoid infinite recursion
|
|
203
|
+
if (!visitedSet.has(targetName)) {
|
|
204
|
+
visitedSet.add(targetName);
|
|
205
|
+
// First check if it's a sequence
|
|
206
|
+
if (allSequences) {
|
|
207
|
+
const subSequenceContent = allSequences.get(targetName);
|
|
208
|
+
if (subSequenceContent) {
|
|
209
|
+
// Recursively extract API calls from the sub-sequence
|
|
210
|
+
const subCalls = extractApiCalls(subSequenceContent, allSequences, allNamedRequests, visitedSet);
|
|
211
|
+
calls.push(...subCalls);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Then check if it's a named request block
|
|
216
|
+
if (allNamedRequests) {
|
|
217
|
+
const namedRequest = allNamedRequests.get(targetName);
|
|
218
|
+
if (namedRequest) {
|
|
219
|
+
// Look for assert status lines following this run command
|
|
220
|
+
const assertedCodes = [];
|
|
221
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
222
|
+
const nextLine = lines[j].trim();
|
|
223
|
+
// Stop at next request, sequence end, or other control flow
|
|
224
|
+
if (/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+/i.test(nextLine) ||
|
|
225
|
+
/^end\s+sequence$/i.test(nextLine) ||
|
|
226
|
+
/^run\s+/i.test(nextLine) ||
|
|
227
|
+
/^if\s+/i.test(nextLine) ||
|
|
228
|
+
/^foreach\s+/i.test(nextLine)) {
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
// Check for assert status
|
|
232
|
+
const statusMatch = nextLine.match(/^assert\s+(?:(?:\$\d+|[a-zA-Z_][a-zA-Z0-9_]*)\.)?status\s*(?:==|>=|<=|>|<|!=)?\s*(\d{3}|[1-5]xx)$/i);
|
|
233
|
+
if (statusMatch) {
|
|
234
|
+
assertedCodes.push(statusMatch[1].toLowerCase());
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Add this as a URL-based API call (will be matched by URL later)
|
|
238
|
+
calls.push({
|
|
239
|
+
endpointName: `__url__${namedRequest.method}__${namedRequest.url}`,
|
|
240
|
+
assertedCodes,
|
|
241
|
+
method: namedRequest.method,
|
|
242
|
+
url: namedRequest.url
|
|
243
|
+
});
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
// Match API request by name with optional variable assignment:
|
|
251
|
+
// - GET EndpointName
|
|
252
|
+
// - GET EndpointName(args)
|
|
253
|
+
// - GET EndpointName(args) HeaderGroup
|
|
254
|
+
// - var result = GET EndpointName
|
|
255
|
+
// - var inventory = GET GetInventory
|
|
256
|
+
// - var order = GET GetOrderById(1)
|
|
257
|
+
const apiCallMatch = line.match(/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\s*\(.*?\))?(?:\s+[a-zA-Z_][a-zA-Z0-9_]*)?$/i);
|
|
258
|
+
if (apiCallMatch) {
|
|
259
|
+
const endpointName = apiCallMatch[2];
|
|
260
|
+
// Look for assert status lines following this request (until next request or end)
|
|
261
|
+
const assertedCodes = [];
|
|
262
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
263
|
+
const nextLine = lines[j].trim();
|
|
264
|
+
// Stop at next request, sequence end, or other control flow
|
|
265
|
+
// Also stop if we see another var assignment with HTTP method
|
|
266
|
+
if (/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+/i.test(nextLine) ||
|
|
267
|
+
/^end\s+sequence$/i.test(nextLine) ||
|
|
268
|
+
/^run\s+/i.test(nextLine) ||
|
|
269
|
+
/^if\s+/i.test(nextLine) ||
|
|
270
|
+
/^foreach\s+/i.test(nextLine)) {
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
// Check for assert status with variable name or $N reference
|
|
274
|
+
const statusMatch = nextLine.match(/^assert\s+(?:(?:\$\d+|[a-zA-Z_][a-zA-Z0-9_]*)\.)?status\s*(?:==|>=|<=|>|<|!=)?\s*(\d{3}|[1-5]xx)$/i);
|
|
275
|
+
if (statusMatch) {
|
|
276
|
+
assertedCodes.push(statusMatch[1].toLowerCase());
|
|
277
|
+
}
|
|
278
|
+
// Simpler pattern
|
|
279
|
+
const simpleMatch = nextLine.match(/^assert\s+status\s+(\d{3}|[1-5]xx)$/i);
|
|
280
|
+
if (simpleMatch) {
|
|
281
|
+
assertedCodes.push(simpleMatch[1].toLowerCase());
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
calls.push({ endpointName, assertedCodes });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return calls;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Extract all test sequences from a .norn file content.
|
|
291
|
+
* Only "test sequence" blocks count towards coverage, not regular "sequence" blocks.
|
|
292
|
+
*/
|
|
293
|
+
function extractTestSequences(content) {
|
|
294
|
+
const sequences = [];
|
|
295
|
+
const lines = content.split('\n');
|
|
296
|
+
let currentSequence = null;
|
|
297
|
+
for (let i = 0; i < lines.length; i++) {
|
|
298
|
+
const line = lines[i].trim();
|
|
299
|
+
// Match only "test sequence Name" - regular sequences don't count towards coverage
|
|
300
|
+
const sequenceMatch = line.match(/^test\s+sequence\s+([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s*\([^)]*\))?$/);
|
|
301
|
+
if (sequenceMatch) {
|
|
302
|
+
currentSequence = { name: sequenceMatch[1], startLine: i };
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
// Match sequence end
|
|
306
|
+
if (/^end\s+sequence$/i.test(line) && currentSequence) {
|
|
307
|
+
const sequenceContent = lines.slice(currentSequence.startLine, i + 1).join('\n');
|
|
308
|
+
sequences.push({ name: currentSequence.name, content: sequenceContent });
|
|
309
|
+
currentSequence = null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return sequences;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Extract ALL sequences from a .norn file content (both test and regular).
|
|
316
|
+
* Used to build a map for following "run SequenceName" calls.
|
|
317
|
+
*/
|
|
318
|
+
function extractAllSequences(content) {
|
|
319
|
+
const sequences = new Map();
|
|
320
|
+
const lines = content.split('\n');
|
|
321
|
+
let currentSequence = null;
|
|
322
|
+
for (let i = 0; i < lines.length; i++) {
|
|
323
|
+
const line = lines[i].trim();
|
|
324
|
+
// Match both "test sequence Name" and "sequence Name"
|
|
325
|
+
const testSequenceMatch = line.match(/^test\s+sequence\s+([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s*\([^)]*\))?$/);
|
|
326
|
+
const sequenceMatch = line.match(/^sequence\s+([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s*\([^)]*\))?$/);
|
|
327
|
+
if (testSequenceMatch || sequenceMatch) {
|
|
328
|
+
const name = testSequenceMatch ? testSequenceMatch[1] : sequenceMatch[1];
|
|
329
|
+
currentSequence = { name, startLine: i };
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
// Match sequence end
|
|
333
|
+
if (/^end\s+sequence$/i.test(line) && currentSequence) {
|
|
334
|
+
const sequenceContent = lines.slice(currentSequence.startLine + 1, i).join('\n');
|
|
335
|
+
sequences.set(currentSequence.name, sequenceContent);
|
|
336
|
+
currentSequence = null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return sequences;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Extract named request blocks from a .norn file content.
|
|
343
|
+
* Named requests are blocks like [RequestName] followed by HTTP method + URL.
|
|
344
|
+
*/
|
|
345
|
+
function extractNamedRequests(content) {
|
|
346
|
+
const requests = new Map();
|
|
347
|
+
const lines = content.split('\n');
|
|
348
|
+
let currentRequest = null;
|
|
349
|
+
for (let i = 0; i < lines.length; i++) {
|
|
350
|
+
const line = lines[i].trim();
|
|
351
|
+
// Match named request start: [RequestName]
|
|
352
|
+
const nameMatch = line.match(/^\[([a-zA-Z_][a-zA-Z0-9_-]*)\]$/);
|
|
353
|
+
if (nameMatch) {
|
|
354
|
+
// Save previous request if exists
|
|
355
|
+
if (currentRequest) {
|
|
356
|
+
const requestContent = lines.slice(currentRequest.startLine + 1, i).join('\n').trim();
|
|
357
|
+
const parsed = parseRequestContent(requestContent);
|
|
358
|
+
if (parsed) {
|
|
359
|
+
requests.set(currentRequest.name, { ...parsed, content: requestContent });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
currentRequest = { name: nameMatch[1], startLine: i };
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
// Check for sequence start or end - terminates named request block
|
|
366
|
+
if (/^(?:test\s+)?sequence\s+/i.test(line) || /^end\s+sequence$/i.test(line)) {
|
|
367
|
+
if (currentRequest) {
|
|
368
|
+
const requestContent = lines.slice(currentRequest.startLine + 1, i).join('\n').trim();
|
|
369
|
+
const parsed = parseRequestContent(requestContent);
|
|
370
|
+
if (parsed) {
|
|
371
|
+
requests.set(currentRequest.name, { ...parsed, content: requestContent });
|
|
372
|
+
}
|
|
373
|
+
currentRequest = null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// Don't forget the last request
|
|
378
|
+
if (currentRequest) {
|
|
379
|
+
const requestContent = lines.slice(currentRequest.startLine + 1).join('\n').trim();
|
|
380
|
+
const parsed = parseRequestContent(requestContent);
|
|
381
|
+
if (parsed) {
|
|
382
|
+
requests.set(currentRequest.name, { ...parsed, content: requestContent });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return requests;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Parse the content of a named request block to extract method and URL.
|
|
389
|
+
*/
|
|
390
|
+
function parseRequestContent(content) {
|
|
391
|
+
const lines = content.split('\n');
|
|
392
|
+
for (const line of lines) {
|
|
393
|
+
const trimmed = line.trim();
|
|
394
|
+
// Match: GET "url" or GET url or POST "url" etc.
|
|
395
|
+
const match = trimmed.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+["']?([^"'\s]+)["']?/i);
|
|
396
|
+
if (match) {
|
|
397
|
+
return { method: match[1].toUpperCase(), url: match[2] };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Calculate coverage for the entire workspace
|
|
404
|
+
* @param useCachedSpecs If true, only fetch swagger specs for new URLs (used after execution)
|
|
405
|
+
*/
|
|
406
|
+
async function calculateWorkspaceCoverage(useCachedSpecs = false) {
|
|
407
|
+
const workspaceFolders = vscode.workspace.workspaceFolders;
|
|
408
|
+
if (!workspaceFolders) {
|
|
409
|
+
return {
|
|
410
|
+
total: 0,
|
|
411
|
+
covered: 0,
|
|
412
|
+
percentage: 0,
|
|
413
|
+
specs: [],
|
|
414
|
+
hasSwagger: false
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
// Step 1: Find all .nornapi files and extract swagger URLs + endpoint mappings
|
|
418
|
+
const nornapiFiles = await vscode.workspace.findFiles('**/*.nornapi', '**/node_modules/**');
|
|
419
|
+
const swaggerUrls = new Map(); // swagger URL -> list of nornapi files
|
|
420
|
+
// Store endpoint info temporarily - we'll create mappings after fetching swagger specs
|
|
421
|
+
const pendingEndpoints = [];
|
|
422
|
+
for (const file of nornapiFiles) {
|
|
423
|
+
try {
|
|
424
|
+
const content = fs.readFileSync(file.fsPath, 'utf-8');
|
|
425
|
+
const swaggerUrl = extractSwaggerUrl(content);
|
|
426
|
+
if (swaggerUrl) {
|
|
427
|
+
// Track this nornapi file for this swagger URL
|
|
428
|
+
if (!swaggerUrls.has(swaggerUrl)) {
|
|
429
|
+
swaggerUrls.set(swaggerUrl, []);
|
|
430
|
+
}
|
|
431
|
+
swaggerUrls.get(swaggerUrl).push({ url: swaggerUrl, filePath: file.fsPath });
|
|
432
|
+
// Parse endpoints from this nornapi file
|
|
433
|
+
const nornApiDef = (0, nornapiParser_1.parseNornApiFile)(content);
|
|
434
|
+
for (const endpoint of nornApiDef.endpoints) {
|
|
435
|
+
// Store for later processing after we have swagger specs
|
|
436
|
+
pendingEndpoints.push({
|
|
437
|
+
endpoint: { name: endpoint.name, method: endpoint.method, path: endpoint.path },
|
|
438
|
+
swaggerUrl,
|
|
439
|
+
filePath: file.fsPath
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
console.error(`Error reading .nornapi file ${file.fsPath}:`, error);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (swaggerUrls.size === 0) {
|
|
449
|
+
return {
|
|
450
|
+
total: 0,
|
|
451
|
+
covered: 0,
|
|
452
|
+
percentage: 0,
|
|
453
|
+
specs: [],
|
|
454
|
+
hasSwagger: false
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
// Detect new swagger URLs (not seen before)
|
|
458
|
+
const currentUrls = new Set(swaggerUrls.keys());
|
|
459
|
+
const newUrls = new Set();
|
|
460
|
+
for (const url of currentUrls) {
|
|
461
|
+
if (!knownSwaggerUrls.has(url)) {
|
|
462
|
+
newUrls.add(url);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// Update known URLs
|
|
466
|
+
knownSwaggerUrls = currentUrls;
|
|
467
|
+
// Step 2: Find all .norn files and extract API calls with assert status
|
|
468
|
+
const nornFiles = await vscode.workspace.findFiles('**/*.norn', '**/node_modules/**');
|
|
469
|
+
// Map: endpoint name -> list of asserted status codes (from all sequences)
|
|
470
|
+
const coverageByName = new Map();
|
|
471
|
+
// First pass: build a combined map of all sequences across all files
|
|
472
|
+
// This allows test sequences to call sub-sequences defined in other files
|
|
473
|
+
const allSequencesMap = new Map();
|
|
474
|
+
const allNamedRequestsMap = new Map();
|
|
475
|
+
for (const file of nornFiles) {
|
|
476
|
+
try {
|
|
477
|
+
const content = fs.readFileSync(file.fsPath, 'utf-8');
|
|
478
|
+
const fileSequences = extractAllSequences(content);
|
|
479
|
+
for (const [name, seqContent] of fileSequences) {
|
|
480
|
+
allSequencesMap.set(name, seqContent);
|
|
481
|
+
}
|
|
482
|
+
const fileNamedRequests = extractNamedRequests(content);
|
|
483
|
+
for (const [name, reqInfo] of fileNamedRequests) {
|
|
484
|
+
allNamedRequestsMap.set(name, reqInfo);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
console.error(`Error reading .norn file ${file.fsPath}:`, error);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// Track URL-based API calls separately (for matching by URL instead of endpoint name)
|
|
492
|
+
const coverageByUrl = new Map();
|
|
493
|
+
// Second pass: extract API calls from test sequences, following run commands
|
|
494
|
+
for (const file of nornFiles) {
|
|
495
|
+
try {
|
|
496
|
+
const content = fs.readFileSync(file.fsPath, 'utf-8');
|
|
497
|
+
const testSequences = extractTestSequences(content);
|
|
498
|
+
for (const sequence of testSequences) {
|
|
499
|
+
// Pass the map of all sequences and named requests so we can follow "run" calls
|
|
500
|
+
const apiCalls = extractApiCalls(sequence.content, allSequencesMap, allNamedRequestsMap);
|
|
501
|
+
for (const call of apiCalls) {
|
|
502
|
+
// Check if this is a URL-based call (from a named request block)
|
|
503
|
+
if (call.url && call.method) {
|
|
504
|
+
const key = `${call.method} ${call.url}`;
|
|
505
|
+
if (!coverageByUrl.has(key)) {
|
|
506
|
+
coverageByUrl.set(key, { method: call.method, url: call.url, assertedCodes: new Set() });
|
|
507
|
+
}
|
|
508
|
+
for (const code of call.assertedCodes) {
|
|
509
|
+
coverageByUrl.get(key).assertedCodes.add(code);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
// Regular endpoint name-based call
|
|
514
|
+
if (!coverageByName.has(call.endpointName)) {
|
|
515
|
+
coverageByName.set(call.endpointName, new Set());
|
|
516
|
+
}
|
|
517
|
+
for (const code of call.assertedCodes) {
|
|
518
|
+
coverageByName.get(call.endpointName).add(code);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
console.error(`Error reading .norn file ${file.fsPath}:`, error);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// Step 3: For each swagger spec, calculate coverage
|
|
529
|
+
// First fetch all specs so we have baseUrl for path normalization
|
|
530
|
+
const swaggerSpecs = new Map();
|
|
531
|
+
for (const [swaggerUrl] of swaggerUrls) {
|
|
532
|
+
try {
|
|
533
|
+
const swaggerSpec = await (0, swaggerParser_1.getCachedSwaggerSpec)(swaggerUrl);
|
|
534
|
+
swaggerSpecs.set(swaggerUrl, swaggerSpec);
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
console.error(`Error fetching swagger spec ${swaggerUrl}:`, error);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
// Now create endpoint mappings with proper path normalization
|
|
541
|
+
const endpointMappings = [];
|
|
542
|
+
for (const pending of pendingEndpoints) {
|
|
543
|
+
const spec = swaggerSpecs.get(pending.swaggerUrl);
|
|
544
|
+
if (spec) {
|
|
545
|
+
// Extract the swagger-style path from the full URL
|
|
546
|
+
const swaggerPath = extractSwaggerPath(pending.endpoint.path, spec.baseUrl);
|
|
547
|
+
const swaggerKey = `${pending.endpoint.method} ${swaggerPath}`;
|
|
548
|
+
endpointMappings.push({
|
|
549
|
+
name: pending.endpoint.name,
|
|
550
|
+
swaggerKey,
|
|
551
|
+
swaggerUrl: pending.swaggerUrl,
|
|
552
|
+
filePath: pending.filePath
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const specs = [];
|
|
557
|
+
for (const [swaggerUrl, swaggerSpec] of swaggerSpecs) {
|
|
558
|
+
const coverageResult = calculateCoverageForSpec(swaggerUrl, swaggerSpec, endpointMappings.filter(m => m.swaggerUrl === swaggerUrl), coverageByName, coverageByUrl);
|
|
559
|
+
specs.push(coverageResult);
|
|
560
|
+
}
|
|
561
|
+
// Step 4: Aggregate results
|
|
562
|
+
let totalCodes = 0;
|
|
563
|
+
let coveredCodes = 0;
|
|
564
|
+
for (const spec of specs) {
|
|
565
|
+
totalCodes += spec.total;
|
|
566
|
+
coveredCodes += spec.covered;
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
total: totalCodes,
|
|
570
|
+
covered: coveredCodes,
|
|
571
|
+
percentage: totalCodes > 0 ? Math.round((coveredCodes / totalCodes) * 100) : 0,
|
|
572
|
+
specs,
|
|
573
|
+
hasSwagger: true
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Calculate coverage for a single swagger spec
|
|
578
|
+
*/
|
|
579
|
+
function calculateCoverageForSpec(swaggerUrl, spec, endpointMappings, coverageByName, coverageByUrl) {
|
|
580
|
+
const endpoints = [];
|
|
581
|
+
let total = 0;
|
|
582
|
+
let covered = 0;
|
|
583
|
+
// Build a lookup from swagger key to endpoint names
|
|
584
|
+
const keyToNames = new Map();
|
|
585
|
+
for (const mapping of endpointMappings) {
|
|
586
|
+
if (!keyToNames.has(mapping.swaggerKey)) {
|
|
587
|
+
keyToNames.set(mapping.swaggerKey, []);
|
|
588
|
+
}
|
|
589
|
+
keyToNames.get(mapping.swaggerKey).push(mapping.name);
|
|
590
|
+
}
|
|
591
|
+
// Process each endpoint in the swagger spec
|
|
592
|
+
for (const section of spec.sections) {
|
|
593
|
+
for (const endpoint of section.endpoints) {
|
|
594
|
+
const swaggerKey = `${endpoint.method} ${endpoint.path}`;
|
|
595
|
+
const names = keyToNames.get(swaggerKey) || [];
|
|
596
|
+
// Get all asserted codes for any name that maps to this endpoint
|
|
597
|
+
const allAssertedCodes = new Set();
|
|
598
|
+
const coveredByNames = {}; // code -> names that cover it
|
|
599
|
+
for (const name of names) {
|
|
600
|
+
const codes = coverageByName.get(name);
|
|
601
|
+
if (codes) {
|
|
602
|
+
for (const code of codes) {
|
|
603
|
+
allAssertedCodes.add(code);
|
|
604
|
+
// Track which names cover which codes
|
|
605
|
+
for (const responseCode of endpoint.responseCodes) {
|
|
606
|
+
if (wildcardMatches(code, responseCode)) {
|
|
607
|
+
if (!coveredByNames[responseCode]) {
|
|
608
|
+
coveredByNames[responseCode] = [];
|
|
609
|
+
}
|
|
610
|
+
if (!coveredByNames[responseCode].includes(name)) {
|
|
611
|
+
coveredByNames[responseCode].push(name);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// Also check URL-based coverage (from named request blocks)
|
|
619
|
+
// Match URLs to swagger endpoints by comparing the path portion
|
|
620
|
+
for (const [, urlCoverage] of coverageByUrl) {
|
|
621
|
+
// Check if method matches
|
|
622
|
+
if (urlCoverage.method.toUpperCase() !== endpoint.method.toUpperCase()) {
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
// Try to match URL to swagger endpoint path
|
|
626
|
+
// Extract the path from the full URL
|
|
627
|
+
try {
|
|
628
|
+
const url = new URL(urlCoverage.url);
|
|
629
|
+
const urlPath = url.pathname;
|
|
630
|
+
// Check if URL path matches the swagger endpoint path
|
|
631
|
+
// Handle path parameters like /pet/{petId} matching /pet/1
|
|
632
|
+
if (pathMatchesSwaggerEndpoint(urlPath, endpoint.path)) {
|
|
633
|
+
for (const code of urlCoverage.assertedCodes) {
|
|
634
|
+
allAssertedCodes.add(code);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
// Invalid URL, skip
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
// Calculate coverage for each response code
|
|
643
|
+
const responseCodes = [];
|
|
644
|
+
let endpointCovered = 0;
|
|
645
|
+
for (const code of endpoint.responseCodes) {
|
|
646
|
+
let isCovered = false;
|
|
647
|
+
const coveredBy = coveredByNames[code] || [];
|
|
648
|
+
// Check if any asserted code matches this response code
|
|
649
|
+
for (const assertedCode of allAssertedCodes) {
|
|
650
|
+
if (wildcardMatches(assertedCode, code)) {
|
|
651
|
+
isCovered = true;
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
responseCodes.push({
|
|
656
|
+
code,
|
|
657
|
+
covered: isCovered,
|
|
658
|
+
coveredBy
|
|
659
|
+
});
|
|
660
|
+
total++;
|
|
661
|
+
if (isCovered) {
|
|
662
|
+
covered++;
|
|
663
|
+
endpointCovered++;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
endpoints.push({
|
|
667
|
+
method: endpoint.method,
|
|
668
|
+
path: endpoint.path,
|
|
669
|
+
tag: endpoint.tag,
|
|
670
|
+
summary: endpoint.summary,
|
|
671
|
+
responseCodes,
|
|
672
|
+
totalCodes: endpoint.responseCodes.length,
|
|
673
|
+
coveredCodes: endpointCovered
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// Sort endpoints by tag, then by path
|
|
678
|
+
endpoints.sort((a, b) => {
|
|
679
|
+
if (a.tag !== b.tag) {
|
|
680
|
+
return a.tag.localeCompare(b.tag);
|
|
681
|
+
}
|
|
682
|
+
return a.path.localeCompare(b.path);
|
|
683
|
+
});
|
|
684
|
+
return {
|
|
685
|
+
total,
|
|
686
|
+
covered,
|
|
687
|
+
percentage: total > 0 ? Math.round((covered / total) * 100) : 0,
|
|
688
|
+
endpoints,
|
|
689
|
+
swaggerUrl,
|
|
690
|
+
swaggerTitle: spec.title,
|
|
691
|
+
lastUpdated: new Date()
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
// Cached coverage result for status bar updates
|
|
695
|
+
let cachedCoverage = null;
|
|
696
|
+
let coverageUpdateListeners = [];
|
|
697
|
+
// Track known swagger URLs to detect new ones
|
|
698
|
+
let knownSwaggerUrls = new Set();
|
|
699
|
+
/**
|
|
700
|
+
* Get cached coverage or calculate if not available
|
|
701
|
+
*/
|
|
702
|
+
async function getCoverage() {
|
|
703
|
+
if (!cachedCoverage) {
|
|
704
|
+
cachedCoverage = await calculateWorkspaceCoverage();
|
|
705
|
+
notifyCoverageListeners();
|
|
706
|
+
}
|
|
707
|
+
return cachedCoverage;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Force recalculate coverage - re-fetches swagger specs
|
|
711
|
+
*/
|
|
712
|
+
async function refreshCoverage() {
|
|
713
|
+
cachedCoverage = await calculateWorkspaceCoverage();
|
|
714
|
+
notifyCoverageListeners();
|
|
715
|
+
return cachedCoverage;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Recalculate coverage after execution - only re-scans .norn files,
|
|
719
|
+
* uses cached swagger specs unless new URLs are detected
|
|
720
|
+
*/
|
|
721
|
+
async function recalculateCoverageAfterExecution() {
|
|
722
|
+
cachedCoverage = await calculateWorkspaceCoverage(true);
|
|
723
|
+
notifyCoverageListeners();
|
|
724
|
+
return cachedCoverage;
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Clear cached coverage
|
|
728
|
+
*/
|
|
729
|
+
function clearCoverageCache() {
|
|
730
|
+
cachedCoverage = null;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Register a listener for coverage updates
|
|
734
|
+
*/
|
|
735
|
+
function onCoverageUpdate(listener) {
|
|
736
|
+
coverageUpdateListeners.push(listener);
|
|
737
|
+
return {
|
|
738
|
+
dispose: () => {
|
|
739
|
+
const index = coverageUpdateListeners.indexOf(listener);
|
|
740
|
+
if (index !== -1) {
|
|
741
|
+
coverageUpdateListeners.splice(index, 1);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Notify all listeners of coverage update
|
|
748
|
+
*/
|
|
749
|
+
function notifyCoverageListeners() {
|
|
750
|
+
if (cachedCoverage) {
|
|
751
|
+
for (const listener of coverageUpdateListeners) {
|
|
752
|
+
listener(cachedCoverage);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
//# sourceMappingURL=coverageCalculator.js.map
|