tryscript Reference

February 21, 2026 · View on GitHub

Complete reference for writing tryscript golden tests. This document covers all syntax, configuration, and patterns needed to write accurate CLI tests on the first try.

Overview

Tryscript is a markdown-based CLI golden testing format. Test files are markdown documents with embedded console code blocks specifying commands and expected output.

Design Philosophy:

  • Shell delegation: Commands run in a real shell with full shell features
  • Markdown-first: Test files are valid markdown, readable as documentation
  • Output matching: Patterns like [..] match variable output; they're not for commands

Quick Start Example

---
sandbox: true
env:
  NO_COLOR: "1"
---

# Test: Basic echo

```console
$ echo "hello world"
hello world
? 0
```

# Test: Command with variable output

```console
$ date +%Y
[..]
? 0
```

Test File Structure

┌──────────────────────────────────────┐
│ ---                                  │  YAML Frontmatter (optional)
│ env:                                 │  - Configuration
│   MY_VAR: value                      │  - Environment variables
│ sandbox: true                        │  - Patterns
│ ---                                  │
├──────────────────────────────────────┤
│ # Test: Description                  │  Test heading (# or ##)
│                                      │
│ ```console                           │  Test block
│ $ command --flag                     │  - Command starts with $
│ expected output                      │  - Expected stdout follows
│ ? 0                                  │  - Exit code (optional, default 0)
│ ```                                  │
└──────────────────────────────────────┘

Command Block Syntax

$ command [arguments...]     # Command to execute (required)
> continuation line          # Multi-line command continuation
expected output              # Expected stdout (line by line)
! stderr line                # Expected stderr (when separating streams)
? exit_code                  # Expected exit code (default: 0)

Examples

Simple command:

$ echo "hello"
hello
? 0

Non-zero exit code:

$ exit 42
? 42

Multi-line command:

$ ls -la | \
> grep ".md" | \
> wc -l
5

Stderr handling:

$ cat nonexistent 2>&1
cat: nonexistent: No such file or directory
? 1

Separate stderr assertion:

$ ./script.sh
stdout line
! stderr line
? 0

Elision Patterns

Patterns in expected output match variable content. There are three categories of wildcards, listed in order of preference:

Named Patterns

Named patterns match typed dynamic values with specific meaning:

PatternMatchesExample
[CWD]Current working directory[CWD]/output.txt
[ROOT]Test file directory[ROOT]/fixtures/
[EXE].exe on Windows, empty otherwisemy-cli[EXE]
[PATTERN]Custom pattern from configUser-defined regex

Unknown Wildcards

Unknown wildcards are temporary placeholders for output you haven't filled in yet. They are intended to be expanded with --expand before finalizing tests. A warning is always shown when unknown wildcards are present.

PatternMatchesExample
[??]Any text on a single lineResult: [??]
???Zero or more complete lines???\nDone

Generic Wildcards

Generic wildcards intentionally omit unpredictable or irrelevant output. Use these when the exact value doesn't matter for the test.

PatternMatchesExample
[..]Any text on a single lineBuilt in [..]ms
...Zero or more complete lines...\nDone

Pattern Examples

Single-line wildcard:

$ date
[..]
? 0

Unknown wildcard (to be expanded later):

$ my-cli process data.json
[??]
? 0

Multi-line wildcard:

$ ls -la
total [..]
...
-rw-r--r-- 1 user user [..] README.md

Custom pattern:

patterns:
  VERSION: '\d+\.\d+\.\d+'
$ my-cli --version
my-cli version [VERSION]

Wildcard Best Practices

  1. Prefer named patterns when the output has a known structure (e.g., [VERSION], [HASH]). This makes tests self-documenting.

  2. Use unknown wildcards ([??]/???) as temporary scaffolding when writing new tests. Run with --expand to fill them in with actual output.

  3. Use generic wildcards ([..]/...) for output that is intentionally variable (timestamps, durations, dynamic content) and should remain elided.

Configuration (Frontmatter)

All options are optional. Place at the top of the file:

---
cwd: ./subdir              # Working directory (relative to test file)
sandbox: true              # Run in isolated temp directory
env:                       # Environment variables
  NO_COLOR: "1"
  MY_VAR: value
timeout: 5000              # Command timeout in milliseconds
patterns:                  # Custom elision patterns
  UUID: '[0-9a-f]{8}-...'
fixtures:                  # Files to copy to sandbox
  - data/input.txt
