norn-cli 1.2.4 → 1.3.0

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/cli.js +408 -144
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  All notable changes to the "Norn" extension will be documented in this file.
4
4
 
5
+ ## [1.3.0] - 2026-02-07
6
+
7
+ ### Added
8
+ - **Retry and Backoff**: Automatically retry failed HTTP requests with configurable backoff
9
+ - Syntax: `GET url retry 3 backoff 200 ms` (retry up to 3 times with 200ms linear backoff)
10
+ - Works with direct URLs, named endpoints, and named requests
11
+ - Supports time units: `ms`, `s`, `seconds`, `milliseconds`
12
+ - Response panel shows retry indicator: 🔄 `retried 2x`
13
+ - CLI shows retry attempts in real-time
14
+ - Retries on 5xx errors, 429 rate limiting, and network failures
15
+
16
+ ### Improved
17
+ - **Syntax highlighting**: Variable references `{{var}}` now highlighted inside print strings
18
+ - **Better pattern recognition**: `test sequence` correctly terminates request blocks in all contexts
19
+
20
+ ## [1.2.5] - 2026-02-07
21
+
22
+ ### Improved
23
+ - **Rich HTML Reports**: Completely redesigned HTML report output for QA professionals
24
+ - **Pass rate badge**: Large visual indicator showing pass percentage (green/yellow/red)
25
+ - **Filter bar**: Search tests by name, filter by All/Passed/Failed
26
+ - **Clickable stat cards**: Click total/passed/failed to filter results
27
+ - **Expand/Collapse All**: Buttons to quickly expand or collapse all test details
28
+ - **Failures first**: Failed tests automatically sorted to top and expanded
29
+ - **Friendly assertion names**: Shows `user.body.status` instead of `$1.body.status`
30
+ - **JSON path context**: Failed assertions show the path that failed
31
+ - **Variable labels on requests**: Shows `user = GET /api/users` instead of `$1`
32
+ - **Environment display**: Shows which environment was used in report header
33
+
5
34
  ## [1.2.4] - 2026-02-07
6
35
 
7
36
  ### Improved
package/dist/cli.js CHANGED
@@ -13286,6 +13286,31 @@ function stripInlineComment(line2) {
13286
13286
  }
13287
13287
  return line2;
13288
13288
  }
13289
+ function extractRetryOptions(line2) {
13290
+ let cleanedLine = line2;
13291
+ let retryCount;
13292
+ let backoffMs;
13293
+ const retryMatch = line2.match(/\bretry\s+(\d+)\b/i);
13294
+ if (retryMatch) {
13295
+ retryCount = parseInt(retryMatch[1], 10);
13296
+ cleanedLine = cleanedLine.replace(retryMatch[0], "").trim();
13297
+ }
13298
+ const backoffMatch = line2.match(/\bbackoff\s+(\d+(?:\.\d+)?)\s*(s|ms|seconds?|milliseconds?)?\b/i);
13299
+ if (backoffMatch) {
13300
+ const value = parseFloat(backoffMatch[1]);
13301
+ const unit = (backoffMatch[2] || "ms").toLowerCase();
13302
+ if (unit === "s" || unit.startsWith("second")) {
13303
+ backoffMs = value * 1e3;
13304
+ } else {
13305
+ backoffMs = value;
13306
+ }
13307
+ cleanedLine = cleanedLine.replace(backoffMatch[0], "").trim();
13308
+ }
13309
+ if (retryCount !== void 0 && backoffMs === void 0) {
13310
+ backoffMs = 1e3;
13311
+ }
13312
+ return { cleanedLine, retryCount, backoffMs };
13313
+ }
13289
13314
  function extractNamedRequests(text) {
13290
13315
  const lines = text.split("\n");
13291
13316
  const namedRequests = [];
@@ -13441,7 +13466,9 @@ function parserHttpRequest(text, variables = {}) {
13441
13466
  if (requestLineIndex === -1) {
13442
13467
  throw new Error("No valid HTTP method found");
13443
13468
  }
13444
- const requestLine = stripInlineComment(allLines[requestLineIndex].trim());
13469
+ let requestLine = stripInlineComment(allLines[requestLineIndex].trim());
13470
+ const { cleanedLine, retryCount, backoffMs } = extractRetryOptions(requestLine);
13471
+ requestLine = cleanedLine;
13445
13472
  const [method, ...urlParts] = requestLine.split(" ");
13446
13473
  let url2 = urlParts.join(" ");
13447
13474
  if (url2.startsWith('"') && url2.endsWith('"') || url2.startsWith("'") && url2.endsWith("'")) {
@@ -13490,7 +13517,7 @@ function parserHttpRequest(text, variables = {}) {
13490
13517
  body = void 0;
13491
13518
  }
13492
13519
  }
13493
- return { method: method.toUpperCase(), url: url2, headers, body };
13520
+ return { method: method.toUpperCase(), url: url2, headers, body, retryCount, backoffMs };
13494
13521
  }
13495
13522
  function extractImports(text) {
13496
13523
  const lines = text.split("\n");
@@ -19463,114 +19490,157 @@ async function getAllCookies(jar) {
19463
19490
  secure: Boolean(c.secure)
19464
19491
  }));
19465
19492
  }
