docs/e2e-tests/worktree.md
End-to-end tests for the generic worktree capability:
EnterWorktree / ExitWorktree tools + SessionService stateAgent tool isolation: 'worktree' parameter + auto-cleanup + worktree noticeEach test group runs in its own temp git repo and tmux session to avoid collisions. Template setup:
TEST_DIR=$(mktemp -d -t worktree-test-XXXXXX)
cd "$TEST_DIR"
git init -q
git config user.email "[email protected]"
git config user.name "Test"
echo "hello" > README.md
git add README.md
git commit -q -m "initial"
Each group uses a unique tmux session name (e.g. wt-test-a, wt-test-b) and a unique temp dir.
Baseline binary: globally installed qwen (0.15.10).
Local build binary: node /Users/mochi/code/qwen-code/.claude/worktrees/trusting-euclid-6fdfb9/bundle/qwen.js.
Mode: Headless, --approval-mode yolo, --output-format json
Steps:
<qwen> "say hello" --approval-mode yolo --output-format json 2>/dev/null \
| jq -r 'select(.type=="system") | .tools[]' \
| grep -E "^(enter_worktree|exit_worktree)$"
Pre-implementation: empty (tools not registered).
Post-implementation: outputs enter_worktree and exit_worktree.
Steps:
<qwen> "create a new git worktree using the enter_worktree tool" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/a2.json
# Check worktree dir created
ls -la .qwen/worktrees/ | grep -v "^\." | wc -l
# Should have a directory matching the auto-generated slug pattern
Pre-implementation: model says it can't find the tool; no .qwen/worktrees/ directory.
Post-implementation: .qwen/worktrees/<slug> exists with auto-generated slug (format: {adj}-{noun}-{4hex}).
Steps:
<qwen> "use the enter_worktree tool with name='my-feature' to create a worktree" \
--approval-mode yolo --output-format json 2>/dev/null
ls .qwen/worktrees/my-feature/
git branch | grep worktree-my-feature
Pre-implementation: tool unknown.
Post-implementation: .qwen/worktrees/my-feature/ directory exists; branch worktree-my-feature exists.
Steps:
<qwen> "use enter_worktree with name='../../../etc' to create a worktree" \
--approval-mode yolo --output-format json 2>/dev/null \
| jq 'select(.type=="user") | .message.content[] | select(.is_error) | .content'
Pre-implementation: tool unknown. Post-implementation: tool result is_error=true with a validation error message.
Mode: Headless, two-step interaction within one prompt.
Steps:
<qwen> "create a worktree named 'temp-keep' using enter_worktree, then immediately exit it with action='keep' using exit_worktree" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/b1.json
# Directory should still exist (keep preserves it)
ls -d .qwen/worktrees/temp-keep
# Branch should still exist
git branch | grep worktree-temp-keep
# CWD should be original
Pre-implementation: tools unknown. Post-implementation: worktree dir and branch both still exist after exit.
Steps:
<qwen> "create a worktree named 'temp-remove' using enter_worktree, then immediately exit it with action='remove' using exit_worktree" \
--approval-mode yolo --output-format json 2>/dev/null
ls -d .qwen/worktrees/temp-remove 2>&1
git branch | grep worktree-temp-remove
Pre-implementation: tools unknown. Post-implementation: worktree dir is removed; branch is deleted.
Steps: Spawn an interactive tmux session, manually create files in worktree, then attempt exit.
tmux new-session -d -s wt-test-b3 -x 200 -y 50 "cd $TEST_DIR && <qwen> --approval-mode yolo"
sleep 3
tmux send-keys -t wt-test-b3 "create a worktree named 'dirty-test' using enter_worktree"
sleep 0.5
tmux send-keys -t wt-test-b3 Enter
# Wait for completion
for i in $(seq 1 30); do
sleep 2
tmux capture-pane -t wt-test-b3 -p | grep -q "Type your message" && break
done
# Create dirty file in worktree
echo "dirty" > "$TEST_DIR/.qwen/worktrees/dirty-test/dirty.txt"
# Try to remove without discard_changes
tmux send-keys -t wt-test-b3 "use exit_worktree with action='remove' to exit the worktree"
sleep 0.5
tmux send-keys -t wt-test-b3 Enter
for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-test-b3 -p | grep -q "Type your message" && break; done
tmux capture-pane -t wt-test-b3 -p -S -100 > /tmp/b3.out
# Should mention "uncommitted changes" or "discard_changes" in output
grep -E "uncommitted|discard_changes" /tmp/b3.out
tmux kill-session -t wt-test-b3
Pre-implementation: tools unknown.
Post-implementation: exit fails with a message about uncommitted changes and the discard_changes flag.
Steps:
SESSION_ID=$(<qwen> "create a worktree named 'persist-test' using enter_worktree" \
--approval-mode yolo --output-format json 2>/dev/null \
| jq -r 'select(.type=="system") | .session_id' | head -1)
# Check session storage for worktree state
find ~/.qwen -name "*${SESSION_ID}*" 2>/dev/null | head
grep -l "persist-test" ~/.qwen/projects/*/sessions/*.json 2>/dev/null || \
grep -rl "worktreeSession\|persist-test" ~/.qwen/projects/ 2>/dev/null | head -5
Pre-implementation: no worktree session state stored anywhere.
Post-implementation: session JSON contains a worktreeSession field with slug='persist-test', worktreePath, originalCwd, etc.
Steps:
<qwen> "spawn an agent using the agent tool with isolation='worktree' to run 'echo hello'" \
--approval-mode yolo --output-format json 2>/dev/null \
| jq 'select(.type=="assistant") | .message.content[] | select(.type=="tool_use" and .name=="agent") | .input'
# Check that .qwen/worktrees/ contains an agent-* slug during execution
Pre-implementation: agent tool schema has no isolation parameter; model either omits it or the schema rejects it.
Post-implementation: agent runs successfully with isolation='worktree'; an agent-<7hex> worktree is created.
Steps:
ls .qwen/worktrees/ > /tmp/d2-before.txt 2>/dev/null
<qwen> "spawn an agent with isolation='worktree' to list files in the current directory using ls" \
--approval-mode yolo --output-format json 2>/dev/null
ls .qwen/worktrees/ > /tmp/d2-after.txt 2>/dev/null
# After should equal before (no leftover agent-* dirs)
diff /tmp/d2-before.txt /tmp/d2-after.txt
Pre-implementation: N/A (no isolation parameter). Post-implementation: worktrees dir is unchanged after agent completes with no changes.
Steps:
<qwen> "spawn an agent with isolation='worktree' to write 'test content' to a new file called test.txt" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/d3.json
# Worktree should be preserved with the change
ls .qwen/worktrees/agent-* 2>/dev/null
ls .qwen/worktrees/agent-*/test.txt 2>/dev/null
# Agent result should include worktreePath/worktreeBranch
jq 'select(.type=="user") | .message.content[] | select(.tool_use_id) | .content' /tmp/d3.json | head
Pre-implementation: N/A.
Post-implementation: .qwen/worktrees/agent-<7hex>/test.txt exists; agent result mentions worktree path and branch.
This is harder to test e2e because it requires aging. Cover via unit tests in worktreeCleanup.test.ts:
agent-<7hex> pattern → removedmy-feature) → preservedE2E spot check (optional): manually touch -t 200001010000 .qwen/worktrees/agent-aabcdef0 and invoke cleanup; verify removal.
Steps: Run an Arena session (separate from EnterWorktree); verify it still creates worktrees under ~/.qwen/arena/<sessionId>/worktrees/ and not under .qwen/worktrees/.
# Setup: requires Arena-enabled config. Detailed steps depend on Arena CLI invocation.
# Pre-implementation: arena worktrees are under ~/.qwen/arena/.
# Post-implementation: SAME — arena path is independent.
(If Arena is not easily reachable from headless mode, this group is verified by unit test that ArenaManager.ts:125 (this.arenaBaseDir = arenaSettings?.worktreeBaseDir ?? path.join(Storage.getGlobalQwenDir(), 'arena')) is unchanged.)
Outside of the E2E plan, these unit tests must accompany the implementation:
EnterWorktreeTool.test.ts: schema validation, slug rejection, nested-worktree rejection, cwd change, SessionService writeExitWorktreeTool.test.ts: keep vs remove paths, dirty-state guard, discard_changes bypass, cwd restorationgitWorktreeService.test.ts extensions: createUserWorktree, removeUserWorktree, createAgentWorktree, removeAgentWorktreesessionService.test.ts extensions: WorktreeSession field read/write, resume restorationworktreeCleanup.test.ts: cleanup pattern matching, age filter, fail-closed conditionsagent.test.ts extensions: isolation parameter accepted, worktree created and (in some cases) cleaned| Group | Pre-build expected | Post-build expected |
|---|---|---|
| A1 | tools not listed | both tools listed |
| A2 | error/no-op | .qwen/worktrees/<auto-slug> created |
| A3 | error/no-op | .qwen/worktrees/my-feature created, branch present |
| A4 | error/no-op | tool result is_error with validation message |
| B1 | error/no-op | worktree dir + branch preserved |
| B2 | error/no-op | worktree dir + branch removed |
| B3 | error/no-op | exit refuses with uncommitted-changes message |
| C1 | no worktree state | session has worktreeSession field |
| D1 | no isolation param | agent runs in agent-<7hex> worktree |
| D2 | N/A | worktrees dir unchanged after agent with no changes |
| D3 | N/A | agent-<7hex> preserved with changes |
Local build at dist/cli.js (commit at the tip of claude/trusting-euclid-6fdfb9).
| Group | Result | Notes |
|---|---|---|
| A1 | ✅ | enter_worktree and exit_worktree listed in system.tools |
| A3 | ✅ | .qwen/worktrees/my-feature created, branch worktree-my-feature present |
| A4 | covered by unit test | validateUserWorktreeSlug rejects path-traversal etc. (enter-worktree.test.ts) |
| B1 | ✅ | keep action preserved both directory and branch |
| B2 | ✅ | remove action deleted directory and branch |
| B3 | ✅ | remove refused with Refusing to remove worktree "dirty-test" — it has 0 tracked change(s) and 1 untracked file(s). |
| C1 | scope-out | SessionService persistence deferred from Phase A (see scope notes in docs/design/worktree.md) |
| D1 | ✅ | Agent invocation accepted isolation: 'worktree', created agent-2c4e759 |
| D2 | ✅ | After agent finished with no changes, worktrees dir was empty |
| D3 | ✅ | After agent wrote test.txt, worktree agent-bad55bd and branch worktree-agent-bad55bd preserved; result included [worktree preserved: ... (branch ...)] suffix |
| E1 | covered by unit test | worktreeCleanup.test.ts verifies isEphemeralSlug matches only agent-<7hex> |
| F1 | scope-out (no Arena E2E in this run) | Arena code paths untouched: ArenaManager.ts:125 and setupWorktrees() unchanged |
Config.targetDir. Resume support requires SessionService extension and is documented for a future phase.