before: npm run build      # Run before first test
after: rm -rf ./cache      # Run after all tests
path:                      # Directories to prepend to PATH
  - ../dist
  - $TRYSCRIPT_PACKAGE_BIN # Access node_modules/.bin via env var
---

Config Options Reference

OptionTypeDefaultDescription
cwdpath"."Working directory (relative to test file)
sandboxboolean | pathfalseRun in isolated temp directory
envobject{}Environment variables passed to shell
timeoutnumber30000Command timeout in milliseconds
patternsobject{}Custom regex patterns for [NAME]
fixturesarray[]Files to copy to sandbox
beforestring-Shell command before first test
afterstring-Shell command after all tests
pathstring[][]Directories to prepend to PATH (supports $VAR expansion)

Sandbox Mode

Sandbox provides test isolation by running commands in a temporary directory:

ConfigurationBehavior
sandbox: false (default)Commands run in cwd (test file dir)
sandbox: trueCreates empty temp dir, commands run there
sandbox: ./fixturesCopies ./fixtures/ to temp dir, runs there

When sandbox is enabled:

  • Fresh temp directory created for each test file
  • Fixtures are copied before tests run
  • [CWD] matches the sandbox directory
  • Files created by tests don't pollute source

Sandbox with Fixtures

---
sandbox: true
fixtures:
  - data/input.txt                 # Copies to sandbox/input.txt
  - source: config/settings.json   # Copies to sandbox/custom.json
    dest: custom.json
---

Environment Variables

Use env to set variables. The shell handles $VAR expansion:

env:
  CLI: ./dist/cli.mjs
  DEBUG: "true"
$ $CLI --version
1.0.0

Important: Variables are for the shell, not for output matching.

Built-in Environment Variables

Tryscript sets these environment variables for test commands:

VariableDescription
NO_COLORSet to "1" by default (disables colors)
FORCE_COLORSet to "0" (disables forced colors)
TRYSCRIPT_TEST_DIRAbsolute path to directory containing the test file
TRYSCRIPT_PACKAGE_ROOTAbsolute path to directory containing nearest package.json (if found)
TRYSCRIPT_GIT_ROOTAbsolute path to directory containing nearest .git (if found)
TRYSCRIPT_PROJECT_ROOTMost specific of PACKAGE_ROOT or GIT_ROOT (deepest path)
TRYSCRIPT_PACKAGE_BINAbsolute path to node_modules/.bin directory (if exists)

Project root variables help write portable tests that work across different project types:

  • TRYSCRIPT_PACKAGE_ROOT - For npm/Node.js projects with package.json
  • TRYSCRIPT_GIT_ROOT - For any git repository (Rust, Go, Python, etc.)
  • TRYSCRIPT_PROJECT_ROOT - Use this when you don't care about project type
  • TRYSCRIPT_PACKAGE_BIN - For npm packages with node_modules/.bin (use in path:)

Example using TRYSCRIPT_PROJECT_ROOT:

$ test -n "$TRYSCRIPT_PROJECT_ROOT" && echo "in a project"
in a project
? 0

Testing CLI Applications

Tryscript provides several ways to make CLI binaries available in tests.

path: Custom Binary Directories

Use path to prepend directories to PATH, making executables available by name:

---
sandbox: true
path:
  - ../dist                  # Relative to test file directory
  - $TRYSCRIPT_PACKAGE_BIN   # Use node_modules/.bin via env var
---
$ my-cli --version
1.0.0
? 0

Key behaviors:

  • Paths are resolved relative to the test file directory (not the sandbox CWD)
  • Multiple paths are prepended in order (first has highest priority)
  • Works with or without sandbox mode
  • Frontmatter and config file paths are merged (frontmatter first)
  • Environment variable expansion: Path entries support standard shell variable syntax:
    • $VAR - expands any environment variable (lowercase or uppercase)
    • ${VAR} - braced syntax also supported
    • Tryscript env vars (TRYSCRIPT_*) are checked first, then process env vars
    • Undefined variables expand to empty string

Using node_modules/.bin (npm/pnpm/bun)

For Node.js projects using npm, pnpm, or bun, use $TRYSCRIPT_PACKAGE_BIN to access installed CLI tools:

---
sandbox: true
path:
  - $TRYSCRIPT_PACKAGE_BIN   # Expands to node_modules/.bin
---

