tryscript 0.1.6 → 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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"src-BQxIhzgF.mjs","names":["DEFAULT_COVERAGE_CONFIG: ResolvedCoverageConfig","results: CodeBlockMatch[]","offsets: number[]","config: TestConfig","parseYaml","blocks: TestBlock[]","commandLines: string[]","outputLines: string[]","stderrLines: string[]","expectedStderr: string | undefined","resolve","cwd: string","tryscriptEnvVars: Record<string, string>","expandEnvVars","pathParts: string[]","combinedChunks: { data: Buffer; type: 'stdout' | 'stderr' }[]","stdoutChunks: Buffer[]","stderrChunks: Buffer[]","processed","m: RegExpExecArray | null","groups: CaptureGroupMeta[]","WILDCARD_TOKENS: {\n token: string | RegExp;\n category: WildcardCategory;\n multiline: boolean;\n}[]","tokenPositions: { pos: number; length: number }[]","m: RegExpExecArray | null","changes: string[]","lines: string[]","VERSION: string"],"sources":["../src/lib/config.ts","../src/lib/parser.ts","../src/lib/package-bin.ts","../src/lib/env-vars.ts","../src/lib/runner.ts","../src/lib/matcher.ts","../src/lib/expander.ts","../src/lib/yaml-utils.ts","../src/lib/capture-log.ts","../src/index.ts"],"sourcesContent":["import { pathToFileURL } from 'node:url';\nimport { existsSync } from 'node:fs';\nimport { resolve } from 'node:path';\nimport type { TestConfig, CoverageConfig } from './types.js';\n\n/** Fixture configuration for copying files to sandbox directory */\nexport interface Fixture {\n /** Source path (resolved relative to test file) */\n source: string;\n /** Destination path (resolved relative to sandbox dir) */\n dest?: string;\n}\n\nexport interface TryscriptConfig {\n /** Working directory for commands (default: test file directory) */\n cwd?: string;\n /** Run in isolated sandbox: true = empty temp, path = copy to temp */\n sandbox?: boolean | string;\n /** Fixtures to copy to sandbox directory before tests */\n fixtures?: (string | Fixture)[];\n /** Script to run before first test block */\n before?: string;\n /** Script to run after all test blocks */\n after?: string;\n env?: Record<string, string>;\n timeout?: number;\n patterns?: Record<string, RegExp | string>;\n tests?: string[];\n /** Coverage configuration (used with --coverage flag) */\n coverage?: CoverageConfig;\n /**\n * Directories to prepend to PATH (resolved relative to test file).\n * Makes executables in these directories available by name in commands.\n * Supports env var expansion: $VAR or ${VAR} syntax.\n */\n path?: string[];\n}\n\n/** Resolved coverage config with required fields (except optional mergeLcov) */\nexport type ResolvedCoverageConfig = Omit<Required<CoverageConfig>, 'mergeLcov'> & {\n mergeLcov?: string;\n};\n\n/** Default coverage configuration values. */\nexport const DEFAULT_COVERAGE_CONFIG: ResolvedCoverageConfig = {\n reportsDir: 'coverage-tryscript',\n reporters: ['text', 'html'],\n include: ['dist/**'],\n exclude: [],\n excludeNodeModules: true,\n excludeAfterRemap: false,\n skipFull: false,\n allowExternal: false,\n src: 'src',\n monocart: false,\n};\n\n/**\n * Resolve coverage options by merging user config with defaults.\n */\nexport function resolveCoverageConfig(config?: CoverageConfig): ResolvedCoverageConfig {\n return {\n reportsDir: config?.reportsDir ?? DEFAULT_COVERAGE_CONFIG.reportsDir,\n reporters: config?.reporters ?? DEFAULT_COVERAGE_CONFIG.reporters,\n include: config?.include ?? DEFAULT_COVERAGE_CONFIG.include,\n exclude: config?.exclude ?? DEFAULT_COVERAGE_CONFIG.exclude,\n excludeNodeModules: config?.excludeNodeModules ?? DEFAULT_COVERAGE_CONFIG.excludeNodeModules,\n excludeAfterRemap: config?.excludeAfterRemap ?? DEFAULT_COVERAGE_CONFIG.excludeAfterRemap,\n skipFull: config?.skipFull ?? DEFAULT_COVERAGE_CONFIG.skipFull,\n allowExternal: config?.allowExternal ?? DEFAULT_COVERAGE_CONFIG.allowExternal,\n src: config?.src ?? DEFAULT_COVERAGE_CONFIG.src,\n monocart: config?.monocart ?? DEFAULT_COVERAGE_CONFIG.monocart,\n mergeLcov: config?.mergeLcov,\n };\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 fixtures: [...(base.fixtures ?? []), ...(frontmatter.fixtures ?? [])],\n // Frontmatter paths have higher priority, so they come first\n path: [...(frontmatter.path ?? []), ...(base.path ?? [])],\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 markdown headings (for test names) */\nconst HEADING_REGEX = /^#+\\s+(?:Test:\\s*)?(.+)$/m;\n\n/** Regex to match skip annotation in heading or nearby HTML comment */\nconst SKIP_ANNOTATION_REGEX = /<!--\\s*skip\\s*-->/i;\n\n/** Regex to match only annotation in heading or nearby HTML comment */\nconst ONLY_ANNOTATION_REGEX = /<!--\\s*only\\s*-->/i;\n\ninterface CodeBlockMatch {\n fullMatch: string;\n infoString: string;\n content: string;\n index: number;\n}\n\n/**\n * Find console/bash fenced code blocks, supporting extended fences (4+ backticks).\n *\n * Extended fences allow embedding triple-backtick blocks in expected output.\n * A closing fence must have at least as many backticks as the opening fence\n * (per CommonMark spec).\n */\nfunction findConsoleCodeBlocks(text: string): CodeBlockMatch[] {\n const results: CodeBlockMatch[] = [];\n const lines = text.split('\\n');\n\n const offsets: number[] = new Array<number>(lines.length);\n offsets[0] = 0;\n for (let j = 1; j < lines.length; j++) {\n offsets[j] = offsets[j - 1]! + lines[j - 1]!.length + 1;\n }\n\n let i = 0;\n while (i < lines.length) {\n const line = lines[i]!;\n const trimmed = line.endsWith('\\r') ? line.slice(0, -1) : line;\n const openMatch = /^(`{3,})(console|bash)\\s*$/.exec(trimmed);\n\n if (!openMatch) {\n i++;\n continue;\n }\n\n const fenceLen = openMatch[1]!.length;\n const infoString = openMatch[2]!;\n const openLineIdx = i;\n const closingRe = new RegExp(`^\\`{${fenceLen},}\\\\s*$`);\n\n i++;\n while (i < lines.length) {\n const cur = lines[i]!;\n const curTrimmed = cur.endsWith('\\r') ? cur.slice(0, -1) : cur;\n if (closingRe.test(curTrimmed)) {\n const startOffset = offsets[openLineIdx]!;\n const endOffset = offsets[i]! + lines[i]!.length;\n const contentStart = offsets[openLineIdx + 1]!;\n const contentEnd = offsets[i]!;\n\n results.push({\n fullMatch: text.slice(startOffset, endOffset),\n infoString,\n content: text.slice(contentStart, contentEnd),\n index: startOffset,\n });\n i++;\n break;\n }\n i++;\n }\n }\n\n return results;\n}\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 (supports extended fences with 4+ backticks)\n const blocks: TestBlock[] = [];\n const codeBlocks = findConsoleCodeBlocks(body);\n\n for (const codeBlock of codeBlocks) {\n const blockContent = codeBlock.content;\n const blockStart = codeBlock.index;\n\n // Find the line number (1-indexed)\n const precedingContent = content.slice(0, content.indexOf(codeBlock.fullMatch));\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 // Check for skip/only annotations in the heading line or nearby comments\n const headingContext = lastHeadingMatch\n ? contentBefore.slice(contentBefore.lastIndexOf(lastHeadingMatch[0]))\n : '';\n const skip = SKIP_ANNOTATION_REGEX.test(headingContext);\n const only = ONLY_ANNOTATION_REGEX.test(headingContext);\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 expectedStderr: parsed.expectedStderr,\n expectedExitCode: parsed.expectedExitCode,\n lineNumber,\n rawContent: codeBlock.fullMatch,\n skip,\n only,\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 expectedStderr?: string;\n expectedExitCode: number;\n} | null {\n const lines = content.split('\\n');\n const commandLines: string[] = [];\n const outputLines: string[] = [];\n const stderrLines: 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 if (line.startsWith('! ')) {\n // Stderr line (prefixed with !)\n inCommand = false;\n stderrLines.push(line.slice(2));\n } else {\n // Output line (stdout or combined)\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 // Join stderr lines if any\n let expectedStderr: string | undefined;\n if (stderrLines.length > 0) {\n expectedStderr = stderrLines.join('\\n');\n expectedStderr = expectedStderr.replace(/\\n+$/, '');\n if (expectedStderr) {\n expectedStderr += '\\n';\n }\n }\n\n return { command: command.trim(), expectedOutput, expectedStderr, expectedExitCode };\n}\n","import { existsSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\n\n/**\n * Find nearest package.json by walking up from startDir.\n * Returns the path to package.json, or null if not found.\n */\nexport function findPackageJson(startDir: string): string | null {\n let dir = startDir;\n\n while (true) {\n const pkgPath = join(dir, 'package.json');\n if (existsSync(pkgPath)) {\n return pkgPath;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n break;\n } // Reached filesystem root\n dir = parent;\n }\n\n return null;\n}\n\n/**\n * Find nearest .git directory by walking up from startDir.\n * Returns the directory containing .git, or null if not found.\n */\nexport function findGitRoot(startDir: string): string | null {\n let dir = startDir;\n\n while (true) {\n const gitPath = join(dir, '.git');\n if (existsSync(gitPath)) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n break;\n } // Reached filesystem root\n dir = parent;\n }\n\n return null;\n}\n","/**\n * Environment variable expansion utilities.\n *\n * Provides shell-compatible variable expansion for configuration values.\n */\n\n/**\n * Expand environment variable references in a string.\n *\n * Supports standard shell variable syntax:\n * - `$VAR` - simple variable reference\n * - `${VAR}` - braced variable reference\n *\n * Variables are resolved in order:\n * 1. Custom env vars (if provided)\n * 2. Process environment variables\n * 3. Empty string (if undefined)\n *\n * @param str - String containing variable references\n * @param customEnv - Optional custom environment to check first\n * @returns String with variables expanded\n *\n * @example\n * ```ts\n * expandEnvVars('$HOME/bin') // '/home/user/bin'\n * expandEnvVars('${HOME}/bin') // '/home/user/bin'\n * expandEnvVars('$UNDEFINED') // ''\n * expandEnvVars('$MY_VAR', { MY_VAR: 'value' }) // 'value'\n * ```\n */\nexport function expandEnvVars(str: string, customEnv?: Record<string, string>): string {\n const resolve = (varName: string): string => {\n return customEnv?.[varName] ?? process.env[varName] ?? '';\n };\n\n return str\n .replace(/\\$\\{([A-Za-z_][A-Za-z0-9_]*)\\}/g, (_, varName: string) => resolve(varName))\n .replace(/\\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, varName: string) => resolve(varName));\n}\n\n/**\n * Create a bound expander with a preset custom environment.\n *\n * Useful when expanding multiple strings with the same custom env.\n *\n * @param customEnv - Custom environment variables to use\n * @returns Bound expand function\n *\n * @example\n * ```ts\n * const expand = createEnvExpander({ TRYSCRIPT_ROOT: '/project' });\n * expand('$TRYSCRIPT_ROOT/dist') // '/project/dist'\n * expand('$HOME/bin') // Uses process.env.HOME\n * ```\n */\nexport function createEnvExpander(customEnv: Record<string, string>): (str: string) => string {\n return (str: string) => expandEnvVars(str, customEnv);\n}\n","import { spawn } from 'node:child_process';\nimport { existsSync } from 'node:fs';\nimport { mkdtemp, realpath, rm, cp } from 'node:fs/promises';\nimport { tmpdir } from 'node:os';\nimport { join, dirname, resolve, basename, delimiter } from 'node:path';\nimport treeKill from 'tree-kill';\nimport type { TestBlock, TestBlockResult } from './types.js';\nimport type { TryscriptConfig, Fixture } from './config.js';\nimport { findPackageJson, findGitRoot } from './package-bin.js';\nimport { createEnvExpander } from './env-vars.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 directory paths and config.\n */\nexport interface ExecutionContext {\n /** Temporary directory for this test file (resolved, no symlinks) */\n tempDir: string;\n /** Directory containing the test file */\n testDir: string;\n /** Working directory for command execution */\n cwd: string;\n /** Whether running in sandbox mode */\n sandbox: boolean;\n /** Environment variables */\n env: Record<string, string>;\n /** Timeout per command */\n timeout: number;\n /** Before hook script */\n before?: string;\n /** After hook script */\n after?: string;\n /** Whether before hook has been run */\n beforeRan?: boolean;\n}\n\n/**\n * Normalize fixture config to Fixture object.\n */\nfunction normalizeFixture(fixture: string | Fixture): Fixture {\n if (typeof fixture === 'string') {\n return { source: fixture };\n }\n return fixture;\n}\n\n/**\n * Setup fixtures by copying files to sandbox directory.\n */\nasync function setupFixtures(\n fixtures: (string | Fixture)[] | undefined,\n testDir: string,\n sandboxDir: string,\n): Promise<void> {\n if (!fixtures || fixtures.length === 0) {\n return;\n }\n\n for (const f of fixtures) {\n const fixture = normalizeFixture(f);\n const src = resolve(testDir, fixture.source);\n const destName = fixture.dest ?? basename(fixture.source);\n const dst = resolve(sandboxDir, destName);\n await cp(src, dst, { recursive: true });\n }\n}\n\n/**\n * Create an execution context for a test file.\n * @param config - Test configuration\n * @param testFilePath - Path to the test file\n * @param coverageEnv - Optional coverage environment variables (e.g., NODE_V8_COVERAGE)\n */\nexport async function createExecutionContext(\n config: TryscriptConfig,\n testFilePath: string,\n coverageEnv?: Record<string, 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 // Determine working directory based on sandbox config\n let cwd: string;\n let sandbox = false;\n\n if (config.sandbox === true) {\n // Empty sandbox: run in temp directory\n cwd = tempDir;\n sandbox = true;\n } else if (typeof config.sandbox === 'string') {\n // Copy directory to sandbox: copy source to temp, run in temp\n const srcPath = resolve(testDir, config.sandbox);\n await cp(srcPath, tempDir, { recursive: true });\n cwd = tempDir;\n sandbox = true;\n } else if (config.cwd) {\n // Run in specified directory (relative to test file)\n cwd = resolve(testDir, config.cwd);\n } else {\n // Default: run in test file directory\n cwd = testDir;\n }\n\n // Copy additional fixtures to sandbox (only if sandbox enabled)\n if (sandbox && config.fixtures) {\n await setupFixtures(config.fixtures, testDir, tempDir);\n }\n\n // Find package root for TRYSCRIPT_PACKAGE_ROOT (always available)\n const pkgPath = findPackageJson(testDir);\n const packageRoot = pkgPath ? dirname(pkgPath) : undefined;\n\n // Find git root for TRYSCRIPT_GIT_ROOT\n const gitRoot = findGitRoot(testDir) ?? undefined;\n\n // TRYSCRIPT_PROJECT_ROOT is the most specific (deepest) of package or git root\n // Deeper path = longer string = more specific project boundary\n const projectRoot =\n packageRoot && gitRoot\n ? packageRoot.length >= gitRoot.length\n ? packageRoot\n : gitRoot\n : (packageRoot ?? gitRoot);\n\n // TRYSCRIPT_PACKAGE_BIN points to node_modules/.bin if it exists\n const packageBinPath = packageRoot ? join(packageRoot, 'node_modules', '.bin') : undefined;\n const packageBin = packageBinPath && existsSync(packageBinPath) ? packageBinPath : undefined;\n\n // Build env vars map for path expansion (before building PATH)\n const tryscriptEnvVars: Record<string, string> = {\n ...(packageRoot && { TRYSCRIPT_PACKAGE_ROOT: packageRoot }),\n ...(gitRoot && { TRYSCRIPT_GIT_ROOT: gitRoot }),\n ...(projectRoot && { TRYSCRIPT_PROJECT_ROOT: projectRoot }),\n ...(packageBin && { TRYSCRIPT_PACKAGE_BIN: packageBin }),\n TRYSCRIPT_TEST_DIR: testDir,\n };\n\n // Create expander with tryscript env vars taking precedence\n const expandEnvVars = createEnvExpander(tryscriptEnvVars);\n\n // Build PATH: config paths > system PATH\n const pathParts: string[] = [];\n if (config.path && config.path.length > 0) {\n // Expand env vars in path entries, then resolve relative to testDir\n pathParts.push(\n ...config.path.map((p) => {\n const expanded = expandEnvVars(p);\n // If already absolute (after expansion), use as-is; otherwise resolve relative to testDir\n return expanded.startsWith('/') ? expanded : resolve(testDir, expanded);\n }),\n );\n }\n pathParts.push(process.env.PATH ?? '');\n\n const ctx: ExecutionContext = {\n tempDir,\n testDir,\n cwd,\n sandbox,\n env: {\n ...process.env,\n ...config.env,\n ...coverageEnv,\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 // Provide project roots for manual path construction\n ...tryscriptEnvVars,\n // Custom PATH with config paths\n PATH: pathParts.join(delimiter),\n } as Record<string, string>,\n timeout: config.timeout ?? DEFAULT_TIMEOUT,\n before: config.before,\n after: config.after,\n };\n\n return ctx;\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 the before hook if it hasn't run yet.\n */\nexport async function runBeforeHook(ctx: ExecutionContext): Promise<void> {\n if (ctx.before && !ctx.beforeRan) {\n ctx.beforeRan = true;\n await executeCommand(ctx.before, ctx);\n }\n}\n\n/**\n * Run the after hook.\n */\nexport async function runAfterHook(ctx: ExecutionContext): Promise<void> {\n if (ctx.after) {\n await executeCommand(ctx.after, ctx);\n }\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 // Handle skip annotation\n if (block.skip) {\n return {\n block,\n passed: true,\n actualOutput: '',\n actualExitCode: 0,\n duration: 0,\n skipped: true,\n };\n }\n\n try {\n // Run before hook if this is the first test\n await runBeforeHook(ctx);\n\n // Execute command directly (shell handles $VAR expansion)\n const { output, stdout, stderr, 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 actualStdout: stdout,\n actualStderr: stderr,\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/** Command execution result with separate stdout/stderr */\ninterface CommandResult {\n output: string;\n stdout: string;\n stderr: string;\n exitCode: number;\n}\n\n/**\n * Execute a command and capture output.\n */\nasync function executeCommand(command: string, ctx: ExecutionContext): Promise<CommandResult> {\n return new Promise((resolve, reject) => {\n const proc = spawn(command, {\n shell: true,\n cwd: ctx.cwd,\n env: ctx.env as NodeJS.ProcessEnv,\n // Pipe both to capture\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n\n const combinedChunks: { data: Buffer; type: 'stdout' | 'stderr' }[] = [];\n const stdoutChunks: Buffer[] = [];\n const stderrChunks: Buffer[] = [];\n\n // Capture data as it comes in to preserve order\n proc.stdout.on('data', (data: Buffer) => {\n combinedChunks.push({ data, type: 'stdout' });\n stdoutChunks.push(data);\n });\n proc.stderr.on('data', (data: Buffer) => {\n combinedChunks.push({ data, type: 'stderr' });\n stderrChunks.push(data);\n });\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(combinedChunks.map((c) => c.data)).toString('utf-8');\n const stdout = Buffer.concat(stdoutChunks).toString('utf-8');\n const stderr = Buffer.concat(stderrChunks).toString('utf-8');\n resolve({\n output,\n stdout,\n stderr,\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';\nimport type { WildcardCapture, WildcardCategory } from './types.js';\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 * Metadata for a capturing group inside a regex built by `patternToCapturingRegex()`.\n */\ninterface CaptureGroupMeta {\n category: WildcardCategory;\n name?: string;\n multiline: boolean;\n}\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 [??] with marker (unknown single-line, same regex as [..])\n const unknownDotdotMarker = getMarker();\n replacements.set(unknownDotdotMarker, '[^\\\\n]*');\n processed = processed.replaceAll('[??]', unknownDotdotMarker);\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 ??? (followed by newline) with marker (unknown multi-line, same regex as ...)\n const unknownEllipsisMarker = getMarker();\n replacements.set(unknownEllipsisMarker, '(?:[^\\\\n]*\\\\n)*');\n processed = processed.replace(/\\?\\?\\?\\n/g, unknownEllipsisMarker);\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 * Like `patternToRegex()` but wraps each wildcard in a capturing group and\n * returns metadata describing what each group represents.\n *\n * Each occurrence gets a unique marker so that the `groups` array is ordered\n * by position in the string, matching the regex capture group indices.\n */\nfunction patternToCapturingRegex(\n expected: string,\n customPatterns: Record<string, string | RegExp> = {},\n): { regex: RegExp; groups: CaptureGroupMeta[] } {\n const replacements = new Map<string, string>();\n // Maps marker string -> its CaptureGroupMeta (or null for non-capture markers)\n const markerMeta = new Map<string, CaptureGroupMeta | null>();\n let markerIndex = 0;\n\n const getMarker = (): string => {\n return `${MARKER}${markerIndex++}${MARKER}`;\n };\n\n // Replace each occurrence individually to maintain position ordering.\n const replaceEach = (\n processed: string,\n pattern: string | RegExp,\n regexStr: string,\n meta: CaptureGroupMeta | null,\n ): string => {\n let result = processed;\n if (typeof pattern === 'string') {\n while (result.includes(pattern)) {\n const marker = getMarker();\n replacements.set(marker, regexStr);\n markerMeta.set(marker, meta);\n result = result.replace(pattern, marker);\n }\n } else {\n let m: RegExpExecArray | null;\n while ((m = pattern.exec(result)) !== null) {\n const marker = getMarker();\n replacements.set(marker, regexStr);\n markerMeta.set(marker, meta);\n result = result.slice(0, m.index) + marker + result.slice(m.index + m[0].length);\n }\n }\n return result;\n };\n\n let processed = expected;\n\n // [..] — generic single-line\n processed = replaceEach(processed, '[..]', '([^\\\\n]*)', {\n category: 'generic',\n multiline: false,\n });\n\n // [??] — unknown single-line\n processed = replaceEach(processed, '[??]', '([^\\\\n]*)', {\n category: 'unknown',\n multiline: false,\n });\n\n // ... — generic multi-line (must be followed by newline)\n processed = replaceEach(processed, /\\.\\.\\.\\n/, '((?:[^\\\\n]*\\\\n)*)', {\n category: 'generic',\n multiline: true,\n });\n\n // ??? — unknown multi-line (must be followed by newline)\n processed = replaceEach(processed, /\\?\\?\\?\\n/, '((?:[^\\\\n]*\\\\n)*)', {\n category: 'unknown',\n multiline: true,\n });\n\n // [EXE] — no capture\n const exe = process.platform === 'win32' ? '\\\\.exe' : '';\n processed = replaceEach(processed, '[EXE]', exe, null);\n\n // Custom named patterns\n for (const [name, pattern] of Object.entries(customPatterns)) {\n const placeholder = `[${name}]`;\n const patternStr = pattern instanceof RegExp ? pattern.source : pattern;\n processed = replaceEach(processed, placeholder, `(${patternStr})`, {\n category: 'named',\n name,\n multiline: false,\n });\n }\n\n // Sort markers by their position in the processed string so that\n // the groups array matches the left-to-right capture group order.\n const sortedEntries = [...replacements.entries()].sort((a, b) => {\n return processed.indexOf(a[0]) - processed.indexOf(b[0]);\n });\n\n let regex = escapeRegex(processed);\n const groups: CaptureGroupMeta[] = [];\n for (const [marker, replacement] of sortedEntries) {\n const meta = markerMeta.get(marker);\n if (meta) {\n groups.push(meta);\n }\n regex = regex.replaceAll(escapeRegex(marker), replacement);\n }\n\n return { regex: new RegExp(`^${regex}$`, 's'), groups };\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\n/**\n * Match actual output against expected pattern and return wildcard captures.\n * Returns `null` if the output does not match.\n */\nexport function matchAndCapture(\n actual: string,\n expected: string,\n context: { root: string; cwd: string },\n customPatterns: Record<string, string | RegExp> = {},\n): { captures: WildcardCapture[] } | null {\n const normalizedActual = normalizeOutput(actual);\n const normalizedExpected = normalizeOutput(expected);\n\n if (normalizedExpected === '' && normalizedActual === '') {\n return { captures: [] };\n }\n\n const preprocessed = preprocessPaths(normalizedExpected, context);\n const { regex, groups } = patternToCapturingRegex(preprocessed, customPatterns);\n const match = regex.exec(normalizedActual);\n\n if (!match) {\n return null;\n }\n\n const captures: WildcardCapture[] = groups.map((meta, i) => ({\n category: meta.category,\n name: meta.name,\n multiline: meta.multiline,\n captured: match[i + 1] ?? '',\n }));\n\n return { captures };\n}\n","import { writeFile } from 'atomically';\nimport { matchAndCapture, normalizeOutput } from './matcher.js';\nimport type {\n ExpandLevel,\n ExpansionResult,\n TestFile,\n TestBlockResult,\n WildcardCategory,\n} from './types.js';\n\n/**\n * Whether a wildcard category should be expanded at the given level.\n *\n * The hierarchy is: unknown < generic < all.\n */\nexport function shouldExpandCategory(category: WildcardCategory, level: ExpandLevel): boolean {\n switch (level) {\n case 'unknown':\n return category === 'unknown';\n case 'generic':\n return category === 'unknown' || category === 'generic';\n case 'all':\n return true;\n }\n}\n\n// Wildcard token patterns in the order they should be searched.\n// Multi-line variants must include the trailing newline.\nconst WILDCARD_TOKENS: {\n token: string | RegExp;\n category: WildcardCategory;\n multiline: boolean;\n}[] = [\n { token: '[..]', category: 'generic', multiline: false },\n { token: '[??]', category: 'unknown', multiline: false },\n { token: /\\.\\.\\.\\n/, category: 'generic', multiline: true },\n { token: /\\?\\?\\?\\n/, category: 'unknown', multiline: true },\n];\n\n/**\n * Expand wildcards in expected output by replacing them with captured actual text.\n *\n * Only wildcards whose category is targeted by `level` are replaced; others are\n * left intact. Returns `null` if actual output doesn't match expected pattern.\n */\nexport function expandExpectedOutput(\n expected: string,\n actual: string,\n context: { root: string; cwd: string },\n level: ExpandLevel,\n customPatterns?: Record<string, string | RegExp>,\n): ExpansionResult | null {\n const normalizedExpected = normalizeOutput(expected);\n const normalizedActual = normalizeOutput(actual);\n\n if (normalizedExpected === '' && normalizedActual === '') {\n return { expandedOutput: '', captures: [], expandedCount: 0 };\n }\n\n const result = matchAndCapture(actual, expected, context, customPatterns);\n if (!result) {\n return null;\n }\n\n // Find all wildcard token positions, sort by position to match the\n // left-to-right capture order from matchAndCapture(), then assign captures.\n let output = normalizedExpected;\n let expandedCount = 0;\n\n const tokenPositions: { pos: number; length: number }[] = [];\n\n // Find positions of built-in wildcard tokens.\n for (const wt of WILDCARD_TOKENS) {\n let searchFrom = 0;\n if (typeof wt.token === 'string') {\n while (true) {\n const pos = output.indexOf(wt.token, searchFrom);\n if (pos === -1) {\n break;\n }\n tokenPositions.push({ pos, length: wt.token.length });\n searchFrom = pos + wt.token.length;\n }\n } else {\n const re = new RegExp(wt.token.source, 'g');\n let m: RegExpExecArray | null;\n while ((m = re.exec(output)) !== null) {\n tokenPositions.push({ pos: m.index, length: m[0].length });\n }\n }\n }\n\n // Find positions of named custom pattern tokens.\n if (customPatterns) {\n for (const name of Object.keys(customPatterns)) {\n const placeholder = `[${name}]`;\n let searchFrom = 0;\n while (true) {\n const pos = output.indexOf(placeholder, searchFrom);\n if (pos === -1) {\n break;\n }\n tokenPositions.push({ pos, length: placeholder.length });\n searchFrom = pos + placeholder.length;\n }\n }\n }\n\n // Sort by position (left-to-right) to match matchAndCapture() capture order.\n tokenPositions.sort((a, b) => a.pos - b.pos);\n\n // Assign captures in position order.\n const replacements = tokenPositions.map((tp, i) => ({\n ...tp,\n capture: result.captures[i]!,\n }));\n\n // Apply replacements in reverse to maintain offsets.\n for (let i = replacements.length - 1; i >= 0; i--) {\n const r = replacements[i]!;\n if (shouldExpandCategory(r.capture.category, level)) {\n const replacement = r.capture.captured;\n // Multi-line captures already include trailing newlines from the regex;\n // the token also consumed the trailing newline, so this is 1:1.\n output = output.slice(0, r.pos) + replacement + output.slice(r.pos + r.length);\n expandedCount++;\n }\n }\n\n return { expandedOutput: output, captures: result.captures, expandedCount };\n}\n\n/**\n * Expand wildcards in a test file in place.\n *\n * Uses the same reverse-order strategy as `updater.ts` to maintain correct\n * string offsets when modifying multiple blocks.\n */\nexport async function expandTestFile(\n file: TestFile,\n results: TestBlockResult[],\n level: ExpandLevel,\n context: { root: string; cwd: string },\n customPatterns?: Record<string, string | RegExp>,\n): Promise<{ expanded: boolean; expandedCount: number; changes: string[] }> {\n let content = file.rawContent;\n const changes: string[] = [];\n let totalExpanded = 0;\n\n // Map by block identity so expansion works correctly with --filter/<!-- only -->\n // where `results` can be a strict subset of `file.blocks`.\n const resultByBlock = new Map(results.map((result) => [result.block, result]));\n // Process blocks in reverse order to maintain correct offsets\n const blocksWithResults = [...file.blocks]\n .map((block) => ({ block, result: resultByBlock.get(block) }))\n .reverse();\n\n for (const { block, result } of blocksWithResults) {\n if (!result || !block.expectedOutput) {\n continue;\n }\n\n const expansion = expandExpectedOutput(\n block.expectedOutput,\n result.actualOutput,\n context,\n level,\n customPatterns,\n );\n\n if (!expansion || expansion.expandedCount === 0) {\n continue;\n }\n\n // Rebuild the block with expanded expected output\n const fence = '`'.repeat(/^(`+)/.exec(block.rawContent)?.[1]?.length ?? 3);\n const commandLines = block.command.split('\\n').map((line, i) => {\n return i === 0 ? `$ ${line}` : `> ${line}`;\n });\n\n const lines: string[] = [`${fence}console`, ...commandLines];\n const trimmedOutput = expansion.expandedOutput.trimEnd();\n if (trimmedOutput) {\n lines.push(trimmedOutput);\n }\n lines.push(`? ${block.expectedExitCode ?? result.actualExitCode}`, fence);\n\n const newBlockContent = lines.join('\\n');\n const blockStart = content.indexOf(block.rawContent);\n if (blockStart !== -1) {\n content =\n content.slice(0, blockStart) +\n newBlockContent +\n content.slice(blockStart + block.rawContent.length);\n changes.push(block.name ?? `Line ${block.lineNumber}`);\n totalExpanded += expansion.expandedCount;\n }\n }\n\n if (changes.length > 0) {\n await writeFile(file.path, content);\n }\n\n return { expanded: changes.length > 0, expandedCount: totalExpanded, changes };\n}\n","import { stringify } from 'yaml';\n\n/**\n * Manual key order comparator for YAML `sortMapEntries`.\n *\n * Keys listed in `order` appear first (in that order); unlisted keys sort\n * to the end alphabetically. Adapted from tbd sorting patterns\n * (`ordering.manual`).\n */\nexport function manualKeyOrder(order: readonly string[]) {\n const orderMap = new Map(order.map((key, index) => [key, index]));\n return (a: { key: { value: string } }, b: { key: { value: string } }): number => {\n const indexA = orderMap.get(a.key.value);\n const indexB = orderMap.get(b.key.value);\n if (indexA === undefined && indexB === undefined) {\n return a.key.value.localeCompare(b.key.value);\n }\n if (indexA === undefined) {\n return 1;\n }\n if (indexB === undefined) {\n return -1;\n }\n return indexA - indexB;\n };\n}\n\nconst DEFAULT_YAML_LINE_WIDTH = 88;\n\nexport const YAML_STRINGIFY_OPTIONS = {\n lineWidth: DEFAULT_YAML_LINE_WIDTH,\n defaultStringType: 'PLAIN' as const,\n defaultKeyType: 'PLAIN' as const,\n};\n\nexport function stringifyYaml(data: unknown, options?: object): string {\n return stringify(data, { ...YAML_STRINGIFY_OPTIONS, ...options });\n}\n","import { writeFile } from 'atomically';\nimport { stringifyYaml, manualKeyOrder } from './yaml-utils.js';\nimport { matchAndCapture } from './matcher.js';\nimport type { TestFile, TestFileResult } from './types.js';\n\n// Field orderings for each level of the capture log YAML.\n// Keys appear in this order; unlisted keys sort alphabetically at the end.\n\nconst TOP_LEVEL_ORDER = manualKeyOrder(['generated', 'files']);\n\nconst FILE_ORDER = manualKeyOrder(['path', 'blocks']);\n\nconst BLOCK_ORDER = manualKeyOrder([\n 'name',\n 'command',\n 'expected_exit_code',\n 'actual_exit_code',\n 'expected_output',\n 'actual_output',\n 'captures',\n 'passed',\n]);\n\nconst CAPTURE_ORDER = manualKeyOrder(['category', 'name', 'multiline', 'matched']);\n\n/**\n * Sort comparator for `yaml.stringify`'s `sortMapEntries`.\n *\n * Dispatches to the correct field ordering based on which keys are present,\n * since the `yaml` package calls this for every map node in the document.\n */\nconst BLOCK_KEYS = new Set([\n 'name',\n 'command',\n 'expected_exit_code',\n 'actual_exit_code',\n 'expected_output',\n 'actual_output',\n 'captures',\n 'passed',\n]);\n\nconst CAPTURE_KEYS = new Set(['category', 'name', 'multiline', 'matched']);\n\nexport function captureLogSortMapEntries(\n a: { key: { value: string } },\n b: { key: { value: string } },\n): number {\n const aKey = a.key.value;\n const bKey = b.key.value;\n\n if (aKey === 'generated' || aKey === 'files' || bKey === 'generated' || bKey === 'files') {\n return TOP_LEVEL_ORDER(a, b);\n }\n if (aKey === 'blocks' || bKey === 'blocks') {\n return FILE_ORDER(a, b);\n }\n if (BLOCK_KEYS.has(aKey) && BLOCK_KEYS.has(bKey)) {\n return BLOCK_ORDER(a, b);\n }\n if (CAPTURE_KEYS.has(aKey) && CAPTURE_KEYS.has(bKey)) {\n return CAPTURE_ORDER(a, b);\n }\n\n return aKey.localeCompare(bKey);\n}\n\ninterface CaptureLogBlock {\n name: string | undefined;\n command: string;\n expected_exit_code: number;\n actual_exit_code: number;\n expected_output: string;\n actual_output: string;\n captures: { category: string; name?: string; multiline: boolean; matched: string }[];\n passed: boolean;\n}\n\ninterface CaptureLogFile {\n path: string;\n blocks: CaptureLogBlock[];\n}\n\ninterface CaptureLogDoc {\n generated: string;\n files: CaptureLogFile[];\n}\n\n/**\n * Build the capture log document structure from test results.\n *\n * `customPatterns` can be a static object or a per-file callback.\n * Separated from `writeCaptureLog` for testability.\n */\nexport function buildCaptureLogDoc(\n fileResults: TestFileResult[],\n matchContext: (file: TestFile) => { root: string; cwd: string },\n customPatterns?:\n | Record<string, string | RegExp>\n | ((file: TestFile) => Record<string, string | RegExp>),\n): CaptureLogDoc {\n const files = fileResults.map((fr) => {\n const ctx = matchContext(fr.file);\n const patterns =\n typeof customPatterns === 'function' ? customPatterns(fr.file) : customPatterns;\n const blocks = fr.results.map((r) => {\n const captureResult = matchAndCapture(r.actualOutput, r.block.expectedOutput, ctx, patterns);\n const captures = (captureResult?.captures ?? []).map((c) => ({\n category: c.category,\n ...(c.name ? { name: c.name } : {}),\n multiline: c.multiline,\n matched: c.captured,\n }));\n return {\n name: r.block.name,\n command: r.block.command,\n expected_exit_code: r.block.expectedExitCode,\n actual_exit_code: r.actualExitCode,\n expected_output: r.block.expectedOutput,\n actual_output: r.actualOutput,\n captures,\n passed: r.passed,\n };\n });\n return { path: fr.file.path, blocks };\n });\n\n return {\n generated: new Date().toISOString(),\n files,\n };\n}\n\n/**\n * Write a YAML capture log file recording wildcard captures and execution metadata.\n */\nexport async function writeCaptureLog(\n path: string,\n fileResults: TestFileResult[],\n matchContext: (file: TestFile) => { root: string; cwd: string },\n customPatterns?:\n | Record<string, string | RegExp>\n | ((file: TestFile) => Record<string, string | RegExp>),\n): Promise<void> {\n const doc = buildCaptureLogDoc(fileResults, matchContext, customPatterns);\n const header = '# tryscript capture log\\n';\n const yaml = stringifyYaml(doc, { sortMapEntries: captureLogSortMapEntries });\n await writeFile(path, header + yaml);\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 CoverageConfig,\n CoverageContext,\n WildcardCategory,\n ExpandLevel,\n WildcardCapture,\n ExpansionResult,\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, matchAndCapture } from './lib/matcher.js';\nexport { expandExpectedOutput, expandTestFile, shouldExpandCategory } from './lib/expander.js';\nexport { writeCaptureLog, buildCaptureLogDoc } from './lib/capture-log.js';\nexport { stringifyYaml, manualKeyOrder } from './lib/yaml-utils.js';\n"],"mappings":";;;;;;;;;;;;;;AA4CA,MAAaA,0BAAkD;CAC7D,YAAY;CACZ,WAAW,CAAC,QAAQ,OAAO;CAC3B,SAAS,CAAC,UAAU;CACpB,SAAS,EAAE;CACX,oBAAoB;CACpB,mBAAmB;CACnB,UAAU;CACV,eAAe;CACf,KAAK;CACL,UAAU;CACX;;;;AAKD,SAAgB,sBAAsB,QAAiD;AACrF,QAAO;EACL,YAAY,QAAQ,cAAc,wBAAwB;EAC1D,WAAW,QAAQ,aAAa,wBAAwB;EACxD,SAAS,QAAQ,WAAW,wBAAwB;EACpD,SAAS,QAAQ,WAAW,wBAAwB;EACpD,oBAAoB,QAAQ,sBAAsB,wBAAwB;EAC1E,mBAAmB,QAAQ,qBAAqB,wBAAwB;EACxE,UAAU,QAAQ,YAAY,wBAAwB;EACtD,eAAe,QAAQ,iBAAiB,wBAAwB;EAChE,KAAK,QAAQ,OAAO,wBAAwB;EAC5C,UAAU,QAAQ,YAAY,wBAAwB;EACtD,WAAW,QAAQ;EACpB;;AAGH,MAAM,eAAe;CAAC;CAAuB;CAAuB;CAAuB;;;;;AAM3F,eAAsB,WAAW,SAA2C;AAC1E,MAAK,MAAM,YAAY,cAAc;EACnC,MAAM,aAAa,QAAQ,SAAS,SAAS;AAC7C,MAAI,WAAW,WAAW,EAAE;GAE1B,MAAM,SAAU,MAAM,OADJ,cAAc,WAAW,CAAC;AAE5C,UAAQ,OAAyC,WAAY;;;AAGjE,QAAO,EAAE;;;;;;AAOX,SAAgB,YAAY,MAAuB,aAA0C;AAC3F,QAAO;EACL,GAAG;EACH,GAAG;EACH,KAAK;GAAE,GAAG,KAAK;GAAK,GAAG,YAAY;GAAK;EACxC,UAAU;GAAE,GAAG,KAAK;GAAU,GAAG,YAAY;GAAU;EACvD,UAAU,CAAC,GAAI,KAAK,YAAY,EAAE,EAAG,GAAI,YAAY,YAAY,EAAE,CAAE;EAErE,MAAM,CAAC,GAAI,YAAY,QAAQ,EAAE,EAAG,GAAI,KAAK,QAAQ,EAAE,CAAE;EAC1D;;;;;AAMH,SAAgB,aAAa,QAA0C;AACrE,QAAO;;;;;;AC9GT,MAAM,oBAAoB;;AAG1B,MAAM,gBAAgB;;AAGtB,MAAM,wBAAwB;;AAG9B,MAAM,wBAAwB;;;;;;;;AAgB9B,SAAS,sBAAsB,MAAgC;CAC7D,MAAMC,UAA4B,EAAE;CACpC,MAAM,QAAQ,KAAK,MAAM,KAAK;CAE9B,MAAMC,UAAoB,IAAI,MAAc,MAAM,OAAO;AACzD,SAAQ,KAAK;AACb,MAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,IAChC,SAAQ,KAAK,QAAQ,IAAI,KAAM,MAAM,IAAI,GAAI,SAAS;CAGxD,IAAI,IAAI;AACR,QAAO,IAAI,MAAM,QAAQ;EACvB,MAAM,OAAO,MAAM;EACnB,MAAM,UAAU,KAAK,SAAS,KAAK,GAAG,KAAK,MAAM,GAAG,GAAG,GAAG;EAC1D,MAAM,YAAY,6BAA6B,KAAK,QAAQ;AAE5D,MAAI,CAAC,WAAW;AACd;AACA;;EAGF,MAAM,WAAW,UAAU,GAAI;EAC/B,MAAM,aAAa,UAAU;EAC7B,MAAM,cAAc;EACpB,MAAM,4BAAY,IAAI,OAAO,OAAO,SAAS,SAAS;AAEtD;AACA,SAAO,IAAI,MAAM,QAAQ;GACvB,MAAM,MAAM,MAAM;GAClB,MAAM,aAAa,IAAI,SAAS,KAAK,GAAG,IAAI,MAAM,GAAG,GAAG,GAAG;AAC3D,OAAI,UAAU,KAAK,WAAW,EAAE;IAC9B,MAAM,cAAc,QAAQ;IAC5B,MAAM,YAAY,QAAQ,KAAM,MAAM,GAAI;IAC1C,MAAM,eAAe,QAAQ,cAAc;IAC3C,MAAM,aAAa,QAAQ;AAE3B,YAAQ,KAAK;KACX,WAAW,KAAK,MAAM,aAAa,UAAU;KAC7C;KACA,SAAS,KAAK,MAAM,cAAc,WAAW;KAC7C,OAAO;KACR,CAAC;AACF;AACA;;AAEF;;;AAIJ,QAAO;;;;;AAMT,SAAgB,cAAc,SAAiB,UAA4B;CACzE,MAAM,aAAa;CACnB,IAAIC,SAAqB,EAAE;CAC3B,IAAI,OAAO;CAGX,MAAM,mBAAmB,kBAAkB,KAAK,QAAQ;AACxD,KAAI,kBAAkB;AAEpB,WAASC,MADW,iBAAiB,MAAM,GACZ;AAC/B,SAAO,QAAQ,MAAM,iBAAiB,GAAG,OAAO;;CAIlD,MAAMC,SAAsB,EAAE;CAC9B,MAAM,aAAa,sBAAsB,KAAK;AAE9C,MAAK,MAAM,aAAa,YAAY;EAClC,MAAM,eAAe,UAAU;EAC/B,MAAM,aAAa,UAAU;EAI7B,MAAM,aADmB,QAAQ,MAAM,GAAG,QAAQ,QAAQ,UAAU,UAAU,CAAC,CAC3C,MAAM,KAAK,CAAC;EAGhD,MAAM,gBAAgB,KAAK,MAAM,GAAG,WAAW;EAC/C,MAAM,mBAAmB,CACvB,GAAG,cAAc,SAAS,IAAI,OAAO,cAAc,QAAQ,KAAK,CAAC,CAClE,CAAC,KAAK;EACP,MAAM,OAAO,mBAAmB,IAAI,MAAM;EAG1C,MAAM,iBAAiB,mBACnB,cAAc,MAAM,cAAc,YAAY,iBAAiB,GAAG,CAAC,GACnE;EACJ,MAAM,OAAO,sBAAsB,KAAK,eAAe;EACvD,MAAM,OAAO,sBAAsB,KAAK,eAAe;EAGvD,MAAM,SAAS,kBAAkB,aAAa;AAC9C,MAAI,OACF,QAAO,KAAK;GACV;GACA,SAAS,OAAO;GAChB,gBAAgB,OAAO;GACvB,gBAAgB,OAAO;GACvB,kBAAkB,OAAO;GACzB;GACA,YAAY,UAAU;GACtB;GACA;GACD,CAAC;;AAIN,QAAO;EAAE,MAAM;EAAU;EAAQ;EAAQ;EAAY;;;;;AAMvD,SAAS,kBAAkB,SAKlB;CACP,MAAM,QAAQ,QAAQ,MAAM,KAAK;CACjC,MAAMC,eAAyB,EAAE;CACjC,MAAMC,cAAwB,EAAE;CAChC,MAAMC,cAAwB,EAAE;CAChC,IAAI,mBAAmB;CACvB,IAAI,YAAY;AAEhB,MAAK,MAAM,QAAQ,MACjB,KAAI,KAAK,WAAW,KAAK,EAAE;AAEzB,cAAY;AACZ,eAAa,KAAK,KAAK,MAAM,EAAE,CAAC;YACvB,KAAK,WAAW,KAAK,IAAI,UAElC,cAAa,KAAK,KAAK,MAAM,EAAE,CAAC;UACvB,KAAK,WAAW,KAAK,EAAE;AAEhC,cAAY;AACZ,qBAAmB,SAAS,KAAK,MAAM,EAAE,CAAC,MAAM,EAAE,GAAG;YAC5C,KAAK,WAAW,KAAK,EAAE;AAEhC,cAAY;AACZ,cAAY,KAAK,KAAK,MAAM,EAAE,CAAC;QAC1B;AAEL,cAAY;AACZ,cAAY,KAAK,KAAK;;AAI1B,KAAI,aAAa,WAAW,EAC1B,QAAO;CAIT,IAAI,UAAU;AACd,MAAK,IAAI,IAAI,GAAG,IAAI,aAAa,QAAQ,KAAK;EAC5C,MAAM,OAAO,aAAa,MAAM;AAChC,MAAI,KAAK,SAAS,KAAK,CACrB,YAAW,KAAK,MAAM,GAAG,GAAG,GAAG;OAC1B;AACL,cAAW;AACX,OAAI,IAAI,aAAa,SAAS,EAC5B,YAAW;;;CAMjB,IAAI,iBAAiB,YAAY,KAAK,KAAK;AAC3C,kBAAiB,eAAe,QAAQ,QAAQ,GAAG;AACnD,KAAI,eACF,mBAAkB;CAIpB,IAAIC;AACJ,KAAI,YAAY,SAAS,GAAG;AAC1B,mBAAiB,YAAY,KAAK,KAAK;AACvC,mBAAiB,eAAe,QAAQ,QAAQ,GAAG;AACnD,MAAI,eACF,mBAAkB;;AAItB,QAAO;EAAE,SAAS,QAAQ,MAAM;EAAE;EAAgB;EAAgB;EAAkB;;;;;;;;;AClNtF,SAAgB,gBAAgB,UAAiC;CAC/D,IAAI,MAAM;AAEV,QAAO,MAAM;EACX,MAAM,UAAU,KAAK,KAAK,eAAe;AACzC,MAAI,WAAW,QAAQ,CACrB,QAAO;EAET,MAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,WAAW,IACb;AAEF,QAAM;;AAGR,QAAO;;;;;;AAOT,SAAgB,YAAY,UAAiC;CAC3D,IAAI,MAAM;AAEV,QAAO,MAAM;AAEX,MAAI,WADY,KAAK,KAAK,OAAO,CACV,CACrB,QAAO;EAET,MAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,WAAW,IACb;AAEF,QAAM;;AAGR,QAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACdT,SAAgB,cAAc,KAAa,WAA4C;CACrF,MAAMC,aAAW,YAA4B;AAC3C,SAAO,YAAY,YAAY,QAAQ,IAAI,YAAY;;AAGzD,QAAO,IACJ,QAAQ,oCAAoC,GAAG,YAAoBA,UAAQ,QAAQ,CAAC,CACpF,QAAQ,gCAAgC,GAAG,YAAoBA,UAAQ,QAAQ,CAAC;;;;;;;;;;;;;;;;;AAkBrF,SAAgB,kBAAkB,WAA4D;AAC5F,SAAQ,QAAgB,cAAc,KAAK,UAAU;;;;;;AC5CvD,MAAM,kBAAkB;;;;AA8BxB,SAAS,iBAAiB,SAAoC;AAC5D,KAAI,OAAO,YAAY,SACrB,QAAO,EAAE,QAAQ,SAAS;AAE5B,QAAO;;;;;AAMT,eAAe,cACb,UACA,SACA,YACe;AACf,KAAI,CAAC,YAAY,SAAS,WAAW,EACnC;AAGF,MAAK,MAAM,KAAK,UAAU;EACxB,MAAM,UAAU,iBAAiB,EAAE;AAInC,QAAM,GAHM,QAAQ,SAAS,QAAQ,OAAO,EAEhC,QAAQ,YADH,QAAQ,QAAQ,SAAS,QAAQ,OAAO,CAChB,EACtB,EAAE,WAAW,MAAM,CAAC;;;;;;;;;AAU3C,eAAsB,uBACpB,QACA,cACA,aAC2B;CAI3B,MAAM,UAAU,MAAM,SADH,MAAM,QAAQ,KAAK,QAAQ,EAAE,aAAa,CAAC,CACpB;CAG1C,MAAM,UAAU,QAAQ,QAAQ,aAAa,CAAC;CAG9C,IAAIC;CACJ,IAAI,UAAU;AAEd,KAAI,OAAO,YAAY,MAAM;AAE3B,QAAM;AACN,YAAU;YACD,OAAO,OAAO,YAAY,UAAU;AAG7C,QAAM,GADU,QAAQ,SAAS,OAAO,QAAQ,EAC9B,SAAS,EAAE,WAAW,MAAM,CAAC;AAC/C,QAAM;AACN,YAAU;YACD,OAAO,IAEhB,OAAM,QAAQ,SAAS,OAAO,IAAI;KAGlC,OAAM;AAIR,KAAI,WAAW,OAAO,SACpB,OAAM,cAAc,OAAO,UAAU,SAAS,QAAQ;CAIxD,MAAM,UAAU,gBAAgB,QAAQ;CACxC,MAAM,cAAc,UAAU,QAAQ,QAAQ,GAAG;CAGjD,MAAM,UAAU,YAAY,QAAQ,IAAI;CAIxC,MAAM,cACJ,eAAe,UACX,YAAY,UAAU,QAAQ,SAC5B,cACA,UACD,eAAe;CAGtB,MAAM,iBAAiB,cAAc,KAAK,aAAa,gBAAgB,OAAO,GAAG;CACjF,MAAM,aAAa,kBAAkB,WAAW,eAAe,GAAG,iBAAiB;CAGnF,MAAMC,mBAA2C;EAC/C,GAAI,eAAe,EAAE,wBAAwB,aAAa;EAC1D,GAAI,WAAW,EAAE,oBAAoB,SAAS;EAC9C,GAAI,eAAe,EAAE,wBAAwB,aAAa;EAC1D,GAAI,cAAc,EAAE,uBAAuB,YAAY;EACvD,oBAAoB;EACrB;CAGD,MAAMC,kBAAgB,kBAAkB,iBAAiB;CAGzD,MAAMC,YAAsB,EAAE;AAC9B,KAAI,OAAO,QAAQ,OAAO,KAAK,SAAS,EAEtC,WAAU,KACR,GAAG,OAAO,KAAK,KAAK,MAAM;EACxB,MAAM,WAAWD,gBAAc,EAAE;AAEjC,SAAO,SAAS,WAAW,IAAI,GAAG,WAAW,QAAQ,SAAS,SAAS;GACvE,CACH;AAEH,WAAU,KAAK,QAAQ,IAAI,QAAQ,GAAG;AA0BtC,QAxB8B;EAC5B;EACA;EACA;EACA;EACA,KAAK;GACH,GAAG,QAAQ;GACX,GAAG,OAAO;GACV,GAAG;GAEH,UAAU,OAAO,KAAK,YAAY;GAClC,aAAa;GAEb,oBAAoB;GAEpB,GAAG;GAEH,MAAM,UAAU,KAAK,UAAU;GAChC;EACD,SAAS,OAAO,WAAW;EAC3B,QAAQ,OAAO;EACf,OAAO,OAAO;EACf;;;;;AAQH,eAAsB,wBAAwB,KAAsC;AAClF,OAAM,GAAG,IAAI,SAAS;EAAE,WAAW;EAAM,OAAO;EAAM,CAAC;;;;;AAMzD,eAAsB,cAAc,KAAsC;AACxE,KAAI,IAAI,UAAU,CAAC,IAAI,WAAW;AAChC,MAAI,YAAY;AAChB,QAAM,eAAe,IAAI,QAAQ,IAAI;;;;;;AAOzC,eAAsB,aAAa,KAAsC;AACvE,KAAI,IAAI,MACN,OAAM,eAAe,IAAI,OAAO,IAAI;;;;;AAOxC,eAAsB,SAAS,OAAkB,KAAiD;CAChG,MAAM,YAAY,KAAK,KAAK;AAG5B,KAAI,MAAM,KACR,QAAO;EACL;EACA,QAAQ;EACR,cAAc;EACd,gBAAgB;EAChB,UAAU;EACV,SAAS;EACV;AAGH,KAAI;AAEF,QAAM,cAAc,IAAI;EAGxB,MAAM,EAAE,QAAQ,QAAQ,QAAQ,aAAa,MAAM,eAAe,MAAM,SAAS,IAAI;AAIrF,SAAO;GACL;GACA,QAAQ;GACR,cAAc;GACd,cAAc;GACd,cAAc;GACd,gBAAgB;GAChB,UATe,KAAK,KAAK,GAAG;GAU7B;UACM,OAAO;AAId,SAAO;GACL;GACA,QAAQ;GACR,cAAc;GACd,gBAAgB;GAChB,UARe,KAAK,KAAK,GAAG;GAS5B,OARc,iBAAiB,QAAQ,MAAM,UAAU,OAAO,MAAM;GASrE;;;;;;AAeL,eAAe,eAAe,SAAiB,KAA+C;AAC5F,QAAO,IAAI,SAAS,WAAS,WAAW;EACtC,MAAM,OAAO,MAAM,SAAS;GAC1B,OAAO;GACP,KAAK,IAAI;GACT,KAAK,IAAI;GAET,OAAO;IAAC;IAAU;IAAQ;IAAO;GAClC,CAAC;EAEF,MAAME,iBAAgE,EAAE;EACxE,MAAMC,eAAyB,EAAE;EACjC,MAAMC,eAAyB,EAAE;AAGjC,OAAK,OAAO,GAAG,SAAS,SAAiB;AACvC,kBAAe,KAAK;IAAE;IAAM,MAAM;IAAU,CAAC;AAC7C,gBAAa,KAAK,KAAK;IACvB;AACF,OAAK,OAAO,GAAG,SAAS,SAAiB;AACvC,kBAAe,KAAK;IAAE;IAAM,MAAM;IAAU,CAAC;AAC7C,gBAAa,KAAK,KAAK;IACvB;EAEF,MAAM,YAAY,iBAAiB;AACjC,OAAI,KAAK,IACP,UAAS,KAAK,KAAK,UAAU;AAE/B,0BAAO,IAAI,MAAM,2BAA2B,IAAI,QAAQ,IAAI,CAAC;KAC5D,IAAI,QAAQ;AAEf,OAAK,GAAG,UAAU,SAAS;AACzB,gBAAa,UAAU;AAIvB,aAAQ;IACN,QAJa,OAAO,OAAO,eAAe,KAAK,MAAM,EAAE,KAAK,CAAC,CAAC,SAAS,QAAQ;IAK/E,QAJa,OAAO,OAAO,aAAa,CAAC,SAAS,QAAQ;IAK1D,QAJa,OAAO,OAAO,aAAa,CAAC,SAAS,QAAQ;IAK1D,UAAU,QAAQ;IACnB,CAAC;IACF;AAEF,OAAK,GAAG,UAAU,QAAQ;AACxB,gBAAa,UAAU;AACvB,UAAO,IAAI;IACX;GACF;;;;;;;;AC/TJ,SAAS,YAAY,KAAqB;AACxC,QAAO,IAAI,QAAQ,uBAAuB,OAAO;;AAInD,MAAM,SAAS;;;;;;;;;;;;AAsBf,SAAS,eACP,UACA,iBAAkD,EAAE,EAC5C;CAER,MAAM,+BAAe,IAAI,KAAqB;CAC9C,IAAI,cAAc;CAElB,MAAM,kBAA0B;AAC9B,SAAO,GAAG,SAAS,gBAAgB;;CAGrC,IAAI,YAAY;CAGhB,MAAM,eAAe,WAAW;AAChC,cAAa,IAAI,cAAc,UAAU;AACzC,aAAY,UAAU,WAAW,QAAQ,aAAa;CAGtD,MAAM,sBAAsB,WAAW;AACvC,cAAa,IAAI,qBAAqB,UAAU;AAChD,aAAY,UAAU,WAAW,QAAQ,oBAAoB;CAG7D,MAAM,iBAAiB,WAAW;AAClC,cAAa,IAAI,gBAAgB,kBAAkB;AACnD,aAAY,UAAU,QAAQ,aAAa,eAAe;CAG1D,MAAM,wBAAwB,WAAW;AACzC,cAAa,IAAI,uBAAuB,kBAAkB;AAC1D,aAAY,UAAU,QAAQ,aAAa,sBAAsB;CAGjE,MAAM,YAAY,WAAW;CAC7B,MAAM,MAAM,QAAQ,aAAa,UAAU,WAAW;AACtD,cAAa,IAAI,WAAW,IAAI;AAChC,aAAY,UAAU,WAAW,SAAS,UAAU;AAGpD,MAAK,MAAM,CAAC,MAAM,YAAY,OAAO,QAAQ,eAAe,EAAE;EAC5D,MAAM,cAAc,IAAI,KAAK;EAC7B,MAAM,aAAa,mBAAmB,SAAS,QAAQ,SAAS;EAChE,MAAM,SAAS,WAAW;AAC1B,eAAa,IAAI,QAAQ,IAAI,WAAW,GAAG;AAC3C,cAAY,UAAU,WAAW,aAAa,OAAO;;CAIvD,IAAI,QAAQ,YAAY,UAAU;AAGlC,MAAK,MAAM,CAAC,QAAQ,gBAAgB,aAClC,SAAQ,MAAM,WAAW,YAAY,OAAO,EAAE,YAAY;AAI5D,QAAO,IAAI,OAAO,IAAI,MAAM,IAAI,IAAI;;;;;;AAOtC,SAAS,gBAAgB,UAAkB,SAAgD;CACzF,IAAI,SAAS;CAEb,MAAM,iBAAiB,QAAQ,KAAK,QAAQ,OAAO,IAAI;CACvD,MAAM,gBAAgB,QAAQ,IAAI,QAAQ,OAAO,IAAI;AACrD,UAAS,OAAO,WAAW,UAAU,eAAe;AACpD,UAAS,OAAO,WAAW,SAAS,cAAc;AAClD,QAAO;;;;;;;;;;AAWT,SAAgB,gBAAgB,QAAwB;CAEtD,IAAI,aAAa,UAAU,OAAO;AAElC,cAAa,WACV,QAAQ,SAAS,KAAK,CACtB,QAAQ,OAAO,KAAK,CACpB,MAAM,KAAK,CACX,KAAK,SAAS,KAAK,SAAS,CAAC,CAC7B,KAAK,KAAK,CACV,QAAQ,QAAQ,KAAK;AAGxB,KAAI,eAAe,KACjB,cAAa;AAGf,QAAO;;;;;;;;;AAUT,SAAS,wBACP,UACA,iBAAkD,EAAE,EACL;CAC/C,MAAM,+BAAe,IAAI,KAAqB;CAE9C,MAAM,6BAAa,IAAI,KAAsC;CAC7D,IAAI,cAAc;CAElB,MAAM,kBAA0B;AAC9B,SAAO,GAAG,SAAS,gBAAgB;;CAIrC,MAAM,eACJ,aACA,SACA,UACA,SACW;EACX,IAAI,SAASC;AACb,MAAI,OAAO,YAAY,SACrB,QAAO,OAAO,SAAS,QAAQ,EAAE;GAC/B,MAAM,SAAS,WAAW;AAC1B,gBAAa,IAAI,QAAQ,SAAS;AAClC,cAAW,IAAI,QAAQ,KAAK;AAC5B,YAAS,OAAO,QAAQ,SAAS,OAAO;;OAErC;GACL,IAAIC;AACJ,WAAQ,IAAI,QAAQ,KAAK,OAAO,MAAM,MAAM;IAC1C,MAAM,SAAS,WAAW;AAC1B,iBAAa,IAAI,QAAQ,SAAS;AAClC,eAAW,IAAI,QAAQ,KAAK;AAC5B,aAAS,OAAO,MAAM,GAAG,EAAE,MAAM,GAAG,SAAS,OAAO,MAAM,EAAE,QAAQ,EAAE,GAAG,OAAO;;;AAGpF,SAAO;;CAGT,IAAI,YAAY;AAGhB,aAAY,YAAY,WAAW,QAAQ,aAAa;EACtD,UAAU;EACV,WAAW;EACZ,CAAC;AAGF,aAAY,YAAY,WAAW,QAAQ,aAAa;EACtD,UAAU;EACV,WAAW;EACZ,CAAC;AAGF,aAAY,YAAY,WAAW,YAAY,qBAAqB;EAClE,UAAU;EACV,WAAW;EACZ,CAAC;AAGF,aAAY,YAAY,WAAW,YAAY,qBAAqB;EAClE,UAAU;EACV,WAAW;EACZ,CAAC;CAGF,MAAM,MAAM,QAAQ,aAAa,UAAU,WAAW;AACtD,aAAY,YAAY,WAAW,SAAS,KAAK,KAAK;AAGtD,MAAK,MAAM,CAAC,MAAM,YAAY,OAAO,QAAQ,eAAe,EAAE;EAC5D,MAAM,cAAc,IAAI,KAAK;EAC7B,MAAM,aAAa,mBAAmB,SAAS,QAAQ,SAAS;AAChE,cAAY,YAAY,WAAW,aAAa,IAAI,WAAW,IAAI;GACjE,UAAU;GACV;GACA,WAAW;GACZ,CAAC;;CAKJ,MAAM,gBAAgB,CAAC,GAAG,aAAa,SAAS,CAAC,CAAC,MAAM,GAAG,MAAM;AAC/D,SAAO,UAAU,QAAQ,EAAE,GAAG,GAAG,UAAU,QAAQ,EAAE,GAAG;GACxD;CAEF,IAAI,QAAQ,YAAY,UAAU;CAClC,MAAMC,SAA6B,EAAE;AACrC,MAAK,MAAM,CAAC,QAAQ,gBAAgB,eAAe;EACjD,MAAM,OAAO,WAAW,IAAI,OAAO;AACnC,MAAI,KACF,QAAO,KAAK,KAAK;AAEnB,UAAQ,MAAM,WAAW,YAAY,OAAO,EAAE,YAAY;;AAG5D,QAAO;EAAE,OAAO,IAAI,OAAO,IAAI,MAAM,IAAI,IAAI;EAAE;EAAQ;;;;;AAMzD,SAAgB,YACd,QACA,UACA,SACA,iBAAkD,EAAE,EAC3C;CACT,MAAM,mBAAmB,gBAAgB,OAAO;CAChD,MAAM,qBAAqB,gBAAgB,SAAS;AAGpD,KAAI,uBAAuB,MAAM,qBAAqB,GACpD,QAAO;AAKT,QADc,eADO,gBAAgB,oBAAoB,QAAQ,EACtB,eAAe,CAC7C,KAAK,iBAAiB;;;;;;AAOrC,SAAgB,gBACd,QACA,UACA,SACA,iBAAkD,EAAE,EACZ;CACxC,MAAM,mBAAmB,gBAAgB,OAAO;CAChD,MAAM,qBAAqB,gBAAgB,SAAS;AAEpD,KAAI,uBAAuB,MAAM,qBAAqB,GACpD,QAAO,EAAE,UAAU,EAAE,EAAE;CAIzB,MAAM,EAAE,OAAO,WAAW,wBADL,gBAAgB,oBAAoB,QAAQ,EACD,eAAe;CAC/E,MAAM,QAAQ,MAAM,KAAK,iBAAiB;AAE1C,KAAI,CAAC,MACH,QAAO;AAUT,QAAO,EAAE,UAP2B,OAAO,KAAK,MAAM,OAAO;EAC3D,UAAU,KAAK;EACf,MAAM,KAAK;EACX,WAAW,KAAK;EAChB,UAAU,MAAM,IAAI,MAAM;EAC3B,EAAE,EAEgB;;;;;;;;;;AC1RrB,SAAgB,qBAAqB,UAA4B,OAA6B;AAC5F,SAAQ,OAAR;EACE,KAAK,UACH,QAAO,aAAa;EACtB,KAAK,UACH,QAAO,aAAa,aAAa,aAAa;EAChD,KAAK,MACH,QAAO;;;AAMb,MAAMC,kBAIA;CACJ;EAAE,OAAO;EAAQ,UAAU;EAAW,WAAW;EAAO;CACxD;EAAE,OAAO;EAAQ,UAAU;EAAW,WAAW;EAAO;CACxD;EAAE,OAAO;EAAY,UAAU;EAAW,WAAW;EAAM;CAC3D;EAAE,OAAO;EAAY,UAAU;EAAW,WAAW;EAAM;CAC5D;;;;;;;AAQD,SAAgB,qBACd,UACA,QACA,SACA,OACA,gBACwB;CACxB,MAAM,qBAAqB,gBAAgB,SAAS;CACpD,MAAM,mBAAmB,gBAAgB,OAAO;AAEhD,KAAI,uBAAuB,MAAM,qBAAqB,GACpD,QAAO;EAAE,gBAAgB;EAAI,UAAU,EAAE;EAAE,eAAe;EAAG;CAG/D,MAAM,SAAS,gBAAgB,QAAQ,UAAU,SAAS,eAAe;AACzE,KAAI,CAAC,OACH,QAAO;CAKT,IAAI,SAAS;CACb,IAAI,gBAAgB;CAEpB,MAAMC,iBAAoD,EAAE;AAG5D,MAAK,MAAM,MAAM,iBAAiB;EAChC,IAAI,aAAa;AACjB,MAAI,OAAO,GAAG,UAAU,SACtB,QAAO,MAAM;GACX,MAAM,MAAM,OAAO,QAAQ,GAAG,OAAO,WAAW;AAChD,OAAI,QAAQ,GACV;AAEF,kBAAe,KAAK;IAAE;IAAK,QAAQ,GAAG,MAAM;IAAQ,CAAC;AACrD,gBAAa,MAAM,GAAG,MAAM;;OAEzB;GACL,MAAM,KAAK,IAAI,OAAO,GAAG,MAAM,QAAQ,IAAI;GAC3C,IAAIC;AACJ,WAAQ,IAAI,GAAG,KAAK,OAAO,MAAM,KAC/B,gBAAe,KAAK;IAAE,KAAK,EAAE;IAAO,QAAQ,EAAE,GAAG;IAAQ,CAAC;;;AAMhE,KAAI,eACF,MAAK,MAAM,QAAQ,OAAO,KAAK,eAAe,EAAE;EAC9C,MAAM,cAAc,IAAI,KAAK;EAC7B,IAAI,aAAa;AACjB,SAAO,MAAM;GACX,MAAM,MAAM,OAAO,QAAQ,aAAa,WAAW;AACnD,OAAI,QAAQ,GACV;AAEF,kBAAe,KAAK;IAAE;IAAK,QAAQ,YAAY;IAAQ,CAAC;AACxD,gBAAa,MAAM,YAAY;;;AAMrC,gBAAe,MAAM,GAAG,MAAM,EAAE,MAAM,EAAE,IAAI;CAG5C,MAAM,eAAe,eAAe,KAAK,IAAI,OAAO;EAClD,GAAG;EACH,SAAS,OAAO,SAAS;EAC1B,EAAE;AAGH,MAAK,IAAI,IAAI,aAAa,SAAS,GAAG,KAAK,GAAG,KAAK;EACjD,MAAM,IAAI,aAAa;AACvB,MAAI,qBAAqB,EAAE,QAAQ,UAAU,MAAM,EAAE;GACnD,MAAM,cAAc,EAAE,QAAQ;AAG9B,YAAS,OAAO,MAAM,GAAG,EAAE,IAAI,GAAG,cAAc,OAAO,MAAM,EAAE,MAAM,EAAE,OAAO;AAC9E;;;AAIJ,QAAO;EAAE,gBAAgB;EAAQ,UAAU,OAAO;EAAU;EAAe;;;;;;;;AAS7E,eAAsB,eACpB,MACA,SACA,OACA,SACA,gBAC0E;CAC1E,IAAI,UAAU,KAAK;CACnB,MAAMC,UAAoB,EAAE;CAC5B,IAAI,gBAAgB;CAIpB,MAAM,gBAAgB,IAAI,IAAI,QAAQ,KAAK,WAAW,CAAC,OAAO,OAAO,OAAO,CAAC,CAAC;CAE9E,MAAM,oBAAoB,CAAC,GAAG,KAAK,OAAO,CACvC,KAAK,WAAW;EAAE;EAAO,QAAQ,cAAc,IAAI,MAAM;EAAE,EAAE,CAC7D,SAAS;AAEZ,MAAK,MAAM,EAAE,OAAO,YAAY,mBAAmB;AACjD,MAAI,CAAC,UAAU,CAAC,MAAM,eACpB;EAGF,MAAM,YAAY,qBAChB,MAAM,gBACN,OAAO,cACP,SACA,OACA,eACD;AAED,MAAI,CAAC,aAAa,UAAU,kBAAkB,EAC5C;EAIF,MAAM,QAAQ,IAAI,OAAO,QAAQ,KAAK,MAAM,WAAW,GAAG,IAAI,UAAU,EAAE;EAC1E,MAAM,eAAe,MAAM,QAAQ,MAAM,KAAK,CAAC,KAAK,MAAM,MAAM;AAC9D,UAAO,MAAM,IAAI,KAAK,SAAS,KAAK;IACpC;EAEF,MAAMC,QAAkB,CAAC,GAAG,MAAM,UAAU,GAAG,aAAa;EAC5D,MAAM,gBAAgB,UAAU,eAAe,SAAS;AACxD,MAAI,cACF,OAAM,KAAK,cAAc;AAE3B,QAAM,KAAK,KAAK,MAAM,oBAAoB,OAAO,kBAAkB,MAAM;EAEzE,MAAM,kBAAkB,MAAM,KAAK,KAAK;EACxC,MAAM,aAAa,QAAQ,QAAQ,MAAM,WAAW;AACpD,MAAI,eAAe,IAAI;AACrB,aACE,QAAQ,MAAM,GAAG,WAAW,GAC5B,kBACA,QAAQ,MAAM,aAAa,MAAM,WAAW,OAAO;AACrD,WAAQ,KAAK,MAAM,QAAQ,QAAQ,MAAM,aAAa;AACtD,oBAAiB,UAAU;;;AAI/B,KAAI,QAAQ,SAAS,EACnB,OAAM,UAAU,KAAK,MAAM,QAAQ;AAGrC,QAAO;EAAE,UAAU,QAAQ,SAAS;EAAG,eAAe;EAAe;EAAS;;;;;;;;;;;;AClMhF,SAAgB,eAAe,OAA0B;CACvD,MAAM,WAAW,IAAI,IAAI,MAAM,KAAK,KAAK,UAAU,CAAC,KAAK,MAAM,CAAC,CAAC;AACjE,SAAQ,GAA+B,MAA0C;EAC/E,MAAM,SAAS,SAAS,IAAI,EAAE,IAAI,MAAM;EACxC,MAAM,SAAS,SAAS,IAAI,EAAE,IAAI,MAAM;AACxC,MAAI,WAAW,UAAa,WAAW,OACrC,QAAO,EAAE,IAAI,MAAM,cAAc,EAAE,IAAI,MAAM;AAE/C,MAAI,WAAW,OACb,QAAO;AAET,MAAI,WAAW,OACb,QAAO;AAET,SAAO,SAAS;;;AAIpB,MAAM,0BAA0B;AAEhC,MAAa,yBAAyB;CACpC,WAAW;CACX,mBAAmB;CACnB,gBAAgB;CACjB;AAED,SAAgB,cAAc,MAAe,SAA0B;AACrE,QAAO,UAAU,MAAM;EAAE,GAAG;EAAwB,GAAG;EAAS,CAAC;;;;;AC5BnE,MAAM,kBAAkB,eAAe,CAAC,aAAa,QAAQ,CAAC;AAE9D,MAAM,aAAa,eAAe,CAAC,QAAQ,SAAS,CAAC;AAErD,MAAM,cAAc,eAAe;CACjC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD,CAAC;AAEF,MAAM,gBAAgB,eAAe;CAAC;CAAY;CAAQ;CAAa;CAAU,CAAC;;;;;;;AAQlF,MAAM,aAAa,IAAI,IAAI;CACzB;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD,CAAC;AAEF,MAAM,eAAe,IAAI,IAAI;CAAC;CAAY;CAAQ;CAAa;CAAU,CAAC;AAE1E,SAAgB,yBACd,GACA,GACQ;CACR,MAAM,OAAO,EAAE,IAAI;CACnB,MAAM,OAAO,EAAE,IAAI;AAEnB,KAAI,SAAS,eAAe,SAAS,WAAW,SAAS,eAAe,SAAS,QAC/E,QAAO,gBAAgB,GAAG,EAAE;AAE9B,KAAI,SAAS,YAAY,SAAS,SAChC,QAAO,WAAW,GAAG,EAAE;AAEzB,KAAI,WAAW,IAAI,KAAK,IAAI,WAAW,IAAI,KAAK,CAC9C,QAAO,YAAY,GAAG,EAAE;AAE1B,KAAI,aAAa,IAAI,KAAK,IAAI,aAAa,IAAI,KAAK,CAClD,QAAO,cAAc,GAAG,EAAE;AAG5B,QAAO,KAAK,cAAc,KAAK;;;;;;;;AA8BjC,SAAgB,mBACd,aACA,cACA,gBAGe;CACf,MAAM,QAAQ,YAAY,KAAK,OAAO;EACpC,MAAM,MAAM,aAAa,GAAG,KAAK;EACjC,MAAM,WACJ,OAAO,mBAAmB,aAAa,eAAe,GAAG,KAAK,GAAG;EACnE,MAAM,SAAS,GAAG,QAAQ,KAAK,MAAM;GAEnC,MAAM,YADgB,gBAAgB,EAAE,cAAc,EAAE,MAAM,gBAAgB,KAAK,SAAS,EAC3D,YAAY,EAAE,EAAE,KAAK,OAAO;IAC3D,UAAU,EAAE;IACZ,GAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,EAAE;IAClC,WAAW,EAAE;IACb,SAAS,EAAE;IACZ,EAAE;AACH,UAAO;IACL,MAAM,EAAE,MAAM;IACd,SAAS,EAAE,MAAM;IACjB,oBAAoB,EAAE,MAAM;IAC5B,kBAAkB,EAAE;IACpB,iBAAiB,EAAE,MAAM;IACzB,eAAe,EAAE;IACjB;IACA,QAAQ,EAAE;IACX;IACD;AACF,SAAO;GAAE,MAAM,GAAG,KAAK;GAAM;GAAQ;GACrC;AAEF,QAAO;EACL,4BAAW,IAAI,MAAM,EAAC,aAAa;EACnC;EACD;;;;;AAMH,eAAsB,gBACpB,MACA,aACA,cACA,gBAGe;AAIf,OAAM,UAAU,MAFD,8BACF,cAFD,mBAAmB,aAAa,cAAc,eAAe,EAEzC,EAAE,gBAAgB,0BAA0B,CAAC,CACzC;;;;;AC/ItC,MAAaC"}
@@ -109,17 +109,41 @@ stdout line
109
109
 
