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.
@@ -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