From 45b1dbf9622cf4ce2cfe7cefe1d1938447229bac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 7 Aug 2026 14:18:12 -0700 Subject: [PATCH] test(qa): add managed-worktrees CLI lifecycle scenario coverage (#120335) * test(qa): add managed-worktrees CLI lifecycle scenario coverage Managed worktrees had zero QA scenario-pack coverage despite being a headline feature. Mint agent-runtime.managed-worktrees-lifecycle in the taxonomy, add a runtime scenario, and prove the real child CLI through create with .worktreeinclude provisioning and the .openclaw setup hook, dirty removal pinning a snapshot ref, restore rebuilding tracked, untracked, and provisioned files with their modes, and gc preserving manual worktrees. * fix(qa): align model-switch catalog assertion with expectedAlternate flow qa/scenarios/models/model-switch-follow-up.yaml switched to expectedAlternate.model in 5a795f4ddac but the catalog test still greps for the retired alternate?.model literal; the test is outside the PR change-classification lanes, so the break only surfaces on direct runs. * test(qa): narrow managed-worktrees taxonomy description to proven manual-owner gc ClawSweeper P2 on #120335: the scenario proves manual-owner gc retention only; session and Workboard cleanup lifecycles are not exercised, so the coverage description must not claim them. --- .../qa-lab/src/scenario-catalog.test.ts | 2 +- .../runtime/managed-worktrees-cli.yaml | 27 +++ taxonomy.yaml | 4 + ...ed-worktrees-cli-product-proof.e2e.test.ts | 199 ++++++++++++++++++ 4 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 qa/scenarios/runtime/managed-worktrees-cli.yaml create mode 100644 test/e2e/qa-lab/runtime/managed-worktrees-cli-product-proof.e2e.test.ts diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index 0372a7c98cef..914b9369be94 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -789,7 +789,7 @@ describe("qa scenario catalog", () => { const scenario = requireFlowScenario(readQaScenarioById("model-switch-follow-up")); const flow = JSON.stringify(scenario.execution.flow); - expect(flow).toContain("alternate?.model"); + expect(flow).toContain("expectedAlternate.model"); expect(flow).toContain("config.followupPrompt"); expect(flow).not.toContain("gpt-5.6-luna-alt"); }); diff --git a/qa/scenarios/runtime/managed-worktrees-cli.yaml b/qa/scenarios/runtime/managed-worktrees-cli.yaml new file mode 100644 index 000000000000..1b908d84f872 --- /dev/null +++ b/qa/scenarios/runtime/managed-worktrees-cli.yaml @@ -0,0 +1,27 @@ +title: Managed worktrees CLI lifecycle + +scenario: + id: managed-worktrees-cli + surface: runtime + category: agent-runtime.agent-turn-execution + coverage: + primary: + - agent-runtime.managed-worktrees-lifecycle + objective: Prove the real child CLI creates a manual managed worktree with ignored-file provisioning and its repository setup hook, snapshots dirty state during removal, restores tracked, untracked, and provisioned files with their modes, and preserves the active manual worktree through gc. + successCriteria: + - Create provisions ignored files with their modes and runs the repository setup hook with canonical source and worktree paths. + - Dirty removal pins a snapshot ref in the source repository and retains the removed record for restoration. + - Restore rebuilds tracked and untracked changes plus provisioned ignored files with their modes without putting the synthetic snapshot commit in branch history. + - Garbage collection returns its structured result while preserving the active manual worktree. + docsRefs: + - docs/concepts/managed-worktrees.md + - docs/concepts/qa-e2e-automation.md + codeRefs: + - src/cli/worktrees-cli.ts + - src/agents/worktrees/service.ts + - src/agents/worktrees/provisioned-files.ts + - test/e2e/qa-lab/runtime/managed-worktrees-cli-product-proof.e2e.test.ts + execution: + kind: vitest + path: test/e2e/qa-lab/runtime/managed-worktrees-cli-product-proof.e2e.test.ts + summary: Run the real child CLI through managed worktree provisioning, snapshot removal, restoration, and manual-owner garbage collection. diff --git a/taxonomy.yaml b/taxonomy.yaml index 393f03c2948e..1066825ca501 100644 --- a/taxonomy.yaml +++ b/taxonomy.yaml @@ -1530,10 +1530,14 @@ surfaces: - name: "Abort and terminal outcomes — Long run stability" coverageIds: [agent-runtime.terminal-long-run-stability] description: "Exact coverage for long run stability within Abort and terminal outcomes." + - name: Managed worktrees + coverageIds: [agent-runtime.managed-worktrees-lifecycle] + description: Managed worktree create with ignored-file provisioning and repository setup hooks, dirty-removal snapshots, snapshot restore, and manual-owner gc retention. docs: - docs/concepts/agent-loop.md - docs/cli/agent.md - docs/concepts/agent-runtimes.md + - docs/concepts/managed-worktrees.md search_anchors: - agent RPC shape and event stream - executeAgentTurn diff --git a/test/e2e/qa-lab/runtime/managed-worktrees-cli-product-proof.e2e.test.ts b/test/e2e/qa-lab/runtime/managed-worktrees-cli-product-proof.e2e.test.ts new file mode 100644 index 000000000000..c9f9d9317e46 --- /dev/null +++ b/test/e2e/qa-lab/runtime/managed-worktrees-cli-product-proof.e2e.test.ts @@ -0,0 +1,199 @@ +// QA Lab product proof for the managed-worktree child CLI lifecycle. +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import type { + ManagedWorktreeGcResult, + ManagedWorktreeRecord, + RemoveManagedWorktreeResult, +} from "../../../../src/agents/worktrees/types.js"; +import { closeOpenClawStateDatabaseForTest } from "../../../../src/state/openclaw-state-db.js"; +import { + createOpenClawTestInstance, + type OpenClawTestInstance, +} from "../../../helpers/openclaw-test-instance.js"; + +const execFileAsync = promisify(execFile); +const WORKTREE_NAME = "qa-managed-worktree"; + +type CommandResult = Awaited>; +type WorktreeListJson = { worktrees: ManagedWorktreeRecord[] }; + +let instance: OpenClawTestInstance | undefined; +let tempRoot: string | undefined; + +afterEach(async () => { + closeOpenClawStateDatabaseForTest(); + await instance?.cleanup(); + instance = undefined; + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }); + tempRoot = undefined; + } +}); + +function parseCommandJson(label: string, result: CommandResult): T { + if (result.code !== 0) { + throw new Error( + `${label} failed with exit ${String(result.code)}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } + return JSON.parse(result.stdout) as T; +} + +async function git(cwd: string, ...args: string[]): Promise { + const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], { encoding: "utf8" }); + return stdout.trimEnd(); +} + +async function initializeRepository(root: string): Promise<{ baseCommit: string; repo: string }> { + const repo = path.join(root, "source"); + await fs.mkdir(path.join(repo, ".openclaw"), { recursive: true }); + await fs.mkdir(path.join(repo, "generated"), { recursive: true }); + await git(repo, "init", "-b", "main"); + await git(repo, "config", "user.name", "OpenClaw Test"); + await git(repo, "config", "user.email", "openclaw-test@example.invalid"); + await fs.writeFile(path.join(repo, "README.md"), "base\n"); + await fs.writeFile(path.join(repo, ".gitignore"), ".env.local\ngenerated/\n"); + await fs.writeFile(path.join(repo, ".worktreeinclude"), ".env.local\ngenerated/**\n"); + const setupScript = path.join(repo, ".openclaw", "worktree-setup.sh"); + await fs.writeFile( + setupScript, + '#!/bin/sh\nset -eu\nprintf "%s\\n%s\\n" "$OPENCLAW_SOURCE_TREE_PATH" "$OPENCLAW_WORKTREE_PATH" > "$OPENCLAW_WORKTREE_PATH/setup-marker.txt"\n', + ); + await fs.chmod(setupScript, 0o755); + await git(repo, "add", "README.md", ".gitignore", ".worktreeinclude", setupScript); + await git(repo, "commit", "-m", "initialize managed worktree fixture"); + + await fs.writeFile(path.join(repo, ".env.local"), "TOKEN=fixture\n"); + await fs.chmod(path.join(repo, ".env.local"), 0o600); + await fs.writeFile(path.join(repo, "generated", "tool.sh"), "#!/bin/sh\necho generated\n"); + await fs.chmod(path.join(repo, "generated", "tool.sh"), 0o755); + + return { + baseCommit: await git(repo, "rev-parse", "HEAD"), + repo: await fs.realpath(repo), + }; +} + +describe("managed worktrees child CLI product proof", () => { + it( + "provisions, snapshots, restores, and preserves a manual worktree through gc", + { timeout: 180_000 }, + async () => { + const canonicalTmp = await fs.realpath(os.tmpdir()); + tempRoot = await fs.mkdtemp(path.join(canonicalTmp, "openclaw-managed-worktree-cli-")); + const { baseCommit, repo } = await initializeRepository(tempRoot); + instance = await createOpenClawTestInstance({ name: "qa-managed-worktree-cli" }); + const stateDir = await fs.realpath(instance.stateDir); + + const created = parseCommandJson( + "worktrees create", + await instance.cli(["worktrees", "create", repo, "--name", WORKTREE_NAME, "--json"]), + ); + expect(created).toMatchObject({ + name: WORKTREE_NAME, + repoRoot: repo, + branch: `openclaw/${WORKTREE_NAME}`, + baseRef: "HEAD", + ownerKind: "manual", + }); + expect(created.id).toEqual(expect.any(String)); + expect(created.repoFingerprint).toEqual(expect.any(String)); + expect(created.createdAt).toEqual(expect.any(Number)); + expect(created.lastActiveAt).toEqual(expect.any(Number)); + expect(await fs.realpath(created.path)).toBe( + path.join(stateDir, "worktrees", created.repoFingerprint, WORKTREE_NAME), + ); + expect(await git(repo, "rev-parse", `refs/heads/${created.branch}`)).toBe(baseCommit); + + const provisionedEnv = path.join(created.path, ".env.local"); + const provisionedTool = path.join(created.path, "generated", "tool.sh"); + await expect(fs.readFile(provisionedEnv, "utf8")).resolves.toBe("TOKEN=fixture\n"); + await expect(fs.readFile(provisionedTool, "utf8")).resolves.toContain("echo generated"); + expect((await fs.stat(provisionedEnv)).mode & 0o777).toBe(0o600); + expect((await fs.stat(provisionedTool)).mode & 0o777).toBe(0o755); + + const setupPaths = (await fs.readFile(path.join(created.path, "setup-marker.txt"), "utf8")) + .trim() + .split("\n"); + expect(setupPaths).toHaveLength(2); + expect(await fs.realpath(setupPaths[0]!)).toBe(repo); + expect(await fs.realpath(setupPaths[1]!)).toBe(await fs.realpath(created.path)); + + await fs.writeFile(path.join(created.path, "README.md"), "dirty tracked change\n"); + await fs.writeFile(path.join(created.path, "notes.txt"), "restored note\n"); + + const removed = parseCommandJson( + "worktrees remove", + await instance.cli(["worktrees", "remove", created.id, "--json"]), + ); + const expectedSnapshotRef = `refs/openclaw/snapshots/${created.id}`; + expect(removed).toEqual({ removed: true, snapshotRef: expectedSnapshotRef }); + const snapshotCommit = await git(repo, "rev-parse", expectedSnapshotRef); + expect(await git(repo, "show-ref", "--verify", expectedSnapshotRef)).toContain( + expectedSnapshotRef, + ); + await expect(fs.access(created.path)).rejects.toMatchObject({ code: "ENOENT" }); + + const removedList = parseCommandJson( + "worktrees list after remove", + await instance.cli(["worktrees", "list", "--json"]), + ); + expect(removedList.worktrees).toContainEqual( + expect.objectContaining({ id: created.id, removedAt: expect.any(Number) }), + ); + + const restored = parseCommandJson( + "worktrees restore", + await instance.cli(["worktrees", "restore", created.id, "--json"]), + ); + expect(restored).toMatchObject({ + id: created.id, + branch: created.branch, + path: created.path, + }); + expect(restored.removedAt).toBeUndefined(); + const status = await git(restored.path, "status", "--porcelain"); + expect(status.split("\n")).toEqual(expect.arrayContaining([" M README.md", "?? notes.txt"])); + await expect(fs.readFile(path.join(restored.path, "README.md"), "utf8")).resolves.toBe( + "dirty tracked change\n", + ); + await expect(fs.readFile(path.join(restored.path, "notes.txt"), "utf8")).resolves.toBe( + "restored note\n", + ); + await expect(fs.readFile(provisionedEnv, "utf8")).resolves.toBe("TOKEN=fixture\n"); + await expect(fs.readFile(provisionedTool, "utf8")).resolves.toContain("echo generated"); + expect((await fs.stat(provisionedEnv)).mode & 0o777).toBe(0o600); + expect((await fs.stat(provisionedTool)).mode & 0o777).toBe(0o755); + expect(await git(repo, "rev-parse", `refs/heads/${created.branch}`)).toBe(baseCommit); + expect((await git(repo, "log", "--format=%H", created.branch)).split("\n")).not.toContain( + snapshotCommit, + ); + + const gc = parseCommandJson( + "worktrees gc", + await instance.cli(["worktrees", "gc", "--json"]), + ); + expect(gc).toEqual({ + removed: [], + orphansDeleted: expect.any(Number), + snapshotsPruned: expect.any(Number), + }); + const activeList = parseCommandJson( + "worktrees list after gc", + await instance.cli(["worktrees", "list", "--json"]), + ); + expect(activeList.worktrees).toContainEqual( + expect.objectContaining({ id: created.id, ownerKind: "manual" }), + ); + expect( + activeList.worktrees.find((record) => record.id === created.id)?.removedAt, + ).toBeUndefined(); + await expect(fs.access(created.path)).resolves.toBeUndefined(); + }, + ); +});