norn-cli 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +66 -2
- package/dist/cli.js +184 -24
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the "Norn" extension will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.1.2] - 2026-02-01
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **VS Code Test Explorer Integration**: Run test sequences from the Testing sidebar
|
|
9
|
+
- Automatic test discovery for all `.norn` files
|
|
10
|
+
- Tests grouped by tag (`@smoke`, `@regression`, etc.)
|
|
11
|
+
- Colorful streaming output with ANSI colors
|
|
12
|
+
- Persistent output when selecting tests
|
|
13
|
+
- Detailed failure info with expected vs actual diffs
|
|
14
|
+
- Request/response details for failed HTTP calls
|
|
15
|
+
|
|
16
|
+
- **Parameterized Tests**: Data-driven testing with `@data` and `@theory` annotations
|
|
17
|
+
- `@data(1, "Widget")` syntax for inline test data
|
|
18
|
+
- `@theory("./testdata.json")` for external data files
|
|
19
|
+
- Typed value parsing (numbers, booleans, strings)
|
|
20
|
+
- Each data row creates a separate test case in Test Explorer
|
|
21
|
+
- Example: `@data(1, 200)` with `test sequence ValidateStatus(id, expectedStatus)`
|
|
22
|
+
|
|
23
|
+
- **Test Sequence Validation**: Diagnostics for test annotations
|
|
24
|
+
- Error if `@data`/`@theory` used on regular sequences (must use `test sequence`)
|
|
25
|
+
- Error if tags like `@smoke` used on regular sequences
|
|
26
|
+
- Error if test sequence has required params but no `@data`/`@theory`
|
|
27
|
+
|
|
28
|
+
### Improved
|
|
29
|
+
- **Test Explorer Output**: Rich colorized output with icons
|
|
30
|
+
- HTTP methods in cyan, status codes color-coded (green/red/yellow)
|
|
31
|
+
- Checkmarks for passed assertions, X for failures
|
|
32
|
+
- Clear test headers when running multiple tests
|
|
33
|
+
- Duration shown for each test
|
|
34
|
+
|
|
5
35
|
## [1.1.0] - 2026-02-01
|
|
6
36
|
|
|
7
37
|
### Added
|
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@ A powerful REST client extension for VS Code with sequences, assertions, environ
|
|
|
13
13
|
- **Environments**: Manage dev/staging/prod configurations with `.nornenv` files
|
|
14
14
|
- **Sequences**: Chain multiple requests with response capture using `$N.path`
|
|
15
15
|
- **Test Sequences**: Mark sequences as tests with `test sequence` for CLI execution
|
|
16
|
+
- **Test Explorer**: Run tests from VS Code's Testing sidebar with colorful output
|
|
17
|
+
- **Parameterized Tests**: Data-driven testing with `@data` and `@theory` annotations
|
|
16
18
|
- **Sequence Tags**: Tag sequences with `@smoke`, `@team(CustomerExp)` for filtering in CI/CD
|
|
17
19
|
- **Secret Variables**: Mark sensitive environment variables with `secret` for automatic redaction
|
|
18
20
|
- **Assertions**: Validate responses with `assert` statements supporting comparison, type checking, and existence
|
|
@@ -753,6 +755,65 @@ npx norn --help
|
|
|
753
755
|
| `--no-fail` | Don't exit with error code on failed tests |
|
|
754
756
|
| `-h, --help` | Show help message |
|
|
755
757
|
|
|
758
|
+
## Test Explorer
|
|
759
|
+
|
|
760
|
+
Run tests directly from VS Code's Testing sidebar:
|
|
761
|
+
|
|
762
|
+
- **Automatic Discovery**: Test sequences appear in the Testing view
|
|
763
|
+
- **Tag Grouping**: Tests organized by tags (`@smoke`, `@regression`, etc.)
|
|
764
|
+
- **Colorful Output**: ANSI-colored results with icons and status codes
|
|
765
|
+
- **Persistent Output**: Select a test to see its full output anytime
|
|
766
|
+
- **Failure Details**: Expected vs actual diffs, request/response info
|
|
767
|
+
|
|
768
|
+
### Parameterized Tests
|
|
769
|
+
|
|
770
|
+
Use `@data` for data-driven testing - each data row becomes a separate test:
|
|
771
|
+
|
|
772
|
+
```bash
|
|
773
|
+
# Single parameter - runs 3 times with id = 1, 2, 3
|
|
774
|
+
@data(1, 2, 3)
|
|
775
|
+
test sequence TodoTest(id)
|
|
776
|
+
GET {{baseUrl}}/todos/{{id}}
|
|
777
|
+
assert $1.status == 200
|
|
778
|
+
assert $1.body.id == {{id}}
|
|
779
|
+
end sequence
|
|
780
|
+
|
|
781
|
+
# Multiple parameters - runs 2 times
|
|
782
|
+
@data(1, "delectus aut autem")
|
|
783
|
+
@data(2, "quis ut nam facilis")
|
|
784
|
+
test sequence TodoTitleTest(id, expectedTitle)
|
|
785
|
+
GET {{baseUrl}}/todos/{{id}}
|
|
786
|
+
assert $1.status == 200
|
|
787
|
+
assert $1.body.title == "{{expectedTitle}}"
|
|
788
|
+
end sequence
|
|
789
|
+
|
|
790
|
+
# Typed values (numbers, booleans, strings)
|
|
791
|
+
@data(1, true, "active")
|
|
792
|
+
@data(2, false, "inactive")
|
|
793
|
+
test sequence UserStatusTest(userId, isActive, status)
|
|
794
|
+
GET {{baseUrl}}/users/{{userId}}
|
|
795
|
+
assert $1.status == 200
|
|
796
|
+
end sequence
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
Use `@theory` for external data files:
|
|
800
|
+
|
|
801
|
+
```bash
|
|
802
|
+
@theory("./testdata.json")
|
|
803
|
+
test sequence DataFileTest(id, name)
|
|
804
|
+
GET {{baseUrl}}/items/{{id}}
|
|
805
|
+
assert $1.body.name == "{{name}}"
|
|
806
|
+
end sequence
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
Where `testdata.json` contains:
|
|
810
|
+
```json
|
|
811
|
+
[
|
|
812
|
+
{"id": 1, "name": "Widget"},
|
|
813
|
+
{"id": 2, "name": "Gadget"}
|
|
814
|
+
]
|
|
815
|
+
```
|
|
816
|
+
|
|
756
817
|
### CI/CD Example (GitHub Actions)
|
|
757
818
|
|
|
758
819
|
```yaml
|
|
@@ -793,8 +854,11 @@ jobs:
|
|
|
793
854
|
| `run RequestName` | Execute a named request |
|
|
794
855
|
| `sequence Name` | Start a helper sequence block |
|
|
795
856
|
| `test sequence Name` | Start a test sequence (runs from CLI) |
|
|
796
|
-
|
|
|
797
|
-
| `@
|
|
857
|
+
| `test sequence Name(params)` | Test sequence with parameters |
|
|
858
|
+
| `@tagname` | Simple tag on a test sequence |
|
|
859
|
+
| `@key(value)` | Key-value tag on a test sequence |
|
|
860
|
+
| `@data(val1, val2)` | Inline test data for parameterized tests |
|
|
861
|
+
| `@theory("file.json")` | External test data file |
|
|
798
862
|
| `end sequence` | End a sequence block |
|
|
799
863
|
| `var x = $1.path` | Capture value from response 1 |
|
|
800
864
|
| `$N.status` | Access status code of response N |
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
#!/usr/bin/env node
|
|
3
2
|
"use strict";
|
|
4
3
|
var __create = Object.create;
|
|
5
4
|
var __defProp = Object.defineProperty;
|
|
@@ -20099,6 +20098,8 @@ function parseSequenceParameters(line2) {
|
|
|
20099
20098
|
}
|
|
20100
20099
|
function parseSequenceTags(lines, sequenceLineIndex) {
|
|
20101
20100
|
const tags = [];
|
|
20101
|
+
const dataCases = [];
|
|
20102
|
+
let theorySource;
|
|
20102
20103
|
let tagStartLine = sequenceLineIndex;
|
|
20103
20104
|
for (let i = sequenceLineIndex - 1; i >= 0; i--) {
|
|
20104
20105
|
const line2 = lines[i].trim();
|
|
@@ -20108,10 +20109,26 @@ function parseSequenceTags(lines, sequenceLineIndex) {
|
|
|
20108
20109
|
if (!line2.startsWith("@")) {
|
|
20109
20110
|
break;
|
|
20110
20111
|
}
|
|
20112
|
+
const dataMatch = line2.match(/^@data\s*\((.+)\)\s*$/);
|
|
20113
|
+
if (dataMatch) {
|
|
20114
|
+
const values = parseDataValues(dataMatch[1]);
|
|
20115
|
+
dataCases.push(values);
|
|
20116
|
+
tagStartLine = i;
|
|
20117
|
+
continue;
|
|
20118
|
+
}
|
|
20119
|
+
const theoryMatch = line2.match(/^@theory\s*\(\s*["']([^"']+)["']\s*\)\s*$/);
|
|
20120
|
+
if (theoryMatch) {
|
|
20121
|
+
theorySource = theoryMatch[1];
|
|
20122
|
+
tagStartLine = i;
|
|
20123
|
+
continue;
|
|
20124
|
+
}
|
|
20111
20125
|
const tagPattern = /@([a-zA-Z_][a-zA-Z0-9_-]*)(?:\(([^)]+)\))?/g;
|
|
20112
20126
|
let match;
|
|
20113
20127
|
while ((match = tagPattern.exec(line2)) !== null) {
|
|
20114
20128
|
const tagName = match[1];
|
|
20129
|
+
if (tagName === "data" || tagName === "theory") {
|
|
20130
|
+
continue;
|
|
20131
|
+
}
|
|
20115
20132
|
const tagValue = match[2]?.trim();
|
|
20116
20133
|
tags.push({
|
|
20117
20134
|
name: tagName,
|
|
@@ -20120,7 +20137,75 @@ function parseSequenceTags(lines, sequenceLineIndex) {
|
|
|
20120
20137
|
}
|
|
20121
20138
|
tagStartLine = i;
|
|
20122
20139
|
}
|
|
20123
|
-
|
|
20140
|
+
let theoryData;
|
|
20141
|
+
if (dataCases.length > 0 || theorySource) {
|
|
20142
|
+
theoryData = {
|
|
20143
|
+
cases: [],
|
|
20144
|
+
// Will be populated with param names after we know them
|
|
20145
|
+
source: theorySource
|
|
20146
|
+
};
|
|
20147
|
+
theoryData._rawCases = dataCases.reverse();
|
|
20148
|
+
}
|
|
20149
|
+
return { tags, tagStartLine, theoryData };
|
|
20150
|
+
}
|
|
20151
|
+
function parseDataValues(valuesStr) {
|
|
20152
|
+
const values = [];
|
|
20153
|
+
let current = "";
|
|
20154
|
+
let inQuote = null;
|
|
20155
|
+
let i = 0;
|
|
20156
|
+
while (i < valuesStr.length) {
|
|
20157
|
+
const char = valuesStr[i];
|
|
20158
|
+
if (inQuote) {
|
|
20159
|
+
if (char === inQuote) {
|
|
20160
|
+
values.push(current);
|
|
20161
|
+
current = "";
|
|
20162
|
+
inQuote = null;
|
|
20163
|
+
i++;
|
|
20164
|
+
while (i < valuesStr.length && valuesStr[i] !== ",") {
|
|
20165
|
+
i++;
|
|
20166
|
+
}
|
|
20167
|
+
i++;
|
|
20168
|
+
continue;
|
|
20169
|
+
} else {
|
|
20170
|
+
current += char;
|
|
20171
|
+
}
|
|
20172
|
+
} else {
|
|
20173
|
+
if (char === '"' || char === "'") {
|
|
20174
|
+
inQuote = char;
|
|
20175
|
+
current = "";
|
|
20176
|
+
} else if (char === ",") {
|
|
20177
|
+
const trimmed = current.trim();
|
|
20178
|
+
if (trimmed) {
|
|
20179
|
+
values.push(parseTypedValue(trimmed));
|
|
20180
|
+
}
|
|
20181
|
+
current = "";
|
|
20182
|
+
} else {
|
|
20183
|
+
current += char;
|
|
20184
|
+
}
|
|
20185
|
+
}
|
|
20186
|
+
i++;
|
|
20187
|
+
}
|
|
20188
|
+
if (inQuote) {
|
|
20189
|
+
values.push(current);
|
|
20190
|
+
} else {
|
|
20191
|
+
const trimmed = current.trim();
|
|
20192
|
+
if (trimmed) {
|
|
20193
|
+
values.push(parseTypedValue(trimmed));
|
|
20194
|
+
}
|
|
20195
|
+
}
|
|
20196
|
+
return values;
|
|
20197
|
+
}
|
|
20198
|
+
function parseTypedValue(value) {
|
|
20199
|
+
if (value === "true") return true;
|
|
20200
|
+
if (value === "false") return false;
|
|
20201
|
+
if (value === "null") return null;
|
|
20202
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) {
|
|
20203
|
+
return parseFloat(value);
|
|
20204
|
+
}
|
|
20205
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
20206
|
+
return value.slice(1, -1);
|
|
20207
|
+
}
|
|
20208
|
+
return value;
|
|
20124
20209
|
}
|
|
20125
20210
|
function extractSequences(text) {
|
|
20126
20211
|
const lines = text.split("\n");
|
|
@@ -20134,7 +20219,21 @@ function extractSequences(text) {
|
|
|
20134
20219
|
const isTest = !!testSequenceMatch;
|
|
20135
20220
|
const name = isTest ? testSequenceMatch[1] : sequenceMatch[1];
|
|
20136
20221
|
const parameters = parseSequenceParameters(lines[i]);
|
|
20137
|
-
const { tags, tagStartLine } = parseSequenceTags(lines, i);
|
|
20222
|
+
const { tags, tagStartLine, theoryData } = parseSequenceTags(lines, i);
|
|
20223
|
+
let finalTheoryData = theoryData;
|
|
20224
|
+
if (theoryData && theoryData._rawCases) {
|
|
20225
|
+
const rawCases = theoryData._rawCases;
|
|
20226
|
+
finalTheoryData = {
|
|
20227
|
+
cases: rawCases.map((values) => {
|
|
20228
|
+
const caseObj = {};
|
|
20229
|
+
parameters.forEach((param, idx) => {
|
|
20230
|
+
caseObj[param.name] = values[idx];
|
|
20231
|
+
});
|
|
20232
|
+
return caseObj;
|
|
20233
|
+
}),
|
|
20234
|
+
source: theoryData.source
|
|
20235
|
+
};
|
|
20236
|
+
}
|
|
20138
20237
|
currentSequence = {
|
|
20139
20238
|
name,
|
|
20140
20239
|
startLine: tagStartLine,
|
|
@@ -20142,7 +20241,8 @@ function extractSequences(text) {
|
|
|
20142
20241
|
lines: [],
|
|
20143
20242
|
parameters,
|
|
20144
20243
|
tags,
|
|
20145
|
-
isTest
|
|
20244
|
+
isTest,
|
|
20245
|
+
theoryData: finalTheoryData
|
|
20146
20246
|
};
|
|
20147
20247
|
continue;
|
|
20148
20248
|
}
|
|
@@ -20154,7 +20254,8 @@ function extractSequences(text) {
|
|
|
20154
20254
|
content: currentSequence.lines.join("\n"),
|
|
20155
20255
|
parameters: currentSequence.parameters,
|
|
20156
20256
|
tags: currentSequence.tags,
|
|
20157
|
-
isTest: currentSequence.isTest
|
|
20257
|
+
isTest: currentSequence.isTest,
|
|
20258
|
+
theoryData: currentSequence.theoryData
|
|
20158
20259
|
});
|
|
20159
20260
|
currentSequence = null;
|
|
20160
20261
|
continue;
|
|
@@ -23025,34 +23126,93 @@ function countTestSequences(fileContent, tagFilterOptions) {
|
|
|
23025
23126
|
const filtered = tagFilterOptions && tagFilterOptions.filters.length > 0 ? testSequences.filter((seq) => sequenceMatchesTags(seq, tagFilterOptions)).length : testSequences.length;
|
|
23026
23127
|
return { total: testSequences.length, filtered };
|
|
23027
23128
|
}
|
|
23129
|
+
async function loadTheoryFile(theoryPath, workingDir) {
|
|
23130
|
+
const absolutePath = path3.resolve(workingDir, theoryPath);
|
|
23131
|
+
const content = await fsPromises.readFile(absolutePath, "utf-8");
|
|
23132
|
+
return JSON.parse(content);
|
|
23133
|
+
}
|
|
23134
|
+
function formatCaseLabel(params) {
|
|
23135
|
+
const parts = Object.entries(params).map(([key, value]) => {
|
|
23136
|
+
if (typeof value === "string") {
|
|
23137
|
+
return `${key}="${value}"`;
|
|
23138
|
+
}
|
|
23139
|
+
return `${key}=${value}`;
|
|
23140
|
+
});
|
|
23141
|
+
return `[${parts.join(", ")}]`;
|
|
23142
|
+
}
|
|
23028
23143
|
async function runAllSequences(fileContent, variables, cookieJar, workingDir, apiDefinitions, tagFilterOptions) {
|
|
23029
23144
|
const allSequences = extractSequences(fileContent);
|
|
23030
23145
|
const results = [];
|
|
23031
23146
|
const testSequences = allSequences.filter((seq) => seq.isTest);
|
|
23032
23147
|
const sequences = tagFilterOptions && tagFilterOptions.filters.length > 0 ? testSequences.filter((seq) => sequenceMatchesTags(seq, tagFilterOptions)) : testSequences;
|
|
23033
23148
|
for (const seq of sequences) {
|
|
23034
|
-
|
|
23035
|
-
if (
|
|
23036
|
-
|
|
23037
|
-
|
|
23038
|
-
|
|
23149
|
+
let theoryData = seq.theoryData;
|
|
23150
|
+
if (theoryData?.source && theoryData.cases.length === 0) {
|
|
23151
|
+
try {
|
|
23152
|
+
const cases = await loadTheoryFile(theoryData.source, workingDir);
|
|
23153
|
+
theoryData = { ...theoryData, cases };
|
|
23154
|
+
} catch (error) {
|
|
23155
|
+
const errorResult = {
|
|
23156
|
+
name: seq.name,
|
|
23157
|
+
success: false,
|
|
23158
|
+
responses: [],
|
|
23159
|
+
scriptResults: [],
|
|
23160
|
+
assertionResults: [],
|
|
23161
|
+
steps: [],
|
|
23162
|
+
errors: [`Failed to load theory file '${theoryData.source}': ${error instanceof Error ? error.message : String(error)}`],
|
|
23163
|
+
duration: 0
|
|
23164
|
+
};
|
|
23165
|
+
results.push(errorResult);
|
|
23166
|
+
continue;
|
|
23167
|
+
}
|
|
23168
|
+
}
|
|
23169
|
+
if (theoryData && theoryData.cases.length > 0) {
|
|
23170
|
+
for (let caseIdx = 0; caseIdx < theoryData.cases.length; caseIdx++) {
|
|
23171
|
+
const caseParams = theoryData.cases[caseIdx];
|
|
23172
|
+
const caseLabel = formatCaseLabel(caseParams);
|
|
23173
|
+
const caseArgs = {};
|
|
23174
|
+
for (const [key, value] of Object.entries(caseParams)) {
|
|
23175
|
+
caseArgs[key] = String(value);
|
|
23039
23176
|
}
|
|
23177
|
+
const result = await runSequenceWithJar(
|
|
23178
|
+
seq.content,
|
|
23179
|
+
variables,
|
|
23180
|
+
cookieJar,
|
|
23181
|
+
workingDir,
|
|
23182
|
+
fileContent,
|
|
23183
|
+
void 0,
|
|
23184
|
+
void 0,
|
|
23185
|
+
caseArgs,
|
|
23186
|
+
apiDefinitions,
|
|
23187
|
+
tagFilterOptions
|
|
23188
|
+
);
|
|
23189
|
+
result.name = `${seq.name}${caseLabel}`;
|
|
23190
|
+
results.push(result);
|
|
23040
23191
|
}
|
|
23192
|
+
} else {
|
|
23193
|
+
const defaultArgs = {};
|
|
23194
|
+
if (seq.parameters) {
|
|
23195
|
+
for (const param of seq.parameters) {
|
|
23196
|
+
if (param.defaultValue !== void 0) {
|
|
23197
|
+
defaultArgs[param.name] = param.defaultValue;
|
|
23198
|
+
}
|
|
23199
|
+
}
|
|
23200
|
+
}
|
|
23201
|
+
const result = await runSequenceWithJar(
|
|
23202
|
+
seq.content,
|
|
23203
|
+
variables,
|
|
23204
|
+
cookieJar,
|
|
23205
|
+
workingDir,
|
|
23206
|
+
fileContent,
|
|
23207
|
+
void 0,
|
|
23208
|
+
void 0,
|
|
23209
|
+
defaultArgs,
|
|
23210
|
+
apiDefinitions,
|
|
23211
|
+
tagFilterOptions
|
|
23212
|
+
);
|
|
23213
|
+
result.name = seq.name;
|
|
23214
|
+
results.push(result);
|
|
23041
23215
|
}
|
|
23042
|
-
const result = await runSequenceWithJar(
|
|
23043
|
-
seq.content,
|
|
23044
|
-
variables,
|
|
23045
|
-
cookieJar,
|
|
23046
|
-
workingDir,
|
|
23047
|
-
fileContent,
|
|
23048
|
-
void 0,
|
|
23049
|
-
void 0,
|
|
23050
|
-
defaultArgs,
|
|
23051
|
-
apiDefinitions,
|
|
23052
|
-
tagFilterOptions
|
|
23053
|
-
);
|
|
23054
|
-
result.name = seq.name;
|
|
23055
|
-
results.push(result);
|
|
23056
23216
|
}
|
|
23057
23217
|
return results;
|
|
23058
23218
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "norn-cli",
|
|
3
3
|
"displayName": "Norn - REST Client",
|
|
4
4
|
"description": "A powerful REST client for making HTTP requests with sequences, variables, scripts, and cookie support",
|
|
5
|
-
"version": "1.1.
|
|
5
|
+
"version": "1.1.2",
|
|
6
6
|
"publisher": "Norn-PeterKrustanov",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Peter Krastanov"
|