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.
@@ -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
- try {
60
- // If it's already just a path (starts with /), return as-is
61
- if (fullUrl.startsWith('/')) {
62
- return fullUrl;
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
- // 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;
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 path || '/';
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
- return match ? match[1] : fullUrl;
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 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());
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_]*\s*=\s*)?run\s+([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s*\(.*?\))?$/i);
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 targetName = runMatch[1];
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_]*\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);
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 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 });
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 nornapiFiles = await vscode.workspace.findFiles('**/*.nornapi', '**/node_modules/**');
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
- // 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
- }
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), coverageByName, coverageByUrl);
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, coverageByName, coverageByUrl) {
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 endpoint names
584
- const keyToNames = new Map();
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 (!keyToNames.has(mapping.swaggerKey)) {
587
- keyToNames.set(mapping.swaggerKey, []);
644
+ if (!keyToMappings.has(mapping.swaggerKey)) {
645
+ keyToMappings.set(mapping.swaggerKey, []);
588
646
  }
589
- keyToNames.get(mapping.swaggerKey).push(mapping.name);
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 names = keyToNames.get(swaggerKey) || [];
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
- for (const name of names) {
600
- const codes = coverageByName.get(name);
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 [, urlCoverage] of coverageByUrl) {
621
- // Check if method matches
622
- if (urlCoverage.method.toUpperCase() !== endpoint.method.toUpperCase()) {
682
+ for (const rootPath of endpointRootPaths) {
683
+ const scopedCoverage = scopedCoverageByRoot.get(rootPath);
684
+ if (!scopedCoverage) {
623
685
  continue;
624
686
  }
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);
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
- catch {
639
- // Invalid URL, skip
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
@@ -42,14 +42,17 @@ class CoveragePanel {
42
42
  static currentPanel;
43
43
  _panel;
44
44
  _disposables = [];
45
- constructor(panel) {
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);