19466
- async function sendRequest(request) {
19467
- return sendRequestWithJar(request, sharedCookieJar);
19493
+ async function sendRequest(request, retryOptions) {
19494
+ return sendRequestWithJar(request, sharedCookieJar, retryOptions);
19468
19495
  }
19469
- async function sendRequestWithJar(request, jar) {
19470
- const startTime = Date.now();
19471
- const maxRedirects = 10;
19472
- let redirectCount = 0;
19473
- let currentUrl = request.url;
19474
- let currentMethod = request.method;
19475
- let currentBody = request.body;
19476
- let finalResponse = null;
19477
- try {
19478
- while (redirectCount <= maxRedirects) {
19479
- const existingCookies = await jar.getCookies(currentUrl);
19480
- const cookieHeader = existingCookies.map((c) => `${c.key}=${c.value}`).join("; ");
19481
- const headers = { ...request.headers };
19482
- if (cookieHeader) {
19483
- headers["Cookie"] = cookieHeader;
19484
- }
19485
- let data = void 0;
19486
- if (currentBody) {
19487
- const contentType = Object.keys(headers).find(
19488
- (key) => key.toLowerCase() === "content-type"
19489
- );
19490
- const contentTypeValue = contentType ? headers[contentType] : "";
19491
- if (contentTypeValue.includes("application/x-www-form-urlencoded")) {
19492
- const lines = currentBody.split("\n").map((line2) => line2.trim()).filter((line2) => line2);
19493
- const params = lines.map((line2) => {
19494
- if (line2.includes("=")) {
19495
- const eqIndex = line2.indexOf("=");
19496
- const key = line2.substring(0, eqIndex).trim();
19497
- const value = line2.substring(eqIndex + 1).trim();
19498
- return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
19496
+ function sleep(ms) {
19497
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
19498
+ }
19499
+ function shouldRetry(response, error) {
19500
+ if (error) return true;
19501
+ if (response && (response.status >= 500 || response.status === 429)) return true;
19502
+ return false;
19503
+ }
19504
+ async function sendRequestWithJar(request, jar, retryOptions) {
19505
+ const totalAttempts = (retryOptions?.retryCount ?? 0) + 1;
19506
+ const backoffMs = retryOptions?.backoffMs ?? 1e3;
19507
+ const retriedErrors = [];
19508
+ let lastError = null;
19509
+ let attemptsMade = 0;
19510
+ for (let attempt = 1; attempt <= totalAttempts; attempt++) {
19511
+ attemptsMade = attempt;
19512
+ const startTime = Date.now();
19513
+ const maxRedirects = 10;
19514
+ let redirectCount = 0;
19515
+ let currentUrl = request.url;
19516
+ let currentMethod = request.method;
19517
+ let currentBody = request.body;
19518
+ let finalResponse = null;
19519
+ try {
19520
+ while (redirectCount <= maxRedirects) {
19521
+ const existingCookies = await jar.getCookies(currentUrl);
19522
+ const cookieHeader = existingCookies.map((c) => `${c.key}=${c.value}`).join("; ");
19523
+ const headers = { ...request.headers };
19524
+ if (cookieHeader) {
19525
+ headers["Cookie"] = cookieHeader;
19526
+ }
19527
+ let data = void 0;
19528
+ if (currentBody) {
19529
+ const contentType = Object.keys(headers).find(
19530
+ (key) => key.toLowerCase() === "content-type"
19531
+ );
19532
+ const contentTypeValue = contentType ? headers[contentType] : "";
19533
+ if (contentTypeValue.includes("application/x-www-form-urlencoded")) {
19534
+ const lines = currentBody.split("\n").map((line2) => line2.trim()).filter((line2) => line2);
19535
+ const params = lines.map((line2) => {
19536
+ if (line2.includes("=")) {
19537
+ const eqIndex = line2.indexOf("=");
19538
+ const key = line2.substring(0, eqIndex).trim();
19539
+ const value = line2.substring(eqIndex + 1).trim();
19540
+ return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
19541
+ }
19542
+ const colonIndex = line2.indexOf(":");
19543
+ if (colonIndex > 0) {
19544
+ const key = line2.substring(0, colonIndex).trim();
19545
+ const value = line2.substring(colonIndex + 1).trim();
19546
+ return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
19547
+ }
19548
+ return encodeURIComponent(line2);
19549
+ });
19550
+ data = params.join("&");
19551
+ } else if (contentTypeValue.includes("application/json")) {
19552
+ try {
19553
+ data = JSON.parse(currentBody);
19554
+ } catch {
19555
+ data = currentBody;
19499
19556
  }
19500
- const colonIndex = line2.indexOf(":");
19501
- if (colonIndex > 0) {
19502
- const key = line2.substring(0, colonIndex).trim();
19503
- const value = line2.substring(colonIndex + 1).trim();
19504
- return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
19557
+ } else {
19558
+ try {
19559
+ data = JSON.parse(currentBody);
19560
+ } catch {
19561
+ data = currentBody;
19505
19562
  }
19506
- return encodeURIComponent(line2);
19507
- });
19508
- data = params.join("&");
19509
- } else if (contentTypeValue.includes("application/json")) {
19510
- try {
19511
- data = JSON.parse(currentBody);
19512
- } catch {
19513
- data = currentBody;
19514
19563
  }
19515
- } else {
19516
- try {
19517
- data = JSON.parse(currentBody);
19518
- } catch {
19519
- data = currentBody;
19564
+ }
19565
+ const response = await axios_default({
19566
+ method: currentMethod,
19567
+ url: currentUrl,
19568
+ headers,
19569
+ data,
19570
+ timeout: 3e4,
19571
+ maxRedirects: 0,
19572
+ validateStatus: () => true
19573
+ });
19574
+ const setCookieHeader = response.headers["set-cookie"];
19575
+ if (setCookieHeader) {
19576
+ const cookies2 = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
19577
+ for (const cookieStr of cookies2) {
19578
+ try {
19579
+ await jar.setCookie(cookieStr, currentUrl);
19580
+ } catch {
19581
+ }
19520
19582
  }
19521
19583
  }
19522
- }
19523
- const response = await axios_default({
19524
- method: currentMethod,
19525
- url: currentUrl,
19526
- headers,
19527
- data,
19528
- timeout: 3e4,
19529
- maxRedirects: 0,
19530
- // Disable automatic redirects
19531
- validateStatus: () => true
19532
- });
19533
- const setCookieHeader = response.headers["set-cookie"];
19534
- if (setCookieHeader) {
19535
- const cookies2 = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
19536
- for (const cookieStr of cookies2) {
19537
- try {
19538
- await jar.setCookie(cookieStr, currentUrl);
19539
- } catch {
19584
+ if (response.status >= 300 && response.status < 400 && response.headers["location"]) {
19585
+ redirectCount++;
19586
+ const location = response.headers["location"];
19587
+ currentUrl = new URL(location, currentUrl).toString();
19588
+ if (currentMethod !== "GET" && currentMethod !== "HEAD") {
19589
+ currentMethod = "GET";
19590
+ currentBody = void 0;
19540
19591
  }
19592
+ continue;
19541
19593
  }
19594
+ finalResponse = response;
19595
+ break;
19596
+ }
19597
+ if (!finalResponse) {
19598
+ throw new Error("Too many redirects");
19542
19599
  }
19543
- if (response.status >= 300 && response.status < 400 && response.headers["location"]) {
19544
- redirectCount++;
19545
- const location = response.headers["location"];
19546
- currentUrl = new URL(location, currentUrl).toString();
19547
- if (currentMethod !== "GET" && currentMethod !== "HEAD") {
19548
- currentMethod = "GET";
19549
- currentBody = void 0;
19600
+ if (shouldRetry(finalResponse, null) && attempt < totalAttempts) {
19601
+ const waitMs = backoffMs * attempt;
19602
+ const errorMessage = `HTTP ${finalResponse.status} ${finalResponse.statusText}`;
19603
+ retriedErrors.push(`Attempt ${attempt}: ${errorMessage}`);
19604
+ if (retryOptions?.onRetry) {
19605
+ retryOptions.onRetry(attempt, totalAttempts, errorMessage, waitMs);
19550
19606
  }
19607
+ await sleep(waitMs);
19551
19608
  continue;
19552
19609
  }
19553
- finalResponse = response;
19554
- break;
19555
- }
19556
- if (!finalResponse) {
19557
- throw new Error("Too many redirects");
19610
+ const cookies = await getAllCookies(jar);
19611
+ const result = {
19612
+ status: finalResponse.status,
19613
+ statusText: finalResponse.statusText,
19614
+ headers: finalResponse.headers,
19615
+ body: finalResponse.data,
19616
+ duration: Date.now() - startTime,
19617
+ cookies
19618
+ };
19619
+ if (attemptsMade > 1 || totalAttempts > 1) {
19620
+ result.retryInfo = {
19621
+ attemptsMade,
19622
+ totalAttempts,
19623
+ retriedErrors
19624
+ };
19625
+ }
19626
+ return result;
19627
+ } catch (error) {
19628
+ const errorMessage = error.message || String(error);
19629
+ lastError = new Error(`${errorMessage}
19630
+ URL used: ${currentUrl || request.url}`);
19631
+ if (attempt < totalAttempts) {
19632
+ const waitMs = backoffMs * attempt;
19633
+ retriedErrors.push(`Attempt ${attempt}: ${errorMessage}`);
19634
+ if (retryOptions?.onRetry) {
19635
+ retryOptions.onRetry(attempt, totalAttempts, errorMessage, waitMs);
19636
+ }
19637
+ await sleep(waitMs);
19638
+ continue;
19639
+ }
19640
+ throw lastError;
19558
19641
  }
19559
- const cookies = await getAllCookies(jar);
19560
- return {
19561
- status: finalResponse.status,
19562
- statusText: finalResponse.statusText,
19563
- headers: finalResponse.headers,
19564
- body: finalResponse.data,
19565
- duration: Date.now() - startTime,
19566
- cookies
19567
- };
19568
- } catch (error) {
19569
- const originalError = error.message || String(error);
19570
- const urlUsed = currentUrl || request.url;
19571
- throw new Error(`${originalError}
19572
- URL used: ${urlUsed}`);
19573
19642
  }
19643
+ throw lastError || new Error("Request failed");
19574
19644
  }
19575
19645
 
19576
19646
  // src/scriptRunner.ts
@@ -19955,17 +20025,20 @@ function isRunNamedRequestCommand(line2) {
19955
20025
  if (/^(bash|js|powershell)\s+/i.test(afterRun)) {
19956
20026
  return false;
19957
20027
  }
19958
- return /^[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*\([^)]*\))?$/.test(afterRun);
20028
+ return /^[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*\([^)]*\))?(?:\s+retry\s+\d+)?(?:\s+backoff\s+\d+(?:\.\d+)?\s*(?:s|ms|seconds?|milliseconds?)?)?$/i.test(afterRun);
19959
20029
  }
19960
20030
  function parseRunNamedRequestCommand(line2) {
19961
20031
  const trimmed = line2.trim();
19962
- const match = trimmed.match(/^run\s+([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s*\(([^)]*)\))?$/i);
20032
+ const { cleanedLine, retryCount, backoffMs } = extractRetryOptions(trimmed);
20033
+ const match = cleanedLine.match(/^run\s+([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s*\(([^)]*)\))?$/i);
19963
20034
  if (!match) {
19964
20035
  return null;
19965
20036
  }
19966
20037
  return {
19967
20038
  name: match[1],
19968
- args: parseRunArguments(match[2] || "")
20039
+ args: parseRunArguments(match[2] || ""),
20040
+ retryCount,
20041
+ backoffMs
19969
20042
  };
19970
20043
  }
19971
20044
  function isReturnStatement(line2) {
@@ -20061,7 +20134,7 @@ function parseRunArguments(argsStr) {
20061
20134
  }
20062
20135
  function isVarRunSequenceCommand(line2) {
20063
20136
  const trimmed = line2.trim();
20064
- if (!/^var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*run\s+[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*\([^)]*\))?\s*$/i.test(trimmed)) {
20137
+ if (!/^var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*run\s+[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*\([^)]*\))?(?:\s+retry\s+\d+)?(?:\s+backoff\s+\d+(?:\.\d+)?\s*(?:s|ms|seconds?|milliseconds?)?)?\s*$/i.test(trimmed)) {
20065
20138
  return false;
20066
20139
  }
20067
20140
  const match = trimmed.match(/^var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*run\s+([a-zA-Z_][a-zA-Z0-9_-]*)/i);
@@ -20075,14 +20148,17 @@ function isVarRunSequenceCommand(line2) {
20075
20148
  }
20076
20149
  function parseVarRunSequenceCommand(line2) {
20077
20150
  const trimmed = line2.trim();
20078
- const match = trimmed.match(/^var\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*run\s+([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s*\(([^)]*)\))?\s*$/i);
20151
+ const { cleanedLine, retryCount, backoffMs } = extractRetryOptions(trimmed);
20152
+ const match = cleanedLine.match(/^var\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*run\s+([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s*\(([^)]*)\))?\s*$/i);
20079
20153
  if (!match) {
20080
20154
  return null;
20081
20155
  }
20082
20156
  return {
20083
20157
  varName: match[1],
20084
20158
  sequenceName: match[2],
20085
- args: parseRunArguments(match[3] || "")
20159
+ args: parseRunArguments(match[3] || ""),
20160
+ retryCount,
20161
+ backoffMs
20086
20162
  };
20087
20163
  }
20088
20164
  function bindSequenceArguments(params, args, runtimeVariables) {
@@ -20434,10 +20510,13 @@ function parseVarRequestCommand(line2) {
20434
20510
  if (!match) {
20435
20511
  return null;
20436
20512
  }
20513
+ const { cleanedLine: url2, retryCount, backoffMs } = extractRetryOptions(match[3].trim());
20437
20514
  return {
20438
20515
  varName: match[1],
20439
20516
  method: match[2].toUpperCase(),
20440
- url: match[3].trim()
20517
+ url: url2,
20518
+ retryCount,
20519
+ backoffMs
20441
20520
  };
20442
20521
  }
20443
20522
  function isVarAssignCommand(line2) {
@@ -20668,7 +20747,7 @@ function valueToString2(value) {
20668
20747
  }
20669
20748
  return String(value);
20670
20749
  }
20671
- function sleep(ms) {
20750
+ function sleep2(ms) {
20672
20751
  return new Promise((resolve4) => setTimeout(resolve4, ms));
20673
20752
  }
20674
20753
  function isIfCommand(line2) {
@@ -21147,7 +21226,7 @@ async function runSequenceWithJar(sequenceContent, fileVariables, cookieJar, wor
21147
21226
  if (step.type === "wait") {
21148
21227
  const durationMs = parseWaitCommand(step.content);
21149
21228
  reportProgress(stepIdx, "wait", `wait: ${durationMs}ms`);
21150
- await sleep(durationMs);
21229
+ await sleep2(durationMs);
21151
21230
  const stepResult = {
21152
21231
  type: "print",
21153
21232
  stepIndex: stepIdx,
@@ -21388,7 +21467,14 @@ async function runSequenceWithJar(sequenceContent, fileVariables, cookieJar, wor
21388
21467
  requestText += "\n" + headerLines.join("\n");
21389
21468
  }
21390
21469
  const requestParsed = parserHttpRequest(requestText, runtimeVariables);
21391
- const response = cookieJar ? await sendRequestWithJar(requestParsed, cookieJar) : await sendRequest(requestParsed);
21470
+ const retryOpts = parsed.retryCount ? {
21471
+ retryCount: parsed.retryCount,
21472
+ backoffMs: parsed.backoffMs || 1e3,
21473
+ onRetry: (attempt, total, error, waitMs) => {
21474
+ reportProgress(stepIdx, "request", `[Retry ${attempt}/${total}] ${requestDescription} - waiting ${waitMs}ms`, void 0);
21475
+ }
21476
+ } : void 0;
21477
+ const response = cookieJar ? await sendRequestWithJar(requestParsed, cookieJar, retryOpts) : await sendRequest(requestParsed, retryOpts);
21392
21478
  addResponse(response);
21393
21479
  responseIndexToVariable.set(responses.length, parsed.varName);
21394
21480
  runtimeVariables[parsed.varName] = response;
@@ -21641,20 +21727,29 @@ async function runSequenceWithJar(sequenceContent, fileVariables, cookieJar, wor
21641
21727
  let requestUrl = "";
21642
21728
  let requestMethod = "";
21643
21729
  try {
21644
- const parsed2 = parserHttpRequest(namedRequest.content, runtimeVariables);
21645
- requestUrl = parsed2.url;
21646
- requestMethod = parsed2.method;
21647
- const response = cookieJar ? await sendRequestWithJar(parsed2, cookieJar) : await sendRequest(parsed2);
21730
+ const requestParsed = parserHttpRequest(namedRequest.content, runtimeVariables);
21731
+ requestUrl = requestParsed.url;
21732
+ requestMethod = requestParsed.method;
21733
+ const effectiveRetryCount = parsed.retryCount ?? requestParsed.retryCount;
21734
+ const effectiveBackoffMs = parsed.backoffMs ?? requestParsed.backoffMs ?? 1e3;
21735
+ const retryOpts = effectiveRetryCount ? {
21736
+ retryCount: effectiveRetryCount,
21737
+ backoffMs: effectiveBackoffMs,
21738
+ onRetry: (attempt, total, error, waitMs) => {
21739
+ reportProgress(stepIdx, "request", `[Retry ${attempt}/${total}] run ${targetName} - waiting ${waitMs}ms`, void 0);
21740
+ }
21741
+ } : void 0;
21742
+ const response = cookieJar ? await sendRequestWithJar(requestParsed, cookieJar, retryOpts) : await sendRequest(requestParsed, retryOpts);
21648
21743
  addResponse(response);
21649
21744
  const stepResult = {
21650
21745
  type: "request",
21651
21746
  stepIndex: stepIdx,
21652
21747
  response,
21653
- requestMethod: parsed2.method,
21654
- requestUrl: parsed2.url
21748
+ requestMethod: requestParsed.method,
21749
+ requestUrl: requestParsed.url
21655
21750
  };
21656
21751
  orderedSteps.push(stepResult);
21657
- reportProgress(stepIdx, "namedRequest", `run ${targetName} \u2192 ${parsed2.method} ${parsed2.url}`, stepResult);
21752
+ reportProgress(stepIdx, "namedRequest", `run ${targetName} \u2192 ${requestParsed.method} ${requestParsed.url}`, stepResult);
21658
21753
  const relevantCaptures = captures.filter((c) => c.afterRequest === requestIndex);
21659
21754
  for (const capture of relevantCaptures) {
21660
21755
  const value = getValueByPath(response, capture.path);
@@ -21732,20 +21827,29 @@ async function runSequenceWithJar(sequenceContent, fileVariables, cookieJar, wor
21732
21827
  let requestUrl = "";
21733
21828
  let requestMethod = "";
21734
21829
  try {
21735
- const parsed2 = parserHttpRequest(namedRequest.content, runtimeVariables);
21736
- requestUrl = parsed2.url;
21737
- requestMethod = parsed2.method;
21738
- const response = cookieJar ? await sendRequestWithJar(parsed2, cookieJar) : await sendRequest(parsed2);
21830
+ const requestParsed = parserHttpRequest(namedRequest.content, runtimeVariables);
21831
+ requestUrl = requestParsed.url;
21832
+ requestMethod = requestParsed.method;
21833
+ const effectiveRetryCount = parsed.retryCount ?? requestParsed.retryCount;
21834
+ const effectiveBackoffMs = parsed.backoffMs ?? requestParsed.backoffMs ?? 1e3;
21835
+ const retryOpts = effectiveRetryCount ? {
21836
+ retryCount: effectiveRetryCount,
21837
+ backoffMs: effectiveBackoffMs,
21838
+ onRetry: (attempt, total, error, waitMs) => {
21839
+ reportProgress(stepIdx, "request", `[Retry ${attempt}/${total}] var ${varName} = run ${sequenceName} - waiting ${waitMs}ms`, void 0);
21840
+ }
21841
+ } : void 0;
21842
+ const response = cookieJar ? await sendRequestWithJar(requestParsed, cookieJar, retryOpts) : await sendRequest(requestParsed, retryOpts);
21739
21843
  addResponse(response);
21740
21844
  const stepResult = {
21741
21845
  type: "request",
21742
21846
  stepIndex: stepIdx,
21743
21847
  response,
21744
- requestMethod: parsed2.method,
21745
- requestUrl: parsed2.url
21848
+ requestMethod: requestParsed.method,
21849
+ requestUrl: requestParsed.url
21746
21850
  };
21747
21851
  orderedSteps.push(stepResult);
21748
- reportProgress(stepIdx, "namedRequest", `var ${varName} = run ${sequenceName} \u2192 ${parsed2.method} ${parsed2.url}`, stepResult);
21852
+ reportProgress(stepIdx, "namedRequest", `var ${varName} = run ${sequenceName} \u2192 ${requestParsed.method} ${requestParsed.url}`, stepResult);
21749
21853
  const capturedResponse = {
21750
21854
  status: response.status,
21751
21855
  statusText: response.statusText,
@@ -21951,7 +22055,15 @@ async function runSequenceWithJar(sequenceContent, fileVariables, cookieJar, wor
21951
22055
  parsed = parserHttpRequest(step.content, runtimeVariables);
21952
22056
  requestDescription = `${parsed.method} ${parsed.url}`;
21953
22057
  }
21954
- const response = cookieJar ? await sendRequestWithJar(parsed, cookieJar) : await sendRequest(parsed);
22058
+ const { retryCount, backoffMs } = extractRetryOptions(step.content);
22059
+ const retryOpts = retryCount ?? parsed.retryCount ? {
22060
+ retryCount: retryCount ?? parsed.retryCount ?? 0,
22061
+ backoffMs: backoffMs ?? parsed.backoffMs ?? 1e3,
22062
+ onRetry: (attempt, total, error, waitMs) => {
22063
+ reportProgress(stepIdx, "request", `[Retry ${attempt}/${total}] ${requestDescription} - waiting ${waitMs}ms`, void 0);
22064
+ }
22065
+ } : void 0;
22066
+ const response = cookieJar ? await sendRequestWithJar(parsed, cookieJar, retryOpts) : await sendRequest(parsed, retryOpts);
21955
22067
  addResponse(response);
21956
22068
  const stepResult = {
21957
22069
  type: "request",
@@ -22264,12 +22376,14 @@ function formatResponse(response, options) {
22264
22376
  const icon = isSuccess ? colors.checkmark : colors.cross;
22265
22377
  if (method && url2) {
22266
22378
  const displayUrl = redactUrl(url2, redaction);
22379
+ const retryInfo = response.retryInfo && response.retryInfo.attemptsMade > 1 ? ` ${colors.warning(`[retried ${response.retryInfo.attemptsMade - 1}x]`)}` : "";
22267
22380
  lines.push(
22268
- `${prefix}${icon} ${colors.method(method)} ${displayUrl} ${statusColor(`(${response.status} ${response.statusText})`)} ${colors.duration(formatDuration(response.duration))}`
22381
+ `${prefix}${icon} ${colors.method(method)} ${displayUrl} ${statusColor(`(${response.status} ${response.statusText})`)} ${colors.duration(formatDuration(response.duration))}${retryInfo}`
22269
22382
  );
22270
22383
  } else {
22384
+ const retryInfo = response.retryInfo && response.retryInfo.attemptsMade > 1 ? ` ${colors.warning(`[retried ${response.retryInfo.attemptsMade - 1}x]`)}` : "";
22271
22385
  lines.push(
22272
- `${prefix}${statusColor(`${response.status} ${response.statusText}`)} ${colors.duration(`(${formatDuration(response.duration)})`)}`
22386
+ `${prefix}${statusColor(`${response.status} ${response.statusText}`)} ${colors.duration(`(${formatDuration(response.duration)})`)}${retryInfo}`
22273
22387
  );
22274
22388
  }
22275
22389
  if (verbose || showDetails) {
@@ -22319,11 +22433,12 @@ function formatResponseCompact(response, options) {
22319
22433
  const statusColor = getStatusColor(colors, response.status);
22320
22434
  const isSuccess = response.status >= 200 && response.status < 300;
22321
22435
  const icon = isSuccess ? colors.checkmark : colors.cross;
22436
+ const retryInfo = response.retryInfo && response.retryInfo.attemptsMade > 1 ? ` ${colors.warning(`[retried ${response.retryInfo.attemptsMade - 1}x]`)}` : "";
22322
22437
  if (method && url2) {
22323
22438
  const displayUrl = redactUrl(url2, redaction);
22324
- return `${prefix}${icon} ${colors.method(method)} ${displayUrl} ${statusColor(`${response.status}`)} ${colors.duration(formatDuration(response.duration))}`;
22439
+ return `${prefix}${icon} ${colors.method(method)} ${displayUrl} ${statusColor(`${response.status}`)} ${colors.duration(formatDuration(response.duration))}${retryInfo}`;
22325
22440
  }
22326
- return `${prefix}${icon} ${statusColor(`${response.status} ${response.statusText}`)} ${colors.duration(formatDuration(response.duration))}`;
22441
+ return `${prefix}${icon} ${statusColor(`${response.status} ${response.statusText}`)} ${colors.duration(formatDuration(response.duration))}${retryInfo}`;
22327
22442
  }
22328
22443
 
22329
22444
  // src/cli/formatters/assertion.ts
@@ -22677,7 +22792,7 @@ function formatTimestamp() {
22677
22792
  return (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC");
22678
22793
  }
22679
22794
  function generateHtmlReport(results, options) {
22680
- const { outputPath, redaction, title = "Norn Test Report" } = options;
22795
+ const { outputPath, redaction, title = "Norn Test Report", environment } = options;
22681
22796
  const totalSequences = results.length;
22682
22797
  const passedSequences = results.filter((r) => r.success).length;
22683
22798
  const failedSequences = totalSequences - passedSequences;
@@ -22686,6 +22801,11 @@ function generateHtmlReport(results, options) {
22686
22801
  const failedAssertions = totalAssertions - passedAssertions;
22687
22802
  const totalRequests = results.reduce((sum, r) => sum + r.steps.filter((s) => s.type === "request").length, 0);
22688
22803
  const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
22804
+ const sortedResults = [...results].sort((a, b) => {
22805
+ if (a.success !== b.success) return a.success ? 1 : -1;
22806
+ return a.name.localeCompare(b.name);
22807
+ });
22808
+ const passRate = totalSequences > 0 ? Math.round(passedSequences / totalSequences * 100) : 100;
22689
22809
  const html = `<!DOCTYPE html>
22690
22810
  <html lang="en">
22691
22811
  <head>
@@ -22700,23 +22820,32 @@ function generateHtmlReport(results, options) {
22700
22820
  <div class="container">
22701
22821
  <header>
22702
22822
  <h1>${escapeHtml(title)}</h1>
22703
- <p class="timestamp">Generated: ${formatTimestamp()}</p>
22823
+ <div class="header-meta">
22824
+ <span class="timestamp">Generated: ${formatTimestamp()}</span>
22825
+ ${environment ? `<span class="environment">Environment: <strong>${escapeHtml(environment)}</strong></span>` : ""}
22826
+ </div>
22704
22827
  </header>
22705
22828
 
22706
22829
  <section class="summary">
22707
- <h2>Summary</h2>
22830
+ <div class="summary-header">
22831
+ <h2>Summary</h2>
22832
+ <div class="pass-rate ${passRate === 100 ? "perfect" : passRate >= 80 ? "good" : "poor"}">
22833
+ <span class="rate-value">${passRate}%</span>
22834
+ <span class="rate-label">Pass Rate</span>
22835
+ </div>
22836
+ </div>
22708
22837
  <div class="stats-grid">
22709
- <div class="stat-card ${failedSequences > 0 ? "has-failures" : "all-passed"}">
22710
- <div class="stat-value">${passedSequences}/${totalSequences}</div>
22711
- <div class="stat-label">Sequences Passed</div>
22838
+ <div class="stat-card clickable ${failedSequences > 0 ? "has-failures" : "all-passed"}" onclick="filterByStatus('all')" data-filter="all">
22839
+ <div class="stat-value">${totalSequences}</div>
22840
+ <div class="stat-label">Total Tests</div>
22712
22841
  </div>
22713
- <div class="stat-card ${failedAssertions > 0 ? "has-failures" : "all-passed"}">
22714
- <div class="stat-value">${passedAssertions}/${totalAssertions}</div>
22715
- <div class="stat-label">Assertions Passed</div>
22842
+ <div class="stat-card clickable ${passedSequences > 0 ? "all-passed" : ""}" onclick="filterByStatus('passed')" data-filter="passed">
22843
+ <div class="stat-value">${passedSequences}</div>
22844
+ <div class="stat-label">Passed</div>
22716
22845
  </div>
22717
- <div class="stat-card">
22718
- <div class="stat-value">${totalRequests}</div>
22719
- <div class="stat-label">Total Requests</div>
22846
+ <div class="stat-card clickable ${failedSequences > 0 ? "has-failures" : ""}" onclick="filterByStatus('failed')" data-filter="failed">
22847
+ <div class="stat-value">${failedSequences}</div>
22848
+ <div class="stat-label">Failed</div>
22720
22849
  </div>
22721
22850
  <div class="stat-card">
22722
22851
  <div class="stat-value">${formatDuration2(totalDuration)}</div>
@@ -22730,9 +22859,29 @@ function generateHtmlReport(results, options) {
22730
22859
  </div>
22731
22860
  </section>
22732
22861
 
22862
+ <section class="controls">
22863
+ <div class="filter-bar">
22864
+ <input type="text" id="search-input" placeholder="Search tests..." onkeyup="filterTests()">
22865
+ <div class="filter-buttons">
22866
+ <button class="filter-btn active" data-filter="all" onclick="setFilter('all')">All (${totalSequences})</button>
22867
+ <button class="filter-btn" data-filter="passed" onclick="setFilter('passed')">Passed (${passedSequences})</button>
22868
+ <button class="filter-btn" data-filter="failed" onclick="setFilter('failed')">Failed (${failedSequences})</button>
22869
+ </div>
22870
+ </div>
22871
+ <div class="action-buttons">
22872
+ <button class="action-btn" onclick="expandAll()">Expand All</button>
22873
+ <button class="action-btn" onclick="collapseAll()">Collapse All</button>
22874
+ </div>
22875
+ </section>
22876
+
22733
22877
  <section class="results">
22734
22878
  <h2>Test Results</h2>
22735
- ${results.map((r, i) => generateSequenceHtml(r, i, redaction)).join("\n")}
22879
+ <div id="results-container">
22880
+ ${sortedResults.map((r, i) => generateSequenceHtml(r, i, redaction)).join("\n")}
22881
+ </div>
22882
+ <div id="no-results" class="no-results" style="display: none;">
22883
+ No tests match your filter criteria.
22884
+ </div>
22736
22885
  </section>
22737
22886
  </div>
22738
22887
 
@@ -22797,11 +22946,13 @@ function generateRequestHtml(step, seqIndex, reqIndex, redaction) {
22797
22946
  const statusClass = isSuccess ? "passed" : "failed";
22798
22947
  const method = step.requestMethod || "REQUEST";
22799
22948
  const url2 = step.requestUrl ? redactUrl(step.requestUrl, redaction) : "unknown";
22949
+ const varLabel = step.variableName ? `<span class="var-label">${escapeHtml(step.variableName)}</span><span class="var-equals">=</span>` : "";
22800
22950
  const bodyHtml = response.body ? generateBodyHtml(response.body, redaction) : "<em>No body</em>";
22801
22951
  const headersHtml = response.headers ? generateHeadersHtml(response.headers, redaction) : "";
22802
22952
  return `
22803
22953
  <div class="step request ${statusClass}">
22804
22954
  <div class="step-header" onclick="toggleStep('step-${seqIndex}-${reqIndex}')">
22955
+ ${varLabel}
22805
22956
  <span class="method">${escapeHtml(method)}</span>
22806
22957
  <span class="url">${escapeHtml(url2)}</span>
22807
22958
  <span class="status-code">${response.status} ${escapeHtml(response.statusText)}</span>
@@ -22840,20 +22991,32 @@ function generateAssertionHtml(step, redaction) {
22840
22991
  const assertion = step.assertion;
22841
22992
  const statusClass = assertion.passed ? "passed" : "failed";
22842
22993
  const statusIcon = assertion.passed ? "\u2713" : "\u2717";
22843
- const displayText = assertion.message || assertion.expression;
22994
+ const displayText = assertion.friendlyName || assertion.message || assertion.expression;
22844
22995
  let detailsHtml = "";
22845
22996
  if (!assertion.passed) {
22997
+ let actualDisplay = "";
22998
+ if (assertion.leftValue === void 0) {
22999
+ actualDisplay = "undefined";
23000
+ } else if (assertion.leftValue === null) {
23001
+ actualDisplay = "null";
23002
+ } else if (typeof assertion.leftValue === "object") {
23003
+ actualDisplay = JSON.stringify(assertion.leftValue, null, 2);
23004
+ } else {
23005
+ actualDisplay = String(assertion.leftValue);
23006
+ }
23007
+ const pathInfo = assertion.jsonPath ? `<div class="assertion-path"><strong>Path:</strong> <code>${escapeHtml(assertion.jsonPath)}</code></div>` : "";
22846
23008
  detailsHtml = `
22847
23009
  <div class="assertion-details">
22848
- <div><strong>Expected:</strong> ${escapeHtml(String(assertion.rightExpression || assertion.rightValue))}</div>
22849
- <div><strong>Actual:</strong> ${escapeHtml(JSON.stringify(assertion.leftValue))}</div>
23010
+ ${pathInfo}
23011
+ <div><strong>Expected:</strong> <code>${escapeHtml(String(assertion.rightExpression || assertion.rightValue))}</code></div>
23012
+ <div><strong>Actual:</strong> <code>${escapeHtml(actualDisplay)}</code></div>
22850
23013
  ${assertion.error ? `<div class="error"><strong>Error:</strong> ${escapeHtml(redactString(assertion.error, redaction))}</div>` : ""}
22851
23014
  </div>`;
22852
23015
  }
22853
23016
  return `
22854
23017
  <div class="step assertion ${statusClass}">
22855
23018
  <span class="status-icon">${statusIcon}</span>
22856
- <span class="assertion-text">assert ${escapeHtml(displayText)}</span>
23019
+ <span class="assertion-text">${escapeHtml(displayText)}</span>
22857
23020
  ${detailsHtml}
22858
23021
  </div>`;
22859
23022
  }
@@ -22893,17 +23056,48 @@ function getEmbeddedCSS() {
22893
23056
  .container { max-width: 1200px; margin: 0 auto; padding: 20px; }
22894
23057
  header { text-align: center; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 1px solid #333; }
22895
23058
  header h1 { color: #fff; font-size: 2em; margin-bottom: 10px; }
23059
+ .header-meta { display: flex; justify-content: center; gap: 20px; flex-wrap: wrap; }
22896
23060
  .timestamp { color: #888; font-size: 0.9em; }
23061
+ .environment { color: #888; font-size: 0.9em; }
23062
+ .environment strong { color: #569cd6; }
22897
23063
  h2 { color: #fff; margin-bottom: 15px; font-size: 1.4em; }
22898
23064
 
22899
- .summary { background: #252526; border-radius: 8px; padding: 20px; margin-bottom: 30px; }
23065
+ .summary { background: #252526; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
23066
+ .summary-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
23067
+ .pass-rate { text-align: center; padding: 10px 20px; border-radius: 8px; }
23068
+ .pass-rate.perfect { background: #1e3a2f; }
23069
+ .pass-rate.good { background: #2a3a1e; }
23070
+ .pass-rate.poor { background: #3a1e1e; }
23071
+ .rate-value { display: block; font-size: 2em; font-weight: bold; }
23072
+ .pass-rate.perfect .rate-value { color: #4ec9b0; }
23073
+ .pass-rate.good .rate-value { color: #b5cea8; }
23074
+ .pass-rate.poor .rate-value { color: #f14c4c; }
23075
+ .rate-label { font-size: 0.8em; color: #888; }
23076
+
22900
23077
  .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; margin-bottom: 20px; }
22901
- .stat-card { background: #1e1e1e; border-radius: 6px; padding: 15px; text-align: center; }
23078
+ .stat-card { background: #1e1e1e; border-radius: 6px; padding: 15px; text-align: center; transition: transform 0.2s, box-shadow 0.2s; }
23079
+ .stat-card.clickable { cursor: pointer; }
23080
+ .stat-card.clickable:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.3); }
22902
23081
  .stat-card.all-passed { border-left: 4px solid #4ec9b0; }
22903
23082
  .stat-card.has-failures { border-left: 4px solid #f14c4c; }
22904
23083
  .stat-value { font-size: 2em; font-weight: bold; color: #fff; }
22905
23084
  .stat-label { color: #888; font-size: 0.85em; margin-top: 5px; }
22906
23085
 
23086
+ .controls { background: #252526; border-radius: 8px; padding: 15px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
23087
+ .filter-bar { display: flex; align-items: center; gap: 15px; flex-wrap: wrap; flex: 1; }
23088
+ #search-input { background: #1e1e1e; border: 1px solid #333; border-radius: 4px; padding: 8px 12px; color: #d4d4d4; min-width: 200px; font-size: 0.9em; }
23089
+ #search-input:focus { outline: none; border-color: #569cd6; }
23090
+ #search-input::placeholder { color: #666; }
23091
+ .filter-buttons { display: flex; gap: 5px; }
23092
+ .filter-btn { background: #1e1e1e; border: 1px solid #333; border-radius: 4px; padding: 8px 16px; color: #888; cursor: pointer; font-size: 0.9em; transition: all 0.2s; }
23093
+ .filter-btn:hover { border-color: #569cd6; color: #d4d4d4; }
23094
+ .filter-btn.active { background: #264f78; border-color: #264f78; color: #fff; }
23095
+ .action-buttons { display: flex; gap: 10px; }
23096
+ .action-btn { background: transparent; border: 1px solid #444; border-radius: 4px; padding: 8px 16px; color: #888; cursor: pointer; font-size: 0.85em; transition: all 0.2s; }
23097
+ .action-btn:hover { border-color: #666; color: #d4d4d4; }
23098
+
23099
+ .no-results { text-align: center; padding: 40px; color: #888; font-size: 1.1em; }
23100
+
22907
23101
  .progress-bar { height: 8px; background: #333; border-radius: 4px; overflow: hidden; }
22908
23102
  .progress-fill { height: 100%; transition: width 0.3s; }
22909
23103
  .progress-fill.all-passed { background: #4ec9b0; }
@@ -22940,6 +23134,8 @@ function getEmbeddedCSS() {
22940
23134
 
22941
23135
  .step-header { display: flex; align-items: center; gap: 10px; cursor: pointer; }
22942
23136
  .step-header:hover { opacity: 0.9; }
23137
+ .var-label { color: #4fc1ff; font-family: monospace; font-weight: 500; }
23138
+ .var-equals { color: #d4d4d4; margin-right: 5px; }
22943
23139
  .method { background: #264f78; color: #fff; padding: 2px 6px; border-radius: 3px; font-size: 0.8em; font-weight: 600; }
22944
23140
  .url { flex: 1; color: #9cdcfe; font-family: monospace; font-size: 0.9em; word-break: break-all; }
22945
23141
  .status-code { font-weight: 600; }
@@ -22959,6 +23155,9 @@ function getEmbeddedCSS() {
22959
23155
  .assertion-text { font-family: monospace; }
22960
23156
  .assertion-details { width: 100%; margin-top: 8px; padding: 10px; background: #1a1a1a; border-radius: 4px; font-size: 0.9em; }
22961
23157
  .assertion-details div { margin-bottom: 5px; }
23158
+ .assertion-details code { background: #2d2d2d; padding: 2px 6px; border-radius: 3px; font-family: monospace; color: #ce9178; }
23159
+ .assertion-path { color: #888; }
23160
+ .assertion-path code { color: #9cdcfe; }
22962
23161
 
22963
23162
  .print-icon { font-size: 1em; }
22964
23163
  .print-text { color: #dcdcaa; }
@@ -22975,6 +23174,8 @@ function getEmbeddedCSS() {
22975
23174
  }
22976
23175
  function getEmbeddedJS() {
22977
23176
  return `
23177
+ let currentFilter = 'all';
23178
+
22978
23179
  function toggleSequence(index) {
22979
23180
  const body = document.getElementById('sequence-body-' + index);
22980
23181
  const sequence = body.closest('.sequence');
@@ -23000,6 +23201,69 @@ function getEmbeddedJS() {
23000
23201
  }
23001
23202
  }
23002
23203
 
23204
+ function setFilter(filter) {
23205
+ currentFilter = filter;
23206
+ document.querySelectorAll('.filter-btn').forEach(btn => {
23207
+ btn.classList.toggle('active', btn.dataset.filter === filter);
23208
+ });
23209
+ filterTests();
23210
+ }
23211
+
23212
+ function filterByStatus(filter) {
23213
+ setFilter(filter);
23214
+ }
23215
+
23216
+ function filterTests() {
23217
+ const searchTerm = document.getElementById('search-input').value.toLowerCase();
23218
+ const sequences = document.querySelectorAll('.sequence');
23219
+ let visibleCount = 0;
23220
+
23221
+ sequences.forEach(seq => {
23222
+ const name = seq.querySelector('.sequence-name').textContent.toLowerCase();
23223
+ const isPassed = seq.classList.contains('passed');
23224
+ const isFailed = seq.classList.contains('failed');
23225
+
23226
+ let matchesFilter = currentFilter === 'all' ||
23227
+ (currentFilter === 'passed' && isPassed) ||
23228
+ (currentFilter === 'failed' && isFailed);
23229
+
23230
+ let matchesSearch = !searchTerm || name.includes(searchTerm);
23231
+
23232
+ if (matchesFilter && matchesSearch) {
23233
+ seq.style.display = 'block';
23234
+ visibleCount++;
23235
+ } else {
23236
+ seq.style.display = 'none';
23237
+ }
23238
+ });
23239
+
23240
+ document.getElementById('no-results').style.display = visibleCount === 0 ? 'block' : 'none';
23241
+ }
23242
+
23243
+ function expandAll() {
23244
+ document.querySelectorAll('.sequence').forEach(seq => {
23245
+ if (seq.style.display !== 'none') {
23246
+ const index = seq.dataset.sequence;
23247
+ const body = document.getElementById('sequence-body-' + index);
23248
+ if (body) {
23249
+ body.style.display = 'block';
23250
+ seq.classList.add('open');
23251
+ }
23252
+ }
23253
+ });
23254
+ }
23255
+
23256
+ function collapseAll() {
23257
+ document.querySelectorAll('.sequence').forEach(seq => {
23258
+ const index = seq.dataset.sequence;
23259
+ const body = document.getElementById('sequence-body-' + index);
23260
+ if (body) {
23261
+ body.style.display = 'none';
23262
+ seq.classList.remove('open');
23263
+ }
23264
+ });
23265
+ }
23266
+
23003
23267
  // Expand all failed sequences by default
23004
23268
  document.querySelectorAll('.sequence.failed').forEach((seq, i) => {
23005
23269
  const index = seq.dataset.sequence;
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.2.4",
5
+ "version": "1.3.0",
6
6
  "publisher": "Norn-PeterKrustanov",
7
7
  "author": {
8
8
  "name": "Peter Krastanov"