Skip to content

test(cli): add node-pty E2E suite for the stash CLI#370

Merged
coderdan merged 3 commits intomainfrom
cli-pty-integration-tests
Apr 30, 2026
Merged

test(cli): add node-pty E2E suite for the stash CLI#370
coderdan merged 3 commits intomainfrom
cli-pty-integration-tests

Conversation

@coderdan
Copy link
Copy Markdown
Contributor

@coderdan coderdan commented Apr 30, 2026

Summary

Adds a pty-driven integration test suite for @cipherstash/cli so we can cover @clack/prompts flows (raw mode, escape sequences, cancellation) that piped-stdin mocks can't reach. Drives the built dist/bin/stash.js through a real pseudo-terminal via node-pty.

  • New vitest.integration.config.ts (forks pool, 30s timeout) and test:e2e script + turbo task that depend on build.
  • Default vitest.config.ts now excludes tests/e2e/** so the existing pnpm test stays fast and build-free.
  • tests/helpers/pty.ts — small render / waitFor / key / exit wrapper. Auto-fixes the exec bit pnpm strips from node-pty's macOS spawn-helper prebuild.
  • 8 new tests: 7 non-interactive smoke (help, version, unknown commands, db migrate stub) + 1 interactive cancel test (auth login → ctrl-c at the region prompt).
  • packages/cli/AGENTS.md documents when to add/update E2E tests, the harness API, and the gotchas. Root AGENTS.md gets a one-line pointer.
  • docs/plans/cli-pty-integration-tests.md captures the rationale, alternatives considered, and a planned phase-2 messages-handle refactor.

Why node-pty and not a mock

@clack/prompts switches stdin to raw mode and renders differently when stdout isn't a TTY. mock-stdin and cli-testing-library don't drive a real pty so the high-fidelity prompt flows (select/multiselect, ctrl-c) aren't exercised. expect(1) adds a Tcl runtime and lives outside Vitest. node-pty keeps tests inside Vitest with no second toolchain.

Test plan

  • pnpm --filter @cipherstash/cli test — 76 unit tests pass, ~400ms, no e2e bleed-through
  • pnpm --filter @cipherstash/cli test:e2e — 8 e2e tests pass, ~1s, all spawning the built binary through a real pty
  • CI confirms node-pty's prebuilt binaries resolve on the runner matrix (Linux x64 / darwin arm64 on Node 22)
  • Reviewer sanity check: pnpm --filter @cipherstash/cli build && pnpm --filter @cipherstash/cli test:e2e locally

Summary by CodeRabbit

Release Notes

  • New Features

    • Added end-to-end testing infrastructure for the CLI with interactive terminal support, enabling comprehensive testing of user interactions and command behavior.
  • Tests

    • Added smoke tests validating help, version, error handling, and command stubs.
    • Added interactive test for auth flow cancellation.
  • Documentation

    • Added comprehensive guides for running and maintaining CLI test suites with platform-specific considerations.
    • Added specification document outlining E2E testing strategy and rollout plan.
  • Chores

    • Updated build and test configurations to support E2E test execution.
    • Added necessary test dependencies.

Adds a second Vitest config (`vitest.integration.config.ts`) that drives
the built `dist/bin/stash.js` through a real pseudo-terminal via
`node-pty`, so we can cover @clack/prompts flows that piped-stdin mocks
can't reach (raw mode, escape sequences, cancellation).

- `tests/helpers/pty.ts` exposes `render`/`waitFor`/`key`/`exit` and
  auto-fixes the exec bit pnpm strips from node-pty's macOS spawn-helper
  prebuild.
- `tests/e2e/smoke.e2e.test.ts` covers help/version/unknown-command
  exits and the `db migrate` stub (7 tests).
- `tests/e2e/auth-login-cancel.e2e.test.ts` waits for the region
  `p.select` prompt and asserts ctrl-c cancels cleanly (1 test).
- Default `vitest.config.ts` now excludes `tests/e2e/**` so
  `pnpm --filter @cipherstash/cli test` stays fast and build-free.
- New `test:e2e` package script and matching turbo task that depends on
  `^build` and `build`.
- `packages/cli/AGENTS.md` documents when to add/update E2E tests, the
  harness API, and the gotchas (build-first, spawn-helper chmod, prompt
  string stability). Root `AGENTS.md` gets a one-line pointer.
- `docs/plans/cli-pty-integration-tests.md` captures the rationale and
  the planned phase-2 messages-handle refactor.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 30, 2026

⚠️ No Changeset found

Latest commit: bb013a0

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

Warning

Rate limit exceeded

@coderdan has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 51 minutes and 54 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2ea6ee58-238f-48b3-9a98-46903e64b90e

📥 Commits

Reviewing files that changed from the base of the PR and between a4532a5 and bb013a0.

📒 Files selected for processing (5)
  • .github/workflows/tests.yml
  • docs/plans/cli-pty-integration-tests.md
  • package.json
  • packages/cli/tests/e2e/smoke.e2e.test.ts
  • packages/cli/tests/helpers/pty.ts
📝 Walkthrough

Walkthrough

This PR introduces a node-pty-backed end-to-end test framework for the CLI package. It adds test configuration files, a PTY-based test helper for spawning the built CLI in a pseudo-terminal environment, initial smoke and auth-interaction test files, and comprehensive documentation explaining the testing infrastructure and contributions workflow.

Changes

Cohort / File(s) Summary
Documentation
AGENTS.md, docs/plans/cli-pty-integration-tests.md, packages/cli/AGENTS.md
Added comprehensive guidance on E2E testing: high-level contribution instructions in root AGENTS.md, detailed RFC-style specification of the PTY integration test plan and architecture, and package-specific documentation of test harness APIs and platform gotchas.
Test Configuration
packages/cli/vitest.config.ts, packages/cli/vitest.integration.config.ts
Updated main test config to exclude E2E tests; added new integration config file restricting discovery to tests/**/*.e2e.test.ts, configuring forks pool, 30s timeouts, and @/ module resolution.
PTY Test Helper
packages/cli/tests/helpers/pty.ts
Introduced new test helper exporting render() function and supporting types. Spawns the built CLI binary in a pseudo-terminal, manages output capture (raw and ANSI-stripped), handles typed input/keypresses via escape sequences, implements waitFor() polling with timeout, and ensures macOS spawn-helper permissions on module load.
E2E Test Suites
packages/cli/tests/e2e/smoke.e2e.test.ts, packages/cli/tests/e2e/auth-login-cancel.e2e.test.ts
Added smoke tests validating --help, --version, unknown commands, unknown subcommands, auth/db help, and db migrate stub; added interactive test confirming auth login region-prompt cancellation via CtrlC.
Package Configuration
packages/cli/package.json, turbo.json
Added test:e2e npm script and node-pty and strip-ansi devDependencies; added test:e2e Turbo task configured to depend on upstream and local build tasks with cache disabled.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hop, hop—the CLI hops so true,
Through pseudo-terminals, a PTY view!
From cancels to help, each test runs deep,
While node-pty guards and TTY secrets keep. 🌳✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: adding a node-pty E2E test suite for the stash CLI, which is the primary focus of the PR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cli-pty-integration-tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 51 minutes and 54 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