110
110
  ## Elision Patterns
111
111
 
112
- Patterns in expected output match variable content:
112
+ Patterns in expected output match variable content. There are three categories
113
+ of wildcards, listed in order of preference:
114
+
115
+ ### Named Patterns
116
+
117
+ Named patterns match typed dynamic values with specific meaning:
113
118
 
114
119
  | Pattern | Matches | Example |
115
120
  |---------|---------|---------|
116
- | `[..]` | Any text on a single line | `Built in [..]ms` |
117
- | `...` | Zero or more complete lines | `...\nDone` |
118
121
  | `[CWD]` | Current working directory | `[CWD]/output.txt` |
119
122
  | `[ROOT]` | Test file directory | `[ROOT]/fixtures/` |
120
123
  | `[EXE]` | `.exe` on Windows, empty otherwise | `my-cli[EXE]` |
121
124
  | `[PATTERN]` | Custom pattern from config | User-defined regex |
122
125
 
126
+ ### Unknown Wildcards
127
+
128
+ Unknown wildcards are temporary placeholders for output you haven't filled in yet.
129
+ They are intended to be expanded with `--expand` before finalizing tests.
130
+ A warning is always shown when unknown wildcards are present.
131
+
132
+ | Pattern | Matches | Example |
133
+ |---------|---------|---------|
134
+ | `[??]` | Any text on a single line | `Result: [??]` |
135
+ | `???` | Zero or more complete lines | `???\nDone` |
136
+
137
+ ### Generic Wildcards
138
+
139
+ Generic wildcards intentionally omit unpredictable or irrelevant output.
140
+ Use these when the exact value doesn't matter for the test.
141
+
142
+ | Pattern | Matches | Example |
143
+ |---------|---------|---------|
144
+ | `[..]` | Any text on a single line | `Built in [..]ms` |
145
+ | `...` | Zero or more complete lines | `...\nDone` |
146
+
123
147
  ### Pattern Examples
