-
-
Notifications
You must be signed in to change notification settings - Fork 1
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:
-
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. -
Integration tests via the Jest adapter —
describeWithScenario('name', (getRepo) => { ... })for zero-boilerplate setup and teardown. -
Integration tests via the raw API —
spinUpScenario('feature-pr-ready')in your tests for a fully-builtTempGitRepoif 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.
# 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 --ephemeralThe -- separator is required because npm run scenario already accepts subcommands; flags after -- are passed through to the underlying git-scenarios binary.
Current set: 27 scenarios across 6 kinds. The live list is npm run scenario list — the table below is a quick reference.
| 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. |
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. |
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. |
| 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). |
| 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. |
| 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. |
npm run scenario create branch-sync-showcase -- --run-uiHEAD 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 |
npm run scenario create chip-rendering-showcase -- --run-uiSix 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
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-uiCycle through all four to make sure your changes hold across every flow.
npm run scenario create multiple-worktrees -- --run-uiLands you in a repo with 3 linked worktrees alongside the primary. Drives the gz view's listing, switching, and per-worktree status surfaces.
npm run scenario create large-repo -- --run-ui115 commits across 3 branches. Useful for testing pagination, density tiers, lane topology, and per-row hydration performance.
npm run scenario create feature-pr-ready -- --remote git@github.com:org/repo.git --run-uiAdds 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.
npm run scenario create feature-pr-ready -- --run-ui --ephemeralTmp dir gets removed when coco ui exits.
There are two API surfaces. Pick the one that fits the test you're writing.
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')
})
},
)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).
The scenarios listed above cover common cases. If you need something different, two paths.
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 assertionsSee the git-scenarios cookbook for many more recipes.
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': '// …' } }),
)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.
If the shape is reusable beyond coco, contribute it to @gfargo/git-scenarios:
- Add
src/scenarios/your-scenario.tsusingdefineScenario(...)(see existing scenarios for the pattern — every one is a single self-contained file). - Add a
.test.tsasserting your scenario's contracts. - Wire it into
src/scenarios/index.ts(allScenariosarray + the re-export). - Run
npm testto verify; submit a PR togfargo/git-scenarios.
A scenario is roughly 30–60 lines including JSDoc, and defineScenario validates the shape at module-load time so typos surface immediately.
| 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. |
-
Package source:
@gfargo/git-scenarios— atoms, scenarios, CLI binary, Jest adapter -
Package on npm:
@gfargo/git-scenarios -
Jest adapter:
@gfargo/git-scenarios/jestsubpath —describeWithScenario,describeEachScenario,describeWithScenarioExtended -
Coco-side wiring:
package.jsondeclares the dep +bin/scenarioRunner.tswraps the CLI so the documented--run-uishortcut actually launches the workstation -
Integration test consumer:
src/commands/commands.integration.test.tsusesspinUpScenarioto set up fixtures -
Eval harness consumer:
src/lib/parsers/default/__evals__/scenarioInputs.tsusesfindScenario+createTempGitRepoto build the structural-extract eval corpus
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.ZThen run the integration test to catch any breaking changes:
npm run test:jest -- src/commands/commands.integration.test.tsAnd verify the new scenarios show up:
npm run scenario list- 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