Adds a step after the existing `pnpm test` that runs
`turbo run test:e2e --filter @cipherstash/cli`. Going via turbo honors
the `^build` and `build` deps declared on the task so the CLI binary is
built before E2E runs.

The runner is Linux x64 / Node 20 — node-pty 1.1.0 ships prebuilds for
that combo, but if resolution misses we'll see a node-gyp fallback
build (Blacksmith Ubuntu 24.04 has the toolchain).
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
packages/cli/tests/helpers/pty.ts (1)

125-129: Consider resetting regex lastIndex to prevent potential issues with stateful patterns.

The matches function on line 127–128 calls match.test(s) which mutates lastIndex for regexes with /g or /y flags. While currently all waitFor calls pass strings, this could be a source of subtle bugs if regex literals with stateful flags are passed in the future. Defensively resetting lastIndex = 0 before calling test() ensures predictable behavior:

Suggested patch
   const waitFor = async (match: string | RegExp, timeoutMs = 5_000) => {
     const deadline = Date.now() + timeoutMs
-    const matches = (s: string) =>
-      typeof match === 'string' ? s.includes(match) : match.test(s)
+    const matches = (s: string) => {
+      if (typeof match === 'string') return s.includes(match)
+      match.lastIndex = 0
+      return match.test(s)
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/tests/helpers/pty.ts` around lines 125 - 129, The matches helper
inside waitFor can call match.test(s) which mutates RegExp.lastIndex for
stateful regexes; before invoking test, detect when match is a RegExp and reset
match.lastIndex = 0 to avoid surprising stateful behavior — update the matches
function inside waitFor (referencing the match variable and matches function and
leaving stripAnsi(raw) usage unchanged) to clear lastIndex on RegExp inputs
before calling test().
packages/cli/tests/e2e/smoke.e2e.test.ts (1)

30-36: ⚡ Quick win

Prefer stable unknown-command substrings in E2E assertions

At Line 34, Line 50, and Line 57, the checks currently lock onto full phrasing. To reduce churn from copy tweaks, assert on stable fragments (Unknown command, Unknown auth command, Unknown db subcommand) plus the subcommand token separately.

Suggested diff
-    expect(r.output).toContain('Unknown command: definitely-not-a-command')
+    expect(r.output).toContain('Unknown command')
+    expect(r.output).toContain('definitely-not-a-command')
@@
-    expect(r.output).toContain('Unknown auth command: bogus-sub')
+    expect(r.output).toContain('Unknown auth command')
+    expect(r.output).toContain('bogus-sub')
@@
-    expect(r.output).toContain('Unknown db subcommand: bogus-sub')
+    expect(r.output).toContain('Unknown db subcommand')
+    expect(r.output).toContain('bogus-sub')

As per coding guidelines: packages/cli/tests/e2e/**/*.e2e.test.{ts,js} — “In E2E tests, prefer asserting on stable prompt substrings rather than full prompt strings to survive copy changes and formatting variations.”

Also applies to: 46-51, 53-58

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/tests/e2e/smoke.e2e.test.ts` around lines 30 - 36, The
assertions in the smoke E2E test lock onto full error messages; update them to
assert on stable substrings plus the token separately: for the test using
render(...) and r.output, replace expect(r.output).toContain('Unknown command:
definitely-not-a-command') with two
assertions—expect(r.output).toContain('Unknown command') and
expect(r.output).toContain('definitely-not-a-command')—and do the same pattern
for the other failing checks that reference 'Unknown auth command' and 'Unknown
db subcommand' so they assert the stable phrase and the specific subcommand
token separately while keeping the existing exitCode check on r.exit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/plans/cli-pty-integration-tests.md`:
- Line 43: Update the plan doc so the cancel flow refers to the implemented
`auth login` scenario instead of `stash init`: change the table row label "init
cancel" and the command cell containing `stash init` to reflect `auth login`,
and update the related sequencing and deliverables text blocks (including the
sections around the table and the lines referenced at ~68-70 and ~203-206) to
describe cancelling `auth login` and expected output (e.g., exit 0, output
contains "Setup cancelled."). Ensure all instances of `stash init` in this doc
are replaced or aligned with `auth login` to match the tests.

In `@packages/cli/tests/helpers/pty.ts`:
- Around line 99-103: The test PTY environment sets CI to '1' but the CLI's
non-interactive guard in packages/cli/src/commands/db/install.ts checks
process.env.CI !== 'true'; update the env object in
packages/cli/tests/helpers/pty.ts (the object containing NO_COLOR, FORCE_COLOR,
CI, ...(opts.env ?? {})) to set CI: 'true' so the test process runs in the same
non-interactive CI mode, ensuring any overrides in opts.env still apply.

---

Nitpick comments:
In `@packages/cli/tests/e2e/smoke.e2e.test.ts`:
- Around line 30-36: The assertions in the smoke E2E test lock onto full error
messages; update them to assert on stable substrings plus the token separately:
for the test using render(...) and r.output, replace
expect(r.output).toContain('Unknown command: definitely-not-a-command') with two
assertions—expect(r.output).toContain('Unknown command') and
expect(r.output).toContain('definitely-not-a-command')—and do the same pattern
for the other failing checks that reference 'Unknown auth command' and 'Unknown
db subcommand' so they assert the stable phrase and the specific subcommand
token separately while keeping the existing exitCode check on r.exit.

In `@packages/cli/tests/helpers/pty.ts`:
- Around line 125-129: The matches helper inside waitFor can call match.test(s)
which mutates RegExp.lastIndex for stateful regexes; before invoking test,
detect when match is a RegExp and reset match.lastIndex = 0 to avoid surprising
stateful behavior — update the matches function inside waitFor (referencing the
match variable and matches function and leaving stripAnsi(raw) usage unchanged)
to clear lastIndex on RegExp inputs before calling test().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 84abd26a-e423-403a-994b-b75a9d6a9e8f

📥 Commits

Reviewing files that changed from the base of the PR and between 7f5fdd3 and a4532a5.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (10)
  • AGENTS.md
  • docs/plans/cli-pty-integration-tests.md
  • packages/cli/AGENTS.md
  • packages/cli/package.json
  • packages/cli/tests/e2e/auth-login-cancel.e2e.test.ts
  • packages/cli/tests/e2e/smoke.e2e.test.ts
  • packages/cli/tests/helpers/pty.ts
  • packages/cli/vitest.config.ts
  • packages/cli/vitest.integration.config.ts
  • turbo.json

Comment thread docs/plans/cli-pty-integration-tests.md Outdated
Comment thread packages/cli/tests/helpers/pty.ts
- Add `node-pty` to root `pnpm.onlyBuiltDependencies` so its install
  script runs on the Linux CI runner. pnpm 10 blocks lifecycle scripts
  by default, which is why the previous run failed loading
  `prebuilds/linux-x64/pty.node` — node-pty doesn't ship a Linux
  prebuild, so the source build via node-gyp must be allowed.
- `tests/helpers/pty.ts`: set `CI: 'true'` (not '1') to match the
  convention used by `commands/db/install.ts:502`.
- `tests/helpers/pty.ts`: reset `match.lastIndex = 0` before
  `match.test()` in `waitFor` so stateful regex flags (`/g`, `/y`)
  don't drift across calls.
- `tests/e2e/smoke.e2e.test.ts`: split full error strings into stable
  phrase + token assertions ("Unknown command" / "definitely-not-a-
  command" etc.) so wording tweaks don't break tests.
- `docs/plans/cli-pty-integration-tests.md`: update the cancel-test
  references to match the implemented `auth login` flow rather than
  the originally-planned `stash init`.
Copy link
Copy Markdown
Contributor

@auxesis auxesis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You absolutely love to see it — thank you @coderdan!

@coderdan coderdan merged commit cf8ebc2 into main Apr 30, 2026
6 checks passed
@coderdan coderdan deleted the cli-pty-integration-tests branch April 30, 2026 04:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants