Skip to content

Testing Scenarios

Griffen Fargo edited this page May 22, 2026 · 2 revisions

Testing Scenarios

Coco ships with a scenario library for spinning up real git repos in deterministic, named states — useful for trying out the TUI against tricky shapes (merge conflicts, diverged branches, mid-bisect, multi-remote forks, shallow clones, multiple worktrees) without manually building those states by hand.

Scenarios live in @gfargo/git-scenarios, a small package extracted from coco's internal test utilities. Coco depends on it and re-exposes the CLI as npm run scenario.

Three ways to use scenarios:

  1. Manual testing — materialize a scenario in a tmp dir and launch coco ui (or any other git tool) against it. Tightest dev loop for trying TUI changes.
  2. Integration tests via the Jest adapterdescribeWithScenario('name', (getRepo) => { ... }) for zero-boilerplate setup and teardown.
  3. Integration tests via the raw APIspinUpScenario('feature-pr-ready') in your tests for a fully-built TempGitRepo if you need finer control than the adapter gives you.

All three paths share the same library, so scenarios you try manually are the exact same shapes the test suite asserts against.

Quick start

# List every scenario, grouped by kind:
npm run scenario list

# Describe a single scenario:
npm run scenario describe feature-pr-ready

# Materialize a scenario in /tmp:
npm run scenario create feature-pr-ready

# Materialize and immediately launch coco ui against it:
npm run scenario create feature-pr-ready -- --run-ui

# Materialize at a custom path:
npm run scenario create feature-pr-ready -- --path ~/sandbox/widget

# Auto-clean the tmp dir when the launched tool exits:
npm run scenario create feature-pr-ready -- --run-ui --ephemeral

The -- separator is required because npm run scenario already accepts subcommands; flags after -- are passed through to the underlying git-scenarios binary.

Available scenarios

Current set: 27 scenarios across 6 kinds. The live list is npm run scenario list — the table below is a quick reference.

Branch shapes

Name What you get
empty-repo Freshly-git init'd repo. No commits, no files, no remotes. HEAD on main but unborn. The "what does your tool do on a brand-new repo?" edge case.
feature-pr-ready feat/widget-v2 4 commits ahead of main, clean worktree. The "ready to open a PR" baseline — drives the C create-pr flow and the L changelog flow.
feature-branch-one-commit main + feat/x (1 commit ahead with src/feature.ts). Minimal branch-vs-base shape.
multi-commit-branch feat/dashboard with 8 varied commits. Baseline for history navigation, filter, yank, etc.
two-commit-feature Baseline + a feat: commit on main, clean worktree. Smoke-test fixture.
detached-head HEAD detached at main~2, main still at its original tip.
signed-commits-required commit.gpgsign=true + user.signingkey set. Commits in the scenario itself are unsigned (CI lacks GPG); tools can read the config to decide whether to nudge the user.

Upstream tracking

Prereq fixtures for testing pull / push UI, ahead-behind indicators, and the branch-tip chip rendering.

Name What you get
branch-tracking-upstream main tracks origin/main, both at the same commit. Baseline "synced" state.
branch-ahead-of-upstream main is 3 commits ahead of origin/main. Classic "unpushed changes" state.
branch-behind-upstream main is 3 commits behind origin/main. Fast-forwardable.
branch-diverged main is 2 ahead AND 2 behind origin/main. The "pull --rebase" case.
branch-sync-showcase Five local branches in five different upstream sync states (behind / ahead / diverged / synced / no-upstream) in a single repo. The fixture for the branches sidebar redesign.
multi-remote-with-tracking Fork workflow: origin + upstream remotes, main tracks upstream/main, feat/fork-work tracks origin/feat/fork-work.

In-progress operations

The conflict matrix is complete — every .git/ state file that can hold a half-applied operation has a dedicated scenario.