# Test: Run your CLI by name
```console
$ my-cli --version
1.0.0
? 0

Test: Use any installed dev dependency

$ prettier --check src/
[..]
? 0

This works for any executable installed via `npm install`, `pnpm add`, or `bun add`. The variable only expands if `node_modules/.bin` exists (i.e., after running your package manager's install command).

**Typical project setup:**

my-project/ ├── package.json # TRYSCRIPT_PACKAGE_ROOT points here ├── node_modules/ │ └── .bin/ # TRYSCRIPT_PACKAGE_BIN points here │ ├── prettier │ ├── eslint │ └── my-cli # Your package's bin entry └── tests/ └── cli.tryscript.md # Your test file


### Language-Specific Examples

**Rust CLIs:**
```yaml
---
path:
  - ../target/release
---

Python with venv:

---
path:
  - ../.venv/bin
---

Go CLIs:

---
path:
  - ../bin
---

Test Annotations

Control test execution with HTML comments:

## This test is skipped <!-- skip -->

## Only run this test <!-- only -->
AnnotationEffect
<!-- skip -->Test is skipped, marked as passed
<!-- only -->Only tests with this annotation run

Complete Example

Here's a complete test file demonstrating all features:

---
sandbox: true
env:
  NO_COLOR: "1"
  CLI: ./dist/my-cli.mjs
timeout: 5000
patterns:
  VERSION: '\d+\.\d+\.\d+'
  TIMESTAMP: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'
fixtures:
  - test-data/config.json
before: echo "Setup complete"
---

# CLI Golden Tests

These tests validate the my-cli command-line tool.

## Basic Commands

# Test: Show version

```console
$ $CLI --version
my-cli version [VERSION]
? 0
```

# Test: Show help

```console
$ $CLI --help
Usage: my-cli [options] [command]

Options:
  --version  Show version
  --help     Show help
...
? 0
```

## Error Handling

# Test: Missing required argument

```console
$ $CLI process
Error: missing required argument 'file'
? 1
```

# Test: File not found

```console
$ $CLI process nonexistent.txt 2>&1
Error: file not found: nonexistent.txt
? 1
```

## Feature Tests

# Test: Process config file

```console
$ $CLI process config.json
Processing: config.json
Done at [TIMESTAMP][..]
? 0
```

# Test: Verbose output <!-- skip -->

```console
$ $CLI --verbose process config.json
[DEBUG] Loading config.json
...
Done
? 0
```

CLI Usage

tryscript                              # Show help (same as --help)
tryscript run [files...]               # Run golden tests
tryscript coverage <commands...>       # Run commands with merged coverage
tryscript docs                         # Show this reference
tryscript readme                       # Show README

Run Options

OptionDescription
--updateUpdate test files with actual output
--expandExpand unknown wildcards (???/[??]) with actual output
--expand-genericExpand unknown + generic wildcards
--expand-allExpand all wildcards (including named patterns)
--capture-log <path>Write wildcard capture log to YAML file
--diff / --no-diffShow/hide diff on failure
--fail-fastStop on first failure
--filter <pattern>Filter tests by name
--verboseShow detailed output
--quietSuppress non-essential output
--coverageEnable code coverage collection (requires c8)

Coverage Options

All coverage options mirror c8 CLI flags for familiarity:

OptionDescriptionDefault
--coverage-dir <dir>Output directory for reportscoverage-tryscript
--coverage-reporter <r...>Coverage reporterstext, html
--coverage-exclude <p...>Patterns to excludenone
--coverage-exclude-node-modulesExclude node_modulestrue
--no-coverage-exclude-node-modulesInclude node_modules-
--coverage-exclude-after-remapExclude after sourcemap remapfalse
--coverage-skip-fullHide 100% covered filesfalse
--coverage-allow-externalAllow files outside cwdfalse
--coverage-monocartUse monocart for accurate line countsfalse
--merge-lcov <path>Merge with external LCOV file (e.g., vitest coverage)-

Code Coverage

Experimental: Coverage features are experimental. Line counts may not perfectly match other tools like vitest, especially without the --monocart flag. Use --monocart for best accuracy when merging coverage reports from multiple sources.

Collect code coverage from subprocess execution using the --coverage flag:

# Basic coverage (node_modules excluded by default)
tryscript run --coverage tests/

# Custom output directory
tryscript run --coverage --coverage-dir my-coverage tests/

# Custom reporters
tryscript run --coverage --coverage-reporter text --coverage-reporter lcov tests/

# Exclude additional patterns
tryscript run --coverage --coverage-exclude '**/vendor/**' tests/

# Include node_modules in coverage (not recommended)
tryscript run --coverage --no-coverage-exclude-node-modules tests/

