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/AGENTS.md +72 -0
- package/CHANGELOG.md +39 -1
- package/README.md +7 -3
- 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
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
|