124
148
 
125
149
  **Single-line wildcard:**
@@ -129,6 +153,13 @@ $ date
129
153
  ? 0
130
154
  ```
131
155
 
156
+ **Unknown wildcard (to be expanded later):**
157
+ ```console
158
+ $ my-cli process data.json
159
+ [??]
160
+ ? 0
161
+ ```
162
+
132
163
  **Multi-line wildcard:**
133
164
  ```console
134
165
  $ ls -la
@@ -147,6 +178,17 @@ $ my-cli --version
147
178
  my-cli version [VERSION]
148
179
  ```
149
180
 
181
+ ### Wildcard Best Practices
182
+
183
+ 1. **Prefer named patterns** when the output has a known structure (e.g., `[VERSION]`,
184
+ `[HASH]`). This makes tests self-documenting.
185
+
186
+ 2. **Use unknown wildcards** (`[??]`/`???`) as temporary scaffolding when writing new
187
+ tests. Run with `--expand` to fill them in with actual output.
188
+
189
+ 3. **Use generic wildcards** (`[..]`/`...`) for output that is intentionally variable
190
+ (timestamps, durations, dynamic content) and should remain elided.
191
+
150
192
  ## Configuration (Frontmatter)
151
193
 
152
194
  All options are optional. Place at the top of the file:
@@ -475,6 +517,10 @@ tryscript readme # Show README
475
517
  | Option | Description |
476
518
  |--------|-------------|
477
519
  | `--update` | Update test files with actual output |
520
+ | `--expand` | Expand unknown wildcards (`???`/`[??]`) with actual output |
521
+ | `--expand-generic` | Expand unknown + generic wildcards |
522
+ | `--expand-all` | Expand all wildcards (including named patterns) |
523
+ | `--capture-log <path>` | Write wildcard capture log to YAML file |
478
524
  | `--diff` / `--no-diff` | Show/hide diff on failure |
479
525
  | `--fail-fast` | Stop on first failure |
480
526
  | `--filter <pattern>` | Filter tests by name |
@@ -734,6 +780,52 @@ export default defineConfig({
734
780
  | `monocart` | `--coverage-monocart` | AST-aware line counts |
735
781
  | `mergeLcov` | `--merge-lcov` | Merge with external LCOV file |
736
782
 
783
+ ## Wildcard Expansion
784
+
785
+ The `--expand` flags replace wildcard placeholders in your test files with actual
786
+ output from a test run. This is a surgical operation -- only targeted wildcards are
787
+ replaced; the rest of the file is left intact.
788
+
789
+ ### Expansion Workflow
790
+
791
+ 1. Write a test with unknown wildcards as temporary placeholders:
792
+
793
+ ```console
794
+ $ my-cli status
795
+ [??]
796
+ ? 0
797
+ ```
798
+
799
+ 2. Run with `--expand` to fill in actual output:
800
+
801
+ ```bash
802
+ tryscript run --expand tests/my-test.tryscript.md
803
+ ```
804
+
805
+ 3. Review the expanded output and commit.
806
+
807
+ ### Expansion Flags
808
+
809
+ The three flags form a hierarchy (each includes the previous):
810
+
811
+ | Flag | Expands |
812
+ |------|---------|
813
+ | `--expand` | Unknown wildcards only (`???`, `[??]`) |
814
+ | `--expand-generic` | Unknown + generic (`...`, `[..]`) |
815
+ | `--expand-all` | All wildcards including named patterns |
816
+
817
+ These flags are mutually exclusive with each other and with `--update`.
818
+
819
+ ### Capture Log
820
+
821
+ Use `--capture-log <path>` to write a YAML sidecar file recording what each wildcard
822
+ matched during a test run. This is useful for debugging pattern matches and reviewing
823
+ captured values.
824
+
825
+ ```bash
826
+ tryscript run --capture-log captures.yaml tests/
827
+ ```
828
+
737
829
  ## Best Practices
738
830
 
739
831
  ### DO: Use shell features directly
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tryscript",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Golden testing for CLI applications - TypeScript port of trycmd",
5
5
  "license": "MIT",
6
6
  "author": "Joshua Levy",
@@ -1 +0,0 @@
1
- {"version":3,"file":"src-1oEnK7GG.cjs","names":["DEFAULT_COVERAGE_CONFIG: ResolvedCoverageConfig","module","config: TestConfig","blocks: TestBlock[]","match: RegExpExecArray | null","commandLines: string[]","outputLines: string[]","stderrLines: string[]","expectedStderr: string | undefined","resolve","cwd: string","tryscriptEnvVars: Record<string, string>","expandEnvVars","pathParts: string[]","delimiter","combinedChunks: { data: Buffer; type: 'stdout' | 'stderr' }[]","stdoutChunks: Buffer[]","stderrChunks: Buffer[]","VERSION: string"],"sources":["../src/lib/config.ts","../src/lib/parser.ts","../src/lib/package-bin.ts","../src/lib/env-vars.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, CoverageConfig } from './types.js';\n\n/** Fixture configuration for copying files to sandbox directory */\nexport interface Fixture {\n /** Source path (resolved relative to test file) */\n source: string;\n /** Destination path (resolved relative to sandbox dir) */\n dest?: string;\n}\n\nexport interface TryscriptConfig {\n /** Working directory for commands (default: test file directory) */\n cwd?: string;\n /** Run in isolated sandbox: true = empty temp, path = copy to temp */\n sandbox?: boolean | string;\n /** Fixtures to copy to sandbox directory before tests */\n fixtures?: (string | Fixture)[];\n /** Script to run before first test block */\n before?: string;\n /** Script to run after all test blocks */\n after?: string;\n env?: Record<string, string>;\n timeout?: number;\n patterns?: Record<string, RegExp | string>;\n tests?: string[];\n /** Coverage configuration (used with --coverage flag) */\n coverage?: CoverageConfig;\n /**\n * Directories to prepend to PATH (resolved relative to test file).\n * Makes executables in these directories available by name in commands.\n * Supports env var expansion: $VAR or ${VAR} syntax.\n */\n path?: string[];\n}\n\n/** Resolved coverage config with required fields (except optional mergeLcov) */\nexport type ResolvedCoverageConfig = Omit<Required<CoverageConfig>, 'mergeLcov'> & {\n mergeLcov?: string;\n};\n\n/** Default coverage configuration values. */\nexport const DEFAULT_COVERAGE_CONFIG: ResolvedCoverageConfig = {\n reportsDir: 'coverage-tryscript',\n reporters: ['text', 'html'],\n include: ['dist/**'],\n exclude: [],\n excludeNodeModules: true,\n excludeAfterRemap: false,\n skipFull: false,\n allowExternal: false,\n src: 'src',\n monocart: false,\n};\n\n/**\n * Resolve coverage options by merging user config with defaults.\n */\nexport function resolveCoverageConfig(config?: CoverageConfig): ResolvedCoverageConfig {\n return {\n reportsDir: config?.reportsDir ?? DEFAULT_COVERAGE_CONFIG.reportsDir,\n reporters: config?.reporters ?? DEFAULT_COVERAGE_CONFIG.reporters,\n include: config?.include ?? DEFAULT_COVERAGE_CONFIG.include,\n exclude: config?.exclude ?? DEFAULT_COVERAGE_CONFIG.exclude,\n excludeNodeModules: config?.excludeNodeModules ?? DEFAULT_COVERAGE_CONFIG.excludeNodeModules,\n excludeAfterRemap: config?.excludeAfterRemap ?? DEFAULT_COVERAGE_CONFIG.excludeAfterRemap,\n skipFull: config?.skipFull ?? DEFAULT_COVERAGE_CONFIG.skipFull,\n allowExternal: config?.allowExternal ?? DEFAULT_COVERAGE_CONFIG.allowExternal,\n src: config?.src ?? DEFAULT_COVERAGE_CONFIG.src,\n monocart: config?.monocart ?? DEFAULT_COVERAGE_CONFIG.monocart,\n mergeLcov: config?.mergeLcov,\n };\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 fixtures: [...(base.fixtures ?? []), ...(frontmatter.fixtures ?? [])],\n // Frontmatter paths have higher priority, so they come first\n path: [...(frontmatter.path ?? []), ...(base.path ?? [])],\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/** Regex to match skip annotation in heading or nearby HTML comment */\nconst SKIP_ANNOTATION_REGEX = /<!--\\s*skip\\s*-->/i;\n\n/** Regex to match only annotation in heading or nearby HTML comment */\nconst ONLY_ANNOTATION_REGEX = /<!--\\s*only\\s*-->/i;\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 // Check for skip/only annotations in the heading line or nearby comments\n const headingContext = lastHeadingMatch\n ? contentBefore.slice(contentBefore.lastIndexOf(lastHeadingMatch[0]))\n : '';\n const skip = SKIP_ANNOTATION_REGEX.test(headingContext);\n const only = ONLY_ANNOTATION_REGEX.test(headingContext);\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 expectedStderr: parsed.expectedStderr,\n expectedExitCode: parsed.expectedExitCode,\n lineNumber,\n rawContent: match[0],\n skip,\n only,\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 expectedStderr?: string;\n expectedExitCode: number;\n} | null {\n const lines = content.split('\\n');\n const commandLines: string[] = [];\n const outputLines: string[] = [];\n const stderrLines: 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 if (line.startsWith('! ')) {\n // Stderr line (prefixed with !)\n inCommand = false;\n stderrLines.push(line.slice(2));\n } else {\n // Output line (stdout or combined)\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 // Join stderr lines if any\n let expectedStderr: string | undefined;\n if (stderrLines.length > 0) {\n expectedStderr = stderrLines.join('\\n');\n expectedStderr = expectedStderr.replace(/\\n+$/, '');\n if (expectedStderr) {\n expectedStderr += '\\n';\n }\n }\n\n return { command: command.trim(), expectedOutput, expectedStderr, expectedExitCode };\n}\n","import { existsSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\n\n/**\n * Find nearest package.json by walking up from startDir.\n * Returns the path to package.json, or null if not found.\n */\nexport function findPackageJson(startDir: string): string | null {\n let dir = startDir;\n\n while (true) {\n const pkgPath = join(dir, 'package.json');\n if (existsSync(pkgPath)) {\n return pkgPath;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n break;\n } // Reached filesystem root\n dir = parent;\n }\n\n return null;\n}\n\n/**\n * Find nearest .git directory by walking up from startDir.\n * Returns the directory containing .git, or null if not found.\n */\nexport function findGitRoot(startDir: string): string | null {\n let dir = startDir;\n\n while (true) {\n const gitPath = join(dir, '.git');\n if (existsSync(gitPath)) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n break;\n } // Reached filesystem root\n dir = parent;\n }\n\n return null;\n}\n","/**\n * Environment variable expansion utilities.\n *\n * Provides shell-compatible variable expansion for configuration values.\n */\n\n/**\n * Expand environment variable references in a string.\n *\n * Supports standard shell variable syntax:\n * - `$VAR` - simple variable reference\n * - `${VAR}` - braced variable reference\n *\n * Variables are resolved in order:\n * 1. Custom env vars (if provided)\n * 2. Process environment variables\n * 3. Empty string (if undefined)\n *\n * @param str - String containing variable references\n * @param customEnv - Optional custom environment to check first\n * @returns String with variables expanded\n *\n * @example\n * ```ts\n * expandEnvVars('$HOME/bin') // '/home/user/bin'\n * expandEnvVars('${HOME}/bin') // '/home/user/bin'\n * expandEnvVars('$UNDEFINED') // ''\n * expandEnvVars('$MY_VAR', { MY_VAR: 'value' }) // 'value'\n * ```\n */\nexport function expandEnvVars(str: string, customEnv?: Record<string, string>): string {\n const resolve = (varName: string): string => {\n return customEnv?.[varName] ?? process.env[varName] ?? '';\n };\n\n return str\n .replace(/\\$\\{([A-Za-z_][A-Za-z0-9_]*)\\}/g, (_, varName: string) => resolve(varName))\n .replace(/\\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, varName: string) => resolve(varName));\n}\n\n/**\n * Create a bound expander with a preset custom environment.\n *\n * Useful when expanding multiple strings with the same custom env.\n *\n * @param customEnv - Custom environment variables to use\n * @returns Bound expand function\n *\n * @example\n * ```ts\n * const expand = createEnvExpander({ TRYSCRIPT_ROOT: '/project' });\n * expand('$TRYSCRIPT_ROOT/dist') // '/project/dist'\n * expand('$HOME/bin') // Uses process.env.HOME\n * ```\n */\nexport function createEnvExpander(customEnv: Record<string, string>): (str: string) => string {\n return (str: string) => expandEnvVars(str, customEnv);\n}\n","import { spawn } from 'node:child_process';\nimport { existsSync } from 'node:fs';\nimport { mkdtemp, realpath, rm, cp } from 'node:fs/promises';\nimport { tmpdir } from 'node:os';\nimport { join, dirname, resolve, basename, delimiter } from 'node:path';\nimport treeKill from 'tree-kill';\nimport type { TestBlock, TestBlockResult } from './types.js';\nimport type { TryscriptConfig, Fixture } from './config.js';\nimport { findPackageJson, findGitRoot } from './package-bin.js';\nimport { createEnvExpander } from './env-vars.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 directory paths and config.\n */\nexport interface ExecutionContext {\n /** Temporary directory for this test file (resolved, no symlinks) */\n tempDir: string;\n /** Directory containing the test file */\n testDir: string;\n /** Working directory for command execution */\n cwd: string;\n /** Whether running in sandbox mode */\n sandbox: boolean;\n /** Environment variables */\n env: Record<string, string>;\n /** Timeout per command */\n timeout: number;\n /** Before hook script */\n before?: string;\n /** After hook script */\n after?: string;\n /** Whether before hook has been run */\n beforeRan?: boolean;\n}\n\n/**\n * Normalize fixture config to Fixture object.\n */\nfunction normalizeFixture(fixture: string | Fixture): Fixture {\n if (typeof fixture === 'string') {\n return { source: fixture };\n }\n return fixture;\n}\n\n/**\n * Setup fixtures by copying files to sandbox directory.\n */\nasync function setupFixtures(\n fixtures: (string | Fixture)[] | undefined,\n testDir: string,\n sandboxDir: string,\n): Promise<void> {\n if (!fixtures || fixtures.length === 0) {\n return;\n }\n\n for (const f of fixtures) {\n const fixture = normalizeFixture(f);\n const src = resolve(testDir, fixture.source);\n const destName = fixture.dest ?? basename(fixture.source);\n const dst = resolve(sandboxDir, destName);\n await cp(src, dst, { recursive: true });\n }\n}\n\n/**\n * Create an execution context for a test file.\n * @param config - Test configuration\n * @param testFilePath - Path to the test file\n * @param coverageEnv - Optional coverage environment variables (e.g., NODE_V8_COVERAGE)\n */\nexport async function createExecutionContext(\n config: TryscriptConfig,\n testFilePath: string,\n coverageEnv?: Record<string, 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 // Determine working directory based on sandbox config\n let cwd: string;\n let sandbox = false;\n\n if (config.sandbox === true) {\n // Empty sandbox: run in temp directory\n cwd = tempDir;\n sandbox = true;\n } else if (typeof config.sandbox === 'string') {\n // Copy directory to sandbox: copy source to temp, run in temp\n const srcPath = resolve(testDir, config.sandbox);\n await cp(srcPath, tempDir, { recursive: true });\n cwd = tempDir;\n sandbox = true;\n } else if (config.cwd) {\n // Run in specified directory (relative to test file)\n cwd = resolve(testDir, config.cwd);\n } else {\n // Default: run in test file directory\n cwd = testDir;\n }\n\n // Copy additional fixtures to sandbox (only if sandbox enabled)\n if (sandbox && config.fixtures) {\n await setupFixtures(config.fixtures, testDir, tempDir);\n }\n\n // Find package root for TRYSCRIPT_PACKAGE_ROOT (always available)\n const pkgPath = findPackageJson(testDir);\n const packageRoot = pkgPath ? dirname(pkgPath) : undefined;\n\n // Find git root for TRYSCRIPT_GIT_ROOT\n const gitRoot = findGitRoot(testDir) ?? undefined;\n\n // TRYSCRIPT_PROJECT_ROOT is the most specific (deepest) of package or git root\n // Deeper path = longer string = more specific project boundary\n const projectRoot =\n packageRoot && gitRoot\n ? packageRoot.length >= gitRoot.length\n ? packageRoot\n : gitRoot\n : (packageRoot ?? gitRoot);\n\n // TRYSCRIPT_PACKAGE_BIN points to node_modules/.bin if it exists\n const packageBinPath = packageRoot ? join(packageRoot, 'node_modules', '.bin') : undefined;\n const packageBin = packageBinPath && existsSync(packageBinPath) ? packageBinPath : undefined;\n\n // Build env vars map for path expansion (before building PATH)\n const tryscriptEnvVars: Record<string, string> = {\n ...(packageRoot && { TRYSCRIPT_PACKAGE_ROOT: packageRoot }),\n ...(gitRoot && { TRYSCRIPT_GIT_ROOT: gitRoot }),\n ...(projectRoot && { TRYSCRIPT_PROJECT_ROOT: projectRoot }),\n ...(packageBin && { TRYSCRIPT_PACKAGE_BIN: packageBin }),\n TRYSCRIPT_TEST_DIR: testDir,\n };\n\n // Create expander with tryscript env vars taking precedence\n const expandEnvVars = createEnvExpander(tryscriptEnvVars);\n\n // Build PATH: config paths > system PATH\n const pathParts: string[] = [];\n if (config.path && config.path.length > 0) {\n // Expand env vars in path entries, then resolve relative to testDir\n pathParts.push(\n ...config.path.map((p) => {\n const expanded = expandEnvVars(p);\n // If already absolute (after expansion), use as-is; otherwise resolve relative to testDir\n return expanded.startsWith('/') ? expanded : resolve(testDir, expanded);\n }),\n );\n }\n pathParts.push(process.env.PATH ?? '');\n\n const ctx: ExecutionContext = {\n tempDir,\n testDir,\n cwd,\n sandbox,\n env: {\n ...process.env,\n ...config.env,\n ...coverageEnv,\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 // Provide project roots for manual path construction\n ...tryscriptEnvVars,\n // Custom PATH with config paths\n PATH: pathParts.join(delimiter),\n } as Record<string, string>,\n timeout: config.timeout ?? DEFAULT_TIMEOUT,\n before: config.before,\n after: config.after,\n };\n\n return ctx;\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 the before hook if it hasn't run yet.\n */\nexport async function runBeforeHook(ctx: ExecutionContext): Promise<void> {\n if (ctx.before && !ctx.beforeRan) {\n ctx.beforeRan = true;\n await executeCommand(ctx.before, ctx);\n }\n}\n\n/**\n * Run the after hook.\n */\nexport async function runAfterHook(ctx: ExecutionContext): Promise<void> {\n if (ctx.after) {\n await executeCommand(ctx.after, ctx);\n }\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 // Handle skip annotation\n if (block.skip) {\n return {\n block,\n passed: true,\n actualOutput: '',\n actualExitCode: 0,\n duration: 0,\n skipped: true,\n };\n }\n\n try {\n // Run before hook if this is the first test\n await runBeforeHook(ctx);\n\n // Execute command directly (shell handles $VAR expansion)\n const { output, stdout, stderr, 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 actualStdout: stdout,\n actualStderr: stderr,\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/** Command execution result with separate stdout/stderr */\ninterface CommandResult {\n output: string;\n stdout: string;\n stderr: string;\n exitCode: number;\n}\n\n/**\n * Execute a command and capture output.\n */\nasync function executeCommand(command: string, ctx: ExecutionContext): Promise<CommandResult> {\n return new Promise((resolve, reject) => {\n const proc = spawn(command, {\n shell: true,\n cwd: ctx.cwd,\n env: ctx.env as NodeJS.ProcessEnv,\n // Pipe both to capture\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n\n const combinedChunks: { data: Buffer; type: 'stdout' | 'stderr' }[] = [];\n const stdoutChunks: Buffer[] = [];\n const stderrChunks: Buffer[] = [];\n\n // Capture data as it comes in to preserve order\n proc.stdout.on('data', (data: Buffer) => {\n combinedChunks.push({ data, type: 'stdout' });\n stdoutChunks.push(data);\n });\n proc.stderr.on('data', (data: Buffer) => {\n combinedChunks.push({ data, type: 'stderr' });\n stderrChunks.push(data);\n });\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(combinedChunks.map((c) => c.data)).toString('utf-8');\n const stdout = Buffer.concat(stdoutChunks).toString('utf-8');\n const stderr = Buffer.concat(stderrChunks).toString('utf-8');\n resolve({\n output,\n stdout,\n stderr,\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 CoverageConfig,\n CoverageContext,\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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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"src-Bd9-Y0qp.mjs","names":["DEFAULT_COVERAGE_CONFIG: ResolvedCoverageConfig","config: TestConfig","parseYaml","blocks: TestBlock[]","match: RegExpExecArray | null","commandLines: string[]","outputLines: string[]","stderrLines: string[]","expectedStderr: string | undefined","resolve","cwd: string","tryscriptEnvVars: Record<string, string>","expandEnvVars","pathParts: string[]","combinedChunks: { data: Buffer; type: 'stdout' | 'stderr' }[]","stdoutChunks: Buffer[]","stderrChunks: Buffer[]","VERSION: string"],"sources":["../src/lib/config.ts","../src/lib/parser.ts","../src/lib/package-bin.ts","../src/lib/env-vars.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, CoverageConfig } from './types.js';\n\n/** Fixture configuration for copying files to sandbox directory */\nexport interface Fixture {\n /** Source path (resolved relative to test file) */\n source: string;\n /** Destination path (resolved relative to sandbox dir) */\n dest?: string;\n}\n\nexport interface TryscriptConfig {\n /** Working directory for commands (default: test file directory) */\n cwd?: string;\n /** Run in isolated sandbox: true = empty temp, path = copy to temp */\n sandbox?: boolean | string;\n /** Fixtures to copy to sandbox directory before tests */\n fixtures?: (string | Fixture)[];\n /** Script to run before first test block */\n before?: string;\n /** Script to run after all test blocks */\n after?: string;\n env?: Record<string, string>;\n timeout?: number;\n patterns?: Record<string, RegExp | string>;\n tests?: string[];\n /** Coverage configuration (used with --coverage flag) */\n coverage?: CoverageConfig;\n /**\n * Directories to prepend to PATH (resolved relative to test file).\n * Makes executables in these directories available by name in commands.\n * Supports env var expansion: $VAR or ${VAR} syntax.\n */\n path?: string[];\n}\n\n/** Resolved coverage config with required fields (except optional mergeLcov) */\nexport type ResolvedCoverageConfig = Omit<Required<CoverageConfig>, 'mergeLcov'> & {\n mergeLcov?: string;\n};\n\n/** Default coverage configuration values. */\nexport const DEFAULT_COVERAGE_CONFIG: ResolvedCoverageConfig = {\n reportsDir: 'coverage-tryscript',\n reporters: ['text', 'html'],\n include: ['dist/**'],\n exclude: [],\n excludeNodeModules: true,\n excludeAfterRemap: false,\n skipFull: false,\n allowExternal: false,\n src: 'src',\n monocart: false,\n};\n\n/**\n * Resolve coverage options by merging user config with defaults.\n */\nexport function resolveCoverageConfig(config?: CoverageConfig): ResolvedCoverageConfig {\n return {\n reportsDir: config?.reportsDir ?? DEFAULT_COVERAGE_CONFIG.reportsDir,\n reporters: config?.reporters ?? DEFAULT_COVERAGE_CONFIG.reporters,\n include: config?.include ?? DEFAULT_COVERAGE_CONFIG.include,\n exclude: config?.exclude ?? DEFAULT_COVERAGE_CONFIG.exclude,\n excludeNodeModules: config?.excludeNodeModules ?? DEFAULT_COVERAGE_CONFIG.excludeNodeModules,\n excludeAfterRemap: config?.excludeAfterRemap ?? DEFAULT_COVERAGE_CONFIG.excludeAfterRemap,\n skipFull: config?.skipFull ?? DEFAULT_COVERAGE_CONFIG.skipFull,\n allowExternal: config?.allowExternal ?? DEFAULT_COVERAGE_CONFIG.allowExternal,\n src: config?.src ?? DEFAULT_COVERAGE_CONFIG.src,\n monocart: config?.monocart ?? DEFAULT_COVERAGE_CONFIG.monocart,\n mergeLcov: config?.mergeLcov,\n };\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 fixtures: [...(base.fixtures ?? []), ...(frontmatter.fixtures ?? [])],\n // Frontmatter paths have higher priority, so they come first\n path: [...(frontmatter.path ?? []), ...(base.path ?? [])],\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/** Regex to match skip annotation in heading or nearby HTML comment */\nconst SKIP_ANNOTATION_REGEX = /<!--\\s*skip\\s*-->/i;\n\n/** Regex to match only annotation in heading or nearby HTML comment */\nconst ONLY_ANNOTATION_REGEX = /<!--\\s*only\\s*-->/i;\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 // Check for skip/only annotations in the heading line or nearby comments\n const headingContext = lastHeadingMatch\n ? contentBefore.slice(contentBefore.lastIndexOf(lastHeadingMatch[0]))\n : '';\n const skip = SKIP_ANNOTATION_REGEX.test(headingContext);\n const only = ONLY_ANNOTATION_REGEX.test(headingContext);\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 expectedStderr: parsed.expectedStderr,\n expectedExitCode: parsed.expectedExitCode,\n lineNumber,\n rawContent: match[0],\n skip,\n only,\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 expectedStderr?: string;\n expectedExitCode: number;\n} | null {\n const lines = content.split('\\n');\n const commandLines: string[] = [];\n const outputLines: string[] = [];\n const stderrLines: 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 if (line.startsWith('! ')) {\n // Stderr line (prefixed with !)\n inCommand = false;\n stderrLines.push(line.slice(2));\n } else {\n // Output line (stdout or combined)\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 // Join stderr lines if any\n let expectedStderr: string | undefined;\n if (stderrLines.length > 0) {\n expectedStderr = stderrLines.join('\\n');\n expectedStderr = expectedStderr.replace(/\\n+$/, '');\n if (expectedStderr) {\n expectedStderr += '\\n';\n }\n }\n\n return { command: command.trim(), expectedOutput, expectedStderr, expectedExitCode };\n}\n","import { existsSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\n\n/**\n * Find nearest package.json by walking up from startDir.\n * Returns the path to package.json, or null if not found.\n */\nexport function findPackageJson(startDir: string): string | null {\n let dir = startDir;\n\n while (true) {\n const pkgPath = join(dir, 'package.json');\n if (existsSync(pkgPath)) {\n return pkgPath;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n break;\n } // Reached filesystem root\n dir = parent;\n }\n\n return null;\n}\n\n/**\n * Find nearest .git directory by walking up from startDir.\n * Returns the directory containing .git, or null if not found.\n */\nexport function findGitRoot(startDir: string): string | null {\n let dir = startDir;\n\n while (true) {\n const gitPath = join(dir, '.git');\n if (existsSync(gitPath)) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n break;\n } // Reached filesystem root\n dir = parent;\n }\n\n return null;\n}\n","/**\n * Environment variable expansion utilities.\n *\n * Provides shell-compatible variable expansion for configuration values.\n */\n\n/**\n * Expand environment variable references in a string.\n *\n * Supports standard shell variable syntax:\n * - `$VAR` - simple variable reference\n * - `${VAR}` - braced variable reference\n *\n * Variables are resolved in order:\n * 1. Custom env vars (if provided)\n * 2. Process environment variables\n * 3. Empty string (if undefined)\n *\n * @param str - String containing variable references\n * @param customEnv - Optional custom environment to check first\n * @returns String with variables expanded\n *\n * @example\n * ```ts\n * expandEnvVars('$HOME/bin') // '/home/user/bin'\n * expandEnvVars('${HOME}/bin') // '/home/user/bin'\n * expandEnvVars('$UNDEFINED') // ''\n * expandEnvVars('$MY_VAR', { MY_VAR: 'value' }) // 'value'\n * ```\n */\nexport function expandEnvVars(str: string, customEnv?: Record<string, string>): string {\n const resolve = (varName: string): string => {\n return customEnv?.[varName] ?? process.env[varName] ?? '';\n };\n\n return str\n .replace(/\\$\\{([A-Za-z_][A-Za-z0-9_]*)\\}/g, (_, varName: string) => resolve(varName))\n .replace(/\\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, varName: string) => resolve(varName));\n}\n\n/**\n * Create a bound expander with a preset custom environment.\n *\n * Useful when expanding multiple strings with the same custom env.\n *\n * @param customEnv - Custom environment variables to use\n * @returns Bound expand function\n *\n * @example\n * ```ts\n * const expand = createEnvExpander({ TRYSCRIPT_ROOT: '/project' });\n * expand('$TRYSCRIPT_ROOT/dist') // '/project/dist'\n * expand('$HOME/bin') // Uses process.env.HOME\n * ```\n */\nexport function createEnvExpander(customEnv: Record<string, string>): (str: string) => string {\n return (str: string) => expandEnvVars(str, customEnv);\n}\n","import { spawn } from 'node:child_process';\nimport { existsSync } from 'node:fs';\nimport { mkdtemp, realpath, rm, cp } from 'node:fs/promises';\nimport { tmpdir } from 'node:os';\nimport { join, dirname, resolve, basename, delimiter } from 'node:path';\nimport treeKill from 'tree-kill';\nimport type { TestBlock, TestBlockResult } from './types.js';\nimport type { TryscriptConfig, Fixture } from './config.js';\nimport { findPackageJson, findGitRoot } from './package-bin.js';\nimport { createEnvExpander } from './env-vars.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 directory paths and config.\n */\nexport interface ExecutionContext {\n /** Temporary directory for this test file (resolved, no symlinks) */\n tempDir: string;\n /** Directory containing the test file */\n testDir: string;\n /** Working directory for command execution */\n cwd: string;\n /** Whether running in sandbox mode */\n sandbox: boolean;\n /** Environment variables */\n env: Record<string, string>;\n /** Timeout per command */\n timeout: number;\n /** Before hook script */\n before?: string;\n /** After hook script */\n after?: string;\n /** Whether before hook has been run */\n beforeRan?: boolean;\n}\n\n/**\n * Normalize fixture config to Fixture object.\n */\nfunction normalizeFixture(fixture: string | Fixture): Fixture {\n if (typeof fixture === 'string') {\n return { source: fixture };\n }\n return fixture;\n}\n\n/**\n * Setup fixtures by copying files to sandbox directory.\n */\nasync function setupFixtures(\n fixtures: (string | Fixture)[] | undefined,\n testDir: string,\n sandboxDir: string,\n): Promise<void> {\n if (!fixtures || fixtures.length === 0) {\n return;\n }\n\n for (const f of fixtures) {\n const fixture = normalizeFixture(f);\n const src = resolve(testDir, fixture.source);\n const destName = fixture.dest ?? basename(fixture.source);\n const dst = resolve(sandboxDir, destName);\n await cp(src, dst, { recursive: true });\n }\n}\n\n/**\n * Create an execution context for a test file.\n * @param config - Test configuration\n * @param testFilePath - Path to the test file\n * @param coverageEnv - Optional coverage environment variables (e.g., NODE_V8_COVERAGE)\n */\nexport async function createExecutionContext(\n config: TryscriptConfig,\n testFilePath: string,\n coverageEnv?: Record<string, 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 // Determine working directory based on sandbox config\n let cwd: string;\n let sandbox = false;\n\n if (config.sandbox === true) {\n // Empty sandbox: run in temp directory\n cwd = tempDir;\n sandbox = true;\n } else if (typeof config.sandbox === 'string') {\n // Copy directory to sandbox: copy source to temp, run in temp\n const srcPath = resolve(testDir, config.sandbox);\n await cp(srcPath, tempDir, { recursive: true });\n cwd = tempDir;\n sandbox = true;\n } else if (config.cwd) {\n // Run in specified directory (relative to test file)\n cwd = resolve(testDir, config.cwd);\n } else {\n // Default: run in test file directory\n cwd = testDir;\n }\n\n // Copy additional fixtures to sandbox (only if sandbox enabled)\n if (sandbox && config.fixtures) {\n await setupFixtures(config.fixtures, testDir, tempDir);\n }\n\n // Find package root for TRYSCRIPT_PACKAGE_ROOT (always available)\n const pkgPath = findPackageJson(testDir);\n const packageRoot = pkgPath ? dirname(pkgPath) : undefined;\n\n // Find git root for TRYSCRIPT_GIT_ROOT\n const gitRoot = findGitRoot(testDir) ?? undefined;\n\n // TRYSCRIPT_PROJECT_ROOT is the most specific (deepest) of package or git root\n // Deeper path = longer string = more specific project boundary\n const projectRoot =\n packageRoot && gitRoot\n ? packageRoot.length >= gitRoot.length\n ? packageRoot\n : gitRoot\n : (packageRoot ?? gitRoot);\n\n // TRYSCRIPT_PACKAGE_BIN points to node_modules/.bin if it exists\n const packageBinPath = packageRoot ? join(packageRoot, 'node_modules', '.bin') : undefined;\n const packageBin = packageBinPath && existsSync(packageBinPath) ? packageBinPath : undefined;\n\n // Build env vars map for path expansion (before building PATH)\n const tryscriptEnvVars: Record<string, string> = {\n ...(packageRoot && { TRYSCRIPT_PACKAGE_ROOT: packageRoot }),\n ...(gitRoot && { TRYSCRIPT_GIT_ROOT: gitRoot }),\n ...(projectRoot && { TRYSCRIPT_PROJECT_ROOT: projectRoot }),\n ...(packageBin && { TRYSCRIPT_PACKAGE_BIN: packageBin }),\n TRYSCRIPT_TEST_DIR: testDir,\n };\n\n // Create expander with tryscript env vars taking precedence\n const expandEnvVars = createEnvExpander(tryscriptEnvVars);\n\n // Build PATH: config paths > system PATH\n const pathParts: string[] = [];\n if (config.path && config.path.length > 0) {\n // Expand env vars in path entries, then resolve relative to testDir\n pathParts.push(\n ...config.path.map((p) => {\n const expanded = expandEnvVars(p);\n // If already absolute (after expansion), use as-is; otherwise resolve relative to testDir\n return expanded.startsWith('/') ? expanded : resolve(testDir, expanded);\n }),\n );\n }\n pathParts.push(process.env.PATH ?? '');\n\n const ctx: ExecutionContext = {\n tempDir,\n testDir,\n cwd,\n sandbox,\n env: {\n ...process.env,\n ...config.env,\n ...coverageEnv,\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 // Provide project roots for manual path construction\n ...tryscriptEnvVars,\n // Custom PATH with config paths\n PATH: pathParts.join(delimiter),\n } as Record<string, string>,\n timeout: config.timeout ?? DEFAULT_TIMEOUT,\n before: config.before,\n after: config.after,\n };\n\n return ctx;\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 the before hook if it hasn't run yet.\n */\nexport async function runBeforeHook(ctx: ExecutionContext): Promise<void> {\n if (ctx.before && !ctx.beforeRan) {\n ctx.beforeRan = true;\n await executeCommand(ctx.before, ctx);\n }\n}\n\n/**\n * Run the after hook.\n */\nexport async function runAfterHook(ctx: ExecutionContext): Promise<void> {\n if (ctx.after) {\n await executeCommand(ctx.after, ctx);\n }\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 // Handle skip annotation\n if (block.skip) {\n return {\n block,\n passed: true,\n actualOutput: '',\n actualExitCode: 0,\n duration: 0,\n skipped: true,\n };\n }\n\n try {\n // Run before hook if this is the first test\n await runBeforeHook(ctx);\n\n // Execute command directly (shell handles $VAR expansion)\n const { output, stdout, stderr, 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 actualStdout: stdout,\n actualStderr: stderr,\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/** Command execution result with separate stdout/stderr */\ninterface CommandResult {\n output: string;\n stdout: string;\n stderr: string;\n exitCode: number;\n}\n\n/**\n * Execute a command and capture output.\n */\nasync function executeCommand(command: string, ctx: ExecutionContext): Promise<CommandResult> {\n return new Promise((resolve, reject) => {\n const proc = spawn(command, {\n shell: true,\n cwd: ctx.cwd,\n env: ctx.env as NodeJS.ProcessEnv,\n // Pipe both to capture\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n\n const combinedChunks: { data: Buffer; type: 'stdout' | 'stderr' }[] = [];\n const stdoutChunks: Buffer[] = [];\n const stderrChunks: Buffer[] = [];\n\n // Capture data as it comes in to preserve order\n proc.stdout.on('data', (data: Buffer) => {\n combinedChunks.push({ data, type: 'stdout' });\n stdoutChunks.push(data);\n });\n proc.stderr.on('data', (data: Buffer) => {\n combinedChunks.push({ data, type: 'stderr' });\n stderrChunks.push(data);\n });\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(combinedChunks.map((c) => c.data)).toString('utf-8');\n const stdout = Buffer.concat(stdoutChunks).toString('utf-8');\n const stderr = Buffer.concat(stderrChunks).toString('utf-8');\n resolve({\n output,\n stdout,\n stderr,\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 CoverageConfig,\n CoverageContext,\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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"}