norn-cli 1.1.1 → 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 -23
- 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
|
@@ -20098,6 +20098,8 @@ function parseSequenceParameters(line2) {
|
|
|
20098
20098
|
}
|
|
20099
20099
|
function parseSequenceTags(lines, sequenceLineIndex) {
|
|
20100
20100
|
const tags = [];
|
|
20101
|
+
const dataCases = [];
|
|
20102
|
+
let theorySource;
|
|
20101
20103
|
let tagStartLine = sequenceLineIndex;
|
|
20102
20104
|
for (let i = sequenceLineIndex - 1; i >= 0; i--) {
|
|
20103
20105
|
const line2 = lines[i].trim();
|
|
@@ -20107,10 +20109,26 @@ function parseSequenceTags(lines, sequenceLineIndex) {
|
|
|
20107
20109
|
if (!line2.startsWith("@")) {
|
|
20108
20110
|
break;
|
|
20109
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
|
+
}
|
|
20110
20125
|
const tagPattern = /@([a-zA-Z_][a-zA-Z0-9_-]*)(?:\(([^)]+)\))?/g;
|
|
20111
20126
|
let match;
|
|
20112
20127
|
while ((match = tagPattern.exec(line2)) !== null) {
|
|
20113
20128
|
const tagName = match[1];
|
|
20129
|
+
if (tagName === "data" || tagName === "theory") {
|
|
20130
|
+
continue;
|
|
20131
|
+
}
|
|
20114
20132
|
const tagValue = match[2]?.trim();
|
|
20115
20133
|
tags.push({
|
|
20116
20134
|
name: tagName,
|
|
@@ -20119,7 +20137,75 @@ function parseSequenceTags(lines, sequenceLineIndex) {
|
|
|
20119
20137
|
}
|
|
20120
20138
|
tagStartLine = i;
|
|
20121
20139
|
}
|
|
20122
|
-
|
|
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;
|
|
20123
20209
|
}
|
|
20124
20210
|
function extractSequences(text) {
|
|
20125
20211
|
const lines = text.split("\n");
|
|
@@ -20133,7 +20219,21 @@ function extractSequences(text) {
|
|
|
20133
20219
|
const isTest = !!testSequenceMatch;
|
|
20134
20220
|
const name = isTest ? testSequenceMatch[1] : sequenceMatch[1];
|
|
20135
20221
|
const parameters = parseSequenceParameters(lines[i]);
|
|
20136
|
-
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
|
+
}
|
|
20137
20237
|
currentSequence = {
|
|
20138
20238
|
name,
|
|
20139
20239
|
startLine: tagStartLine,
|
|
@@ -20141,7 +20241,8 @@ function extractSequences(text) {
|
|
|
20141
20241
|
lines: [],
|
|
20142
20242
|
parameters,
|
|
20143
20243
|
tags,
|
|
20144
|
-
isTest
|
|
20244
|
+
isTest,
|
|
20245
|
+
theoryData: finalTheoryData
|
|
20145
20246
|
};
|
|
20146
20247
|
continue;
|
|
20147
20248
|
}
|
|
@@ -20153,7 +20254,8 @@ function extractSequences(text) {
|
|
|
20153
20254
|
content: currentSequence.lines.join("\n"),
|
|
20154
20255
|
parameters: currentSequence.parameters,
|
|
20155
20256
|
tags: currentSequence.tags,
|
|
20156
|
-
isTest: currentSequence.isTest
|
|
20257
|
+
isTest: currentSequence.isTest,
|
|
20258
|
+
theoryData: currentSequence.theoryData
|
|
20157
20259
|
});
|
|
20158
20260
|
currentSequence = null;
|
|
20159
20261
|
continue;
|
|
@@ -23024,34 +23126,93 @@ function countTestSequences(fileContent, tagFilterOptions) {
|
|
|
23024
23126
|
const filtered = tagFilterOptions && tagFilterOptions.filters.length > 0 ? testSequences.filter((seq) => sequenceMatchesTags(seq, tagFilterOptions)).length : testSequences.length;
|
|
23025
23127
|
return { total: testSequences.length, filtered };
|
|
23026
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
|
+
}
|
|
23027
23143
|
async function runAllSequences(fileContent, variables, cookieJar, workingDir, apiDefinitions, tagFilterOptions) {
|
|
23028
23144
|
const allSequences = extractSequences(fileContent);
|
|
23029
23145
|
const results = [];
|
|
23030
23146
|
const testSequences = allSequences.filter((seq) => seq.isTest);
|
|
23031
23147
|
const sequences = tagFilterOptions && tagFilterOptions.filters.length > 0 ? testSequences.filter((seq) => sequenceMatchesTags(seq, tagFilterOptions)) : testSequences;
|
|
23032
23148
|
for (const seq of sequences) {
|
|
23033
|
-
|
|
23034
|
-
if (
|
|
23035
|
-
|
|
23036
|
-
|
|
23037
|
-
|
|
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);
|
|
23038
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);
|
|
23039
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);
|
|
23040
23215
|
}
|
|
23041
|
-
const result = await runSequenceWithJar(
|
|
23042
|
-
seq.content,
|
|
23043
|
-
variables,
|
|
23044
|
-
cookieJar,
|
|
23045
|
-
workingDir,
|
|
23046
|
-
fileContent,
|
|
23047
|
-
void 0,
|
|
23048
|
-
void 0,
|
|
23049
|
-
defaultArgs,
|
|
23050
|
-
apiDefinitions,
|
|
23051
|
-
tagFilterOptions
|
|
23052
|
-
);
|
|
23053
|
-
result.name = seq.name;
|
|
23054
|
-
results.push(result);
|
|
23055
23216
|
}
|
|
23056
23217
|
return results;
|
|
23057
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"
|