Name Marker file What you get
mid-bisect BISECT_* 20 commits + active git bisect, HEAD at midpoint. Drives the bisect view.
mid-merge-conflict MERGE_HEAD In-progress merge, 1 unresolved conflict in src/widget.ts.
mid-rebase-conflict REBASE_HEAD, .git/rebase-merge/ In-progress rebase, 1 unresolved conflict in src/config.ts.
mid-cherry-pick-conflict CHERRY_PICK_HEAD In-progress cherry-pick, 1 unresolved conflict in src/utils.ts.
mid-revert-conflict REVERT_HEAD In-progress revert, 1 unresolved conflict in src/service.ts.

Worktree shapes

Name What you get
single-staged-file Baseline + 1 staged README. Minimum "ready to commit" shape.
dirty-many-files 12 staged + 6 unstaged + 3 untracked files across src/, tests/, docs/. The fixture for the split flow.
multiple-worktrees Primary worktree on main + 3 linked worktrees on feature/hotfix branches. The fixture for the worktrees view (gz).

History shapes

Name What you get
rich-history-graph 20+ commits across 6 date buckets, 2 --no-ff merges, 1 live unmerged feat/wip. Exercises compact + full-graph rendering, bucket dividers, type colouring, lane topology.
chip-rendering-showcase 6-commit history with every branch-tip-chip variant visible at once (HEAD, plain local, slashy local, origin/main, upstream/main, tag in trailing decoration).
shallow-clone Shallow repo with only 3 of 10 commits reachable from HEAD. Edge case for tools that walk history past the shallow boundary.
large-repo 115 commits across 3 branches with tags. For pagination + density-tier perf testing.

Stash & submodule shapes

Name What you get
stashed-changes Clean main + 3 stashes (LIFO ordered, each touching a distinct file). Drives the stash view.
submodule-with-history Parent with 4 commits + vendor/lib submodule (clean pin, 4 commits, branch = main). Drives recursive submodule navigation.

Common dev workflows

"I'm working on the branches view"

npm run scenario create branch-sync-showcase -- --run-ui

HEAD lands on main which is 2 behind origin/main. The history view should show the "↓ 2 commits behind origin/main" banner. The branches sidebar (gb) should render five branches with five distinct sync glyphs:

Branch Marker Color
main (current) * green
feat/ahead-only blue
feat/diverged yellow
feat/synced muted
local-only muted

"I'm working on the chip rendering"

npm run scenario create chip-rendering-showcase -- --run-ui

Six commits, each carrying a different chip kind. Confirm:

  • Row 0 (HEAD): green chip for main
  • Row 1: yellow chip for origin/main
  • Row 2: blue chip for feat/widgets (slash, but still local!)
  • Row 3: yellow chip for upstream/main
  • Row 4: blue chip for develop
  • Row 5: no chip, but [tag: v0.1.0] in trailing decoration

"I'm working on the conflicts view"

The conflict matrix has four scenarios. Each populates a different .git/ state file and triggers a different resolution flow:

npm run scenario create mid-merge-conflict -- --run-ui
npm run scenario create mid-rebase-conflict -- --run-ui
npm run scenario create mid-cherry-pick-conflict -- --run-ui
npm run scenario create mid-revert-conflict -- --run-ui

Cycle through all four to make sure your changes hold across every flow.

"I'm working on the worktrees view"

npm run scenario create multiple-worktrees -- --run-ui

Lands you in a repo with 3 linked worktrees alongside the primary. Drives the gz view's listing, switching, and per-worktree status surfaces.

"I want to stress-test history rendering"

npm run scenario create large-repo -- --run-ui

115 commits across 3 branches. Useful for testing pagination, density tiers, lane topology, and per-row hydration performance.

"I want a real remote"

npm run scenario create feature-pr-ready -- --remote git@github.com:org/repo.git --run-ui

Adds origin pointing at the given URL. Useful for testing gh-aware flows (PR creation, issue triage). Use a fake URL if you don't want any risk of accidental push.

"I want to try something fully ephemeral"

npm run scenario create feature-pr-ready -- --run-ui --ephemeral

