Add a small, sustainable integration test layer for the stash CLI that
exercises the built binary through a real pseudo‑terminal, so we can catch
regressions in @clack/prompts flows (rendering, key handling, cancellation,
exit codes) that the existing unit tests can't see.
Non‑goals for v1:
- Full coverage of every command/flag combination.
- Spinning up real Postgres / real auth servers.
- Replacing existing unit tests.
mock-stdindoesn't drive a real TTY; clack's raw‑mode select/multiselect prompts behave differently or not at all.cli-testing-library(crutchcorn) drives stdin without a pty — same fidelity concern; revisit only if we hitnode-ptybuild pain.expect(1)adds a Tcl runtime and lives outside Vitest — the team has found it clunky in past projects.
node-pty (or the prebuilt fork node-pty-prebuilt-multiarch) gives us a
real pty inside Vitest with no second toolchain. The cost is one native
module on install.
Cover the cheap, high‑value surface first. Each test runs the built
packages/cli/dist/bin/stash.js so we exercise the same artifact users
get.
| Test | Command | What it asserts |
|---|---|---|
| help | stash --help |
exit 0, output contains "CipherStash CLI v" and command list |
| version | stash --version |
exit 0, output matches package.json#version |
| unknown top-level | stash bogus |
exit 1, output contains "Unknown command" + help |
| auth no-subcommand | stash auth |
exit 0, output contains auth HELP |
| auth unknown sub | stash auth bogus |
exit 1, output contains "Unknown auth command" |
| db unknown sub | stash db bogus |
exit 1, output contains "Unknown db subcommand" |
| db migrate stub | stash db migrate |
exit 0, warns "not yet implemented" |
| auth login cancel | stash auth login then ctrl‑c at the region prompt |
exit 0, output contains "Cancelled." |
The auth login cancel test is the only one that drives a clack prompt —
enough to validate the helper end‑to‑end without standing up auth or a
database. selectRegion() is a synchronous p.select that runs before any
network I/O, so cancelling there is deterministic. Deeper flows (full
init, db install --dry-run with fixtures) come in a follow‑up once the
harness is proven.
node-pty-prebuilt-multiarchadded as a dev dependency onpackages/cli. Use the prebuilt fork rather than upstreamnode-ptyto skip the C++ toolchain requirement on dev machines and CI.packages/cli/vitest.integration.config.ts— separate config so integration tests don't slow the defaultvitest run. Usespool: 'forks'and a 30stestTimeout; matches**/*.e2e.test.ts.packages/cli/tests/helpers/pty.ts— small wrapper aroundnode-pty.spawnexposing:render(args, opts?)→{ output, waitFor, write, key, exitCode, kill }outputis the cumulative ANSI‑stripped buffer.waitFor(text, timeoutMs?)polls until text appears or rejects.key('Enter' | 'Up' | 'Down' | 'CtrlC' | …)writes the right escape.exitCoderesolves when the pty exits. Target: ~80 lines, no external deps beyondstrip-ansi.
packages/cli/tests/e2e/smoke.e2e.test.ts— covers the help / version / unknown‑command /auth/db migraterows from the table above.packages/cli/tests/e2e/auth-login-cancel.e2e.test.ts— runsstash auth login, waits for the region prompt ("Select a region"), sends ctrl-c, asserts the cancellation message and exit code 0.packages/cli/package.jsonscript:"test:e2e": "vitest run --config vitest.integration.config.ts". Defaulttestscript unchanged.turbo.jsontasktest:e2ewithdependsOn: ["^build", "build"]so the CLI is rebuilt before E2E runs. Keepcache: false.- CI: extend the existing test workflow to also run
pnpm --filter stash test:e2e. Confirm the prebuilt binary resolves on the GH runners (Linux x64, macOS arm64). If it doesn't, fall back to upstreamnode-ptyand addpython3/ build tools to the workflow.
// tests/helpers/pty.ts
import { spawn, type IPty } from 'node-pty-prebuilt-multiarch'
import stripAnsi from 'strip-ansi'
import { resolve } from 'node:path'
const STASH_BIN = resolve(__dirname, '../../dist/bin/stash.js')
export interface RenderOptions {
cwd?: string
env?: Record<string, string>
cols?: number
rows?: number
}
export function render(args: string[], opts: RenderOptions = {}) {
const pty = spawn('node', [STASH_BIN, ...args], {
cwd: opts.cwd ?? process.cwd(),
cols: opts.cols ?? 100,
rows: opts.rows ?? 30,
env: { ...process.env, NO_COLOR: '1', CI: '1', ...opts.env },
})
let raw = ''
pty.onData((d) => { raw += d })
const exitCode = new Promise<number>((res) => {
pty.onExit(({ exitCode }) => res(exitCode))
})
return {
pty,
get output() { return stripAnsi(raw) },
get raw() { return raw },
exitCode,
write: (s: string) => pty.write(s),
key: (k: 'Enter' | 'Up' | 'Down' | 'Space' | 'CtrlC' | 'Esc') =>
pty.write(KEYS[k]),
waitFor: (text: string | RegExp, timeoutMs = 5000) => /* poll */ undefined,
kill: () => pty.kill(),
}
}
const KEYS = {
Enter: '\r',
Up: '�[A',
Down: '�[B',
Space: ' ',
CtrlC: '�',
Esc: '�',
} as const- Land harness + smoke tests behind
test:e2e(not part of defaultpnpm test). One PR. - Wire CI in a follow‑up PR after we've watched the suite locally on both macOS and Linux for a few runs.
- Add deeper flows (full
init,db install --dry-runagainst a throw‑away Postgres) in subsequent PRs once the harness is stable.
- Native binary on CI: CI has build tools available, so upstream
node-ptyis fine ifnode-pty-prebuilt-multiarchis missing prebuilts for any of our targets. Decide at install time.
- Telemetry — not a concern for the CLI. A grep across
packages/cli/src/shows zeroposthogimports; analytics moved topackages/wizardwhen that package was extracted. The CLI'spackage.jsonstill listsposthog-nodeas a dep — stale, worth removing in a follow-up but not blocking. For future wizard E2E tests:posthog-nodehas no built-in disable env var, butwizard/src/lib/analytics.tsalready short-circuits when the API key is empty, so passing an emptyPOSTHOG_API_KEYin test env is enough.
Once the harness is landed, introduce a lightweight messages module to decouple test assertions from prompt wording. Goal: when product tweaks copy, no test changes are needed.
Shape — not an i18n framework. A single src/messages.ts (or
per-command messages.ts) exporting a typed as const object:
export const messages = {
init: {
intro: 'CipherStash Stack Setup',
cancelled: 'Setup cancelled.',
complete: 'Setup complete!',
},
auth: {
unknownSubcommand: (sub: string) => `Unknown auth command: ${sub}`,
},
db: {
migrateNotImplemented:
'"npx stash db migrate" is not yet implemented.',
},
} as const- Production code substitutes literals with
messages.x.y. - Tests
import { messages }and assertoutput.includes(messages.x.y). - TypeScript catches every site on rename — including tests.
- Zero deps, zero runtime cost.
Hybrid policy: only extract strings that tests assert on. Inline strings that no test depends on stay inline — premature extraction is worse than copy-paste here.
Anti-pattern, do not adopt: emitting hidden marker tokens (e.g.
[init.cancelled]) in user-facing output and stripping them before
display. Complicates the renderer, breaks terminal copy/paste, earns
nothing the messages module doesn't already give us.
Sequencing:
- Phase 1 PR: harness + smoke tests using literal-string assertions
against text that's been stable across many releases (help banner,
"Unknown command", thedb migratewarning,"Setup cancelled."). - Phase 2 PR: introduce
messages.ts, mechanically migrate prod call sites and test assertions in the same change. Small, reviewable. - Defer full i18n until there's an actual localisation requirement.