norn-cli 1.4.0 → 1.4.2
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/.norn-cache/swagger-body-intellisense.json +1 -1
- package/CHANGELOG.md +16 -0
- package/out/chatParticipant.js +722 -0
- package/out/cli.js +99 -36
- package/out/codeLensProvider.js +14 -20
- package/out/completionProvider.js +543 -25
- package/out/coverageCalculator.js +250 -169
- package/out/coveragePanel.js +7 -4
- package/out/diagnosticProvider.js +135 -2
- package/out/environmentProvider.js +96 -27
- package/out/extension.js +98 -9
- package/out/nornPrompt.js +580 -0
- package/out/swaggerBodyIntellisenseCache.js +147 -0
- package/out/swaggerParser.js +154 -74
- package/out/test/coverageCalculator.test.js +100 -0
- package/out/testProvider.js +1 -1
- package/package.json +1 -1
|
@@ -41,12 +41,15 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
41
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
42
|
exports.calculateWorkspaceCoverage = calculateWorkspaceCoverage;
|
|
43
43
|
exports.getCoverage = getCoverage;
|
|
44
|
+
exports.getCoverageForNornapiFile = getCoverageForNornapiFile;
|
|
44
45
|
exports.refreshCoverage = refreshCoverage;
|
|
46
|
+
exports.refreshCoverageForNornapiFile = refreshCoverageForNornapiFile;
|
|
45
47
|
exports.recalculateCoverageAfterExecution = recalculateCoverageAfterExecution;
|
|
46
48
|
exports.clearCoverageCache = clearCoverageCache;
|
|
47
49
|
exports.onCoverageUpdate = onCoverageUpdate;
|
|
48
50
|
const vscode = __importStar(require("vscode"));
|
|
49
51
|
const fs = __importStar(require("fs"));
|
|
52
|
+
const path = __importStar(require("path"));
|
|
50
53
|
const swaggerParser_1 = require("./swaggerParser");
|
|
51
54
|
const nornapiParser_1 = require("./nornapiParser");
|
|
52
55
|
/**
|
|
@@ -56,25 +59,25 @@ const nornapiParser_1 = require("./nornapiParser");
|
|
|
56
59
|
* We need to strip the base URL (which typically includes version like /v2) to match swagger paths.
|
|
57
60
|
*/
|
|
58
61
|
function extractSwaggerPath(fullUrl, swaggerBaseUrl) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
const normalizeWithBasePath = (rawPath) => {
|
|
63
|
+
let normalizedPath = rawPath || '/';
|
|
64
|
+
try {
|
|
65
|
+
normalizedPath = decodeURIComponent(normalizedPath);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Keep original path if decoding fails
|
|
69
|
+
}
|
|
70
|
+
if (!normalizedPath.startsWith('/')) {
|
|
71
|
+
normalizedPath = `/${normalizedPath}`;
|
|
63
72
|
}
|
|
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
73
|
if (swaggerBaseUrl) {
|
|
69
74
|
try {
|
|
70
75
|
const baseUrl = new URL(swaggerBaseUrl);
|
|
71
76
|
const basePath = decodeURIComponent(baseUrl.pathname);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (!path.startsWith('/')) {
|
|
77
|
-
path = '/' + path;
|
|
77
|
+
if (basePath && basePath !== '/' && normalizedPath.startsWith(basePath)) {
|
|
78
|
+
normalizedPath = normalizedPath.substring(basePath.length);
|
|
79
|
+
if (!normalizedPath.startsWith('/')) {
|
|
80
|
+
normalizedPath = '/' + normalizedPath;
|
|
78
81
|
}
|
|
79
82
|
}
|
|
80
83
|
}
|
|
@@ -82,12 +85,47 @@ function extractSwaggerPath(fullUrl, swaggerBaseUrl) {
|
|
|
82
85
|
// Ignore base URL parsing errors
|
|
83
86
|
}
|
|
84
87
|
}
|
|
85
|
-
return
|
|
88
|
+
return normalizedPath || '/';
|
|
89
|
+
};
|
|
90
|
+
const normalizeTemplatePath = (rawPath) => {
|
|
91
|
+
let candidate = rawPath.trim();
|
|
92
|
+
if (!candidate.startsWith('/')) {
|
|
93
|
+
candidate = `/${candidate}`;
|
|
94
|
+
}
|
|
95
|
+
// Remove one or more leading template segments such as
|
|
96
|
+
// /{{baseUrl}}/{{version}}/pet/{petId} -> /pet/{petId}
|
|
97
|
+
candidate = candidate.replace(/^(?:\/\{\{[^}]+\}\})+/, '');
|
|
98
|
+
if (!candidate.startsWith('/')) {
|
|
99
|
+
candidate = `/${candidate}`;
|
|
100
|
+
}
|
|
101
|
+
return normalizeWithBasePath(candidate);
|
|
102
|
+
};
|
|
103
|
+
try {
|
|
104
|
+
const trimmed = fullUrl.trim();
|
|
105
|
+
// Handle template variables such as {{baseUrl}}/pet/{petId}
|
|
106
|
+
const variablePrefixMatch = trimmed.match(/^\{\{[^}]+\}\}(.*)$/);
|
|
107
|
+
if (variablePrefixMatch) {
|
|
108
|
+
const remainder = variablePrefixMatch[1] || '/';
|
|
109
|
+
return normalizeTemplatePath(remainder);
|
|
110
|
+
}
|
|
111
|
+
// Handle path-form template segments such as /{{version}}/pet/{petId}
|
|
112
|
+
if (/^\/(?:\{\{[^}]+\}\}\/)+/.test(trimmed) || /^\/\{\{[^}]+\}\}$/.test(trimmed)) {
|
|
113
|
+
return normalizeTemplatePath(trimmed);
|
|
114
|
+
}
|
|
115
|
+
// If it's already just a path (starts with /), return as-is
|
|
116
|
+
if (trimmed.startsWith('/')) {
|
|
117
|
+
return normalizeWithBasePath(trimmed);
|
|
118
|
+
}
|
|
119
|
+
const url = new URL(trimmed);
|
|
120
|
+
return normalizeWithBasePath(url.pathname);
|
|
86
121
|
}
|
|
87
122
|
catch {
|
|
88
123
|
// If URL parsing fails, try to extract path manually
|
|
89
124
|
const match = fullUrl.match(/https?:\/\/[^\/]+(\/.*)/);
|
|
90
|
-
|
|
125
|
+
if (match) {
|
|
126
|
+
return normalizeWithBasePath(match[1]);
|
|
127
|
+
}
|
|
128
|
+
return normalizeWithBasePath(fullUrl);
|
|
91
129
|
}
|
|
92
130
|
}
|
|
93
131
|
/**
|
|
@@ -105,26 +143,65 @@ function extractAssertedStatusCodes(sequenceContent) {
|
|
|
105
143
|
const codes = [];
|
|
106
144
|
const lines = sequenceContent.split('\n');
|
|
107
145
|
for (const line of lines) {
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
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());
|
|
146
|
+
const code = extractStatusCodeFromAssert(line);
|
|
147
|
+
if (code) {
|
|
148
|
+
codes.push(code);
|
|
124
149
|
}
|
|
125
150
|
}
|
|
126
151
|
return codes;
|
|
127
152
|
}
|
|
153
|
+
function extractStatusCodeFromAssert(line) {
|
|
154
|
+
const trimmed = line.trim();
|
|
155
|
+
// Match various assert status patterns:
|
|
156
|
+
// - assert status == 200
|
|
157
|
+
// - assert $1.status == 200
|
|
158
|
+
// - assert response.status == 200
|
|
159
|
+
// - assert inventory.status == 200
|
|
160
|
+
// - assert variableName.status >= 400
|
|
161
|
+
const statusMatch = trimmed.match(/^assert\s+(?:(?:\$\d+|[a-zA-Z_][a-zA-Z0-9_]*)\.)?status\s*(?:==|>=|<=|>|<|!=)?\s*(\d{3}|[1-5]xx)$/i);
|
|
162
|
+
if (statusMatch) {
|
|
163
|
+
return statusMatch[1].toLowerCase();
|
|
164
|
+
}
|
|
165
|
+
// Also match simpler patterns like: assert status 200
|
|
166
|
+
const simpleMatch = trimmed.match(/^assert\s+status\s+(\d{3}|[1-5]xx)$/i);
|
|
167
|
+
if (simpleMatch) {
|
|
168
|
+
return simpleMatch[1].toLowerCase();
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
function isCoverageScanBoundary(line) {
|
|
173
|
+
return /^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+/i.test(line) ||
|
|
174
|
+
/^end\s+sequence$/i.test(line) ||
|
|
175
|
+
/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+/i.test(line) ||
|
|
176
|
+
/^if\s+/i.test(line) ||
|
|
177
|
+
/^foreach\s+/i.test(line);
|
|
178
|
+
}
|
|
179
|
+
function collectStatusAssertionsFollowingLine(lines, startIndex) {
|
|
180
|
+
const assertedCodes = [];
|
|
181
|
+
for (let j = startIndex + 1; j < lines.length; j++) {
|
|
182
|
+
const nextLine = lines[j].trim();
|
|
183
|
+
// Stop at next request, sequence end, or other control flow
|
|
184
|
+
if (isCoverageScanBoundary(nextLine)) {
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
const code = extractStatusCodeFromAssert(nextLine);
|
|
188
|
+
if (code) {
|
|
189
|
+
assertedCodes.push(code);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return assertedCodes;
|
|
193
|
+
}
|
|
194
|
+
function extractReturnedVariable(sequenceContent) {
|
|
195
|
+
let returnedVariable;
|
|
196
|
+
for (const line of sequenceContent.split('\n')) {
|
|
197
|
+
const trimmed = line.trim();
|
|
198
|
+
const returnMatch = trimmed.match(/^return\s+([a-zA-Z_][a-zA-Z0-9_]*)$/i);
|
|
199
|
+
if (returnMatch) {
|
|
200
|
+
returnedVariable = returnMatch[1];
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return returnedVariable;
|
|
204
|
+
}
|
|
128
205
|
/**
|
|
129
206
|
* Check if a wildcard pattern matches a status code
|
|
130
207
|
* e.g., "2xx" matches "200", "201", "204"
|
|
@@ -196,9 +273,11 @@ function extractApiCalls(sequenceContent, allSequences, allNamedRequests, visite
|
|
|
196
273
|
for (let i = 0; i < lines.length; i++) {
|
|
197
274
|
const line = lines[i].trim();
|
|
198
275
|
// Check for "run Name" or "var x = run Name" calls
|
|
199
|
-
const runMatch = line.match(/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]
|
|
276
|
+
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
277
|
if (runMatch) {
|
|
201
|
-
const
|
|
278
|
+
const assignedVar = runMatch[1];
|
|
279
|
+
const targetName = runMatch[2];
|
|
280
|
+
const assertedCodesAfterRun = collectStatusAssertionsFollowingLine(lines, i);
|
|
202
281
|
// Avoid infinite recursion
|
|
203
282
|
if (!visitedSet.has(targetName)) {
|
|
204
283
|
visitedSet.add(targetName);
|
|
@@ -208,6 +287,15 @@ function extractApiCalls(sequenceContent, allSequences, allNamedRequests, visite
|
|
|
208
287
|
if (subSequenceContent) {
|
|
209
288
|
// Recursively extract API calls from the sub-sequence
|
|
210
289
|
const subCalls = extractApiCalls(subSequenceContent, allSequences, allNamedRequests, visitedSet);
|
|
290
|
+
// If caller asserts status after "run Sequence", apply those assertions
|
|
291
|
+
// to the response returned by that sequence (fallback: last API call).
|
|
292
|
+
if (assertedCodesAfterRun.length > 0 && subCalls.length > 0) {
|
|
293
|
+
const returnedVar = extractReturnedVariable(subSequenceContent);
|
|
294
|
+
const returnedCall = returnedVar
|
|
295
|
+
? [...subCalls].reverse().find(call => call.assignedVar === returnedVar) || subCalls[subCalls.length - 1]
|
|
296
|
+
: subCalls[subCalls.length - 1];
|
|
297
|
+
returnedCall.assertedCodes.push(...assertedCodesAfterRun);
|
|
298
|
+
}
|
|
211
299
|
calls.push(...subCalls);
|
|
212
300
|
continue;
|
|
213
301
|
}
|
|
@@ -216,30 +304,13 @@ function extractApiCalls(sequenceContent, allSequences, allNamedRequests, visite
|
|
|
216
304
|
if (allNamedRequests) {
|
|
217
305
|
const namedRequest = allNamedRequests.get(targetName);
|
|
218
306
|
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
307
|
// Add this as a URL-based API call (will be matched by URL later)
|
|
238
308
|
calls.push({
|
|
239
309
|
endpointName: `__url__${namedRequest.method}__${namedRequest.url}`,
|
|
240
|
-
assertedCodes,
|
|
310
|
+
assertedCodes: assertedCodesAfterRun,
|
|
241
311
|
method: namedRequest.method,
|
|
242
|
-
url: namedRequest.url
|
|
312
|
+
url: namedRequest.url,
|
|
313
|
+
assignedVar
|
|
243
314
|
});
|
|
244
315
|
continue;
|
|
245
316
|
}
|
|
@@ -254,34 +325,12 @@ function extractApiCalls(sequenceContent, allSequences, allNamedRequests, visite
|
|
|
254
325
|
// - var result = GET EndpointName
|
|
255
326
|
// - var inventory = GET GetInventory
|
|
256
327
|
// - var order = GET GetOrderById(1)
|
|
257
|
-
const apiCallMatch = line.match(/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]
|
|
328
|
+
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
329
|
if (apiCallMatch) {
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
const assertedCodes =
|
|
262
|
-
|
|
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 });
|
|
330
|
+
const assignedVar = apiCallMatch[1];
|
|
331
|
+
const endpointName = apiCallMatch[3];
|
|
332
|
+
const assertedCodes = collectStatusAssertionsFollowingLine(lines, i);
|
|
333
|
+
calls.push({ endpointName, assertedCodes, assignedVar });
|
|
285
334
|
}
|
|
286
335
|
}
|
|
287
336
|
return calls;
|
|
@@ -399,11 +448,69 @@ function parseRequestContent(content) {
|
|
|
399
448
|
}
|
|
400
449
|
return null;
|
|
401
450
|
}
|
|
451
|
+
function isPathAtOrBelowRoot(filePath, rootPath) {
|
|
452
|
+
const relative = path.relative(rootPath, filePath);
|
|
453
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
454
|
+
}
|
|
455
|
+
function buildScopedCoverage(nornFiles) {
|
|
456
|
+
const byName = new Map();
|
|
457
|
+
const byUrl = new Map();
|
|
458
|
+
const allSequencesMap = new Map();
|
|
459
|
+
const allNamedRequestsMap = new Map();
|
|
460
|
+
for (const file of nornFiles) {
|
|
461
|
+
try {
|
|
462
|
+
const content = fs.readFileSync(file.fsPath, 'utf-8');
|
|
463
|
+
const fileSequences = extractAllSequences(content);
|
|
464
|
+
for (const [name, seqContent] of fileSequences) {
|
|
465
|
+
allSequencesMap.set(name, seqContent);
|
|
466
|
+
}
|
|
467
|
+
const fileNamedRequests = extractNamedRequests(content);
|
|
468
|
+
for (const [name, reqInfo] of fileNamedRequests) {
|
|
469
|
+
allNamedRequestsMap.set(name, reqInfo);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
console.error(`Error reading .norn file ${file.fsPath}:`, error);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
for (const file of nornFiles) {
|
|
477
|
+
try {
|
|
478
|
+
const content = fs.readFileSync(file.fsPath, 'utf-8');
|
|
479
|
+
const testSequences = extractTestSequences(content);
|
|
480
|
+
for (const sequence of testSequences) {
|
|
481
|
+
const apiCalls = extractApiCalls(sequence.content, allSequencesMap, allNamedRequestsMap);
|
|
482
|
+
for (const call of apiCalls) {
|
|
483
|
+
if (call.url && call.method) {
|
|
484
|
+
const key = `${call.method} ${call.url}`;
|
|
485
|
+
if (!byUrl.has(key)) {
|
|
486
|
+
byUrl.set(key, { method: call.method, url: call.url, assertedCodes: new Set() });
|
|
487
|
+
}
|
|
488
|
+
for (const code of call.assertedCodes) {
|
|
489
|
+
byUrl.get(key).assertedCodes.add(code);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
if (!byName.has(call.endpointName)) {
|
|
494
|
+
byName.set(call.endpointName, new Set());
|
|
495
|
+
}
|
|
496
|
+
for (const code of call.assertedCodes) {
|
|
497
|
+
byName.get(call.endpointName).add(code);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
console.error(`Error reading .norn file ${file.fsPath}:`, error);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return { byName, byUrl };
|
|
508
|
+
}
|
|
402
509
|
/**
|
|
403
510
|
* Calculate coverage for the entire workspace
|
|
404
511
|
* @param useCachedSpecs If true, only fetch swagger specs for new URLs (used after execution)
|
|
405
512
|
*/
|
|
406
|
-
async function calculateWorkspaceCoverage(useCachedSpecs = false) {
|
|
513
|
+
async function calculateWorkspaceCoverage(useCachedSpecs = false, scopedNornapiFilePath) {
|
|
407
514
|
const workspaceFolders = vscode.workspace.workspaceFolders;
|
|
408
515
|
if (!workspaceFolders) {
|
|
409
516
|
return {
|
|
@@ -415,7 +522,11 @@ async function calculateWorkspaceCoverage(useCachedSpecs = false) {
|
|
|
415
522
|
};
|
|
416
523
|
}
|
|
417
524
|
// Step 1: Find all .nornapi files and extract swagger URLs + endpoint mappings
|
|
418
|
-
const
|
|
525
|
+
const allNornapiFiles = await vscode.workspace.findFiles('**/*.nornapi', '**/node_modules/**');
|
|
526
|
+
const normalizedScopedPath = scopedNornapiFilePath ? path.resolve(scopedNornapiFilePath) : undefined;
|
|
527
|
+
const nornapiFiles = normalizedScopedPath
|
|
528
|
+
? allNornapiFiles.filter(file => path.resolve(file.fsPath) === normalizedScopedPath)
|
|
529
|
+
: allNornapiFiles;
|
|
419
530
|
const swaggerUrls = new Map(); // swagger URL -> list of nornapi files
|
|
420
531
|
// Store endpoint info temporarily - we'll create mappings after fetching swagger specs
|
|
421
532
|
const pendingEndpoints = [];
|
|
@@ -466,64 +577,11 @@ async function calculateWorkspaceCoverage(useCachedSpecs = false) {
|
|
|
466
577
|
knownSwaggerUrls = currentUrls;
|
|
467
578
|
// Step 2: Find all .norn files and extract API calls with assert status
|
|
468
579
|
const nornFiles = await vscode.workspace.findFiles('**/*.norn', '**/node_modules/**');
|
|
469
|
-
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
}
|
|
580
|
+
const scopedCoverageByRoot = new Map();
|
|
581
|
+
const uniqueNornapiRoots = Array.from(new Set(nornapiFiles.map(file => path.dirname(file.fsPath))));
|
|
582
|
+
for (const root of uniqueNornapiRoots) {
|
|
583
|
+
const scopedFiles = nornFiles.filter(file => isPathAtOrBelowRoot(file.fsPath, root));
|
|
584
|
+
scopedCoverageByRoot.set(root, buildScopedCoverage(scopedFiles));
|
|
527
585
|
}
|
|
528
586
|
// Step 3: For each swagger spec, calculate coverage
|
|
529
587
|
// First fetch all specs so we have baseUrl for path normalization
|
|
@@ -555,7 +613,7 @@ async function calculateWorkspaceCoverage(useCachedSpecs = false) {
|
|
|
555
613
|
}
|
|
556
614
|
const specs = [];
|
|
557
615
|
for (const [swaggerUrl, swaggerSpec] of swaggerSpecs) {
|
|
558
|
-
const coverageResult = calculateCoverageForSpec(swaggerUrl, swaggerSpec, endpointMappings.filter(m => m.swaggerUrl === swaggerUrl),
|
|
616
|
+
const coverageResult = calculateCoverageForSpec(swaggerUrl, swaggerSpec, endpointMappings.filter(m => m.swaggerUrl === swaggerUrl), scopedCoverageByRoot);
|
|
559
617
|
specs.push(coverageResult);
|
|
560
618
|
}
|
|
561
619
|
// Step 4: Aggregate results
|
|
@@ -576,28 +634,32 @@ async function calculateWorkspaceCoverage(useCachedSpecs = false) {
|
|
|
576
634
|
/**
|
|
577
635
|
* Calculate coverage for a single swagger spec
|
|
578
636
|
*/
|
|
579
|
-
function calculateCoverageForSpec(swaggerUrl, spec, endpointMappings,
|
|
637
|
+
function calculateCoverageForSpec(swaggerUrl, spec, endpointMappings, scopedCoverageByRoot) {
|
|
580
638
|
const endpoints = [];
|
|
581
639
|
let total = 0;
|
|
582
640
|
let covered = 0;
|
|
583
|
-
// Build a lookup from swagger key to
|
|
584
|
-
const
|
|
641
|
+
// Build a lookup from swagger key to mapped .nornapi endpoints (with source file context)
|
|
642
|
+
const keyToMappings = new Map();
|
|
585
643
|
for (const mapping of endpointMappings) {
|
|
586
|
-
if (!
|
|
587
|
-
|
|
644
|
+
if (!keyToMappings.has(mapping.swaggerKey)) {
|
|
645
|
+
keyToMappings.set(mapping.swaggerKey, []);
|
|
588
646
|
}
|
|
589
|
-
|
|
647
|
+
keyToMappings.get(mapping.swaggerKey).push(mapping);
|
|
590
648
|
}
|
|
591
649
|
// Process each endpoint in the swagger spec
|
|
592
650
|
for (const section of spec.sections) {
|
|
593
651
|
for (const endpoint of section.endpoints) {
|
|
594
652
|
const swaggerKey = `${endpoint.method} ${endpoint.path}`;
|
|
595
|
-
const
|
|
653
|
+
const mappingsForEndpoint = keyToMappings.get(swaggerKey) || [];
|
|
596
654
|
// Get all asserted codes for any name that maps to this endpoint
|
|
597
655
|
const allAssertedCodes = new Set();
|
|
598
656
|
const coveredByNames = {}; // code -> names that cover it
|
|
599
|
-
|
|
600
|
-
|
|
657
|
+
const endpointRootPaths = new Set();
|
|
658
|
+
for (const mapping of mappingsForEndpoint) {
|
|
659
|
+
const rootPath = path.dirname(mapping.filePath);
|
|
660
|
+
endpointRootPaths.add(rootPath);
|
|
661
|
+
const scopedCoverage = scopedCoverageByRoot.get(rootPath);
|
|
662
|
+
const codes = scopedCoverage?.byName.get(mapping.name);
|
|
601
663
|
if (codes) {
|
|
602
664
|
for (const code of codes) {
|
|
603
665
|
allAssertedCodes.add(code);
|
|
@@ -607,8 +669,8 @@ function calculateCoverageForSpec(swaggerUrl, spec, endpointMappings, coverageBy
|
|
|
607
669
|
if (!coveredByNames[responseCode]) {
|
|
608
670
|
coveredByNames[responseCode] = [];
|
|
609
671
|
}
|
|
610
|
-
if (!coveredByNames[responseCode].includes(name)) {
|
|
611
|
-
coveredByNames[responseCode].push(name);
|
|
672
|
+
if (!coveredByNames[responseCode].includes(mapping.name)) {
|
|
673
|
+
coveredByNames[responseCode].push(mapping.name);
|
|
612
674
|
}
|
|
613
675
|
}
|
|
614
676
|
}
|
|
@@ -617,26 +679,32 @@ function calculateCoverageForSpec(swaggerUrl, spec, endpointMappings, coverageBy
|
|
|
617
679
|
}
|
|
618
680
|
// Also check URL-based coverage (from named request blocks)
|
|
619
681
|
// Match URLs to swagger endpoints by comparing the path portion
|
|
620
|
-
for (const
|
|
621
|
-
|
|
622
|
-
if (
|
|
682
|
+
for (const rootPath of endpointRootPaths) {
|
|
683
|
+
const scopedCoverage = scopedCoverageByRoot.get(rootPath);
|
|
684
|
+
if (!scopedCoverage) {
|
|
623
685
|
continue;
|
|
624
686
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
687
|
+
for (const [, urlCoverage] of scopedCoverage.byUrl) {
|
|
688
|
+
// Check if method matches
|
|
689
|
+
if (urlCoverage.method.toUpperCase() !== endpoint.method.toUpperCase()) {
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
// Try to match URL to swagger endpoint path
|
|
693
|
+
// Extract the path from the full URL
|
|
694
|
+
try {
|
|
695
|
+
const url = new URL(urlCoverage.url);
|
|
696
|
+
const urlPath = url.pathname;
|
|
697
|
+
// Check if URL path matches the swagger endpoint path
|
|
698
|
+
// Handle path parameters like /pet/{petId} matching /pet/1
|
|
699
|
+
if (pathMatchesSwaggerEndpoint(urlPath, endpoint.path)) {
|
|
700
|
+
for (const code of urlCoverage.assertedCodes) {
|
|
701
|
+
allAssertedCodes.add(code);
|
|
702
|
+
}
|
|
635
703
|
}
|
|
636
704
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
705
|
+
catch {
|
|
706
|
+
// Invalid URL, skip
|
|
707
|
+
}
|
|
640
708
|
}
|
|
641
709
|
}
|
|
642
710
|
// Calculate coverage for each response code
|
|
@@ -706,6 +774,13 @@ async function getCoverage() {
|
|
|
706
774
|
}
|
|
707
775
|
return cachedCoverage;
|
|
708
776
|
}
|
|
777
|
+
/**
|
|
778
|
+
* Calculate coverage scoped to a specific .nornapi file.
|
|
779
|
+
* Includes tests from that file's directory and descendants only.
|
|
780
|
+
*/
|
|
781
|
+
async function getCoverageForNornapiFile(nornapiFilePath) {
|
|
782
|
+
return calculateWorkspaceCoverage(false, nornapiFilePath);
|
|
783
|
+
}
|
|
709
784
|
/**
|
|
710
785
|
* Force recalculate coverage - re-fetches swagger specs
|
|
711
786
|
*/
|
|
@@ -714,6 +789,12 @@ async function refreshCoverage() {
|
|
|
714
789
|
notifyCoverageListeners();
|
|
715
790
|
return cachedCoverage;
|
|
716
791
|
}
|
|
792
|
+
/**
|
|
793
|
+
* Refresh coverage scoped to a specific .nornapi file.
|
|
794
|
+
*/
|
|
795
|
+
async function refreshCoverageForNornapiFile(nornapiFilePath) {
|
|
796
|
+
return calculateWorkspaceCoverage(false, nornapiFilePath);
|
|
797
|
+
}
|
|
717
798
|
/**
|
|
718
799
|
* Recalculate coverage after execution - only re-scans .norn files,
|
|
719
800
|
* uses cached swagger specs unless new URLs are detected
|
package/out/coveragePanel.js
CHANGED
|
@@ -42,14 +42,17 @@ class CoveragePanel {
|
|
|
42
42
|
static currentPanel;
|
|
43
43
|
_panel;
|
|
44
44
|
_disposables = [];
|
|
45
|
-
|
|
45
|
+
_scopeNornapiFilePath;
|
|
46
|
+
constructor(panel, scopeNornapiFilePath) {
|
|
46
47
|
this._panel = panel;
|
|
48
|
+
this._scopeNornapiFilePath = scopeNornapiFilePath;
|
|
47
49
|
this._panel.onDidDispose(() => this.dispose(), null, this._disposables);
|
|
48
50
|
}
|
|
49
|
-
static show(coverage) {
|
|
51
|
+
static show(coverage, scopeNornapiFilePath) {
|
|
50
52
|
const column = vscode.ViewColumn.Beside;
|
|
51
53
|
if (CoveragePanel.currentPanel) {
|
|
52
54
|
CoveragePanel.currentPanel._panel.reveal(column);
|
|
55
|
+
CoveragePanel.currentPanel._scopeNornapiFilePath = scopeNornapiFilePath;
|
|
53
56
|
CoveragePanel.currentPanel._update(coverage);
|
|
54
57
|
return;
|
|
55
58
|
}
|
|
@@ -57,7 +60,7 @@ class CoveragePanel {
|
|
|
57
60
|
enableScripts: true,
|
|
58
61
|
retainContextWhenHidden: true,
|
|
59
62
|
});
|
|
60
|
-
CoveragePanel.currentPanel = new CoveragePanel(panel);
|
|
63
|
+
CoveragePanel.currentPanel = new CoveragePanel(panel, scopeNornapiFilePath);
|
|
61
64
|
CoveragePanel.currentPanel._update(coverage);
|
|
62
65
|
}
|
|
63
66
|
dispose() {
|
|
@@ -77,7 +80,7 @@ class CoveragePanel {
|
|
|
77
80
|
this._panel.webview.onDidReceiveMessage(async (message) => {
|
|
78
81
|
switch (message.type) {
|
|
79
82
|
case 'refresh':
|
|
80
|
-
vscode.commands.executeCommand('norn.refreshCoverage');
|
|
83
|
+
vscode.commands.executeCommand('norn.refreshCoverage', this._scopeNornapiFilePath);
|
|
81
84
|
break;
|
|
82
85
|
}
|
|
83
86
|
}, null, this._disposables);
|