Tmp dir gets removed when coco ui exits.

Using scenarios in jest tests

There are two API surfaces. Pick the one that fits the test you're writing.

Path A — the Jest framework adapter (recommended for new tests)

Zero-boilerplate setup and teardown. Lives under the @gfargo/git-scenarios/jest subpath:

import { describeWithScenario } from '@gfargo/git-scenarios/jest'

describeWithScenario('feature-pr-ready', (getRepo) => {
  it('is on a feature branch', async () => {
    const repo = getRepo()
    const status = await repo.git.status()
    expect(status.current).not.toBe('main')
  })

  it('has a clean worktree', async () => {
    const repo = getRepo()
    const status = await repo.git.status()
    expect(status.isClean()).toBe(true)
  })
})

describeWithScenario handles the beforeAll / afterAll lifecycle automatically. getRepo() returns the same TempGitRepo instance for every test in the block.

Run a test against multiple scenarios at once:

import { describeEachScenario } from '@gfargo/git-scenarios/jest'

describeEachScenario(
  ['mid-merge-conflict', 'mid-rebase-conflict', 'mid-cherry-pick-conflict', 'mid-revert-conflict'],
  (getRepo, scenarioName) => {
    it(`renders the conflicts view in ${scenarioName}`, async () => {
      const repo = getRepo()
      // … exercise the conflicts view against each conflict variant
    })
  },
)

This is exactly the pattern for auditing the conflicts view across all four .git/ state files in one block.

Extend a base scenario with extra steps:

import { describeWithScenarioExtended } from '@gfargo/git-scenarios/jest'
import { addCommit, chain } from '@gfargo/git-scenarios'

describeWithScenarioExtended(
  'single-staged-file',
  chain(
    addCommit({ message: 'extra setup', files: { 'extra.ts': '// …' } }),
  ),
  (getRepo) => {
    it('has the extra commit on top', async () => {
      const repo = getRepo()
      const log = await repo.git.log()
      expect(log.latest?.message).toBe('extra setup')
    })
  },
)

Path B — the raw API (for tests that need finer control)

src/commands/commands.integration.test.ts is the worked example for the manual spinUpScenario pattern, useful when a test needs to teardown mid-suite or compose multiple repos:

import { spinUpScenario } from '@gfargo/git-scenarios'

describe('coco commit', () => {
  it('writes a message for a feature-PR-ready repo', async () => {
    const repo = await spinUpScenario('feature-pr-ready')
    try {
      // run your command against repo.path …
    } finally {
      await repo.cleanup()
    }
  })
})

spinUpScenario(name) returns a fully-built TempGitRepo (path + simple-git instance + writeFile/commitAll helpers + cleanup). Replaces the inline "git init + a stack of repo.git.commit(...) calls" pattern.

For the rare case where no named scenario fits, createTempGitRepo() exposes the raw primitive — a fresh repo with user identity + a main branch — and you compose what you need from the atom layer (see next section).

Composing your own scenarios

The scenarios listed above cover common cases. If you need something different, two paths.

Path A — inline in a test

The package exposes the atom layer that scenarios are built from. Compose what you need inline:

import {
  createTempGitRepo,
  chain,
  addCommit,
  addRemote,
  setUpstream,
  withRemoteTracking,
} from '@gfargo/git-scenarios'

const repo = await createTempGitRepo()
await chain(
  addCommit({ message: 'init', files: { 'README.md': '# repo' } }),
  addRemote('origin', '/fake/url'),
  withRemoteTracking('origin', 'main', chain(
    addCommit({ message: 'upstream B', files: { 'b.ts': 'b' } }),
    addCommit({ message: 'upstream C', files: { 'c.ts': 'c' } }),
  )),
  setUpstream('main', 'origin'),
)(repo)
// repo is now 2 commits behind origin/main, ready for assertions

See the git-scenarios cookbook for many more recipes.

Path B — fromScenario (extend a named scenario with extra atoms)

