tryscript 0.1.5 → 0.1.7

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.
@@ -2,12 +2,13 @@
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { existsSync } from "node:fs";
4
4
  import { basename, delimiter, dirname, join, resolve } from "node:path";
5
- import { parse } from "yaml";
5
+ import { parse, stringify } from "yaml";
6
6
  import { spawn } from "node:child_process";
7
7
  import { cp, mkdtemp, realpath, rm } from "node:fs/promises";
8
8
  import { tmpdir } from "node:os";
9
9
  import treeKill from "tree-kill";
10
10
  import stripAnsi from "strip-ansi";
11
+ import { writeFile } from "atomically";
11
12
 
12
13
  //#region src/lib/config.ts
13
14
  /** Default coverage configuration values. */
@@ -37,7 +38,8 @@ function resolveCoverageConfig(config) {
37
38
  skipFull: config?.skipFull ?? DEFAULT_COVERAGE_CONFIG.skipFull,
38
39
  allowExternal: config?.allowExternal ?? DEFAULT_COVERAGE_CONFIG.allowExternal,
39
40
  src: config?.src ?? DEFAULT_COVERAGE_CONFIG.src,
40
- monocart: config?.monocart ?? DEFAULT_COVERAGE_CONFIG.monocart
41
+ monocart: config?.monocart ?? DEFAULT_COVERAGE_CONFIG.monocart,
42
+ mergeLcov: config?.mergeLcov
41
43
  };
42
44
  }
