tryscript 0.0.1

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.
@@ -0,0 +1,328 @@
1
+
2
+ import { pathToFileURL } from "node:url";
3
+ import { existsSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { parse } from "yaml";
6
+ import { spawn } from "node:child_process";
7
+ import { mkdtemp, realpath, rm } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import treeKill from "tree-kill";
10
+ import stripAnsi from "strip-ansi";
11
+
12
+ //#region src/lib/config.ts
13
+ const CONFIG_FILES = [
14
+ "tryscript.config.ts",
15
+ "tryscript.config.js",
16
+ "tryscript.config.mjs"
17
+ ];
18
+ /**
19
+ * Load config file using dynamic import.
20
+ * Supports both TypeScript (via tsx/ts-node) and JavaScript configs.
21
+ */
22
+ async function loadConfig(baseDir) {
23
+ for (const filename of CONFIG_FILES) {
24
+ const configPath = resolve(baseDir, filename);
25
+ if (existsSync(configPath)) {
26
+ const module = await import(pathToFileURL(configPath).href);
27
+ return module.default ?? module;
28
+ }
29
+ }
30
+ return {};
31
+ }
32
+ /**
33
+ * Merge config with frontmatter overrides.
34
+ * Frontmatter takes precedence over config file.
35
+ */
36
+ function mergeConfig(base, frontmatter) {
37
+ return {
38
+ ...base,
39
+ ...frontmatter,
40
+ env: {
41
+ ...base.env,
42
+ ...frontmatter.env
43
+ },
44
+ patterns: {
45
+ ...base.patterns,
46
+ ...frontmatter.patterns
47
+ }
48
+ };
49
+ }
50
+ /**
51
+ * Helper for typed config files.
52
+ */
53
+ function defineConfig(config) {
54
+ return config;
55
+ }
56
+
57
+ //#endregion
58
+ //#region src/lib/parser.ts
59
+ /** Regex to match YAML frontmatter at the start of a file */
60
+ const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/;
61
+ /** Regex to match fenced code blocks with console/bash info string */
62
+ const CODE_BLOCK_REGEX = /```(console|bash)\r?\n([\s\S]*?)```/g;
63
+ /** Regex to match markdown headings (for test names) */
64
+ const HEADING_REGEX = /^#+\s+(?:Test:\s*)?(.+)$/m;
65
+ /**
66
+ * Parse a .tryscript.md file into structured test data.
67
+ */
68
+ function parseTestFile(content, filePath) {
69
+ const rawContent = content;
70
+ let config = {};
71
+ let body = content;
72
+ const frontmatterMatch = FRONTMATTER_REGEX.exec(content);
73
+ if (frontmatterMatch) {
74
+ config = parse(frontmatterMatch[1] ?? "");
75
+ body = content.slice(frontmatterMatch[0].length);
76
+ }
77
+ const blocks = [];
78
+ CODE_BLOCK_REGEX.lastIndex = 0;
79
+ let match;
80
+ while ((match = CODE_BLOCK_REGEX.exec(body)) !== null) {
81
+ const blockContent = match[2] ?? "";
82
+ const blockStart = match.index;
83
+ const lineNumber = content.slice(0, content.indexOf(match[0])).split("\n").length;
84
+ const name = [...body.slice(0, blockStart).matchAll(new RegExp(HEADING_REGEX.source, "gm"))].pop()?.[1]?.trim();
85
+ const parsed = parseBlockContent(blockContent);
86
+ if (parsed) blocks.push({
87
+ name,
88
+ command: parsed.command,
89
+ expectedOutput: parsed.expectedOutput,
90
+ expectedExitCode: parsed.expectedExitCode,
91
+ lineNumber,
92
+ rawContent: match[0]
93
+ });
94
+ }
95
+ return {
96
+ path: filePath,
97
+ config,
98
+ blocks,
99
+ rawContent
100
+ };
101
+ }
102
+ /**
103
+ * Parse the content of a single console block.
104
+ */
105
+ function parseBlockContent(content) {
106
+ const lines = content.split("\n");
107
+ const commandLines = [];
108
+ const outputLines = [];
109
+ let expectedExitCode = 0;
110
+ let inCommand = false;
111
+ for (const line of lines) if (line.startsWith("$ ")) {
112
+ inCommand = true;
113
+ commandLines.push(line.slice(2));
114
+ } else if (line.startsWith("> ") && inCommand) commandLines.push(line.slice(2));
115
+ else if (line.startsWith("? ")) {
116
+ inCommand = false;
117
+ expectedExitCode = parseInt(line.slice(2).trim(), 10);
118
+ } else {
119
+ inCommand = false;
120
+ outputLines.push(line);
121
+ }
122
+ if (commandLines.length === 0) return null;
123
+ let command = "";
124
+ for (let i = 0; i < commandLines.length; i++) {
125
+ const line = commandLines[i] ?? "";
126
+ if (line.endsWith("\\")) command += line.slice(0, -1) + " ";
127
+ else {
128
+ command += line;
129
+ if (i < commandLines.length - 1) command += " ";
130
+ }
131
+ }
132
+ let expectedOutput = outputLines.join("\n");
133
+ expectedOutput = expectedOutput.replace(/\n+$/, "");
134
+ if (expectedOutput) expectedOutput += "\n";
135
+ return {
136
+ command: command.trim(),
137
+ expectedOutput,
138
+ expectedExitCode
139
+ };
140
+ }
141
+
142
+ //#endregion
143
+ //#region src/lib/runner.ts
144
+ /** Default timeout in milliseconds */
145
+ const DEFAULT_TIMEOUT = 3e4;
146
+ /**
147
+ * Create an execution context for a test file.
148
+ */
149
+ async function createExecutionContext(config, testFilePath) {
150
+ const tempDir = await realpath(await mkdtemp(join(tmpdir(), "tryscript-")));
151
+ const testDir = resolve(dirname(testFilePath));
152
+ let binPath = config.bin ?? "";
153
+ if (binPath && !binPath.startsWith("/")) binPath = join(testDir, binPath);
154
+ return {
155
+ tempDir,
156
+ testDir,
157
+ binPath,
158
+ env: {
159
+ ...process.env,
160
+ ...config.env,
161
+ NO_COLOR: config.env?.NO_COLOR ?? "1",
162
+ FORCE_COLOR: "0",
163
+ TRYSCRIPT_TEST_DIR: testDir
164
+ },
165
+ timeout: config.timeout ?? DEFAULT_TIMEOUT
166
+ };
167
+ }
168
+ /**
169
+ * Clean up execution context (remove temp directory).
170
+ */
171
+ async function cleanupExecutionContext(ctx) {
172
+ await rm(ctx.tempDir, {
173
+ recursive: true,
174
+ force: true
175
+ });
176
+ }
177
+ /**
178
+ * Run a single test block and return the result.
179
+ */
180
+ async function runBlock(block, ctx) {
181
+ const startTime = Date.now();
182
+ try {
183
+ const { output, exitCode } = await executeCommand(block.command, ctx);
184
+ return {
185
+ block,
186
+ passed: true,
187
+ actualOutput: output,
188
+ actualExitCode: exitCode,
189
+ duration: Date.now() - startTime
190
+ };
191
+ } catch (error) {
192
+ return {
193
+ block,
194
+ passed: false,
195
+ actualOutput: "",
196
+ actualExitCode: -1,
197
+ duration: Date.now() - startTime,
198
+ error: error instanceof Error ? error.message : String(error)
199
+ };
200
+ }
201
+ }
202
+ /**
203
+ * Execute a command and capture output.
204
+ */
205
+ async function executeCommand(command, ctx) {
206
+ return new Promise((resolve$1, reject) => {
207
+ const proc = spawn(command, {
208
+ shell: true,
209
+ cwd: ctx.tempDir,
210
+ env: ctx.env,
211
+ stdio: [
212
+ "ignore",
213
+ "pipe",
214
+ "pipe"
215
+ ]
216
+ });
217
+ const chunks = [];
218
+ proc.stdout.on("data", (data) => chunks.push(data));
219
+ proc.stderr.on("data", (data) => chunks.push(data));
220
+ const timeoutId = setTimeout(() => {
221
+ if (proc.pid) treeKill(proc.pid, "SIGKILL");
222
+ reject(/* @__PURE__ */ new Error(`Command timed out after ${ctx.timeout}ms`));
223
+ }, ctx.timeout);
224
+ proc.on("close", (code) => {
225
+ clearTimeout(timeoutId);
226
+ resolve$1({
227
+ output: Buffer.concat(chunks).toString("utf-8"),
228
+ exitCode: code ?? 0
229
+ });
230
+ });
231
+ proc.on("error", (err) => {
232
+ clearTimeout(timeoutId);
233
+ reject(err);
234
+ });
235
+ });
236
+ }
237
+
238
+ //#endregion
239
+ //#region src/lib/matcher.ts
240
+ /**
241
+ * Escape special regex characters in a string.
242
+ */
243
+ function escapeRegex(str) {
244
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
245
+ }
246
+ const MARKER = "";
247
+ /**
248
+ * Convert expected output with elision patterns to a regex.
249
+ *
250
+ * Handles (matching trycmd):
251
+ * - [..] — matches any characters on the same line (trycmd: [^\n]*?)
252
+ * - ... — matches zero or more complete lines (trycmd: \n(([^\n]*\n)*)?)
253
+ * - [EXE] — matches .exe on Windows, empty otherwise
254
+ * - [ROOT] — replaced with test root directory (pre-processed)
255
+ * - [CWD] — replaced with current working directory (pre-processed)
256
+ * - Custom [NAME] patterns from config (trycmd: TestCases::insert_var)
257
+ */
258
+ function patternToRegex(expected, customPatterns = {}) {
259
+ const replacements = /* @__PURE__ */ new Map();
260
+ let markerIndex = 0;
261
+ const getMarker = () => {
262
+ return `${MARKER}${markerIndex++}${MARKER}`;
263
+ };
264
+ let processed = expected;
265
+ const dotdotMarker = getMarker();
266
+ replacements.set(dotdotMarker, "[^\\n]*");
267
+ processed = processed.replaceAll("[..]", dotdotMarker);
268
+ const ellipsisMarker = getMarker();
269
+ replacements.set(ellipsisMarker, "(?:[^\\n]*\\n)*");
270
+ processed = processed.replace(/\.\.\.\n/g, ellipsisMarker);
271
+ const exeMarker = getMarker();
272
+ const exe = process.platform === "win32" ? "\\.exe" : "";
273
+ replacements.set(exeMarker, exe);
274
+ processed = processed.replaceAll("[EXE]", exeMarker);
275
+ for (const [name, pattern] of Object.entries(customPatterns)) {
276
+ const placeholder = `[${name}]`;
277
+ const patternStr = pattern instanceof RegExp ? pattern.source : pattern;
278
+ const marker = getMarker();
279
+ replacements.set(marker, `(${patternStr})`);
280
+ processed = processed.replaceAll(placeholder, marker);
281
+ }
282
+ let regex = escapeRegex(processed);
283
+ for (const [marker, replacement] of replacements) regex = regex.replaceAll(escapeRegex(marker), replacement);
284
+ return new RegExp(`^${regex}$`, "s");
285
+ }
286
+ /**
287
+ * Pre-process expected output to replace path placeholders with actual paths.
288
+ * This happens BEFORE pattern matching.
289
+ */
290
+ function preprocessPaths(expected, context) {
291
+ let result = expected;
292
+ const normalizedRoot = context.root.replace(/\\/g, "/");
293
+ const normalizedCwd = context.cwd.replace(/\\/g, "/");
294
+ result = result.replaceAll("[ROOT]", normalizedRoot);
295
+ result = result.replaceAll("[CWD]", normalizedCwd);
296
+ return result;
297
+ }
298
+ /**
299
+ * Normalize actual output for comparison.
300
+ * - Remove ANSI escape codes (colors, etc.)
301
+ * - Normalize line endings to \n
302
+ * - Normalize paths (Windows backslashes to forward slashes)
303
+ * - Trim trailing whitespace from lines
304
+ * - Ensure single trailing newline
305
+ */
306
+ function normalizeOutput(output) {
307
+ let normalized = stripAnsi(output);
308
+ normalized = normalized.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n").map((line) => line.trimEnd()).join("\n").replace(/\n+$/, "\n");
309
+ if (normalized === "\n") normalized = "";
310
+ return normalized;
311
+ }
312
+ /**
313
+ * Check if actual output matches expected pattern.
314
+ */
315
+ function matchOutput(actual, expected, context, customPatterns = {}) {
316
+ const normalizedActual = normalizeOutput(actual);
317
+ const normalizedExpected = normalizeOutput(expected);
318
+ if (normalizedExpected === "" && normalizedActual === "") return true;
319
+ return patternToRegex(preprocessPaths(normalizedExpected, context), customPatterns).test(normalizedActual);
320
+ }
321
+
322
+ //#endregion
323
+ //#region src/index.ts
324
+ const VERSION = "0.0.1";
325
+
326
+ //#endregion
327
+ export { createExecutionContext as a, defineConfig as c, cleanupExecutionContext as i, loadConfig as l, matchOutput as n, runBlock as o, normalizeOutput as r, parseTestFile as s, VERSION as t, mergeConfig as u };
328
+ //# sourceMappingURL=src-UjaSQrqA.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"src-UjaSQrqA.mjs","names":["config: TestConfig","parseYaml","blocks: TestBlock[]","match: RegExpExecArray | null","commandLines: string[]","outputLines: string[]","chunks: Buffer[]","VERSION: string"],"sources":["../src/lib/config.ts","../src/lib/parser.ts","../src/lib/runner.ts","../src/lib/matcher.ts","../src/index.ts"],"sourcesContent":["import { pathToFileURL } from 'node:url';\nimport { existsSync } from 'node:fs';\nimport { resolve } from 'node:path';\nimport type { TestConfig } from './types.js';\n\nexport interface TryscriptConfig {\n bin?: string;\n env?: Record<string, string>;\n timeout?: number;\n patterns?: Record<string, RegExp | string>;\n tests?: string[];\n}\n\nconst CONFIG_FILES = ['tryscript.config.ts', 'tryscript.config.js', 'tryscript.config.mjs'];\n\n/**\n * Load config file using dynamic import.\n * Supports both TypeScript (via tsx/ts-node) and JavaScript configs.\n */\nexport async function loadConfig(baseDir: string): Promise<TryscriptConfig> {\n for (const filename of CONFIG_FILES) {\n const configPath = resolve(baseDir, filename);\n if (existsSync(configPath)) {\n const configUrl = pathToFileURL(configPath).href;\n const module = (await import(configUrl)) as { default?: TryscriptConfig } | TryscriptConfig;\n return (module as { default?: TryscriptConfig }).default ?? (module as TryscriptConfig);\n }\n }\n return {};\n}\n\n/**\n * Merge config with frontmatter overrides.\n * Frontmatter takes precedence over config file.\n */\nexport function mergeConfig(base: TryscriptConfig, frontmatter: TestConfig): TryscriptConfig {\n return {\n ...base,\n ...frontmatter,\n env: { ...base.env, ...frontmatter.env },\n patterns: { ...base.patterns, ...frontmatter.patterns },\n };\n}\n\n/**\n * Helper for typed config files.\n */\nexport function defineConfig(config: TryscriptConfig): TryscriptConfig {\n return config;\n}\n","import { parse as parseYaml } from 'yaml';\nimport type { TestConfig, TestBlock, TestFile } from './types.js';\n\n/** Regex to match YAML frontmatter at the start of a file */\nconst FRONTMATTER_REGEX = /^---\\r?\\n([\\s\\S]*?)\\r?\\n---\\r?\\n/;\n\n/** Regex to match fenced code blocks with console/bash info string */\nconst CODE_BLOCK_REGEX = /```(console|bash)\\r?\\n([\\s\\S]*?)```/g;\n\n/** Regex to match markdown headings (for test names) */\nconst HEADING_REGEX = /^#+\\s+(?:Test:\\s*)?(.+)$/m;\n\n/**\n * Parse a .tryscript.md file into structured test data.\n */\nexport function parseTestFile(content: string, filePath: string): TestFile {\n const rawContent = content;\n let config: TestConfig = {};\n let body = content;\n\n // Extract frontmatter if present\n const frontmatterMatch = FRONTMATTER_REGEX.exec(content);\n if (frontmatterMatch) {\n const yamlContent = frontmatterMatch[1] ?? '';\n config = parseYaml(yamlContent) as TestConfig;\n body = content.slice(frontmatterMatch[0].length);\n }\n\n // Parse all console blocks\n const blocks: TestBlock[] = [];\n\n // Reset regex lastIndex\n CODE_BLOCK_REGEX.lastIndex = 0;\n\n let match: RegExpExecArray | null;\n while ((match = CODE_BLOCK_REGEX.exec(body)) !== null) {\n const blockContent = match[2] ?? '';\n const blockStart = match.index;\n\n // Find the line number (1-indexed)\n const precedingContent = content.slice(0, content.indexOf(match[0]));\n const lineNumber = precedingContent.split('\\n').length;\n\n // Look for a heading before this block (for test name)\n const contentBefore = body.slice(0, blockStart);\n const lastHeadingMatch = [\n ...contentBefore.matchAll(new RegExp(HEADING_REGEX.source, 'gm')),\n ].pop();\n const name = lastHeadingMatch?.[1]?.trim();\n\n // Parse the block content\n const parsed = parseBlockContent(blockContent);\n if (parsed) {\n blocks.push({\n name,\n command: parsed.command,\n expectedOutput: parsed.expectedOutput,\n expectedExitCode: parsed.expectedExitCode,\n lineNumber,\n rawContent: match[0],\n });\n }\n }\n\n return { path: filePath, config, blocks, rawContent };\n}\n\n/**\n * Parse the content of a single console block.\n */\nfunction parseBlockContent(content: string): {\n command: string;\n expectedOutput: string;\n expectedExitCode: number;\n} | null {\n const lines = content.split('\\n');\n const commandLines: string[] = [];\n const outputLines: string[] = [];\n let expectedExitCode = 0;\n let inCommand = false;\n\n for (const line of lines) {\n if (line.startsWith('$ ')) {\n // Start of a command\n inCommand = true;\n commandLines.push(line.slice(2));\n } else if (line.startsWith('> ') && inCommand) {\n // Continuation of a multi-line command\n commandLines.push(line.slice(2));\n } else if (line.startsWith('? ')) {\n // Exit code specification\n inCommand = false;\n expectedExitCode = parseInt(line.slice(2).trim(), 10);\n } else {\n // Output line\n inCommand = false;\n outputLines.push(line);\n }\n }\n\n if (commandLines.length === 0) {\n return null;\n }\n\n // Join command lines, handling shell continuations\n let command = '';\n for (let i = 0; i < commandLines.length; i++) {\n const line = commandLines[i] ?? '';\n if (line.endsWith('\\\\')) {\n command += line.slice(0, -1) + ' ';\n } else {\n command += line;\n if (i < commandLines.length - 1) {\n command += ' ';\n }\n }\n }\n\n // Join output lines, preserving blank lines but trimming trailing empty lines\n let expectedOutput = outputLines.join('\\n');\n expectedOutput = expectedOutput.replace(/\\n+$/, '');\n if (expectedOutput) {\n expectedOutput += '\\n';\n }\n\n return { command: command.trim(), expectedOutput, expectedExitCode };\n}\n","import { spawn } from 'node:child_process';\nimport { mkdtemp, realpath, rm } from 'node:fs/promises';\nimport { tmpdir } from 'node:os';\nimport { join, dirname, resolve } from 'node:path';\nimport treeKill from 'tree-kill';\nimport type { TestBlock, TestBlockResult } from './types.js';\nimport type { TryscriptConfig } from './config.js';\n\n/** Default timeout in milliseconds */\nconst DEFAULT_TIMEOUT = 30_000;\n\n/**\n * Execution context for a test file.\n * Created once per file, contains the temp directory.\n */\nexport interface ExecutionContext {\n /** Temporary directory for this test file (resolved, no symlinks) */\n tempDir: string;\n /** Directory containing the test file (for portable test commands) */\n testDir: string;\n /** Resolved binary path */\n binPath: string;\n /** Environment variables */\n env: Record<string, string>;\n /** Timeout per command */\n timeout: number;\n}\n\n/**\n * Create an execution context for a test file.\n */\nexport async function createExecutionContext(\n config: TryscriptConfig,\n testFilePath: string,\n): Promise<ExecutionContext> {\n // Create temp directory and resolve symlinks (e.g., /var -> /private/var on macOS)\n // This ensures [CWD] and [ROOT] patterns match pwd output\n const rawTempDir = await mkdtemp(join(tmpdir(), 'tryscript-'));\n const tempDir = await realpath(rawTempDir);\n\n // Resolve test file directory for portable test commands\n const testDir = resolve(dirname(testFilePath));\n\n // Resolve binary path relative to test file directory\n let binPath = config.bin ?? '';\n if (binPath && !binPath.startsWith('/')) {\n binPath = join(testDir, binPath);\n }\n\n return {\n tempDir,\n testDir,\n binPath,\n env: {\n ...process.env,\n ...config.env,\n // Disable colors by default for deterministic output\n NO_COLOR: config.env?.NO_COLOR ?? '1',\n FORCE_COLOR: '0',\n // Provide test directory for portable test commands\n TRYSCRIPT_TEST_DIR: testDir,\n } as Record<string, string>,\n timeout: config.timeout ?? DEFAULT_TIMEOUT,\n };\n}\n\n/**\n * Clean up execution context (remove temp directory).\n */\nexport async function cleanupExecutionContext(ctx: ExecutionContext): Promise<void> {\n await rm(ctx.tempDir, { recursive: true, force: true });\n}\n\n/**\n * Run a single test block and return the result.\n */\nexport async function runBlock(block: TestBlock, ctx: ExecutionContext): Promise<TestBlockResult> {\n const startTime = Date.now();\n\n try {\n const { output, exitCode } = await executeCommand(block.command, ctx);\n\n const duration = Date.now() - startTime;\n\n return {\n block,\n passed: true, // Matching handled separately\n actualOutput: output,\n actualExitCode: exitCode,\n duration,\n };\n } catch (error) {\n const duration = Date.now() - startTime;\n const message = error instanceof Error ? error.message : String(error);\n\n return {\n block,\n passed: false,\n actualOutput: '',\n actualExitCode: -1,\n duration,\n error: message,\n };\n }\n}\n\n/**\n * Execute a command and capture output.\n */\nasync function executeCommand(\n command: string,\n ctx: ExecutionContext,\n): Promise<{ output: string; exitCode: number }> {\n return new Promise((resolve, reject) => {\n const proc = spawn(command, {\n shell: true,\n cwd: ctx.tempDir,\n env: ctx.env as NodeJS.ProcessEnv,\n // Pipe both to capture\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n\n const chunks: Buffer[] = [];\n\n // Capture data as it comes in to preserve order\n proc.stdout.on('data', (data: Buffer) => chunks.push(data));\n proc.stderr.on('data', (data: Buffer) => chunks.push(data));\n\n const timeoutId = setTimeout(() => {\n if (proc.pid) {\n treeKill(proc.pid, 'SIGKILL');\n }\n reject(new Error(`Command timed out after ${ctx.timeout}ms`));\n }, ctx.timeout);\n\n proc.on('close', (code) => {\n clearTimeout(timeoutId);\n const output = Buffer.concat(chunks).toString('utf-8');\n resolve({\n output,\n exitCode: code ?? 0,\n });\n });\n\n proc.on('error', (err) => {\n clearTimeout(timeoutId);\n reject(err);\n });\n });\n}\n","import stripAnsi from 'strip-ansi';\n\n/**\n * Escape special regex characters in a string.\n */\nfunction escapeRegex(str: string): string {\n return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\n// Marker prefix for patterns (uses Unicode private use chars that won't appear in normal output)\nconst MARKER = '\\uE000';\n\n/**\n * Convert expected output with elision patterns to a regex.\n *\n * Handles (matching trycmd):\n * - [..] — matches any characters on the same line (trycmd: [^\\n]*?)\n * - ... — matches zero or more complete lines (trycmd: \\n(([^\\n]*\\n)*)?)\n * - [EXE] — matches .exe on Windows, empty otherwise\n * - [ROOT] — replaced with test root directory (pre-processed)\n * - [CWD] — replaced with current working directory (pre-processed)\n * - Custom [NAME] patterns from config (trycmd: TestCases::insert_var)\n */\nfunction patternToRegex(\n expected: string,\n customPatterns: Record<string, string | RegExp> = {},\n): RegExp {\n // Build a map of markers to their regex replacements\n const replacements = new Map<string, string>();\n let markerIndex = 0;\n\n const getMarker = (): string => {\n return `${MARKER}${markerIndex++}${MARKER}`;\n };\n\n let processed = expected;\n\n // Replace [..] with marker\n const dotdotMarker = getMarker();\n replacements.set(dotdotMarker, '[^\\\\n]*');\n processed = processed.replaceAll('[..]', dotdotMarker);\n\n // Replace ... (followed by newline) with marker\n const ellipsisMarker = getMarker();\n replacements.set(ellipsisMarker, '(?:[^\\\\n]*\\\\n)*');\n processed = processed.replace(/\\.\\.\\.\\n/g, ellipsisMarker);\n\n // Replace [EXE] with marker\n const exeMarker = getMarker();\n const exe = process.platform === 'win32' ? '\\\\.exe' : '';\n replacements.set(exeMarker, exe);\n processed = processed.replaceAll('[EXE]', exeMarker);\n\n // Replace custom patterns with markers\n for (const [name, pattern] of Object.entries(customPatterns)) {\n const placeholder = `[${name}]`;\n const patternStr = pattern instanceof RegExp ? pattern.source : pattern;\n const marker = getMarker();\n replacements.set(marker, `(${patternStr})`);\n processed = processed.replaceAll(placeholder, marker);\n }\n\n // Escape special regex characters\n let regex = escapeRegex(processed);\n\n // Restore markers to their regex replacements\n for (const [marker, replacement] of replacements) {\n regex = regex.replaceAll(escapeRegex(marker), replacement);\n }\n\n // Match the entire string (dotall mode for . to match newlines if needed)\n return new RegExp(`^${regex}$`, 's');\n}\n\n/**\n * Pre-process expected output to replace path placeholders with actual paths.\n * This happens BEFORE pattern matching.\n */\nfunction preprocessPaths(expected: string, context: { root: string; cwd: string }): string {\n let result = expected;\n // Normalize paths for comparison (use forward slashes)\n const normalizedRoot = context.root.replace(/\\\\/g, '/');\n const normalizedCwd = context.cwd.replace(/\\\\/g, '/');\n result = result.replaceAll('[ROOT]', normalizedRoot);\n result = result.replaceAll('[CWD]', normalizedCwd);\n return result;\n}\n\n/**\n * Normalize actual output for comparison.\n * - Remove ANSI escape codes (colors, etc.)\n * - Normalize line endings to \\n\n * - Normalize paths (Windows backslashes to forward slashes)\n * - Trim trailing whitespace from lines\n * - Ensure single trailing newline\n */\nexport function normalizeOutput(output: string): string {\n // Remove ANSI escape codes first\n let normalized = stripAnsi(output);\n\n normalized = normalized\n .replace(/\\r\\n/g, '\\n')\n .replace(/\\r/g, '\\n')\n .split('\\n')\n .map((line) => line.trimEnd())\n .join('\\n')\n .replace(/\\n+$/, '\\n');\n\n // Handle empty output\n if (normalized === '\\n') {\n normalized = '';\n }\n\n return normalized;\n}\n\n/**\n * Check if actual output matches expected pattern.\n */\nexport function matchOutput(\n actual: string,\n expected: string,\n context: { root: string; cwd: string },\n customPatterns: Record<string, string | RegExp> = {},\n): boolean {\n const normalizedActual = normalizeOutput(actual);\n const normalizedExpected = normalizeOutput(expected);\n\n // Empty expected matches empty actual\n if (normalizedExpected === '' && normalizedActual === '') {\n return true;\n }\n\n const preprocessed = preprocessPaths(normalizedExpected, context);\n const regex = patternToRegex(preprocessed, customPatterns);\n return regex.test(normalizedActual);\n}\n","// Public API exports\n\n// Version constant (injected at build time)\ndeclare const __VERSION__: string;\nexport const VERSION: string = typeof __VERSION__ !== 'undefined' ? __VERSION__ : 'development';\n\n// Config helper\nexport { defineConfig } from './lib/config.js';\nexport type { TryscriptConfig } from './lib/config.js';\n\n// Types\nexport type {\n TestConfig,\n TestBlock,\n TestFile,\n TestBlockResult,\n TestFileResult,\n TestRunSummary,\n} from './lib/types.js';\n\n// Core functions (for programmatic use)\nexport { parseTestFile } from './lib/parser.js';\nexport { runBlock, createExecutionContext, cleanupExecutionContext } from './lib/runner.js';\nexport type { ExecutionContext } from './lib/runner.js';\nexport { matchOutput, normalizeOutput } from './lib/matcher.js';\n"],"mappings":"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"}
@@ -0,0 +1,163 @@
1
+ # tryscript Quick Reference
2
+
3
+ Concise syntax reference for writing tryscript test files.
4
+
5
+ ## Test File Format
6
+
7
+ Test files use `.tryscript.md` extension. Each file contains Markdown with console code blocks:
8
+
9
+ ```markdown
10
+ # Test: Description
11
+
12
+ \`\`\`console
13
+ $ command
14
+ expected output
15
+ ? exit_code
16
+ \`\`\`
17
+ ```
18
+
19
+ ## Basic Example
20
+
21
+ ```markdown
22
+ # Test: Echo command
23
+
24
+ \`\`\`console
25
+ $ echo "hello world"
26
+ hello world
27
+ ? 0
28
+ \`\`\`
29
+ ```
30
+
31
+ ## Exit Codes
32
+
33
+ Use `? N` to specify expected exit code:
34
+
35
+ ```console
36
+ $ exit 42
37
+ ? 42
38
+ ```
39
+
40
+ ## Elision Patterns
41
+
42
+ | Pattern | Matches | Example |
43
+ | -------- | -------------------------------- | --------------------- |
44
+ | `[..]` | Any characters on current line | `Built in [..]ms` |
45
+ | `...` | Zero or more complete lines | `...\nDone` |
46
+ | `[EXE]` | `.exe` on Windows, empty on Unix | `my-cli[EXE]` |
47
+ | `[ROOT]` | Test root directory | `[ROOT]/output.txt` |
48
+ | `[CWD]` | Current working directory | `[CWD]/file.txt` |
49
+
50
+ ## YAML Frontmatter
51
+
52
+ Configure test behavior at the top of the file:
53
+
54
+ ```yaml
55
+ ---
56
+ bin: ./my-cli
57
+ env:
58
+ NO_COLOR: "1"
59
+ timeout: 5000
60
+ patterns:
61
+ VERSION: "\\d+\\.\\d+\\.\\d+"
62
+ ---
63
+ ```
64
+
65
+ ### Config Options
66
+
67
+ | Option | Type | Description |
68
+ | ---------- | -------- | ------------------------------------- |
69
+ | `bin` | string | Path to CLI binary |
70
+ | `env` | object | Environment variables |
71
+ | `timeout` | number | Command timeout in milliseconds |
72
+ | `patterns` | object | Custom regex patterns for `[NAME]` |
73
+
74
+ ## Custom Patterns
75
+
76
+ Define reusable patterns in frontmatter:
77
+
78
+ ```yaml
79
+ patterns:
80
+ VERSION: "\\d+\\.\\d+\\.\\d+"
81
+ UUID: "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
82
+ ```
83
+
84
+ Use in output:
85
+
86
+ ```console
87
+ $ my-cli --version
88
+ my-cli version [VERSION]
89
+ ? 0
90
+ ```
91
+
92
+ ## Multiple Commands
93
+
94
+ Multiple tests per file, each with its own heading:
95
+
96
+ ```markdown
97
+ # Test: First test
98
+
99
+ \`\`\`console
100
+ $ echo one
101
+ one
102
+ ? 0
103
+ \`\`\`
104
+
105
+ # Test: Second test
106
+
107
+ \`\`\`console
108
+ $ echo two
109
+ two
110
+ ? 0
111
+ \`\`\`
112
+ ```
113
+
114
+ ## CLI Usage
115
+
116
+ ```bash
117
+ # Run all tests
118
+ npx tryscript
119
+
120
+ # Run specific files
121
+ npx tryscript tests/foo.tryscript.md
122
+
123
+ # Update golden files
124
+ npx tryscript --update
125
+
126
+ # Filter tests by name
127
+ npx tryscript --filter "pattern"
128
+
129
+ # Fail fast on first error
130
+ npx tryscript --fail-fast
131
+
132
+ # Verbose output
133
+ npx tryscript --verbose
134
+ ```
135
+
136
+ ## Options
137
+
138
+ | Option | Description |
139
+ | ------------------ | ---------------------------------------- |
140
+ | `--update` | Update golden files with actual output |
141
+ | `--diff` | Show diff on failure (default: true) |
142
+ | `--no-diff` | Hide diff on failure |
143
+ | `--fail-fast` | Stop on first failure |
144
+ | `--filter <regex>` | Filter tests by name pattern |
145
+ | `--verbose` | Show detailed output |
146
+ | `--quiet` | Suppress non-essential output |
147
+
148
+ ## Config File
149
+
150
+ Create `tryscript.config.ts` in your project root:
151
+
152
+ ```typescript
153
+ import { defineConfig } from 'tryscript';
154
+
155
+ export default defineConfig({
156
+ bin: './dist/cli.js',
157
+ env: { NO_COLOR: '1' },
158
+ timeout: 30000,
159
+ patterns: {
160
+ VERSION: '\\d+\\.\\d+\\.\\d+',
161
+ },
162
+ });
163
+ ```
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "tryscript",
3
+ "version": "0.0.1",
4
+ "description": "Golden testing for CLI applications - TypeScript port of trycmd",
5
+ "license": "MIT",
6
+ "author": "Joshua Levy",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/jlevy/tryscript.git"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "main": "./dist/index.cjs",
14
+ "module": "./dist/index.mjs",
15
+ "types": "./dist/index.d.mts",
16
+ "exports": {
17
+ ".": {
18
+ "import": {
19
+ "types": "./dist/index.d.mts",
20
+ "default": "./dist/index.mjs"
21
+ },
22
+ "require": {
23
+ "types": "./dist/index.d.cts",
24
+ "default": "./dist/index.cjs"
25
+ }
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "bin": {
30
+ "tryscript": "./dist/bin.mjs"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "docs",
35
+ "README.md"
36
+ ],
37
+ "engines": {
38
+ "node": ">=20"
39
+ },
40
+ "scripts": {
41
+ "build": "tsdown",
42
+ "dev": "tsdown --watch",
43
+ "typecheck": "tsc -p tsconfig.json --noEmit",
44
+ "test": "vitest run",
45
+ "test:watch": "vitest",
46
+ "test:golden": "node dist/bin.mjs 'tests/**/*.tryscript.md'",
47
+ "test:golden:coverage": "c8 --src src --all --include 'dist/**' --reporter text --reporter html --reports-dir coverage-golden node dist/bin.mjs 'tests/**/*.tryscript.md'",
48
+ "test:coverage": "vitest run --coverage",
49
+ "test:all:coverage": "pnpm test:golden:coverage && pnpm test:coverage",
50
+ "publint": "publint",
51
+ "prepack": "pnpm build",
52
+ "tryscript": "tsx src/bin.ts"
53
+ },
54
+ "dependencies": {
55
+ "atomically": "^2.0.0",
56
+ "commander": "^14.0.0",
57
+ "diff": "^8.0.0",
58
+ "fast-glob": "^3.3.0",
59
+ "picocolors": "^1.1.0",
60
+ "strip-ansi": "^7.1.0",
61
+ "tree-kill": "^1.2.0",
62
+ "yaml": "^2.6.0",
63
+ "zod": "^3.24.0"
64
+ },
65
+ "devDependencies": {
66
+ "@types/diff": "^7.0.0",
67
+ "@types/node": "^22.0.0",
68
+ "@vitest/coverage-v8": "^4.0.0",
69
+ "c8": "^10.1.3",
70
+ "publint": "^0.3.0",
71
+ "tsdown": "^0.18.0",
72
+ "tsx": "^4.21.0",
73
+ "typescript": "^5.0.0",
74
+ "vitest": "^4.0.0"
75
+ }
76
+ }