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 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
- | `@tagname` | Simple tag on a sequence |
797
- | `@key(value)` | Key-value tag on a sequence |
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
- return { tags, tagStartLine };
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
- const defaultArgs = {};
23035
- if (seq.parameters) {
23036
- for (const param of seq.parameters) {
23037
- if (param.defaultValue !== void 0) {
23038
- defaultArgs[param.name] = param.defaultValue;
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.0",
5
+ "version": "1.1.2",
6
6
  "publisher": "Norn-PeterKrustanov",
7
7
  "author": {
8
8
  "name": "Peter Krastanov"