norn-cli 1.3.17 → 1.3.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/out/cli.js ADDED
@@ -0,0 +1,926 @@
1
+ "use strict";
2
+ /**
3
+ * Norn CLI - Command-line interface for running .norn files
4
+ *
5
+ * Features:
6
+ * - Human-readable output with color coding
7
+ * - Verbose mode for detailed request/response info
8
+ * - Automatic sensitive data redaction
9
+ * - JUnit XML output for CI/CD
10
+ * - HTML report generation
11
+ */
12
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ var desc = Object.getOwnPropertyDescriptor(m, k);
15
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
16
+ desc = { enumerable: true, get: function() { return m[k]; } };
17
+ }
18
+ Object.defineProperty(o, k2, desc);
19
+ }) : (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ o[k2] = m[k];
22
+ }));
23
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
24
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
25
+ }) : function(o, v) {
26
+ o["default"] = v;
27
+ });
28
+ var __importStar = (this && this.__importStar) || (function () {
29
+ var ownKeys = function(o) {
30
+ ownKeys = Object.getOwnPropertyNames || function (o) {
31
+ var ar = [];
32
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
33
+ return ar;
34
+ };
35
+ return ownKeys(o);
36
+ };
37
+ return function (mod) {
38
+ if (mod && mod.__esModule) return mod;
39
+ var result = {};
40
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
41
+ __setModuleDefault(result, mod);
42
+ return result;
43
+ };
44
+ })();
45
+ Object.defineProperty(exports, "__esModule", { value: true });
46
+ const fs = __importStar(require("fs"));
47
+ const fsPromises = __importStar(require("fs/promises"));
48
+ const path = __importStar(require("path"));
49
+ const parser_1 = require("./parser");
50
+ const httpClient_1 = require("./httpClient");
51
+ const sequenceRunner_1 = require("./sequenceRunner");
52
+ const nornapiParser_1 = require("./nornapiParser");
53
+ // CLI modules
54
+ const colors_1 = require("./cli/colors");
55
+ const redaction_1 = require("./cli/redaction");
56
+ const summary_1 = require("./cli/formatters/summary");
57
+ const response_1 = require("./cli/formatters/response");
58
+ const junit_1 = require("./cli/reporters/junit");
59
+ const html_1 = require("./cli/reporters/html");
60
+ const ENV_FILENAME = '.nornenv';
61
+ /**
62
+ * Finds .nornenv file relative to a specific file path (for CLI usage)
63
+ */
64
+ function findEnvFileFromPath(filePath) {
65
+ let dir = path.dirname(filePath);
66
+ const root = path.parse(dir).root;
67
+ while (dir !== root) {
68
+ const envPath = path.join(dir, ENV_FILENAME);
69
+ if (fs.existsSync(envPath)) {
70
+ return envPath;
71
+ }
72
+ dir = path.dirname(dir);
73
+ }
74
+ return undefined;
75
+ }
76
+ /**
77
+ * Parses the .nornenv file content (with secret support)
78
+ */
79
+ function parseEnvFile(content) {
80
+ const lines = content.split('\n');
81
+ const config = {
82
+ common: {},
83
+ environments: [],
84
+ secretNames: new Set(),
85
+ secretValues: new Map()
86
+ };
87
+ let currentEnv = null;
88
+ const envRegex = /^\[env:([a-zA-Z_][a-zA-Z0-9_-]*)\]$/;
89
+ const varRegex = /^var\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$/;
90
+ const secretRegex = /^secret\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$/;
91
+ for (const line of lines) {
92
+ const trimmed = line.trim();
93
+ // Skip empty lines and comments
94
+ if (!trimmed || trimmed.startsWith('#')) {
95
+ continue;
96
+ }
97
+ // Check for environment section
98
+ const envMatch = trimmed.match(envRegex);
99
+ if (envMatch) {
100
+ currentEnv = {
101
+ name: envMatch[1],
102
+ variables: {}
103
+ };
104
+ config.environments.push(currentEnv);
105
+ continue;
106
+ }
107
+ // Check for secret declaration
108
+ const secretMatch = trimmed.match(secretRegex);
109
+ if (secretMatch) {
110
+ const varName = secretMatch[1];
111
+ const varValue = secretMatch[2].trim();
112
+ config.secretNames.add(varName);
113
+ config.secretValues.set(varName, varValue);
114
+ if (currentEnv) {
115
+ currentEnv.variables[varName] = varValue;
116
+ }
117
+ else {
118
+ config.common[varName] = varValue;
119
+ }
120
+ continue;
121
+ }
122
+ // Check for variable declaration
123
+ const varMatch = trimmed.match(varRegex);
124
+ if (varMatch) {
125
+ const varName = varMatch[1];
126
+ const varValue = varMatch[2].trim();
127
+ if (currentEnv) {
128
+ currentEnv.variables[varName] = varValue;
129
+ }
130
+ else {
131
+ config.common[varName] = varValue;
132
+ }
133
+ }
134
+ }
135
+ return config;
136
+ }
137
+ /**
138
+ * Generate a timestamp string for report filenames
139
+ * Format: YYYY-MM-DD-HHmmss
140
+ */
141
+ function generateTimestamp() {
142
+ const now = new Date();
143
+ const year = now.getFullYear();
144
+ const month = String(now.getMonth() + 1).padStart(2, '0');
145
+ const day = String(now.getDate()).padStart(2, '0');
146
+ const hours = String(now.getHours()).padStart(2, '0');
147
+ const minutes = String(now.getMinutes()).padStart(2, '0');
148
+ const seconds = String(now.getSeconds()).padStart(2, '0');
149
+ return `${year}-${month}-${day}-${hours}${minutes}${seconds}`;
150
+ }
151
+ /**
152
+ * Generate output paths for reports when using --output-dir
153
+ */
154
+ function generateReportPaths(outputDir, inputFile, timestamp) {
155
+ const baseName = path.basename(inputFile, path.extname(inputFile));
156
+ return {
157
+ junitPath: path.join(outputDir, `${baseName}-${timestamp}-results.xml`),
158
+ htmlPath: path.join(outputDir, `${baseName}-${timestamp}-report.html`)
159
+ };
160
+ }
161
+ function parseArgs(args) {
162
+ const options = {
163
+ file: '',
164
+ output: 'pretty',
165
+ verbose: false,
166
+ failOnError: true,
167
+ noRedact: false,
168
+ tagFilters: [],
169
+ tagsFilter: [],
170
+ };
171
+ for (let i = 0; i < args.length; i++) {
172
+ const arg = args[i];
173
+ if (arg === '--json' || arg === '-j') {
174
+ options.output = 'json';
175
+ }
176
+ else if (arg === '--verbose' || arg === '-v') {
177
+ options.verbose = true;
178
+ }
179
+ else if (arg === '--sequence' || arg === '-s') {
180
+ options.sequence = args[++i];
181
+ }
182
+ else if (arg === '--request' || arg === '-r') {
183
+ options.request = args[++i];
184
+ }
185
+ else if (arg === '--env' || arg === '-e') {
186
+ options.env = args[++i];
187
+ }
188
+ else if (arg === '--timeout' || arg === '-t') {
189
+ options.timeout = parseInt(args[++i], 10) * 1000;
190
+ }
191
+ else if (arg === '--no-fail') {
192
+ options.failOnError = false;
193
+ }
194
+ else if (arg === '--no-redact') {
195
+ options.noRedact = true;
196
+ }
197
+ else if (arg === '--junit') {
198
+ options.junitOutput = args[++i];
199
+ }
200
+ else if (arg === '--html') {
201
+ options.htmlOutput = args[++i];
202
+ }
203
+ else if (arg === '--output-dir' || arg === '-o') {
204
+ options.outputDir = args[++i];
205
+ }
206
+ else if (arg === '--tag') {
207
+ const tagStr = args[++i];
208
+ if (tagStr) {
209
+ options.tagFilters.push((0, sequenceRunner_1.parseTagFilter)(tagStr));
210
+ }
211
+ }
212
+ else if (arg === '--tags') {
213
+ const tagsStr = args[++i];
214
+ if (tagsStr) {
215
+ const tags = tagsStr.split(',').map(t => t.trim()).filter(t => t);
216
+ options.tagsFilter.push(...tags.map(t => (0, sequenceRunner_1.parseTagFilter)(t)));
217
+ }
218
+ }
219
+ else if (arg === '--help' || arg === '-h') {
220
+ printHelp();
221
+ process.exit(0);
222
+ }
223
+ else if (!arg.startsWith('-')) {
224
+ options.file = arg;
225
+ }
226
+ }
227
+ return options;
228
+ }
229
+ function printHelp() {
230
+ console.log(`
231
+ Norn CLI - Run HTTP requests and test sequences from .norn files
232
+
233
+ Usage: norn <file.norn|directory> [options]
234
+
235
+ When given a directory, recursively discovers and runs all test sequences
236
+ from .norn files within that directory and subdirectories.
237
+
238
+ Options:
239
+ -s, --sequence <name> Run a specific sequence by name (single file only)
240
+ -r, --request <name> Run a specific named request (single file only)
241
+ -e, --env <name> Use environment from .nornenv (e.g., dev, prod)
242
+ -t, --timeout <sec> Request timeout in seconds (default: no timeout)
243
+ -j, --json Output results as JSON (for CI/CD)
244
+ -v, --verbose Show detailed output (headers, request/response bodies)
245
+ --no-fail Don't exit with error code on failed requests
246
+ --no-redact Disable automatic redaction of sensitive data
247
+ --junit <file> Generate JUnit XML report (explicit path)
248
+ --html <file> Generate HTML report (explicit path)
249
+ -o, --output-dir <dir> Output directory for reports (auto-generates timestamped files)
250
+ --tag <filter> Filter sequences by tag (AND logic, can be repeated)
251
+ --tags <filters> Filter sequences by tags (OR logic, comma-separated)
252
+ -h, --help Show this help message
253
+
254
+ Report Generation:
255
+ Use --output-dir for auto-generated timestamped filenames:
256
+ norn tests.norn --output-dir ./reports
257
+ # Creates: ./reports/tests-2026-02-01-120000-results.xml
258
+ # ./reports/tests-2026-02-01-120000-report.html
259
+
260
+ Or use explicit paths with --junit and --html:
261
+ norn tests.norn --junit results.xml --html report.html
262
+
263
+ Output Behavior:
264
+ By default, only errors and failures show full details.
265
+ Use -v/--verbose to see all request/response details.
266
+
267
+ Sensitive Data:
268
+ Authorization headers, tokens, and secrets are automatically redacted.
269
+ Use 'secret' keyword in .nornenv to mark variables for redaction:
270
+ secret apiKey = my-secret-key
271
+ Use --no-redact to disable redaction for debugging.
272
+
273
+ Tag Filtering:
274
+ Tags are decorators on sequences: @smoke, @team(CustomerExp)
275
+
276
+ --tag smoke Run sequences with @smoke tag
277
+ --tag smoke --tag auth Run sequences with BOTH @smoke AND @auth tags
278
+ --tags smoke,auth Run sequences with @smoke OR @auth tag
279
+ --tag team(CustomerExp) Run sequences with @team(CustomerExp) exact match
280
+
281
+ Examples:
282
+ norn api-tests.norn # Run all test sequences in file
283
+ norn tests/ # Run all tests in directory (recursive)
284
+ norn tests/Regression/ -e prelive # Run regression suite with environment
285
+ norn api-tests.norn -s auth # Run 'auth' sequence by name
286
+ norn api-tests.norn -r LoginRequest # Run 'LoginRequest' named request
287
+ norn api-tests.norn -e production # Use 'production' environment
288
+ norn api-tests.norn -j # JSON output for CI/CD
289
+ norn api-tests.norn -v # Verbose output with all details
290
+ norn tests/ -o ./reports # Generate reports for all tests
291
+ norn api-tests.norn --junit results.xml # Generate JUnit report (explicit)
292
+ norn api-tests.norn --html report.html # Generate HTML report (explicit)
293
+ norn api-tests.norn --no-redact # Show all data (no redaction)
294
+ `);
295
+ }
296
+ async function runSingleRequest(fileContent, variables, cookieJar, apiDefinitions) {
297
+ const lines = fileContent.split('\n');
298
+ const requestLines = [];
299
+ let foundRequestLine = false;
300
+ for (const line of lines) {
301
+ const trimmed = line.trim();
302
+ if (!foundRequestLine) {
303
+ if (!trimmed || trimmed.startsWith('import ') || trimmed.startsWith('#')) {
304
+ continue;
305
+ }
306
+ }
307
+ if (!foundRequestLine && /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+/i.test(trimmed)) {
308
+ foundRequestLine = true;
309
+ }
310
+ if (foundRequestLine) {
311
+ requestLines.push(line);
312
+ }
313
+ }
314
+ const requestContent = requestLines.join('\n').trim();
315
+ if (apiDefinitions &&
316
+ apiDefinitions.endpoints.length > 0 &&
317
+ (0, nornapiParser_1.isApiRequestLine)(requestContent, apiDefinitions.endpoints)) {
318
+ const apiRequest = (0, nornapiParser_1.parseApiRequest)(requestContent, apiDefinitions.endpoints, apiDefinitions.headerGroups);
319
+ if (apiRequest) {
320
+ const endpoint = (0, nornapiParser_1.getEndpoint)({ headerGroups: apiDefinitions.headerGroups, endpoints: apiDefinitions.endpoints }, apiRequest.endpointName);
321
+ if (endpoint) {
322
+ let resolvedPath = endpoint.path;
323
+ for (const [key, value] of Object.entries(variables)) {
324
+ resolvedPath = resolvedPath.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(value));
325
+ }
326
+ for (const [paramName, paramValue] of Object.entries(apiRequest.params)) {
327
+ let resolvedValue = paramValue;
328
+ if (variables[paramValue] !== undefined) {
329
+ resolvedValue = String(variables[paramValue]);
330
+ }
331
+ else if (paramValue.startsWith('{{') && paramValue.endsWith('}}')) {
332
+ const varName = paramValue.slice(2, -2);
333
+ if (variables[varName] !== undefined) {
334
+ resolvedValue = String(variables[varName]);
335
+ }
336
+ }
337
+ resolvedPath = resolvedPath.replace(`{${paramName}}`, resolvedValue);
338
+ }
339
+ const combinedHeaders = {};
340
+ for (const groupName of apiRequest.headerGroupNames) {
341
+ const group = apiDefinitions.headerGroups.find(hg => hg.name === groupName);
342
+ if (group) {
343
+ const resolvedHeaders = (0, nornapiParser_1.resolveHeaderValues)(group, variables);
344
+ Object.assign(combinedHeaders, resolvedHeaders);
345
+ }
346
+ }
347
+ // Add inline headers (these take precedence over header groups)
348
+ for (const [headerName, headerValue] of Object.entries(apiRequest.inlineHeaders)) {
349
+ // Resolve variable references in header values
350
+ let resolved = headerValue;
351
+ resolved = resolved.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (_, varName) => {
352
+ return variables[varName] !== undefined ? String(variables[varName]) : `{{${varName}}}`;
353
+ });
354
+ combinedHeaders[headerName] = resolved;
355
+ }
356
+ const parsed = {
357
+ method: apiRequest.method,
358
+ url: resolvedPath,
359
+ headers: combinedHeaders,
360
+ body: apiRequest.body
361
+ };
362
+ return await (0, httpClient_1.sendRequestWithJar)(parsed, cookieJar);
363
+ }
364
+ }
365
+ }
366
+ // Regular HTTP request - parse and apply header groups
367
+ let parsed = (0, parser_1.parserHttpRequest)(requestContent, variables);
368
+ // Check for header group names in the request text
369
+ if (apiDefinitions && apiDefinitions.headerGroups.length > 0) {
370
+ const headerGroupNames = apiDefinitions.headerGroups.map(hg => hg.name);
371
+ const foundGroups = [];
372
+ for (const line of requestLines) {
373
+ const trimmed = line.trim();
374
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('var ')) {
375
+ continue;
376
+ }
377
+ // Check if this is the HTTP method line - look for header groups at the end
378
+ const methodMatch = trimmed.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(.+)$/i);
379
+ if (methodMatch) {
380
+ const afterMethod = methodMatch[2];
381
+ const tokens = afterMethod.split(/\s+/);
382
+ // Scan from the end to find header groups
383
+ for (let i = tokens.length - 1; i >= 0; i--) {
384
+ if (headerGroupNames.includes(tokens[i])) {
385
+ foundGroups.push(tokens[i]);
386
+ }
387
+ else {
388
+ break;
389
+ }
390
+ }
391
+ continue;
392
+ }
393
+ // Skip lines that look like headers (Name: Value)
394
+ if (/^[A-Za-z0-9\-_]+\s*:\s*.+$/.test(trimmed)) {
395
+ continue;
396
+ }
397
+ // Check if this line contains header group names (can be multiple space-separated)
398
+ const potentialGroups = trimmed.split(/\s+/);
399
+ for (const groupName of potentialGroups) {
400
+ if (headerGroupNames.includes(groupName)) {
401
+ foundGroups.push(groupName);
402
+ }
403
+ }
404
+ }
405
+ // Collect headers from all found groups and strip from URL
406
+ for (const groupName of foundGroups) {
407
+ const group = apiDefinitions.headerGroups.find(hg => hg.name === groupName);
408
+ if (group) {
409
+ const resolvedHeaders = (0, nornapiParser_1.resolveHeaderValues)(group, variables);
410
+ parsed.headers = { ...resolvedHeaders, ...parsed.headers };
411
+ }
412
+ // Strip from URL
413
+ const endPattern = new RegExp(`\\s+${groupName}$`);
414
+ parsed.url = parsed.url.replace(endPattern, '');
415
+ }
416
+ parsed.url = parsed.url.trim();
417
+ }
418
+ return await (0, httpClient_1.sendRequestWithJar)(parsed, cookieJar);
419
+ }
420
+ /**
421
+ * Recursively discover all .norn files in a directory
422
+ */
423
+ function discoverNornFiles(dirPath) {
424
+ const files = [];
425
+ function walkDir(currentPath) {
426
+ const entries = fs.readdirSync(currentPath, { withFileTypes: true });
427
+ for (const entry of entries) {
428
+ const fullPath = path.join(currentPath, entry.name);
429
+ if (entry.isDirectory()) {
430
+ // Skip common non-test directories
431
+ if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
432
+ walkDir(fullPath);
433
+ }
434
+ }
435
+ else if (entry.isFile() && entry.name.endsWith('.norn')) {
436
+ files.push(fullPath);
437
+ }
438
+ }
439
+ }
440
+ walkDir(dirPath);
441
+ return files.sort(); // Sort for consistent ordering
442
+ }
443
+ /**
444
+ * Count test sequences in a file without running them
445
+ */
446
+ function countTestSequences(fileContent, tagFilterOptions) {
447
+ const allSequences = (0, sequenceRunner_1.extractSequences)(fileContent);
448
+ const testSequences = allSequences.filter(seq => seq.isTest);
449
+ const filtered = tagFilterOptions && tagFilterOptions.filters.length > 0
450
+ ? testSequences.filter(seq => (0, sequenceRunner_1.sequenceMatchesTags)(seq, tagFilterOptions)).length
451
+ : testSequences.length;
452
+ return { total: testSequences.length, filtered };
453
+ }
454
+ /**
455
+ * Loads theory data from an external JSON file
456
+ */
457
+ async function loadTheoryFile(theoryPath, workingDir) {
458
+ const absolutePath = path.resolve(workingDir, theoryPath);
459
+ const content = await fsPromises.readFile(absolutePath, 'utf-8');
460
+ return JSON.parse(content);
461
+ }
462
+ /**
463
+ * Formats a test case label from parameter names and values
464
+ */
465
+ function formatCaseLabel(params) {
466
+ const parts = Object.entries(params).map(([key, value]) => {
467
+ if (typeof value === 'string') {
468
+ return `${key}="${value}"`;
469
+ }
470
+ return `${key}=${value}`;
471
+ });
472
+ return `[${parts.join(', ')}]`;
473
+ }
474
+ async function runAllSequences(fileContent, variables, cookieJar, workingDir, apiDefinitions, tagFilterOptions, sequenceSources) {
475
+ const allSequences = (0, sequenceRunner_1.extractSequences)(fileContent);
476
+ const results = [];
477
+ // First filter to only test sequences (isTest === true)
478
+ const testSequences = allSequences.filter(seq => seq.isTest);
479
+ // Then apply tag filters if present
480
+ const sequences = tagFilterOptions && tagFilterOptions.filters.length > 0
481
+ ? testSequences.filter(seq => (0, sequenceRunner_1.sequenceMatchesTags)(seq, tagFilterOptions))
482
+ : testSequences;
483
+ for (const seq of sequences) {
484
+ // Check if this is a parameterized test
485
+ let theoryData = seq.theoryData;
486
+ // If we have a @theory source file, load it
487
+ if (theoryData?.source && theoryData.cases.length === 0) {
488
+ try {
489
+ const cases = await loadTheoryFile(theoryData.source, workingDir);
490
+ theoryData = { ...theoryData, cases };
491
+ }
492
+ catch (error) {
493
+ // Report error as a failed result
494
+ const errorResult = {
495
+ name: seq.name,
496
+ success: false,
497
+ responses: [],
498
+ scriptResults: [],
499
+ assertionResults: [],
500
+ steps: [],
501
+ errors: [`Failed to load theory file '${theoryData.source}': ${error instanceof Error ? error.message : String(error)}`],
502
+ duration: 0
503
+ };
504
+ results.push(errorResult);
505
+ continue;
506
+ }
507
+ }
508
+ // If this has theory data with cases, run each case separately
509
+ if (theoryData && theoryData.cases.length > 0) {
510
+ for (let caseIdx = 0; caseIdx < theoryData.cases.length; caseIdx++) {
511
+ const caseParams = theoryData.cases[caseIdx];
512
+ const caseLabel = formatCaseLabel(caseParams);
513
+ // Convert case params to string args
514
+ const caseArgs = {};
515
+ for (const [key, value] of Object.entries(caseParams)) {
516
+ caseArgs[key] = String(value);
517
+ }
518
+ const result = await (0, sequenceRunner_1.runSequenceWithJar)(seq.content, variables, cookieJar, workingDir, fileContent, undefined, undefined, caseArgs, apiDefinitions, tagFilterOptions, sequenceSources);
519
+ result.name = `${seq.name}${caseLabel}`;
520
+ results.push(result);
521
+ }
522
+ }
523
+ else {
524
+ // Regular test sequence (no parameterization)
525
+ const defaultArgs = {};
526
+ if (seq.parameters) {
527
+ for (const param of seq.parameters) {
528
+ if (param.defaultValue !== undefined) {
529
+ defaultArgs[param.name] = param.defaultValue;
530
+ }
531
+ }
532
+ }
533
+ const result = await (0, sequenceRunner_1.runSequenceWithJar)(seq.content, variables, cookieJar, workingDir, fileContent, undefined, undefined, defaultArgs, apiDefinitions, tagFilterOptions, sequenceSources);
534
+ result.name = seq.name;
535
+ results.push(result);
536
+ }
537
+ }
538
+ return results;
539
+ }
540
+ async function main() {
541
+ const args = process.argv.slice(2);
542
+ if (args.length === 0) {
543
+ printHelp();
544
+ process.exit(1);
545
+ }
546
+ const options = parseArgs(args);
547
+ if (!options.file) {
548
+ console.error('Error: No input file or directory specified');
549
+ process.exit(1);
550
+ }
551
+ // Initialize colors (async due to chalk v5 ESM)
552
+ const colors = await (0, colors_1.initColors)();
553
+ const inputPath = path.resolve(options.file);
554
+ if (!fs.existsSync(inputPath)) {
555
+ console.error(`Error: Path not found: ${inputPath}`);
556
+ process.exit(1);
557
+ }
558
+ const isDirectory = fs.statSync(inputPath).isDirectory();
559
+ // Discover files to run
560
+ let filesToRun;
561
+ if (isDirectory) {
562
+ filesToRun = discoverNornFiles(inputPath);
563
+ if (filesToRun.length === 0) {
564
+ console.log(colors.info(`No .norn files found in ${inputPath}`));
565
+ process.exit(0);
566
+ }
567
+ if (options.verbose) {
568
+ console.log(colors.info(`Discovered ${filesToRun.length} .norn file(s) in ${inputPath}`));
569
+ }
570
+ }
571
+ else {
572
+ filesToRun = [inputPath];
573
+ }
574
+ // For directory mode, we aggregate results across all files
575
+ const allResults = [];
576
+ const allErrors = [];
577
+ let overallSuccess = true;
578
+ const startTime = Date.now();
579
+ // Find env file from the input path (directory or file)
580
+ const envFilePath = findEnvFileFromPath(inputPath);
581
+ let envVariables = {};
582
+ let redaction = (0, redaction_1.createRedactionOptions)(new Set(), new Map(), !options.noRedact);
583
+ if (envFilePath) {
584
+ const envContent = fs.readFileSync(envFilePath, 'utf-8');
585
+ const envConfig = parseEnvFile(envContent);
586
+ envVariables = { ...envConfig.common };
587
+ redaction = (0, redaction_1.createRedactionOptions)(envConfig.secretNames, envConfig.secretValues, !options.noRedact);
588
+ if (options.env) {
589
+ const targetEnv = envConfig.environments.find(e => e.name === options.env);
590
+ if (!targetEnv) {
591
+ console.error(`Error: Environment '${options.env}' not found in .nornenv`);
592
+ console.error(`Available environments: ${envConfig.environments.map(e => e.name).join(', ') || 'none'}`);
593
+ process.exit(1);
594
+ }
595
+ Object.assign(envVariables, targetEnv.variables);
596
+ for (const [name, value] of Object.entries(targetEnv.variables)) {
597
+ if (envConfig.secretNames.has(name)) {
598
+ redaction.secretValues.set(name, value);
599
+ }
600
+ }
601
+ if (options.verbose) {
602
+ console.log(colors.info(`Using environment: ${options.env}`));
603
+ }
604
+ }
605
+ }
606
+ else if (options.env) {
607
+ console.error(colors.warning(`Warning: --env specified but no .nornenv file found`));
608
+ }
609
+ let tagFilterOptions = undefined;
610
+ if (options.tagFilters.length > 0) {
611
+ tagFilterOptions = {
612
+ filters: options.tagFilters,
613
+ mode: 'and'
614
+ };
615
+ }
616
+ else if (options.tagsFilter.length > 0) {
617
+ tagFilterOptions = {
618
+ filters: options.tagsFilter,
619
+ mode: 'or'
620
+ };
621
+ }
622
+ // If running a specific sequence or request, only works with single file
623
+ if ((options.sequence || options.request) && isDirectory) {
624
+ console.error('Error: --sequence and --request flags require a specific file, not a directory');
625
+ process.exit(1);
626
+ }
627
+ // Single file mode with specific sequence/request
628
+ if (options.sequence || options.request) {
629
+ const filePath = filesToRun[0];
630
+ const fileContent = fs.readFileSync(filePath, 'utf-8');
631
+ const fileVariables = (0, parser_1.extractFileLevelVariables)(fileContent);
632
+ const variables = { ...envVariables, ...fileVariables };
633
+ const cookieJar = (0, httpClient_1.createCookieJar)();
634
+ const workingDir = path.dirname(filePath);
635
+ const importResult = await (0, parser_1.resolveImports)(fileContent, workingDir, async (importPath) => fsPromises.readFile(importPath, 'utf-8'));
636
+ // Check for duplicate definition errors which should block execution
637
+ const duplicateErrors = importResult.errors.filter(err => err.error.includes('Duplicate header group') ||
638
+ err.error.includes('Duplicate endpoint') ||
639
+ err.error.includes('Duplicate named request') ||
640
+ err.error.includes('Duplicate sequence'));
641
+ const otherErrors = importResult.errors.filter(err => !err.error.includes('Duplicate header group') &&
642
+ !err.error.includes('Duplicate endpoint') &&
643
+ !err.error.includes('Duplicate named request') &&
644
+ !err.error.includes('Duplicate sequence'));
645
+ // Show non-duplicate errors as warnings
646
+ for (const err of otherErrors) {
647
+ console.error(colors.warning(`Import warning: ${err.path} - ${err.error}`));
648
+ }
649
+ // Duplicate errors block execution
650
+ if (duplicateErrors.length > 0) {
651
+ console.error(colors.error('Cannot execute - duplicate definitions found:'));
652
+ for (const err of duplicateErrors) {
653
+ console.error(colors.error(` ${err.path}: ${err.error}`));
654
+ }
655
+ process.exit(1);
656
+ }
657
+ const fileContentWithImports = importResult.importedContent
658
+ ? `${importResult.importedContent}\n\n${fileContent}`
659
+ : fileContent;
660
+ const apiDefinitions = (importResult.headerGroups?.length || 0) > 0 || (importResult.endpoints?.length || 0) > 0
661
+ ? { headerGroups: importResult.headerGroups || [], endpoints: importResult.endpoints || [] }
662
+ : undefined;
663
+ if (options.request) {
664
+ const namedRequests = (0, parser_1.extractNamedRequests)(fileContentWithImports);
665
+ const targetReq = namedRequests.find(r => r.name === options.request);
666
+ if (!targetReq) {
667
+ console.error(`Error: Named request '${options.request}' not found`);
668
+ console.error(`Available requests: ${namedRequests.map(r => r.name).join(', ') || 'none'}`);
669
+ process.exit(1);
670
+ }
671
+ if (options.verbose) {
672
+ console.log(colors.info(`Running request: ${options.request}`));
673
+ }
674
+ const response = await runSingleRequest(targetReq.content, variables, cookieJar, apiDefinitions);
675
+ // Format and output single request result
676
+ if (options.output === 'json') {
677
+ console.log(JSON.stringify({ success: response.status >= 200 && response.status < 400, results: [response] }, null, 2));
678
+ }
679
+ else {
680
+ const isSuccess = response.status >= 200 && response.status < 300;
681
+ const lines = (0, response_1.formatResponse)(response, { colors, verbose: options.verbose, showDetails: !isSuccess || options.verbose, redaction });
682
+ for (const line of lines) {
683
+ console.log(line);
684
+ }
685
+ }
686
+ process.exit(response.status >= 200 && response.status < 400 ? 0 : 1);
687
+ }
688
+ if (options.sequence) {
689
+ const sequences = (0, sequenceRunner_1.extractSequences)(fileContentWithImports);
690
+ const targetSeq = sequences.find(s => s.name === options.sequence);
691
+ if (!targetSeq) {
692
+ console.error(`Error: Sequence '${options.sequence}' not found`);
693
+ console.error(`Available sequences: ${sequences.map(s => s.name).join(', ') || 'none'}`);
694
+ process.exit(1);
695
+ }
696
+ if (options.verbose) {
697
+ console.log(colors.info(`Running sequence: ${options.sequence}`));
698
+ }
699
+ const defaultArgs = {};
700
+ if (targetSeq.parameters) {
701
+ for (const param of targetSeq.parameters) {
702
+ if (param.defaultValue !== undefined) {
703
+ defaultArgs[param.name] = param.defaultValue;
704
+ }
705
+ }
706
+ }
707
+ const seqResult = await (0, sequenceRunner_1.runSequenceWithJar)(targetSeq.content, variables, cookieJar, workingDir, fileContentWithImports, undefined, undefined, defaultArgs, apiDefinitions, tagFilterOptions, importResult.sequenceSources);
708
+ seqResult.name = targetSeq.name;
709
+ // Format and output single sequence result
710
+ if (options.output === 'json') {
711
+ console.log(JSON.stringify({ success: seqResult.success, results: [seqResult] }, null, 2));
712
+ }
713
+ else {
714
+ const lines = (0, summary_1.formatSequenceResult)(seqResult, { colors, verbose: options.verbose, redaction });
715
+ for (const line of lines) {
716
+ console.log(line);
717
+ }
718
+ const summaryLines = (0, summary_1.formatRunSummary)([seqResult], Date.now() - startTime, colors);
719
+ for (const line of summaryLines) {
720
+ console.log(line);
721
+ }
722
+ }
723
+ process.exit(seqResult.success ? 0 : 1);
724
+ }
725
+ }
726
+ // Count total tests before running
727
+ let totalTestCount = 0;
728
+ let filteredTestCount = 0;
729
+ for (const filePath of filesToRun) {
730
+ const fileContent = fs.readFileSync(filePath, 'utf-8');
731
+ const counts = countTestSequences(fileContent, tagFilterOptions);
732
+ totalTestCount += counts.total;
733
+ filteredTestCount += counts.filtered;
734
+ }
735
+ if (filteredTestCount === 0) {
736
+ if (totalTestCount > 0 && tagFilterOptions) {
737
+ console.log(colors.info(`No test sequences match the tag filter (${totalTestCount} total test sequences found)`));
738
+ }
739
+ else if (totalTestCount === 0) {
740
+ console.log(colors.info('No test sequences found. Use "test sequence" to mark sequences for CLI execution.'));
741
+ }
742
+ process.exit(0);
743
+ }
744
+ if (options.verbose && isDirectory) {
745
+ const tagInfo = tagFilterOptions ? ` (${filteredTestCount} matching tag filter)` : '';
746
+ console.log(colors.info(`Running ${filteredTestCount} test sequence(s) from ${filesToRun.length} file(s)${tagInfo}`));
747
+ console.log('');
748
+ }
749
+ // Run tests from each file
750
+ for (const filePath of filesToRun) {
751
+ const fileContent = fs.readFileSync(filePath, 'utf-8');
752
+ const fileVariables = (0, parser_1.extractFileLevelVariables)(fileContent);
753
+ const variables = { ...envVariables, ...fileVariables };
754
+ const cookieJar = (0, httpClient_1.createCookieJar)();
755
+ const workingDir = path.dirname(filePath);
756
+ const importResult = await (0, parser_1.resolveImports)(fileContent, workingDir, async (importPath) => fsPromises.readFile(importPath, 'utf-8'));
757
+ // Check for duplicate definition errors which should block execution
758
+ const duplicateErrors2 = importResult.errors.filter(err => err.error.includes('Duplicate header group') ||
759
+ err.error.includes('Duplicate endpoint') ||
760
+ err.error.includes('Duplicate named request') ||
761
+ err.error.includes('Duplicate sequence'));
762
+ const otherErrors2 = importResult.errors.filter(err => !err.error.includes('Duplicate header group') &&
763
+ !err.error.includes('Duplicate endpoint') &&
764
+ !err.error.includes('Duplicate named request') &&
765
+ !err.error.includes('Duplicate sequence'));
766
+ // Show non-duplicate errors as warnings
767
+ for (const err of otherErrors2) {
768
+ console.error(colors.warning(`Import warning: ${err.path} - ${err.error}`));
769
+ }
770
+ // Duplicate errors block execution
771
+ if (duplicateErrors2.length > 0) {
772
+ console.error(colors.error('Cannot execute - duplicate definitions found:'));
773
+ for (const err of duplicateErrors2) {
774
+ console.error(colors.error(` ${err.path}: ${err.error}`));
775
+ }
776
+ process.exit(1);
777
+ }
778
+ const fileContentWithImports = importResult.importedContent
779
+ ? `${importResult.importedContent}\n\n${fileContent}`
780
+ : fileContent;
781
+ // Check if file has test sequences
782
+ const allSequences = (0, sequenceRunner_1.extractSequences)(fileContent);
783
+ const testSequences = allSequences.filter(seq => seq.isTest);
784
+ const sequences = tagFilterOptions && tagFilterOptions.filters.length > 0
785
+ ? testSequences.filter(seq => (0, sequenceRunner_1.sequenceMatchesTags)(seq, tagFilterOptions))
786
+ : testSequences;
787
+ if (sequences.length === 0) {
788
+ // No test sequences in this file, skip
789
+ continue;
790
+ }
791
+ // Show file header in directory mode
792
+ if (isDirectory && options.output !== 'json') {
793
+ const relPath = path.relative(inputPath, filePath);
794
+ console.log(colors.info(`\n━━━ ${relPath} ━━━`));
795
+ }
796
+ const apiDefinitions = (importResult.headerGroups?.length || 0) > 0 || (importResult.endpoints?.length || 0) > 0
797
+ ? { headerGroups: importResult.headerGroups || [], endpoints: importResult.endpoints || [] }
798
+ : undefined;
799
+ const seqResults = await runAllSequences(fileContentWithImports, variables, cookieJar, workingDir, apiDefinitions, tagFilterOptions, importResult.sequenceSources);
800
+ // Tag results with source file for reporting
801
+ for (const result of seqResults) {
802
+ result.sourceFile = filePath;
803
+ allResults.push(result);
804
+ if (!result.success) {
805
+ overallSuccess = false;
806
+ }
807
+ allErrors.push(...result.errors);
808
+ }
809
+ // Output per-file results in pretty mode
810
+ if (options.output !== 'json') {
811
+ for (const seqResult of seqResults) {
812
+ const lines = (0, summary_1.formatSequenceResult)(seqResult, { colors, verbose: options.verbose, redaction });
813
+ for (const line of lines) {
814
+ console.log(line);
815
+ }
816
+ }
817
+ }
818
+ }
819
+ const totalDuration = Date.now() - startTime;
820
+ // Build result object for reporting
821
+ const result = {
822
+ success: overallSuccess,
823
+ type: 'all-sequences',
824
+ results: allResults,
825
+ errors: allErrors,
826
+ };
827
+ // Determine report paths
828
+ let junitOutputPath = options.junitOutput;
829
+ let htmlOutputPath = options.htmlOutput;
830
+ // If --output-dir is specified, auto-generate timestamped filenames
831
+ if (options.outputDir) {
832
+ const timestamp = generateTimestamp();
833
+ // Use input path name for report naming
834
+ const baseName = isDirectory ? path.basename(inputPath) : path.basename(inputPath, path.extname(inputPath));
835
+ const generatedPaths = generateReportPaths(options.outputDir, baseName + '.norn', timestamp);
836
+ // Only use generated paths if explicit paths weren't provided
837
+ if (!junitOutputPath) {
838
+ junitOutputPath = generatedPaths.junitPath;
839
+ }
840
+ if (!htmlOutputPath) {
841
+ htmlOutputPath = generatedPaths.htmlPath;
842
+ }
843
+ // Ensure output directory exists
844
+ if (!fs.existsSync(options.outputDir)) {
845
+ fs.mkdirSync(options.outputDir, { recursive: true });
846
+ }
847
+ }
848
+ // Generate reports if requested
849
+ if (junitOutputPath) {
850
+ const suiteName = isDirectory ? path.basename(inputPath) : path.basename(inputPath, path.extname(inputPath));
851
+ if (result.type === 'request') {
852
+ (0, junit_1.generateJUnitReportFromResponse)(result.results[0], options.request || suiteName, { outputPath: junitOutputPath, redaction, suiteName });
853
+ }
854
+ else {
855
+ (0, junit_1.generateJUnitReport)(result.results, { outputPath: junitOutputPath, redaction, suiteName });
856
+ }
857
+ console.log(colors.info(`JUnit report written to: ${junitOutputPath}`));
858
+ }
859
+ if (htmlOutputPath) {
860
+ const title = `Norn Test Report - ${path.basename(inputPath)}`;
861
+ if (result.type === 'request') {
862
+ (0, html_1.generateHtmlReportFromResponse)(result.results[0], options.request || path.basename(inputPath), { outputPath: htmlOutputPath, redaction, title });
863
+ }
864
+ else {
865
+ (0, html_1.generateHtmlReport)(result.results, { outputPath: htmlOutputPath, redaction, title });
866
+ }
867
+ console.log(colors.info(`HTML report written to: ${htmlOutputPath}`));
868
+ }
869
+ // Output results
870
+ if (options.output === 'json') {
871
+ // Redact JSON output
872
+ const redactedResult = {
873
+ ...result,
874
+ results: result.results.map(r => {
875
+ if ('responses' in r) {
876
+ // SequenceResult
877
+ return {
878
+ ...r,
879
+ responses: r.responses.map(resp => ({
880
+ ...resp,
881
+ body: (0, redaction_1.redactBody)(resp.body, redaction)
882
+ })),
883
+ steps: r.steps.map(step => ({
884
+ ...step,
885
+ response: step.response ? {
886
+ ...step.response,
887
+ body: (0, redaction_1.redactBody)(step.response.body, redaction)
888
+ } : undefined
889
+ }))
890
+ };
891
+ }
892
+ else {
893
+ // HttpResponse
894
+ return {
895
+ ...r,
896
+ body: (0, redaction_1.redactBody)(r.body, redaction)
897
+ };
898
+ }
899
+ })
900
+ };
901
+ console.log(JSON.stringify(redactedResult, null, 2));
902
+ }
903
+ else {
904
+ // Pretty output - summary only (individual results already printed per-file)
905
+ if (result.type !== 'request' && allResults.length > 0) {
906
+ // Show summary
907
+ const summaryLines = (0, summary_1.formatRunSummary)(allResults, totalDuration, colors);
908
+ for (const line of summaryLines) {
909
+ console.log(line);
910
+ }
911
+ }
912
+ if (result.errors.length > 0 && result.type === 'request') {
913
+ console.error(`\n${colors.error('Errors:')}`);
914
+ result.errors.forEach(err => console.error(` ${colors.bullet} ${err}`));
915
+ }
916
+ }
917
+ // Exit with appropriate code
918
+ if (options.failOnError && !overallSuccess) {
919
+ process.exit(1);
920
+ }
921
+ }
922
+ main().catch((error) => {
923
+ console.error('Fatal error:', error.message);
924
+ process.exit(1);
925
+ });
926
+ //# sourceMappingURL=cli.js.map