Coverage uses c8 and NODE_V8_COVERAGE to track code executed by spawned CLI processes.

Required dependencies:

# Basic coverage
npm install -D c8

# For --monocart flag (recommended for merging with vitest)
npm install -D c8 monocart-coverage-reports

Default Behavior

By default, tryscript coverage:

  • Excludes node_modules - Your reports show only your code, not dependencies
  • Includes all source files - Files with 0% coverage are shown (use --coverage-skip-full to hide 100% covered files)
  • Uses dist/ include pattern - Tracks your built CLI output

Merging Coverage from Multiple Sources

The coverage command merges V8 coverage from multiple CLI commands into a single report:

# Merge coverage from multiple CLI test commands
tryscript coverage "tryscript run tests/cli/" "node dist/bin.mjs --help"

# With monocart for accurate line counts
tryscript coverage --monocart "tryscript run tests/"

Important: Vitest Incompatibility

The tryscript coverage command uses NODE_V8_COVERAGE to collect coverage data from subprocesses. However, vitest does not use NODE_V8_COVERAGE - it controls the V8 profiler directly via node:inspector (see vitest PR #2786).

This means tryscript coverage "vitest run" ... will NOT collect coverage from vitest tests. The coverage command will warn you if a command produces no new coverage files.

Merging Vitest + Tryscript Coverage

Use the built-in --merge-lcov flag to combine vitest and tryscript coverage in one step:

# Step 1: Run vitest with its own coverage (generates coverage/lcov.info)
vitest run --coverage

# Step 2: Run tryscript with coverage, merging vitest's LCOV file
tryscript run --coverage --merge-lcov coverage/lcov.info tests/

The --merge-lcov flag:

  • Automatically adds the lcov reporter if not already specified
  • Merges the external LCOV file with tryscript's generated coverage
  • Outputs the combined lcov.info and coverage-summary.json for badge generation

Alternative: Manual LCOV Merging

If you need more control, you can merge LCOV files manually:

# Step 1: Run vitest with its own coverage
vitest run --coverage

# Step 2: Run tryscript with coverage
tryscript run --coverage --coverage-reporter lcov tests/

# Step 3: Merge the LCOV files using lcov or a merge tool
lcov -a coverage/lcov.info -a coverage-tryscript/lcov.info -o coverage-merged/lcov.info

Or use tools like nyc merge, istanbul-merge, or custom scripts to combine LCOV/JSON coverage.

Coverage Command Options

OptionDescriptionDefault
--reports-dir <dir>Output directorycoverage
--reporters <list>Comma-separated reporterstext,json,json-summary,lcov,html
--include <patterns>Patterns to includedist/**
--exclude <patterns>Patterns to excludenone
--exclude-node-modulesExclude node_modulestrue
--no-exclude-node-modulesInclude node_modules-
--exclude-after-remapPost-sourcemap excludefalse
--skip-fullHide 100% filesfalse
--allow-externalAllow external filesfalse
--monocartAST-aware line countsfalse
--src <dir>Source dir for mappingsrc
--verboseShow coverage after each commandfalse

How It Works

The coverage command:

  1. Creates a shared temporary directory for V8 coverage data
  2. Sets NODE_V8_COVERAGE environment variable
  3. Runs each command in sequence (all inherit the coverage env)
  4. Shows coverage file statistics after each command (warns if none produced)
  5. Generates a merged coverage report using c8

Debugging Coverage Issues

Use --verbose to see intermediate coverage tables after each command:

tryscript coverage --verbose "cmd1" "cmd2"

This helps identify which commands are contributing coverage and which are not.

Why Monocart?

The --monocart flag uses monocart-coverage-reports for AST-aware line counting, producing line counts ~90% aligned with vitest. Without this flag, standard c8 may inflate line counts by 3-4x, making merged coverage percentages inaccurate.

MetricStandard c8With --monocartVitest
Total lines~1700 (inflated)~460~510
Accuracy✅ ~90% match✅ baseline

Sourcemap Requirement

Important: Coverage reports map back to source files only if your build generates sourcemaps. Without sourcemaps, reports show bundled filenames instead of source paths:

Build ConfigurationCoverage Report Shows
Sourcemaps disabledcli-BXvEEW6O.mjs (34% coverage)
Sourcemaps enabledsrc/cli/commands/status.ts (83% coverage)

Enable sourcemaps in your build tool:

tsdown / tsup:

// tsdown.config.ts or tsup.config.ts
export default defineConfig({
  sourcemap: true,
  // ... other options
});

esbuild:

await esbuild.build({
  sourcemap: true,
  // ... other options
});

rollup:

// rollup.config.js
export default {
  output: {
    sourcemap: true,
  },
};

Vite:

// vite.config.ts
export default defineConfig({
  build: {
    sourcemap: true,
  },
});

After enabling sourcemaps, rebuild your project before running coverage.

Configuration

Configure coverage in tryscript.config.ts:

import { defineConfig } from 'tryscript';

export default defineConfig({
  coverage: {
    reportsDir: 'coverage-tryscript',
    reporters: ['text', 'html'],
    include: ['dist/**'],
    exclude: [],                  // Additional exclude patterns
    excludeNodeModules: true,     // Exclude node_modules (recommended)
    excludeAfterRemap: false,     // Apply exclude after sourcemap remap
    skipFull: false,              // Hide 100% covered files
    allowExternal: false,         // Allow files outside cwd
    src: 'src',
    monocart: false,              // Use monocart for vitest-compatible line counts
  },
});
Config OptionCLI FlagDescription
reportsDir--coverage-dirOutput directory
reporters--coverage-reporterReporter list
include-Include patterns (config only)
exclude--coverage-excludeExclude patterns
excludeNodeModules--coverage-exclude-node-modulesExclude node_modules
excludeAfterRemap--coverage-exclude-after-remapPost-sourcemap exclude
skipFull--coverage-skip-fullHide 100% files
allowExternal--coverage-allow-externalAllow external files
src-Source dir for mapping (config only)
monocart--coverage-monocartAST-aware line counts
mergeLcov--merge-lcovMerge with external LCOV file

Wildcard Expansion

The --expand flags replace wildcard placeholders in your test files with actual output from a test run. This is a surgical operation -- only targeted wildcards are replaced; the rest of the file is left intact.

Expansion Workflow

  1. Write a test with unknown wildcards as temporary placeholders:
$ my-cli status
[??]
? 0
  1. Run with --expand to fill in actual output:
tryscript run --expand tests/my-test.tryscript.md
  1. Review the expanded output and commit.

Expansion Flags

The three flags form a hierarchy (each includes the previous):

FlagExpands
--expandUnknown wildcards only (???, [??])
--expand-genericUnknown + generic (..., [..])
--expand-allAll wildcards including named patterns

These flags are mutually exclusive with each other and with --update.

Capture Log

Use --capture-log <path> to write a YAML sidecar file recording what each wildcard matched during a test run. This is useful for debugging pattern matches and reviewing captured values.

tryscript run --capture-log captures.yaml tests/

Best Practices

DO: Use shell features directly

$ echo "hello" | tr 'a-z' 'A-Z'
HELLO

$ cat file.txt 2>/dev/null || echo "not found"
not found

DO: Use env for CLI paths

env:
  BIN: ./dist/cli.mjs
$ $BIN --version
1.0.0

DO: Use sandbox for file operations

sandbox: true
$ echo "test" > output.txt
$ cat output.txt
test

DON'T: Use patterns in commands

# ❌ WRONG: Patterns are for output matching only
$ cat [CWD]/file.txt

DON'T: Rely on exact timestamps or paths

# ❌ WRONG: Exact match will fail
$ date
Mon Jan 3 12:34:56 UTC 2026

# ✓ RIGHT: Use elision
$ date
[..]

Config File

For project-wide settings, create tryscript.config.ts:

import { defineConfig } from 'tryscript';

export default defineConfig({
  env: { NO_COLOR: '1' },
  timeout: 30000,
  patterns: {
    VERSION: '\\d+\\.\\d+\\.\\d+',
    UUID: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}',
  },
  // CLI testing configuration
  path: ['./dist'],       // Directories to add to PATH
});

Execution Model

Test File → Parse YAML + Blocks → Create Execution Context

                    ┌────────────────────┴────────────────────┐
                    │                                         │
              sandbox: false                            sandbox: true
              cwd = testDir/config.cwd                  cwd = /tmp/tryscript-xxx/
                    │                                         │
                    └────────────────────┬────────────────────┘

                    spawn(command, { shell: true, cwd, env })

                    Capture stdout + stderr → Match against expected

Key points:

  1. Commands run in a real shell (shell: true)
  2. Shell handles all variable expansion ($VAR)
  3. Patterns ([..], [CWD]) only apply to output matching
  4. Sandbox creates isolated temp directory per test file