norn-cli 1.3.17 → 1.3.18
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 +21 -1
- package/README.md +4 -2
- 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,658 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.NornTestController = void 0;
|
|
37
|
+
const vscode = __importStar(require("vscode"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const fs = __importStar(require("fs/promises"));
|
|
40
|
+
const sequenceRunner_1 = require("./sequenceRunner");
|
|
41
|
+
const parser_1 = require("./parser");
|
|
42
|
+
const environmentProvider_1 = require("./environmentProvider");
|
|
43
|
+
const httpClient_1 = require("./httpClient");
|
|
44
|
+
const coverageCalculator_1 = require("./coverageCalculator");
|
|
45
|
+
const extension_1 = require("./extension");
|
|
46
|
+
// ANSI color codes for Test Explorer output
|
|
47
|
+
const ansi = {
|
|
48
|
+
reset: '\x1b[0m',
|
|
49
|
+
bold: '\x1b[1m',
|
|
50
|
+
dim: '\x1b[2m',
|
|
51
|
+
// Colors
|
|
52
|
+
green: '\x1b[32m',
|
|
53
|
+
red: '\x1b[31m',
|
|
54
|
+
yellow: '\x1b[33m',
|
|
55
|
+
blue: '\x1b[34m',
|
|
56
|
+
cyan: '\x1b[36m',
|
|
57
|
+
magenta: '\x1b[35m',
|
|
58
|
+
gray: '\x1b[90m',
|
|
59
|
+
// Bright versions
|
|
60
|
+
brightGreen: '\x1b[92m',
|
|
61
|
+
brightRed: '\x1b[91m',
|
|
62
|
+
brightYellow: '\x1b[93m',
|
|
63
|
+
brightCyan: '\x1b[96m',
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Formats a test case label from parameter names and values.
|
|
67
|
+
* e.g., [id=1, expectedName="Widget"]
|
|
68
|
+
*/
|
|
69
|
+
function formatCaseLabel(params) {
|
|
70
|
+
const parts = Object.entries(params).map(([key, value]) => {
|
|
71
|
+
if (typeof value === 'string') {
|
|
72
|
+
return `${key}="${value}"`;
|
|
73
|
+
}
|
|
74
|
+
return `${key}=${value}`;
|
|
75
|
+
});
|
|
76
|
+
return `[${parts.join(', ')}]`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Formats tags for display in test description.
|
|
80
|
+
* e.g., "@smoke @team(CustomerExp)"
|
|
81
|
+
*/
|
|
82
|
+
function formatTagsDisplay(tags) {
|
|
83
|
+
if (tags.length === 0)
|
|
84
|
+
return '';
|
|
85
|
+
return tags.map(t => t.value ? `@${t.name}(${t.value})` : `@${t.name}`).join(' ');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Gets the primary tag for grouping (first simple tag, or "Untagged").
|
|
89
|
+
*/
|
|
90
|
+
function getPrimaryTagGroup(tags) {
|
|
91
|
+
// Look for simple tags (no value) first - these are typically categories like @smoke, @regression
|
|
92
|
+
const simpleTag = tags.find(t => !t.value);
|
|
93
|
+
if (simpleTag)
|
|
94
|
+
return `@${simpleTag.name}`;
|
|
95
|
+
// Fall back to first tag with value
|
|
96
|
+
if (tags.length > 0) {
|
|
97
|
+
const t = tags[0];
|
|
98
|
+
return t.value ? `@${t.name}(${t.value})` : `@${t.name}`;
|
|
99
|
+
}
|
|
100
|
+
return 'Untagged';
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Creates TestTag objects from sequence tags.
|
|
104
|
+
*/
|
|
105
|
+
function createTestTags(controller, tags) {
|
|
106
|
+
return tags.map(tag => {
|
|
107
|
+
const tagId = tag.value ? `${tag.name}:${tag.value}` : tag.name;
|
|
108
|
+
return new vscode.TestTag(tagId);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Norn Test Controller - provides Test Explorer integration for .norn files.
|
|
113
|
+
*
|
|
114
|
+
* Features:
|
|
115
|
+
* - Discovers test sequences from .norn files
|
|
116
|
+
* - Supports parameterized tests via @data and @theory annotations
|
|
117
|
+
* - Streams test progress during execution
|
|
118
|
+
* - Maps sequence tags to Test Explorer tags for filtering
|
|
119
|
+
*/
|
|
120
|
+
class NornTestController {
|
|
121
|
+
controller;
|
|
122
|
+
disposables = [];
|
|
123
|
+
// Map from test item ID to sequence info
|
|
124
|
+
testData = new WeakMap();
|
|
125
|
+
constructor() {
|
|
126
|
+
this.controller = vscode.tests.createTestController('nornTests', 'Norn Tests');
|
|
127
|
+
// Set up resolve handler for lazy loading
|
|
128
|
+
this.controller.resolveHandler = async (item) => {
|
|
129
|
+
if (!item) {
|
|
130
|
+
// Initial request: discover all test files
|
|
131
|
+
await this.discoverAllTests();
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
// Resolve a specific item (file or sequence with theory data)
|
|
135
|
+
await this.resolveTestItem(item);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
// Create run profile
|
|
139
|
+
this.controller.createRunProfile('Run Tests', vscode.TestRunProfileKind.Run, (request, token) => this.runTests(request, token), true // isDefault
|
|
140
|
+
);
|
|
141
|
+
// Watch for file changes
|
|
142
|
+
const fileWatcher = vscode.workspace.createFileSystemWatcher('**/*.norn');
|
|
143
|
+
fileWatcher.onDidCreate(uri => this.onFileCreated(uri));
|
|
144
|
+
fileWatcher.onDidChange(uri => this.onFileChanged(uri));
|
|
145
|
+
fileWatcher.onDidDelete(uri => this.onFileDeleted(uri));
|
|
146
|
+
this.disposables.push(fileWatcher);
|
|
147
|
+
// Watch for document changes (unsaved edits)
|
|
148
|
+
this.disposables.push(vscode.workspace.onDidChangeTextDocument(e => {
|
|
149
|
+
if (e.document.languageId === 'norn') {
|
|
150
|
+
this.updateTestsInDocument(e.document);
|
|
151
|
+
}
|
|
152
|
+
}));
|
|
153
|
+
// Initial discovery for open documents
|
|
154
|
+
vscode.workspace.textDocuments.forEach(doc => {
|
|
155
|
+
if (doc.languageId === 'norn') {
|
|
156
|
+
this.updateTestsInDocument(doc);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Discovers all .norn test files in the workspace.
|
|
162
|
+
*/
|
|
163
|
+
async discoverAllTests() {
|
|
164
|
+
const files = await vscode.workspace.findFiles('**/*.norn', '**/node_modules/**');
|
|
165
|
+
for (const uri of files) {
|
|
166
|
+
await this.parseTestFile(uri);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Parses a .norn file and creates test items for test sequences.
|
|
171
|
+
*/
|
|
172
|
+
async parseTestFile(uri) {
|
|
173
|
+
try {
|
|
174
|
+
const content = await fs.readFile(uri.fsPath, 'utf-8');
|
|
175
|
+
const lines = content.split('\n');
|
|
176
|
+
const sequences = (0, sequenceRunner_1.extractSequences)(content);
|
|
177
|
+
// Filter to only test sequences (isTest: true)
|
|
178
|
+
const testSequences = sequences.filter(seq => seq.isTest);
|
|
179
|
+
if (testSequences.length === 0) {
|
|
180
|
+
// Remove file from test explorer if it had tests before
|
|
181
|
+
this.controller.items.delete(uri.toString());
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// Create or update file item
|
|
185
|
+
const fileName = path.basename(uri.fsPath);
|
|
186
|
+
let fileItem = this.controller.items.get(uri.toString());
|
|
187
|
+
if (!fileItem) {
|
|
188
|
+
fileItem = this.controller.createTestItem(uri.toString(), fileName, uri);
|
|
189
|
+
this.controller.items.add(fileItem);
|
|
190
|
+
}
|
|
191
|
+
// Clear existing children and rebuild
|
|
192
|
+
fileItem.children.replace([]);
|
|
193
|
+
// Group tests by their primary tag
|
|
194
|
+
const tagGroups = new Map();
|
|
195
|
+
for (const seq of testSequences) {
|
|
196
|
+
const groupName = getPrimaryTagGroup(seq.tags);
|
|
197
|
+
if (!tagGroups.has(groupName)) {
|
|
198
|
+
tagGroups.set(groupName, []);
|
|
199
|
+
}
|
|
200
|
+
tagGroups.get(groupName).push(seq);
|
|
201
|
+
}
|
|
202
|
+
// Sort groups: tagged groups first (alphabetically), then Untagged
|
|
203
|
+
const sortedGroups = Array.from(tagGroups.keys()).sort((a, b) => {
|
|
204
|
+
if (a === 'Untagged')
|
|
205
|
+
return 1;
|
|
206
|
+
if (b === 'Untagged')
|
|
207
|
+
return -1;
|
|
208
|
+
return a.localeCompare(b);
|
|
209
|
+
});
|
|
210
|
+
// Create group items and add tests
|
|
211
|
+
for (const groupName of sortedGroups) {
|
|
212
|
+
const seqs = tagGroups.get(groupName);
|
|
213
|
+
// If only one group (all tests have same tag or all untagged), skip group level
|
|
214
|
+
if (sortedGroups.length === 1) {
|
|
215
|
+
for (const seq of seqs) {
|
|
216
|
+
const declarationLine = this.findTestSequenceDeclarationLine(lines, seq);
|
|
217
|
+
const testItem = this.createTestItemForSequence(uri, seq, fileItem, declarationLine);
|
|
218
|
+
fileItem.children.add(testItem);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
// Create a group item
|
|
223
|
+
const groupId = `${uri.toString()}::group::${groupName}`;
|
|
224
|
+
const groupItem = this.controller.createTestItem(groupId, groupName, uri);
|
|
225
|
+
groupItem.description = `${seqs.length} test${seqs.length > 1 ? 's' : ''}`;
|
|
226
|
+
for (const seq of seqs) {
|
|
227
|
+
const declarationLine = this.findTestSequenceDeclarationLine(lines, seq);
|
|
228
|
+
const testItem = this.createTestItemForSequence(uri, seq, groupItem, declarationLine);
|
|
229
|
+
groupItem.children.add(testItem);
|
|
230
|
+
}
|
|
231
|
+
fileItem.children.add(groupItem);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
console.error(`Failed to parse test file ${uri.fsPath}:`, error);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Finds the actual `test sequence` declaration line for better gutter icon placement.
|
|
241
|
+
* Sequence ranges may start at decorator lines (@tag/@data/@theory), but the test icon
|
|
242
|
+
* should anchor to the declaration line users interact with.
|
|
243
|
+
*/
|
|
244
|
+
findTestSequenceDeclarationLine(lines, seq) {
|
|
245
|
+
for (let i = seq.startLine; i <= seq.endLine; i++) {
|
|
246
|
+
if (/^\s*test\s+sequence\s+/.test(lines[i])) {
|
|
247
|
+
return i;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return seq.startLine;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Creates a test item for a sequence, including child items for theory cases.
|
|
254
|
+
*/
|
|
255
|
+
createTestItemForSequence(uri, seq, parent, declarationLine) {
|
|
256
|
+
const testId = `${uri.toString()}::${seq.name}`;
|
|
257
|
+
const testItem = this.controller.createTestItem(testId, seq.name, uri);
|
|
258
|
+
// Set range for navigation
|
|
259
|
+
testItem.range = new vscode.Range(declarationLine, 0, seq.endLine, 0);
|
|
260
|
+
// Add tags
|
|
261
|
+
testItem.tags = createTestTags(this.controller, seq.tags);
|
|
262
|
+
// Build description showing tags and param info
|
|
263
|
+
const descParts = [];
|
|
264
|
+
const tagsDisplay = formatTagsDisplay(seq.tags);
|
|
265
|
+
if (tagsDisplay) {
|
|
266
|
+
descParts.push(tagsDisplay);
|
|
267
|
+
}
|
|
268
|
+
if (seq.theoryData && seq.theoryData.cases.length > 0) {
|
|
269
|
+
descParts.push(`${seq.theoryData.cases.length} cases`);
|
|
270
|
+
}
|
|
271
|
+
else if (seq.parameters.length > 0) {
|
|
272
|
+
const paramNames = seq.parameters.map(p => p.name).join(', ');
|
|
273
|
+
descParts.push(`(${paramNames})`);
|
|
274
|
+
}
|
|
275
|
+
if (descParts.length > 0) {
|
|
276
|
+
testItem.description = descParts.join(' · ');
|
|
277
|
+
}
|
|
278
|
+
// Store sequence data
|
|
279
|
+
this.testData.set(testItem, { sequence: seq, uri });
|
|
280
|
+
// If this sequence has theory data (parameterized), create child items
|
|
281
|
+
if (seq.theoryData && seq.theoryData.cases.length > 0) {
|
|
282
|
+
testItem.canResolveChildren = true;
|
|
283
|
+
// Eagerly create children for known cases
|
|
284
|
+
this.createTheoryCaseItems(testItem, seq, uri);
|
|
285
|
+
}
|
|
286
|
+
return testItem;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Creates child test items for each theory case.
|
|
290
|
+
*/
|
|
291
|
+
createTheoryCaseItems(parentItem, seq, uri) {
|
|
292
|
+
if (!seq.theoryData)
|
|
293
|
+
return;
|
|
294
|
+
for (let i = 0; i < seq.theoryData.cases.length; i++) {
|
|
295
|
+
const caseParams = seq.theoryData.cases[i];
|
|
296
|
+
const caseLabel = formatCaseLabel(caseParams);
|
|
297
|
+
const caseId = `${parentItem.id}::case${i}`;
|
|
298
|
+
const caseItem = this.controller.createTestItem(caseId, caseLabel, uri);
|
|
299
|
+
caseItem.range = parentItem.range; // Same range as parent
|
|
300
|
+
caseItem.tags = parentItem.tags; // Inherit tags
|
|
301
|
+
this.testData.set(caseItem, {
|
|
302
|
+
sequence: seq,
|
|
303
|
+
uri,
|
|
304
|
+
caseIndex: i,
|
|
305
|
+
caseParams
|
|
306
|
+
});
|
|
307
|
+
parentItem.children.add(caseItem);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Resolves children of a test item (for lazy loading theory cases from @theory files).
|
|
312
|
+
*/
|
|
313
|
+
async resolveTestItem(item) {
|
|
314
|
+
const data = this.testData.get(item);
|
|
315
|
+
if (!data)
|
|
316
|
+
return;
|
|
317
|
+
// If this has a @theory source file, load it now
|
|
318
|
+
if (data.sequence.theoryData?.source && data.sequence.theoryData.cases.length === 0) {
|
|
319
|
+
await this.loadTheoryFile(item, data.sequence, data.uri);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Loads theory data from an external JSON file.
|
|
324
|
+
*/
|
|
325
|
+
async loadTheoryFile(parentItem, seq, uri) {
|
|
326
|
+
if (!seq.theoryData?.source)
|
|
327
|
+
return;
|
|
328
|
+
try {
|
|
329
|
+
const theoryPath = path.resolve(path.dirname(uri.fsPath), seq.theoryData.source);
|
|
330
|
+
const content = await fs.readFile(theoryPath, 'utf-8');
|
|
331
|
+
const cases = JSON.parse(content);
|
|
332
|
+
// Update the theory data
|
|
333
|
+
seq.theoryData.cases = cases;
|
|
334
|
+
// Create child items
|
|
335
|
+
this.createTheoryCaseItems(parentItem, seq, uri);
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
parentItem.error = `Failed to load theory file: ${seq.theoryData.source}`;
|
|
339
|
+
console.error(`Failed to load theory file for ${seq.name}:`, error);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Runs the requested tests.
|
|
344
|
+
*/
|
|
345
|
+
async runTests(request, token) {
|
|
346
|
+
const run = this.controller.createTestRun(request);
|
|
347
|
+
const queue = [];
|
|
348
|
+
// Collect tests to run
|
|
349
|
+
if (request.include) {
|
|
350
|
+
request.include.forEach(test => queue.push(test));
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
this.controller.items.forEach(test => queue.push(test));
|
|
354
|
+
}
|
|
355
|
+
// Process queue
|
|
356
|
+
while (queue.length > 0 && !token.isCancellationRequested) {
|
|
357
|
+
const test = queue.shift();
|
|
358
|
+
// Skip excluded tests
|
|
359
|
+
if (request.exclude?.includes(test)) {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const data = this.testData.get(test);
|
|
363
|
+
if (!data) {
|
|
364
|
+
// This is a file item - queue its children
|
|
365
|
+
test.children.forEach(child => queue.push(child));
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
// Check if this is a parameterized test with children
|
|
369
|
+
if (test.children.size > 0 && data.caseIndex === undefined) {
|
|
370
|
+
// Queue children instead of running parent
|
|
371
|
+
test.children.forEach(child => queue.push(child));
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
// Run the test
|
|
375
|
+
await this.runSingleTest(run, test, data, token);
|
|
376
|
+
}
|
|
377
|
+
run.end();
|
|
378
|
+
// Recalculate coverage after test run completes (uses cached swagger specs)
|
|
379
|
+
(0, coverageCalculator_1.recalculateCoverageAfterExecution)().catch(() => {
|
|
380
|
+
// Silently ignore coverage calculation errors
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Runs a single test (or single theory case).
|
|
385
|
+
*/
|
|
386
|
+
async runSingleTest(run, test, data, token) {
|
|
387
|
+
run.started(test);
|
|
388
|
+
if (token.isCancellationRequested) {
|
|
389
|
+
run.skipped(test);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
const startTime = Date.now();
|
|
394
|
+
// Load file content and variables
|
|
395
|
+
const content = await fs.readFile(data.uri.fsPath, 'utf-8');
|
|
396
|
+
const fileVariables = (0, parser_1.extractVariables)(content);
|
|
397
|
+
// Get environment variables
|
|
398
|
+
const envVars = (0, environmentProvider_1.getEnvironmentVariables)();
|
|
399
|
+
// Merge variables (env vars take precedence)
|
|
400
|
+
const mergedVariables = { ...fileVariables, ...envVars };
|
|
401
|
+
// If this is a theory case, add case params
|
|
402
|
+
if (data.caseParams) {
|
|
403
|
+
for (const [key, value] of Object.entries(data.caseParams)) {
|
|
404
|
+
mergedVariables[key] = String(value);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// Helper to output with colors
|
|
408
|
+
const output = (text) => {
|
|
409
|
+
run.appendOutput(text, undefined, test);
|
|
410
|
+
};
|
|
411
|
+
// Output test header
|
|
412
|
+
const testName = data.caseParams
|
|
413
|
+
? `${data.sequence.name}${formatCaseLabel(data.caseParams)}`
|
|
414
|
+
: data.sequence.name;
|
|
415
|
+
output(`\r\n${ansi.bold}${ansi.brightCyan}▸ ${testName}${ansi.reset}\r\n`);
|
|
416
|
+
// Create progress callback for streaming output with colors
|
|
417
|
+
const progressCallback = (info) => {
|
|
418
|
+
const prefix = ' '.repeat(info.nestingDepth || 0);
|
|
419
|
+
const stepNum = `${ansi.gray}[${info.currentStep}/${info.totalSteps}]${ansi.reset}`;
|
|
420
|
+
let icon = '';
|
|
421
|
+
let desc = info.stepDescription;
|
|
422
|
+
// Color based on step type and result
|
|
423
|
+
switch (info.stepType) {
|
|
424
|
+
case 'request':
|
|
425
|
+
case 'namedRequest':
|
|
426
|
+
// HTTP method coloring
|
|
427
|
+
desc = desc.replace(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+/, `${ansi.cyan}$1${ansi.reset} `);
|
|
428
|
+
// Add status code coloring if present
|
|
429
|
+
desc = desc.replace(/\s+(\d{3})\s*$/, (_, code) => {
|
|
430
|
+
const status = parseInt(code);
|
|
431
|
+
if (status >= 200 && status < 300)
|
|
432
|
+
return ` ${ansi.green}${code}${ansi.reset}`;
|
|
433
|
+
if (status >= 400)
|
|
434
|
+
return ` ${ansi.red}${code}${ansi.reset}`;
|
|
435
|
+
return ` ${ansi.yellow}${code}${ansi.reset}`;
|
|
436
|
+
});
|
|
437
|
+
icon = `${ansi.blue}→${ansi.reset}`;
|
|
438
|
+
break;
|
|
439
|
+
case 'assertion':
|
|
440
|
+
if (desc.includes('✓')) {
|
|
441
|
+
icon = `${ansi.green}✓${ansi.reset}`;
|
|
442
|
+
desc = desc.replace('✓ ', '');
|
|
443
|
+
}
|
|
444
|
+
else if (desc.includes('✗')) {
|
|
445
|
+
icon = `${ansi.red}✗${ansi.reset}`;
|
|
446
|
+
desc = desc.replace('✗ ', '');
|
|
447
|
+
}
|
|
448
|
+
break;
|
|
449
|
+
case 'print':
|
|
450
|
+
icon = `${ansi.magenta}◆${ansi.reset}`;
|
|
451
|
+
desc = desc.replace(/^print:\s*/, '');
|
|
452
|
+
desc = `${ansi.dim}${desc}${ansi.reset}`;
|
|
453
|
+
break;
|
|
454
|
+
case 'script':
|
|
455
|
+
icon = `${ansi.yellow}⚡${ansi.reset}`;
|
|
456
|
+
break;
|
|
457
|
+
case 'wait':
|
|
458
|
+
icon = `${ansi.gray}⏱${ansi.reset}`;
|
|
459
|
+
break;
|
|
460
|
+
case 'runSequence':
|
|
461
|
+
case 'sequenceStart':
|
|
462
|
+
icon = `${ansi.brightCyan}▶${ansi.reset}`;
|
|
463
|
+
break;
|
|
464
|
+
case 'sequenceEnd':
|
|
465
|
+
icon = `${ansi.brightCyan}■${ansi.reset}`;
|
|
466
|
+
break;
|
|
467
|
+
default:
|
|
468
|
+
icon = ' ';
|
|
469
|
+
}
|
|
470
|
+
output(`${prefix}${stepNum} ${icon} ${desc}\r\n`);
|
|
471
|
+
};
|
|
472
|
+
// Resolve imports
|
|
473
|
+
const workingDir = path.dirname(data.uri.fsPath);
|
|
474
|
+
const importResult = await (0, parser_1.resolveImports)(content, workingDir, async (p) => fs.readFile(p, 'utf-8'));
|
|
475
|
+
// Check for duplicate definition errors which should fail the test
|
|
476
|
+
const duplicateErrors = importResult.errors.filter(err => err.error.includes('Duplicate header group') ||
|
|
477
|
+
err.error.includes('Duplicate endpoint') ||
|
|
478
|
+
err.error.includes('Duplicate named request') ||
|
|
479
|
+
err.error.includes('Duplicate sequence'));
|
|
480
|
+
if (duplicateErrors.length > 0) {
|
|
481
|
+
const errorMessage = duplicateErrors.map(err => `${err.path}: ${err.error}`).join('\n');
|
|
482
|
+
run.failed(test, new vscode.TestMessage(`Import error: duplicate definitions found\n${errorMessage}`), Date.now() - startTime);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
// Combine content with imports
|
|
486
|
+
const fullContent = importResult.importedContent
|
|
487
|
+
? importResult.importedContent + '\n\n' + content
|
|
488
|
+
: content;
|
|
489
|
+
// Create a fresh cookie jar for this test run
|
|
490
|
+
const cookieJar = (0, httpClient_1.createCookieJar)();
|
|
491
|
+
// Run the sequence
|
|
492
|
+
const result = await (0, sequenceRunner_1.runSequenceWithJar)(data.sequence.content, mergedVariables, cookieJar, workingDir, fullContent, progressCallback, undefined, // callStack
|
|
493
|
+
data.caseParams ? Object.fromEntries(Object.entries(data.caseParams).map(([k, v]) => [k, String(v)])) : undefined, { headerGroups: importResult.headerGroups, endpoints: importResult.endpoints }, undefined, // tagFilterOptions
|
|
494
|
+
importResult.sequenceSources // For resolving script paths relative to imported files
|
|
495
|
+
);
|
|
496
|
+
const duration = Date.now() - startTime;
|
|
497
|
+
// Save schema validation results to cache for gutter icons
|
|
498
|
+
(0, extension_1.saveSchemaValidationResults)(result, data.uri.fsPath, data.sequence.startLine + 1);
|
|
499
|
+
// Output summary line - just show pass/fail with duration
|
|
500
|
+
if (result.success) {
|
|
501
|
+
output(`${ansi.green}${ansi.bold}✓ PASSED${ansi.reset} ${ansi.gray}${duration}ms${ansi.reset}\r\n`);
|
|
502
|
+
run.passed(test, duration);
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
// Output detailed failure info
|
|
506
|
+
output(`${ansi.gray}${'─'.repeat(40)}${ansi.reset}\r\n`);
|
|
507
|
+
// Create failure messages from assertion results
|
|
508
|
+
const messages = [];
|
|
509
|
+
for (const assertion of result.assertionResults) {
|
|
510
|
+
if (!assertion.passed) {
|
|
511
|
+
// Build display expression with friendly name when available
|
|
512
|
+
let displayExpr = assertion.expression;
|
|
513
|
+
if (assertion.friendlyName && assertion.responseIndex) {
|
|
514
|
+
displayExpr = displayExpr.replace('$' + assertion.responseIndex, assertion.friendlyName);
|
|
515
|
+
}
|
|
516
|
+
// Output to test results
|
|
517
|
+
output(`${ansi.red}✗ ${displayExpr}${ansi.reset}\r\n`);
|
|
518
|
+
if (assertion.leftValue !== undefined && assertion.rightValue !== undefined) {
|
|
519
|
+
output(` ${ansi.dim}Expected:${ansi.reset} ${ansi.green}${JSON.stringify(assertion.rightValue)}${ansi.reset}\r\n`);
|
|
520
|
+
output(` ${ansi.dim}Actual:${ansi.reset} ${ansi.red}${JSON.stringify(assertion.leftValue)}${ansi.reset}\r\n`);
|
|
521
|
+
}
|
|
522
|
+
if (assertion.message) {
|
|
523
|
+
output(` ${ansi.yellow}${assertion.message}${ansi.reset}\r\n`);
|
|
524
|
+
}
|
|
525
|
+
output(`\r\n`);
|
|
526
|
+
// Create VS Code diff message
|
|
527
|
+
const msg = vscode.TestMessage.diff(assertion.message || `Assertion failed: ${displayExpr}`, String(assertion.rightValue ?? ''), // expected
|
|
528
|
+
String(assertion.leftValue ?? '') // actual
|
|
529
|
+
);
|
|
530
|
+
messages.push(msg);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// Add any errors with details
|
|
534
|
+
for (const error of result.errors) {
|
|
535
|
+
// Split error into lines and output each properly
|
|
536
|
+
const errorLines = error.split('\n');
|
|
537
|
+
for (const line of errorLines) {
|
|
538
|
+
if (line.trim()) {
|
|
539
|
+
output(`${ansi.red}${line}${ansi.reset}\r\n`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
messages.push(new vscode.TestMessage(error));
|
|
543
|
+
}
|
|
544
|
+
// Add request/response info for failed requests (use steps for URL info)
|
|
545
|
+
for (const step of result.steps) {
|
|
546
|
+
if (step.type === 'request' && step.response && step.response.status >= 400) {
|
|
547
|
+
output(`\r\n${ansi.yellow}Request Details:${ansi.reset}\r\n`);
|
|
548
|
+
if (step.requestUrl) {
|
|
549
|
+
output(` ${ansi.dim}URL:${ansi.reset} ${step.requestMethod || 'GET'} ${step.requestUrl}\r\n`);
|
|
550
|
+
}
|
|
551
|
+
output(` ${ansi.dim}Status:${ansi.reset} ${ansi.red}${step.response.status} ${step.response.statusText}${ansi.reset}\r\n`);
|
|
552
|
+
if (step.response.body) {
|
|
553
|
+
const bodyPreview = typeof step.response.body === 'string'
|
|
554
|
+
? step.response.body.slice(0, 500)
|
|
555
|
+
: JSON.stringify(step.response.body, null, 2).slice(0, 500);
|
|
556
|
+
output(` ${ansi.dim}Response:${ansi.reset}\r\n`);
|
|
557
|
+
// Indent each line of the body for proper formatting
|
|
558
|
+
const indentedBody = bodyPreview.split('\n').map(line => ` ${line}`).join('\r\n');
|
|
559
|
+
output(`${ansi.gray}${indentedBody}${bodyPreview.length >= 500 ? '...' : ''}${ansi.reset}\r\n`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
output(`${ansi.gray}${'─'.repeat(50)}${ansi.reset}\r\n`);
|
|
564
|
+
output(`${ansi.red}${ansi.bold}✗ FAILED${ansi.reset} ${ansi.gray}${duration}ms${ansi.reset}\r\n`);
|
|
565
|
+
if (messages.length === 0) {
|
|
566
|
+
messages.push(new vscode.TestMessage('Test failed'));
|
|
567
|
+
}
|
|
568
|
+
run.failed(test, messages, duration);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
573
|
+
run.appendOutput(`\r\n${ansi.red}${ansi.bold}ERROR${ansi.reset}\r\n${ansi.red}${message}${ansi.reset}\r\n`, undefined, test);
|
|
574
|
+
run.errored(test, new vscode.TestMessage(message));
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Updates tests when a document changes.
|
|
579
|
+
*/
|
|
580
|
+
async updateTestsInDocument(document) {
|
|
581
|
+
if (document.uri.scheme !== 'file')
|
|
582
|
+
return;
|
|
583
|
+
const content = document.getText();
|
|
584
|
+
const lines = content.split('\n');
|
|
585
|
+
const sequences = (0, sequenceRunner_1.extractSequences)(content);
|
|
586
|
+
const testSequences = sequences.filter(seq => seq.isTest);
|
|
587
|
+
if (testSequences.length === 0) {
|
|
588
|
+
this.controller.items.delete(document.uri.toString());
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
// Create or update file item
|
|
592
|
+
const fileName = path.basename(document.uri.fsPath);
|
|
593
|
+
const uri = document.uri;
|
|
594
|
+
let fileItem = this.controller.items.get(uri.toString());
|
|
595
|
+
if (!fileItem) {
|
|
596
|
+
fileItem = this.controller.createTestItem(uri.toString(), fileName, uri);
|
|
597
|
+
this.controller.items.add(fileItem);
|
|
598
|
+
}
|
|
599
|
+
// Rebuild children
|
|
600
|
+
fileItem.children.replace([]);
|
|
601
|
+
// Group tests by their primary tag
|
|
602
|
+
const tagGroups = new Map();
|
|
603
|
+
for (const seq of testSequences) {
|
|
604
|
+
const groupName = getPrimaryTagGroup(seq.tags);
|
|
605
|
+
if (!tagGroups.has(groupName)) {
|
|
606
|
+
tagGroups.set(groupName, []);
|
|
607
|
+
}
|
|
608
|
+
tagGroups.get(groupName).push(seq);
|
|
609
|
+
}
|
|
610
|
+
// Sort groups: tagged groups first (alphabetically), then Untagged
|
|
611
|
+
const sortedGroups = Array.from(tagGroups.keys()).sort((a, b) => {
|
|
612
|
+
if (a === 'Untagged')
|
|
613
|
+
return 1;
|
|
614
|
+
if (b === 'Untagged')
|
|
615
|
+
return -1;
|
|
616
|
+
return a.localeCompare(b);
|
|
617
|
+
});
|
|
618
|
+
// Create group items and add tests
|
|
619
|
+
for (const groupName of sortedGroups) {
|
|
620
|
+
const seqs = tagGroups.get(groupName);
|
|
621
|
+
// If only one group (all tests have same tag or all untagged), skip group level
|
|
622
|
+
if (sortedGroups.length === 1) {
|
|
623
|
+
for (const seq of seqs) {
|
|
624
|
+
const declarationLine = this.findTestSequenceDeclarationLine(lines, seq);
|
|
625
|
+
const testItem = this.createTestItemForSequence(uri, seq, fileItem, declarationLine);
|
|
626
|
+
fileItem.children.add(testItem);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
// Create a group item
|
|
631
|
+
const groupId = `${uri.toString()}::group::${groupName}`;
|
|
632
|
+
const groupItem = this.controller.createTestItem(groupId, groupName, uri);
|
|
633
|
+
groupItem.description = `${seqs.length} test${seqs.length > 1 ? 's' : ''}`;
|
|
634
|
+
for (const seq of seqs) {
|
|
635
|
+
const declarationLine = this.findTestSequenceDeclarationLine(lines, seq);
|
|
636
|
+
const testItem = this.createTestItemForSequence(uri, seq, groupItem, declarationLine);
|
|
637
|
+
groupItem.children.add(testItem);
|
|
638
|
+
}
|
|
639
|
+
fileItem.children.add(groupItem);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
async onFileCreated(uri) {
|
|
644
|
+
await this.parseTestFile(uri);
|
|
645
|
+
}
|
|
646
|
+
async onFileChanged(uri) {
|
|
647
|
+
await this.parseTestFile(uri);
|
|
648
|
+
}
|
|
649
|
+
onFileDeleted(uri) {
|
|
650
|
+
this.controller.items.delete(uri.toString());
|
|
651
|
+
}
|
|
652
|
+
dispose() {
|
|
653
|
+
this.controller.dispose();
|
|
654
|
+
this.disposables.forEach(d => d.dispose());
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
exports.NornTestController = NornTestController;
|
|
658
|
+
//# sourceMappingURL=testProvider.js.map
|