diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index e9b1bb6413..94ea803b26 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -13,36 +13,26 @@ // version (changes per release), so we'd snapshot a moving target. import { describe, expect } from "bun:test" import { Effect } from "effect" -import fs from "node:fs" -import os from "node:os" import { cliIt } from "../../lib/cli-process" +import { normalizeForSnapshot, PATH_SEP } from "../../lib/snapshot" -// Strips dynamic content that varies per run so snapshots are stable. -// Currently only the tmpdir prefix bleeds in (via `--cwd` defaults that -// resolve to `process.cwd()`). Add new patterns here as they surface. +// Composes `normalizeForSnapshot` (CRLF + tmpdir) with two help-specific +// rules: // -// On macOS `os.tmpdir()` returns `/var/folders/...` but `process.cwd()` -// inside the child returns the realpath `/private/var/folders/...` — so -// we strip both forms. -const TMP = os.tmpdir() -const REAL_TMP = fs.realpathSync(TMP) +// 1. The harness's `oc-cli-XXX` subdir under TMPDIR collapses to ``. +// `PATH_SEP` matches `/` and `\\` so the rule works on POSIX + Windows. +// +// 2. yargs wraps the `[string] [default: "..."]` clause based on the +// pre-normalized default's character length, so different random home +// path widths produce different leading-whitespace counts (or even +// line-wraps onto a fresh line on Windows). `\s+` matches both forms. function normalize(text: string): string { - return ( - text - // Windows emits CRLF on stderr; collapse first so the rest of the - // pipeline doesn't need separate Windows-vs-POSIX branches. - .replaceAll("\r\n", "\n") - .replaceAll(REAL_TMP, "") - .replaceAll(TMP, "") - // The harness writes the random home dir at `/oc-cli-XXX` on - // POSIX, `\oc-cli-XXX` on Windows. Strip either form. - .replace(/[/\\]oc-cli-[a-z0-9]+/g, "") - // yargs wraps the `[string] [default: "..."]` clause based on the - // pre-normalized default's character length, so different random home - // path widths produce different leading-whitespace counts (or even - // line-wraps onto a fresh line on Windows). `\s+` matches both forms. - .replace(/\s+\[string\] \[default: ""\]/g, ' [string] [default: ""]') - ) + return normalizeForSnapshot(text, { + pathReplacements: [ + [new RegExp(`${PATH_SEP}oc-cli-[a-z0-9]+`, "g"), ""], + [/\s+\[string\] \[default: ""\]/g, ' [string] [default: ""]'], + ], + }) } // Top-level commands. Order matches what `opencode --help` prints today; diff --git a/packages/opencode/test/lib/snapshot.ts b/packages/opencode/test/lib/snapshot.ts new file mode 100644 index 0000000000..64fa31eb8e --- /dev/null +++ b/packages/opencode/test/lib/snapshot.ts @@ -0,0 +1,73 @@ +// Shared normalization helpers for cross-OS-stable snapshot tests. +// +// Every snapshot test that captures subprocess output, file paths, or other +// OS-flavored strings hits the same two issues: +// 1. Bun emits CRLF line endings on Windows stderr; LF elsewhere. +// 2. Path separators differ (\ on Windows, / on POSIX), and macOS's +// /var/folders symlink resolves to /private/var/folders. +// +// These helpers exist so each test doesn't reinvent the same regexes. +// +// Use individually for fine-grained control, or compose them via +// `normalizeForSnapshot` for the common "snapshot subprocess output" path. +import fs from "node:fs" +import os from "node:os" + +const TMP = os.tmpdir() +const REAL_TMP = fs.realpathSync(TMP) + +/** + * Collapses CRLF to LF. Bun's subprocess pipes emit native line endings — + * snapshots captured on macOS/Linux contain LF, so a Windows run without + * this step always diffs. + */ +export function stripCrlf(text: string): string { + return text.replaceAll("\r\n", "\n") +} + +/** + * Converts Windows-style `\` separators to POSIX `/` so paths render + * identically across OSes. Use for path strings you want stable in a + * snapshot, not for filesystem operations. + */ +export function toPosixPath(p: string): string { + return p.replaceAll("\\", "/") +} + +/** + * Strips both the OS-level `os.tmpdir()` and its realpath form (macOS + * `/var/folders` → `/private/var/folders`) from text, replacing each + * occurrence with `marker` (default ``). + */ +export function withTmpdirStripped(text: string, marker = ""): string { + return text.replaceAll(REAL_TMP, marker).replaceAll(TMP, marker) +} + +/** + * Separator-agnostic match class for path-style strings. Use inside a + * larger regex when you want to match both `/` (POSIX) and `\` (Windows) + * boundaries — e.g. `${PATH_SEP}oc-cli-[a-z0-9]+`. + */ +export const PATH_SEP = "[/\\\\]" + +/** + * One-shot normalization for the common case: strip CRLF, strip tmpdir, + * then apply any caller-supplied path regex substitutions. Does NOT + * blanket-replace `\` with `/` — that would mangle non-path backslash + * content (regex literals in help text, etc.). Use `toPosixPath` or + * `PATH_SEP` in your own regex when you need separator agnosticism. + */ +export function normalizeForSnapshot( + text: string, + options?: { + readonly tmpdirMarker?: string + readonly pathReplacements?: ReadonlyArray + }, +): string { + let out = stripCrlf(text) + out = withTmpdirStripped(out, options?.tmpdirMarker) + for (const [pattern, replacement] of options?.pathReplacements ?? []) { + out = out.replace(pattern, replacement) + } + return out +}