43
45
  const CONFIG_FILES = [
@@ -90,8 +92,6 @@ function defineConfig(config) {
90
92
  //#region src/lib/parser.ts
91
93
  /** Regex to match YAML frontmatter at the start of a file */
92
94
  const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/;
93
- /** Regex to match fenced code blocks with console/bash info string */
94
- const CODE_BLOCK_REGEX = /```(console|bash)\r?\n([\s\S]*?)```/g;
95
95
  /** Regex to match markdown headings (for test names) */
96
96
  const HEADING_REGEX = /^#+\s+(?:Test:\s*)?(.+)$/m;
97
97
  /** Regex to match skip annotation in heading or nearby HTML comment */
@@ -99,6 +99,55 @@ const SKIP_ANNOTATION_REGEX = /<!--\s*skip\s*-->/i;
99
99
  /** Regex to match only annotation in heading or nearby HTML comment */
100
100
  const ONLY_ANNOTATION_REGEX = /<!--\s*only\s*-->/i;
101
101
  /**
102
+ * Find console/bash fenced code blocks, supporting extended fences (4+ backticks).
103
+ *
104
+ * Extended fences allow embedding triple-backtick blocks in expected output.
105
+ * A closing fence must have at least as many backticks as the opening fence
106
+ * (per CommonMark spec).
107
+ */
108
+ function findConsoleCodeBlocks(text) {
109
+ const results = [];
110
+ const lines = text.split("\n");
111
+ const offsets = new Array(lines.length);
112
+ offsets[0] = 0;
113
+ for (let j = 1; j < lines.length; j++) offsets[j] = offsets[j - 1] + lines[j - 1].length + 1;
114
+ let i = 0;
115
+ while (i < lines.length) {
116
+ const line = lines[i];
117
+ const trimmed = line.endsWith("\r") ? line.slice(0, -1) : line;
118
+ const openMatch = /^(`{3,})(console|bash)\s*$/.exec(trimmed);
119
+ if (!openMatch) {
120
+ i++;
121
+ continue;
122
+ }
123
+ const fenceLen = openMatch[1].length;
124
+ const infoString = openMatch[2];
125
+ const openLineIdx = i;
126
+ const closingRe = /* @__PURE__ */ new RegExp(`^\`{${fenceLen},}\\s*$`);
127
+ i++;
128
+ while (i < lines.length) {
129
+ const cur = lines[i];
130
+ const curTrimmed = cur.endsWith("\r") ? cur.slice(0, -1) : cur;
131
+ if (closingRe.test(curTrimmed)) {
132
+ const startOffset = offsets[openLineIdx];
133
+ const endOffset = offsets[i] + lines[i].length;
134
+ const contentStart = offsets[openLineIdx + 1];
135
+ const contentEnd = offsets[i];
136
+ results.push({
137
+ fullMatch: text.slice(startOffset, endOffset),
138
+ infoString,
139
+ content: text.slice(contentStart, contentEnd),
140
+ index: startOffset
141
+ });
142
+ i++;
143
+ break;
144
+ }
145
+ i++;
146
+ }
147
+ }
148
+ return results;
149
+ }
150
+ /**
102
151
  * Parse a .tryscript.md file into structured test data.
103
152
  */
104
153
  function parseTestFile(content, filePath) {
@@ -111,12 +160,11 @@ function parseTestFile(content, filePath) {
111
160
  body = content.slice(frontmatterMatch[0].length);
112
161
  }
113
162
  const blocks = [];
114
- CODE_BLOCK_REGEX.lastIndex = 0;
115
- let match;
116
- while ((match = CODE_BLOCK_REGEX.exec(body)) !== null) {
117
- const blockContent = match[2] ?? "";
118
- const blockStart = match.index;
119
- const lineNumber = content.slice(0, content.indexOf(match[0])).split("\n").length;
163
+ const codeBlocks = findConsoleCodeBlocks(body);
164
+ for (const codeBlock of codeBlocks) {
165
+ const blockContent = codeBlock.content;
166
+ const blockStart = codeBlock.index;
167
+ const lineNumber = content.slice(0, content.indexOf(codeBlock.fullMatch)).split("\n").length;
120
168
  const contentBefore = body.slice(0, blockStart);
121
169
  const lastHeadingMatch = [...contentBefore.matchAll(new RegExp(HEADING_REGEX.source, "gm"))].pop();
122
170
  const name = lastHeadingMatch?.[1]?.trim();
@@ -131,7 +179,7 @@ function parseTestFile(content, filePath) {
131
179
  expectedStderr: parsed.expectedStderr,
132
180
  expectedExitCode: parsed.expectedExitCode,
133
181
  lineNumber,
134
- rawContent: match[0],
182
+ rawContent: codeBlock.fullMatch,
135
183
  skip,
136
184
  only
137
185
  });
@@ -506,9 +554,15 @@ function patternToRegex(expected, customPatterns = {}) {
506
554
  const dotdotMarker = getMarker();
507
555
  replacements.set(dotdotMarker, "[^\\n]*");
508
556
  processed = processed.replaceAll("[..]", dotdotMarker);
557
+ const unknownDotdotMarker = getMarker();
558
+ replacements.set(unknownDotdotMarker, "[^\\n]*");
559
+ processed = processed.replaceAll("[??]", unknownDotdotMarker);
509
560
  const ellipsisMarker = getMarker();
510
561
  replacements.set(ellipsisMarker, "(?:[^\\n]*\\n)*");
511
562
  processed = processed.replace(/\.\.\.\n/g, ellipsisMarker);
563
+ const unknownEllipsisMarker = getMarker();
564
+ replacements.set(unknownEllipsisMarker, "(?:[^\\n]*\\n)*");
565
+ processed = processed.replace(/\?\?\?\n/g, unknownEllipsisMarker);
512
566
  const exeMarker = getMarker();
513
567
  const exe = process.platform === "win32" ? "\\.exe" : "";
514
568
  replacements.set(exeMarker, exe);
@@ -551,6 +605,82 @@ function normalizeOutput(output) {
551
605
  return normalized;
552
606
  }
553
607
  /**
608
+ * Like `patternToRegex()` but wraps each wildcard in a capturing group and
609
+ * returns metadata describing what each group represents.
610
+ *
611
+ * Each occurrence gets a unique marker so that the `groups` array is ordered
612
+ * by position in the string, matching the regex capture group indices.
613
+ */
614
+ function patternToCapturingRegex(expected, customPatterns = {}) {
615
+ const replacements = /* @__PURE__ */ new Map();
616
+ const markerMeta = /* @__PURE__ */ new Map();
617
+ let markerIndex = 0;
618
+ const getMarker = () => {
619
+ return `${MARKER}${markerIndex++}${MARKER}`;
620
+ };
621
+ const replaceEach = (processed$1, pattern, regexStr, meta) => {
622
+ let result = processed$1;
623
+ if (typeof pattern === "string") while (result.includes(pattern)) {
624
+ const marker = getMarker();
625
+ replacements.set(marker, regexStr);
626
+ markerMeta.set(marker, meta);
627
+ result = result.replace(pattern, marker);
628
+ }
629
+ else {
630
+ let m;
631
+ while ((m = pattern.exec(result)) !== null) {
632
+ const marker = getMarker();
633
+ replacements.set(marker, regexStr);
634
+ markerMeta.set(marker, meta);
635
+ result = result.slice(0, m.index) + marker + result.slice(m.index + m[0].length);
636
+ }
637
+ }
638
+ return result;
639
+ };
640
+ let processed = expected;
641
+ processed = replaceEach(processed, "[..]", "([^\\n]*)", {
642
+ category: "generic",
643
+ multiline: false
644
+ });
645
+ processed = replaceEach(processed, "[??]", "([^\\n]*)", {
646
+ category: "unknown",
647
+ multiline: false
648
+ });
649
+ processed = replaceEach(processed, /\.\.\.\n/, "((?:[^\\n]*\\n)*)", {
650
+ category: "generic",
651
+ multiline: true
652
+ });
653
+ processed = replaceEach(processed, /\?\?\?\n/, "((?:[^\\n]*\\n)*)", {
654
+ category: "unknown",
655
+ multiline: true
656
+ });
657
+ const exe = process.platform === "win32" ? "\\.exe" : "";
658
+ processed = replaceEach(processed, "[EXE]", exe, null);
659
+ for (const [name, pattern] of Object.entries(customPatterns)) {
660
+ const placeholder = `[${name}]`;
661
+ const patternStr = pattern instanceof RegExp ? pattern.source : pattern;
662
+ processed = replaceEach(processed, placeholder, `(${patternStr})`, {
663
+ category: "named",
664
+ name,
665
+ multiline: false
666
+ });
667
+ }
668
+ const sortedEntries = [...replacements.entries()].sort((a, b) => {
669
+ return processed.indexOf(a[0]) - processed.indexOf(b[0]);
670
+ });
671
+ let regex = escapeRegex(processed);
672
+ const groups = [];
673
+ for (const [marker, replacement] of sortedEntries) {
674
+ const meta = markerMeta.get(marker);
675
+ if (meta) groups.push(meta);
676
+ regex = regex.replaceAll(escapeRegex(marker), replacement);
677
+ }
678
+ return {
679
+ regex: new RegExp(`^${regex}$`, "s"),
680
+ groups
681
+ };
682
+ }
683
+ /**
554
684
  * Check if actual output matches expected pattern.
555
685
  */
556
686
  function matchOutput(actual, expected, context, customPatterns = {}) {
@@ -559,11 +689,308 @@ function matchOutput(actual, expected, context, customPatterns = {}) {
559
689
  if (normalizedExpected === "" && normalizedActual === "") return true;
560
690
  return patternToRegex(preprocessPaths(normalizedExpected, context), customPatterns).test(normalizedActual);
561
691
  }
692
+ /**
693
+ * Match actual output against expected pattern and return wildcard captures.
694
+ * Returns `null` if the output does not match.
695
+ */
696
+ function matchAndCapture(actual, expected, context, customPatterns = {}) {
697
+ const normalizedActual = normalizeOutput(actual);
698
+ const normalizedExpected = normalizeOutput(expected);
699
+ if (normalizedExpected === "" && normalizedActual === "") return { captures: [] };
700
+ const { regex, groups } = patternToCapturingRegex(preprocessPaths(normalizedExpected, context), customPatterns);
701
+ const match = regex.exec(normalizedActual);
702
+ if (!match) return null;
703
+ return { captures: groups.map((meta, i) => ({
704
+ category: meta.category,
705
+ name: meta.name,
706
+ multiline: meta.multiline,
707
+ captured: match[i + 1] ?? ""
708
+ })) };
709
+ }
710
+
711
+ //#endregion
712
+ //#region src/lib/expander.ts
713
+ /**
714
+ * Whether a wildcard category should be expanded at the given level.
715
+ *
716
+ * The hierarchy is: unknown < generic < all.
717
+ */
718
+ function shouldExpandCategory(category, level) {
719
+ switch (level) {
720
+ case "unknown": return category === "unknown";
721
+ case "generic": return category === "unknown" || category === "generic";
722
+ case "all": return true;
723
+ }
724
+ }
725
+ const WILDCARD_TOKENS = [
726
+ {
727
+ token: "[..]",
728
+ category: "generic",
729
+ multiline: false
730
+ },
731
+ {
732
+ token: "[??]",
733
+ category: "unknown",
734
+ multiline: false
735
+ },
736
+ {
737
+ token: /\.\.\.\n/,
738
+ category: "generic",
739
+ multiline: true
740
+ },
741
+ {
742
+ token: /\?\?\?\n/,
743
+ category: "unknown",
744
+ multiline: true
745
+ }
746
+ ];
747
+ /**
748
+ * Expand wildcards in expected output by replacing them with captured actual text.
749
+ *
750
+ * Only wildcards whose category is targeted by `level` are replaced; others are
751
+ * left intact. Returns `null` if actual output doesn't match expected pattern.
752
+ */
753
+ function expandExpectedOutput(expected, actual, context, level, customPatterns) {
754
+ const normalizedExpected = normalizeOutput(expected);
755
+ const normalizedActual = normalizeOutput(actual);
756
+ if (normalizedExpected === "" && normalizedActual === "") return {
757
+ expandedOutput: "",
758
+ captures: [],
759
+ expandedCount: 0
760
+ };
761
+ const result = matchAndCapture(actual, expected, context, customPatterns);
762
+ if (!result) return null;
763
+ let output = normalizedExpected;
764
+ let expandedCount = 0;
765
+ const tokenPositions = [];
766
+ for (const wt of WILDCARD_TOKENS) {
767
+ let searchFrom = 0;
768
+ if (typeof wt.token === "string") while (true) {
769
+ const pos = output.indexOf(wt.token, searchFrom);
770
+ if (pos === -1) break;
771
+ tokenPositions.push({
772
+ pos,
773
+ length: wt.token.length
774
+ });
775
+ searchFrom = pos + wt.token.length;
776
+ }
777
+ else {
778
+ const re = new RegExp(wt.token.source, "g");
779
+ let m;
780
+ while ((m = re.exec(output)) !== null) tokenPositions.push({
781
+ pos: m.index,
782
+ length: m[0].length
783
+ });
784
+ }
785
+ }
786
+ if (customPatterns) for (const name of Object.keys(customPatterns)) {
787
+ const placeholder = `[${name}]`;
788
+ let searchFrom = 0;
789
+ while (true) {
790
+ const pos = output.indexOf(placeholder, searchFrom);
791
+ if (pos === -1) break;
792
+ tokenPositions.push({
793
+ pos,
794
+ length: placeholder.length
795
+ });
796
+ searchFrom = pos + placeholder.length;
797
+ }
798
+ }
799
+ tokenPositions.sort((a, b) => a.pos - b.pos);
800
+ const replacements = tokenPositions.map((tp, i) => ({
801
+ ...tp,
802
+ capture: result.captures[i]
803
+ }));
804
+ for (let i = replacements.length - 1; i >= 0; i--) {
805
+ const r = replacements[i];
806
+ if (shouldExpandCategory(r.capture.category, level)) {
807
+ const replacement = r.capture.captured;
808
+ output = output.slice(0, r.pos) + replacement + output.slice(r.pos + r.length);
809
+ expandedCount++;
810
+ }
811
+ }
812
+ return {
813
+ expandedOutput: output,
814
+ captures: result.captures,
815
+ expandedCount
816
+ };
817
+ }
818
+ /**
819
+ * Expand wildcards in a test file in place.
820
+ *
821
+ * Uses the same reverse-order strategy as `updater.ts` to maintain correct
822
+ * string offsets when modifying multiple blocks.
823
+ */
824
+ async function expandTestFile(file, results, level, context, customPatterns) {
825
+ let content = file.rawContent;
826
+ const changes = [];
827
+ let totalExpanded = 0;
828
+ const resultByBlock = new Map(results.map((result) => [result.block, result]));
829
+ const blocksWithResults = [...file.blocks].map((block) => ({
830
+ block,
831
+ result: resultByBlock.get(block)
832
+ })).reverse();
833
+ for (const { block, result } of blocksWithResults) {
834
+ if (!result || !block.expectedOutput) continue;
835
+ const expansion = expandExpectedOutput(block.expectedOutput, result.actualOutput, context, level, customPatterns);
836
+ if (!expansion || expansion.expandedCount === 0) continue;
837
+ const fence = "`".repeat(/^(`+)/.exec(block.rawContent)?.[1]?.length ?? 3);
838
+ const commandLines = block.command.split("\n").map((line, i) => {
839
+ return i === 0 ? `$ ${line}` : `> ${line}`;
840
+ });
841
+ const lines = [`${fence}console`, ...commandLines];
842
+ const trimmedOutput = expansion.expandedOutput.trimEnd();
843
+ if (trimmedOutput) lines.push(trimmedOutput);
844
+ lines.push(`? ${block.expectedExitCode ?? result.actualExitCode}`, fence);
845
+ const newBlockContent = lines.join("\n");
846
+ const blockStart = content.indexOf(block.rawContent);
847
+ if (blockStart !== -1) {
848
+ content = content.slice(0, blockStart) + newBlockContent + content.slice(blockStart + block.rawContent.length);
849
+ changes.push(block.name ?? `Line ${block.lineNumber}`);
850
+ totalExpanded += expansion.expandedCount;
851
+ }
852
+ }
853
+ if (changes.length > 0) await writeFile(file.path, content);
854
+ return {
855
+ expanded: changes.length > 0,
856
+ expandedCount: totalExpanded,
857
+ changes
858
+ };
859
+ }
860
+
861
+ //#endregion
862
+ //#region src/lib/yaml-utils.ts
863
+ /**
864
+ * Manual key order comparator for YAML `sortMapEntries`.
865
+ *
866
+ * Keys listed in `order` appear first (in that order); unlisted keys sort
867
+ * to the end alphabetically. Adapted from tbd sorting patterns
868
+ * (`ordering.manual`).
869
+ */
870
+ function manualKeyOrder(order) {
871
+ const orderMap = new Map(order.map((key, index) => [key, index]));
872
+ return (a, b) => {
873
+ const indexA = orderMap.get(a.key.value);
874
+ const indexB = orderMap.get(b.key.value);
875
+ if (indexA === void 0 && indexB === void 0) return a.key.value.localeCompare(b.key.value);
876
+ if (indexA === void 0) return 1;
877
+ if (indexB === void 0) return -1;
878
+ return indexA - indexB;
879
+ };
880
+ }
881
+ const DEFAULT_YAML_LINE_WIDTH = 88;
882
+ const YAML_STRINGIFY_OPTIONS = {
883
+ lineWidth: DEFAULT_YAML_LINE_WIDTH,
884
+ defaultStringType: "PLAIN",
885
+ defaultKeyType: "PLAIN"
886
+ };
887
+ function stringifyYaml(data, options) {
888
+ return stringify(data, {
889
+ ...YAML_STRINGIFY_OPTIONS,
890
+ ...options
891
+ });
892
+ }
893
+
894
+ //#endregion
895
+ //#region src/lib/capture-log.ts
896
+ const TOP_LEVEL_ORDER = manualKeyOrder(["generated", "files"]);
897
+ const FILE_ORDER = manualKeyOrder(["path", "blocks"]);
898
+ const BLOCK_ORDER = manualKeyOrder([
899
+ "name",
900
+ "command",
901
+ "expected_exit_code",
902
+ "actual_exit_code",
903
+ "expected_output",
904
+ "actual_output",
905
+ "captures",
906
+ "passed"
907
+ ]);
908
+ const CAPTURE_ORDER = manualKeyOrder([
909
+ "category",
910
+ "name",
911
+ "multiline",
912
+ "matched"
913
+ ]);
914
+ /**
915
+ * Sort comparator for `yaml.stringify`'s `sortMapEntries`.
916
+ *
917
+ * Dispatches to the correct field ordering based on which keys are present,
918
+ * since the `yaml` package calls this for every map node in the document.
919
+ */
920
+ const BLOCK_KEYS = new Set([
921
+ "name",
922
+ "command",
923
+ "expected_exit_code",
924
+ "actual_exit_code",
925
+ "expected_output",
926
+ "actual_output",
927
+ "captures",
928
+ "passed"
929
+ ]);
930
+ const CAPTURE_KEYS = new Set([
931
+ "category",
932
+ "name",
933
+ "multiline",
934
+ "matched"
935
+ ]);
936
+ function captureLogSortMapEntries(a, b) {
937
+ const aKey = a.key.value;
938
+ const bKey = b.key.value;
939
+ if (aKey === "generated" || aKey === "files" || bKey === "generated" || bKey === "files") return TOP_LEVEL_ORDER(a, b);
940
+ if (aKey === "blocks" || bKey === "blocks") return FILE_ORDER(a, b);
941
+ if (BLOCK_KEYS.has(aKey) && BLOCK_KEYS.has(bKey)) return BLOCK_ORDER(a, b);
942
+ if (CAPTURE_KEYS.has(aKey) && CAPTURE_KEYS.has(bKey)) return CAPTURE_ORDER(a, b);
943
+ return aKey.localeCompare(bKey);
944
+ }
945
+ /**
946
+ * Build the capture log document structure from test results.
947
+ *
948
+ * `customPatterns` can be a static object or a per-file callback.
949
+ * Separated from `writeCaptureLog` for testability.
950
+ */
951
+ function buildCaptureLogDoc(fileResults, matchContext, customPatterns) {
952
+ const files = fileResults.map((fr) => {
953
+ const ctx = matchContext(fr.file);
954
+ const patterns = typeof customPatterns === "function" ? customPatterns(fr.file) : customPatterns;
955
+ const blocks = fr.results.map((r) => {
956
+ const captures = (matchAndCapture(r.actualOutput, r.block.expectedOutput, ctx, patterns)?.captures ?? []).map((c) => ({
957
+ category: c.category,
958
+ ...c.name ? { name: c.name } : {},
959
+ multiline: c.multiline,
960
+ matched: c.captured
961
+ }));
962
+ return {
963
+ name: r.block.name,
964
+ command: r.block.command,
965
+ expected_exit_code: r.block.expectedExitCode,
966
+ actual_exit_code: r.actualExitCode,
967
+ expected_output: r.block.expectedOutput,
968
+ actual_output: r.actualOutput,
969
+ captures,
970
+ passed: r.passed
971
+ };
972
+ });
973
+ return {
974
+ path: fr.file.path,
975
+ blocks
976
+ };
977
+ });
978
+ return {
979
+ generated: (/* @__PURE__ */ new Date()).toISOString(),
980
+ files
981
+ };
982
+ }
983
+ /**
984
+ * Write a YAML capture log file recording wildcard captures and execution metadata.
985
+ */
986
+ async function writeCaptureLog(path, fileResults, matchContext, customPatterns) {
987
+ await writeFile(path, "# tryscript capture log\n" + stringifyYaml(buildCaptureLogDoc(fileResults, matchContext, customPatterns), { sortMapEntries: captureLogSortMapEntries }));
988
+ }
562
989
 
563
990
  //#endregion
564
991
  //#region src/index.ts
565
- const VERSION = "0.1.5";
992
+ const VERSION = "0.1.7";
566
993
 
567
994
  //#endregion
568
- export { createExecutionContext as a, parseTestFile as c, mergeConfig as d, resolveCoverageConfig as f, cleanupExecutionContext as i, defineConfig as l, matchOutput as n, runAfterHook as o, normalizeOutput as r, runBlock as s, VERSION as t, loadConfig as u };
569
- //# sourceMappingURL=src-CC3xA1cp.mjs.map
995
+ export { defineConfig as _, stringifyYaml as a, resolveCoverageConfig as b, shouldExpandCategory as c, normalizeOutput as d, cleanupExecutionContext as f, parseTestFile as g, runBlock as h, manualKeyOrder as i, matchAndCapture as l, runAfterHook as m, buildCaptureLogDoc as n, expandExpectedOutput as o, createExecutionContext as p, writeCaptureLog as r, expandTestFile as s, VERSION as t, matchOutput as u, loadConfig as v, mergeConfig as y };
996
+ //# sourceMappingURL=src-BQxIhzgF.mjs.map