When you want a stock scenario plus a handful of extra steps, fromScenario(name, ...extraSteps) saves the manual spinUpScenario + chain dance:

import { fromScenario, addCommit } from '@gfargo/git-scenarios'

const repo = await fromScenario(
  'feature-pr-ready',
  addCommit({ message: 'extra setup', files: { 'extra.ts': '// …' } }),
)

Path C — register a coco-specific scenario at runtime

Programmatic scenario registration (added in v0.5.0) lets coco define and use a one-off scenario without contributing it upstream:

import { defineScenario, registerScenario, spinUpScenario, addCommit, chain } from '@gfargo/git-scenarios'

registerScenario(defineScenario({
  name: 'coco-commitlint-strict',
  summary: 'repo with strict commitlint config + a staged change that violates it',
  kind: 'worktree',
  setup: chain(
    addCommit({ message: 'chore: init', files: { 'commitlint.config.js': '…' } }),
    // … extra setup
  ),
  contracts: ['commitlint config present', 'staged change violates header-max-length'],
}))

const repo = await spinUpScenario('coco-commitlint-strict')

The scenario participates in the registry for the duration of the process — findScenario('coco-commitlint-strict') resolves it, and tests can reference it by name like any built-in.

Path D — contribute a named scenario back to the package

If the shape is reusable beyond coco, contribute it to @gfargo/git-scenarios:

  1. Add src/scenarios/your-scenario.ts using defineScenario(...) (see existing scenarios for the pattern — every one is a single self-contained file).
  2. Add a .test.ts asserting your scenario's contracts.
  3. Wire it into src/scenarios/index.ts (allScenarios array + the re-export).
  4. Run npm test to verify; submit a PR to gfargo/git-scenarios.

A scenario is roughly 30–60 lines including JSDoc, and defineScenario validates the shape at module-load time so typos surface immediately.

CLI flag reference

Flag Behavior
--path <dir> Materialize at <dir> instead of /tmp. Useful when you want to cd into it later.
--run <cmd> After materializing, spawn <cmd> against the scenario dir (cwd = scenario dir). Examples: --run "lazygit", --run "gitui", --run "code -n".
--run-ui Coco-specific shortcut — spawns the source-tree CLI (tsx <coco>/src/index.ts ui) against the scenario dir. External consumers use --run "coco ui" instead. Implemented via bin/scenarioRunner.ts.
--remote <url> Add origin pointing at <url> so gh-aware tools detect a remote on launch.
--ephemeral Auto-clean the temp dir when the spawned tool exits. Skip for normal use — without --ephemeral, the dir persists so you can re-inspect after closing the tool.

Where things live

  • Package source: @gfargo/git-scenarios — atoms, scenarios, CLI binary, Jest adapter
  • Package on npm: @gfargo/git-scenarios
  • Jest adapter: @gfargo/git-scenarios/jest subpath — describeWithScenario, describeEachScenario, describeWithScenarioExtended
  • Coco-side wiring: package.json declares the dep + bin/scenarioRunner.ts wraps the CLI so the documented --run-ui shortcut actually launches the workstation
  • Integration test consumer: src/commands/commands.integration.test.ts uses spinUpScenario to set up fixtures
  • Eval harness consumer: src/lib/parsers/default/__evals__/scenarioInputs.ts uses findScenario + createTempGitRepo to build the structural-extract eval corpus

Updating to a new release

When @gfargo/git-scenarios ships a new version (new scenarios, new atoms, bug fixes), bump the coco dep:

yarn add --dev @gfargo/git-scenarios@^X.Y.Z

Then run the integration test to catch any breaking changes:

npm run test:jest -- src/commands/commands.integration.test.ts

And verify the new scenarios show up:

npm run scenario list

See also

  • Coco UI — the workstation TUI that scenarios are most often used to drive
  • TUI Navigation — the keymap + chord set
  • git-scenarios README — full atom catalog, cookbook, Jest adapter docs, and contribution rules

Clone this wiki locally