# How to snapshot-test CLI output across operating systems

> Run the built CLI as a child process, normalize line endings, paths, and color, and compare with committed golden files on Linux, macOS, and Windows in one matrix.

Source: https://voxgig.com/howto/snapshot-test-cli-output-across-operating-systems

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-18

## Short answer

Run the built CLI as a child process with `spawnSync` under `node:test`, and capture stdout, stderr, and the exit code into one transcript. Normalize it before comparing: CRLF to LF, ANSI escapes stripped, the scratch directory replaced by `<tmp>`, and backslashes under it turned into slashes. Compare with a committed golden file, rewrite it only behind an explicit `UPDATE_GOLDEN=1`, and run the same test on Linux, macOS, and Windows in a matrix with `fail-fast` off.

---
## You will need

Node 22 or later and a CLI that builds to one binary or runs from one entry file. Verified
2026-10-21 against Node 22.22.0, with the testscript, Jest, and syrupy behavior read from their
documentation rather than run. The sample CLI is two commands over an in-memory list. Its output
carries a path, a color code, and a progress line on purpose, because those are the three things
that differ between machines.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [hand-rolled golden files with node:test](https://nodejs.org/api/test.html) | Any language's binary driven from Node, when you want to own what gets normalized | About ninety lines of runner and normalizer you maintain, and an update switch you have to remember to gate | A framework already in the repository has snapshots and your output has no machine-specific noise |
| [Jest `toMatchSnapshot` with `execa`](https://jestjs.io/docs/snapshot-testing) | A Node CLI in a Jest repository, where a `.snap` file beside the test is the convention | A raw snapshot that records the temp path and the line ending of the recording machine until you normalize before matching | Your tests run under `node:test`, or the binary is not Node |
| [pytest with syrupy](https://syrupy-project.github.io/syrupy/) | A Python CLI, with `assert output == snapshot` and `.ambr` files committed beside the tests | Python's text-mode `print` writes CRLF on Windows, so the raw snapshot differs per OS until you normalize | The CLI is not Python, or you need the exit code and both streams in one artifact |
| [testscript (rogpeppe/go-internal)](https://pkg.go.dev/github.com/rogpeppe/go-internal/testscript) | A Go CLI, with `txtar` scripts that run the command and `cmp stdout` against a file in the same script | A script dialect to learn, and `cmpenv` plus `$WORK` to reach for before a path or temp directory compares equal | The CLI is not Go, or the output needs more normalizing than variable substitution gives |

The framework rows and the hand-rolled row disagree about where the noise goes. Jest and syrupy
record what the command printed and leave you to strip the machine out of it afterwards, with a
property matcher or a serializer. testscript substitutes `$WORK` and environment variables before
it compares,[^1] and the hand-rolled runner normalizes the transcript on the way in. Framework snapshots
are quicker to adopt, because the first run writes them, but the noise that run recorded fails the
first run on a second operating system.

## Capture all three streams in one transcript

A CLI test that reads stdout alone misses the two things a script branches on, the exit code and
the error text. The sample CLI exports a CSV and reports where it put it, which drags a path, a
progress line on stderr, and an optional color code into its output on purpose.

```js title="cli.mjs"
  if (command === 'export') {
    const at = rest.indexOf('--out')
    if (at < 0 || !rest[at + 1]) {
      console.error('meters: export needs --out DIR')
      return 2
    }
    const dir = resolve(rest[at + 1])
    mkdirSync(dir, { recursive: true })
    const file = join(dir, 'meters.csv')
    const rows = METERS.map((m) => `${m.id},${m.serial},${m.state}`)
    writeFileSync(file, ['id,serial,state', ...rows].join('\n') + '\n')
    console.error(`meters: exporting ${METERS.length} meters`)
    console.log(`${green('ok')} wrote ${METERS.length} meters to ${file}`)
    return 0
  }
```

The last line of the file sets `process.exitCode` instead of calling `process.exit()`, because
[`process.exit`](https://nodejs.org/api/process.html#processexitcode_1) can end the process before a
write to a pipe has drained. A test that captures stdout through a pipe then records a truncated
transcript on one platform and a full one on another.

```bash
node cli.mjs export
```

```text output
meters: export needs --out DIR
```

The transcript puts the three streams under markers, so a failing diff names which one changed:

```js title="normalize.mjs"
// The transcript a golden file holds: the exit code, then each stream under a
// marker, so a diff names which of the three changed.
export function transcript({ status, stdout, stderr }) {
  return `exit ${status}\n--- stdout ---\n${stdout}--- stderr ---\n${stderr}`
```

## Normalize the machine out before you compare

Four things differ between a Linux, a macOS, and a Windows run of the same command, and the
normalizer removes each one before the compare. Line endings become LF, because a tool writing in
text mode on Windows emits CRLF.[^2] Color goes, through
[`util.stripVTControlCharacters`](https://nodejs.org/api/util.html#utilstripvtcontrolcharactersstr),
because a CI runner may or may not look like a terminal. The scratch directory becomes `<tmp>`,
because [`os.tmpdir`](https://nodejs.org/api/os.html#ostmpdir) answers differently on every machine,
and backslashes under it become slashes, because Windows paths spell the separator the other way.

```js title="normalize.mjs"
// Make a transcript the same on every operating system: one line ending, no
// colour, one spelling for the scratch directory, and forward slashes under it.
export function normalize(text, { tmp } = {}) {
  let out = stripVTControlCharacters(text).replaceAll('\r\n', '\n')
  if (tmp) {
    const spellings = new Set([tmp, tmp.replaceAll('\\', '/'), tmp.replaceAll('/', '\\')])
    for (const spelling of spellings) out = out.split(spelling).join('<tmp>')
    out = out.replace(/<tmp>[^\s"'`]*/g, (path) => path.replaceAll('\\', '/'))
  }
  return out
}
```

`windows.mjs` holds a transcript with all four kinds of noise, as a Windows runner would record it.
It compares that with the golden a Linux run committed, first byte for byte and then through
`normalize`:

```bash
node windows.mjs
```

```text output
raw Windows transcript against the committed golden: 5 of 5 lines differ
  line 1
    got:  "exit 0\r"
    want: "exit 0"
  line 2
    got:  "--- stdout ---\r"
    want: "--- stdout ---"
  line 3
    got:  "\u001b[32mok\u001b[0m wrote 2 meters to C:\\Users\\ci\\AppData\\Local\\Temp\\meters-k3f9x1\\out\\meters.csv\r"
    want: "ok wrote 2 meters to <tmp>/out/meters.csv"

the same transcript after normalize(): equal
```

Line 3 is the whole page in one line. An escape code, a backslash path under a temp directory with
a random suffix, and a carriage return all sit in the output of a command that did the right thing.

## Record a golden, then make updating deliberate

The test runs the CLI through [`spawnSync`](https://nodejs.org/api/child_process.html#child_processspawnsynccommand-args-options)
with a fresh scratch directory per case. It hands that directory to the normalizer, so the
substitution is exact rather than a guess at what a temp path looks like.

```js title="golden.test.mjs"
// Run the built CLI as a subprocess in a scratch directory of its own, and
// return the normalised transcript of what it did.
function run(args, env = {}) {
  const tmp = mkdtempSync(join(tmpdir(), 'meters-'))
  try {
    const result = spawnSync(process.execPath, [cli, ...args.map((a) => a.replaceAll('<tmp>', tmp))], {
      encoding: 'utf8',
      env: { ...process.env, FORCE_COLOR: undefined, NO_COLOR: undefined, ...env },
    })
    return normalize(transcript(result), { tmp })
  } finally {
    rmSync(tmp, { recursive: true, force: true })
  }
}
```

Updating is a separate, visible act. `UPDATE_GOLDEN=1` rewrites the files; without it a missing
golden fails with the instruction to record one, and a changed one fails with the diff. Jest keeps
the same discipline behind [`--ci`](https://jestjs.io/docs/cli#--ci), which refuses to write a new
snapshot and asks for `--updateSnapshot`, and syrupy behind `--snapshot-update`.

```js title="golden.test.mjs"
// One golden per case, shared by every operating system. A case whose output
// has to differ per platform gets a <name>.<platform>.golden beside it, and
// that file wins when it exists.
function matchGolden(name, actual) {
  const perPlatform = join(goldenDir, `${name}.${process.platform}.golden`)
  const file = existsSync(perPlatform) ? perPlatform : join(goldenDir, `${name}.golden`)
  if (UPDATE) writeFileSync(file, actual)
  assert.ok(existsSync(file), `${file} is missing; record it with UPDATE_GOLDEN=1`)
  // The golden goes through normalize() too, so a checkout that rewrote its
  // line endings cannot fail the test by itself.
  assert.equal(actual, normalize(readFileSync(file, 'utf8')))
}
```

```bash
UPDATE_GOLDEN=1 node --test golden.test.mjs
```

The recorded file is small enough to review in a pull request, which is the point of committing it:

```bash
cat testdata/export-writes-csv.golden
```

```text output
exit 0
--- stdout ---
ok wrote 2 meters to <tmp>/out/meters.csv
--- stderr ---
meters: exporting 2 meters
```

Two tests share that golden: the plain export and the same export with `FORCE_COLOR=1`. One file
for both is the proof that the normalizer, not the recording machine, decides what is compared.
A case whose output has to differ per platform gets a `<name>.<platform>.golden` beside it, and
`matchGolden` prefers that file when it exists; the sample needs none.

## Run the same test on three runners

The matrix is three copies of one job, with `fail-fast` off so that a Windows failure does not
cancel the macOS run before it reports. The
[workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)
documents that cancellation as the default.

```yaml title="matrix.yml"
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: node --test golden.test.mjs
```

The checkout itself is the fourth source of noise. Windows checkouts commonly run with
`core.autocrlf` set to true, the setting GitHub's
[line-ending guide](https://docs.github.com/en/get-started/git-basics/configuring-git-to-handle-line-endings)
recommends there. The [Git configuration reference](https://git-scm.com/docs/git-config) defines
it as the `text` attribute set to `auto` on every file with `core.eol` set to `crlf`, so a
committed golden arrives with CRLF endings on that runner.[^3] One line of
[`.gitattributes`](https://git-scm.com/docs/gitattributes) beside the tests pins the golden files
to LF on every checkout:

```text title=".gitattributes"
*.golden text eol=lf
```

## Check it worked

Run the suite:

```bash
node --test golden.test.mjs
```

```text output
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 407.641091
```

The summary line to read is `# pass 5`. The fifth test feeds a hand-written Windows transcript
through the normalizer and asserts it equals the golden the Linux run recorded, which is the
cross-platform claim checked without a Windows machine.

## When it goes wrong

The golden files fail on every line, on Windows only, before any normalizing. The checkout rewrote them:
with `core.autocrlf` on and no `.gitattributes`, every LF in the committed file became CRLF on the
way to disk. `crlf-checkout.mjs` applies that conversion to the committed golden and shows both
defenses, the attribute that stops the conversion and the normalizer applied to the golden on read:

```bash
node crlf-checkout.mjs
```

```text output
golden as committed:           equal to the transcript
golden after autocrlf:         differs from the transcript
  first line as read:          "exit 0\r"
golden normalized on read:     equal to the transcript
golden kept LF by .gitattributes: never converted, so the compare sees what was committed
```

A snapshot passes locally, but the CI job fails with a new-snapshot error. The framework wrote the
file on the laptop and was told not to write on the runner, by Jest's `--ci` or by the hand-rolled
runner's missing-file assertion. Commit the recorded file; the failure is the gate working.

The transcript shows `exit null`. [`spawnSync`](https://nodejs.org/api/child_process.html#child_processspawnsynccommand-args-options)
reports `status` as `null` when a signal ended the child, with the signal's name in `signal`. A
timeout, or the kernel ending the process for memory, then looks like an exit code that is missing.
Put
`signal` in the transcript beside the status, and give the slow runner a longer `timeout` option
rather than a looser assertion.

## When not to do this

Do not snapshot output you have not made deterministic. A progress bar, a timestamp, a duration, or
a sorted-by-hash listing changes on every run, and Jest's own guidance says the test must be
deterministic before the snapshot can mean anything. Normalize those fields the way this page
normalizes paths, or test a `--json` document with named fields instead of the text people read.

Do not record one golden per operating system as the default. Three files that each pass on their
own runner prove only that the runner has not changed since the recording, and a behavior change
has to be reviewed three times. Reach for the per-platform file when the output has to differ,
such as a path printed for a person to open.

Do not let the update step happen implicitly. A runner that writes a new snapshot when none exists
turns the first run after a refactor into a green run with nothing checked. Gate the write behind a
flag or an environment variable, and keep the assertion that fails when the file is absent.

Do not test terminal behavior this way. A pipe is not a terminal, so a snapshot taken through
`spawnSync` never sees the prompt, the spinner, or the colored path that a person sees. Asserting
on those needs the stream-injection approach of the terminal-behavior page instead.

## Related how-tos

- [Test interactive prompts and terminal behavior in a CLI](/howto/test-interactive-prompts-and-tty-behaviour)

- [Design a CLI a coding agent can drive](/howto/design-a-cli-a-coding-agent-can-drive)

- [Write CLI help that people and agents can both use](/howto/help-text-people-and-agents-can-use)

## Last verified

Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it
printed, on Linux. The Windows transcript in `windows.mjs` and the CRLF conversion in
`crlf-checkout.mjs` are modeled on the documented behavior of Python's text mode and of
`core.autocrlf`, not captured from a Windows runner. `matrix.yml` was not run on one. The
testscript, Jest, and syrupy rows describe their documentation.

[^1]: testscript gives each script a fresh work directory, named `$WORK` in the script, and sets
`TMPDIR` to `$WORK/.tmp`, with the [package documentation](https://pkg.go.dev/github.com/rogpeppe/go-internal/testscript)
adding in parentheses that the variable is `TMP` on Windows. Its `cmpenv` command substitutes
environment variables in the expected file before comparing, so an expected path is written as
`$WORK/out/meters.csv` and never as the directory the run happened to get.

[^2]: The carriage return is Python's doing as much as Windows'. The
[`io` module](https://docs.python.org/3/library/io.html#io.TextIOWrapper) says that when a text
stream's `newline` is `None`, any `\n` written is translated to `os.linesep`, which on Windows is
two characters. `print` writes to such a stream, so the same script prints `\n` on Linux and `\r\n`
on Windows without a line of its code mentioning either. A snapshot taken on each records a
different file.

[^3]: The manual for Git sets the two conventions side by side. `core.autocrlf` true, it says in the
[configuration reference](https://git-scm.com/docs/git-config), is the same as the `text` attribute
set to `auto` on all files and `core.eol` set to `crlf`. GitHub's
[line-ending guide](https://docs.github.com/en/get-started/git-basics/configuring-git-to-handle-line-endings)
recommends exactly that setting to Windows users. A repository with no `.gitattributes` therefore
asks every Windows checkout to rewrite every text file, and a golden file is a text file.