| 1 | // Run: tsx src/__tests__/isolated-worktree.test.ts |
| 2 | import { readFileSync } from "node:fs"; |
| 3 | import { dirname, resolve } from "node:path"; |
| 4 | import { fileURLToPath } from "node:url"; |
| 5 | import { messageActionLabelKey } from "../lib/messageActions"; |
| 6 | import type { TabMeta } from "../lib/types"; |
| 7 | |
| 8 | const dir = dirname(fileURLToPath(import.meta.url)); |
| 9 | const source = (path: string) => readFileSync(resolve(dir, path), "utf8"); |
| 10 | const bridge = source("../lib/bridge.ts"); |
| 11 | const tree = source("../components/ProjectTree.tsx"); |
| 12 | const badge = source("../components/WorktreeBadge.tsx"); |
| 13 | const forkAction = source("../lib/forkWorktree.ts"); |
| 14 | const message = source("../components/ChatNodes.tsx"); |
| 15 | const mergeModal = source("../components/WorktreeMergeModal.tsx"); |
| 16 | const mergeStyles = source("../components/WorktreeMergeModal.css"); |
| 17 | const controller = source("../lib/useController.ts"); |
| 18 | const navigationFence = source("../lib/useNavigationIntentFence.ts"); |
| 19 | |
| 20 | let failed = 0; |
| 21 | function ok(value: unknown, label: string) { |
| 22 | if (value) process.stdout.write(` PASS ${label}\n`); |
| 23 | else { |
| 24 | failed += 1; |
| 25 | process.stdout.write(` FAIL ${label}\n`); |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | console.log("\nisolated worktree"); |
| 30 | ok(/IsolatedWorktreeAvailability\(workspaceRoot: string\)/.test(bridge), "bridge exposes non-mutating availability probe"); |
| 31 | ok(/CreateIsolatedWorktree\(workspaceRoot: string\)/.test(bridge), "bridge exposes isolated workspace creation"); |
| 32 | ok(/app\.IsolatedWorktreeAvailability\(projectRoot\)/.test(tree), "project menu probes Git before enabling isolation"); |
| 33 | ok(/disabled: isolatingProject !== null \|\| isolationAvailability\?\.available === false/.test(tree), "menu disables unavailable or duplicate creation"); |
| 34 | ok(/onCreateIsolatedWorktree\?\.\(workspaceRoot\)/.test(tree), "project menu delegates isolated workspace creation"); |
| 35 | // Project commands drive the production coalescing queue under deferred work |
| 36 | // in project-topic-lifecycle.test.tsx; callback location is not a contract. |
| 37 | // desktop-navigation-lifecycle.test.tsx verifies the actual dirty-worktree notice. |
| 38 | // The top session tab strip is gone; topicbar-region.test.tsx mounts the real |
| 39 | // badge and verifies conditional identity, accessible labeling and source-bound |
| 40 | // merge actions for isolated/ordinary topics. |
| 41 | ok(/node\.isolatedWorktree && <WorktreeBadge/.test(tree), "project tree identifies isolated worktrees"); |
| 42 | ok(/GitBranch/.test(badge) && /#6119/.test(badge), "shared badge preserves the credited #6119 design contribution"); |
| 43 | ok(/bindings\.ForkWorktreeForTab\(sourceTabId, turn\)/.test(forkAction) && /makeMockForkBindings/.test(bridge) && !/async ForkWorktreeForTab\(tabID, turn\)/.test(bridge), "isolated conversation fork and browser mock use the extracted two-argument binding"); |
| 44 | ok(!/ForkForTab\(sourceTabId, turn, isolate/.test(forkAction), "shared fork never sends an extra bridge argument"); |
| 45 | ok(/result\.sourceDirty[\s\S]*forkWorktreeDirtySource/.test(forkAction), "dirty sources are refused with actionable guidance"); |
| 46 | ok(/result\.fallbackToShared[\s\S]*forkWorktreeFallbackNotice/.test(forkAction), "backend fallback state reaches the user"); |
| 47 | ok(!message.includes("fork-worktree") && !message.includes("actions.checkpoints") && /actions\.fork/.test(message), |
| 48 | "chat exposes only the persisted-turn fork entry and never the worktree scope"); |
| 49 | ok(messageActionLabelKey("fork-worktree", false) === "rewind.forkWorktree", "isolated fork keeps its menu label after extraction"); |
| 50 | ok(messageActionLabelKey("fork-worktree", true) === "rewind.confirmForkWorktree", "isolated fork keeps its confirmation label after extraction"); |
| 51 | ok(/useState\(false\)/.test(mergeModal) && /autoCommitDirty/.test(mergeModal), "dirty auto-commit is opt-in by default"); |
| 52 | ok(/InspectWorktreeMerge\(tabId\)[\s\S]*inspectionIdentity\(refreshed\)[\s\S]*MergeWorktreeBack\(\{/.test(mergeModal), "confirm re-inspects before sending one identity-bound merge request"); |
| 53 | ok(/stateChanged/.test(mergeModal) && /setInspection\(refreshed\)/.test(mergeModal), "state drift refreshes the panel instead of continuing"); |
| 54 | ok(/aria-modal="true"/.test(mergeModal) && /event\.key === "Escape"/.test(mergeModal) && /event\.key !== "Tab"/.test(mergeModal), "merge dialog exposes modal, escape, and focus-loop semantics"); |
| 55 | ok(/WorktreeMergeModal\.css/.test(mergeModal) && mergeStyles.includes(".worktree-merge__body") && !/style=\{\{/.test(mergeModal), "lazy merge UI keeps layout rules out of inline styles"); |
| 56 | ok(/worktreeStateToken/.test(mergeModal) && /expectedWorktreeStateToken/.test(mergeModal), "merge confirmation binds the exact dirty worktree content token"); |
| 57 | ok(!/ModalCloseButton autoFocus/.test(mergeModal), "merge modal captures its trigger before moving focus so close restores the trigger"); |
| 58 | ok(/CloseMergedWorktreeTab\(request: CloseMergedWorktreeTabRequest\)/.test(bridge), "worktree close is a request-object bridge call"); |
| 59 | ok(/FinalizeWorktreeMerge\(request: WorktreeCleanupRequest\)/.test(bridge), "cleanup is a separate request-object bridge call"); |
| 60 | const fencedNavigationCalls = [ |
| 61 | ["const resumeSession", "app.ResumeTranscriptSessionForTab"], |
| 62 | ["const openChannelSession", "app.OpenChannelTranscriptSessionForTab"], |
| 63 | ["const pickWorkspace", "app.PickWorkspace"], |
| 64 | ["const switchWorkspace", "app.SwitchWorkspace"], |
| 65 | ["const switchTab", "app.SetActiveTab"], |
| 66 | ["const openProjectTab", "app.OpenProjectTab"], |
| 67 | ["const openGlobalTab", "app.OpenGlobalTab"], |
| 68 | ["const openTopicSession", "app.OpenTopicSession"], |
| 69 | ["const activateTopic", "app.StartTopicActivation"], |
| 70 | ["const ensureBlankTab", "app.EnsureBlankTab"], |
| 71 | ["const ensureBlankSurface", "app.EnsureBlankSurface"], |
| 72 | ["const createIsolatedWorktree", "app.CreateIsolatedWorktree"], |
| 73 | ["const closeTab", "app.CloseTabWithPolicy"], |
| 74 | ]; |
| 75 | ok(fencedNavigationCalls.every(([startMarker, callMarker]) => { |
| 76 | const start = controller.indexOf(startMarker); |
| 77 | const fence = controller.indexOf("await requireRegisteredNavigationIntent", start); |
| 78 | const call = controller.indexOf(callMarker, start); |
| 79 | return start >= 0 && fence > start && call > fence; |
| 80 | }), "navigation entry points await backend intent registration before switching"); |
| 81 | ok(/navigationIntentRegistrationTail\.then/.test(navigationFence) && /navigationIntentRegistrationTail = registered/.test(navigationFence), "navigation registrations preserve user-intent order across deferred bridge calls and remounts"); |
| 82 | |
| 83 | const { increaseMockForkTitle, makeMockForkBindings } = await import("../lib/mockForkWorktree"); |
| 84 | const { settleForkConversationForTab } = await import("../lib/controllerSwitchNotices"); |
| 85 | const original = { id: "source", active: true, workspaceRoot: "/project", topicTitle: "Source" } as TabMeta; |
| 86 | let mockTabs = [original]; |
| 87 | const mockFork = makeMockForkBindings(() => mockTabs, tabs => { mockTabs = tabs; }, "Untitled", async () => []); |
| 88 | const isolated = await mockFork.ForkWorktreeForTab(original.id, 3); |
| 89 | ok(isolated.isolated && isolated.tab.workspaceRoot === "/project-worktree" && mockTabs[0].active === false, |
| 90 | "separate mock bindings retain isolated-worktree and activation behavior"); |
| 91 | ok(isolated.tab.topicTitle === "Source (1)" && increaseMockForkTitle("计划(9)") === "计划(10)", |
| 92 | "browser mock mirrors Harness fork-title numbering"); |
| 93 | const forkCalls: string[] = []; |
| 94 | const bindings = { |
| 95 | ForkForTab: async (id: string, turn: number) => { forkCalls.push(`shared:${id}:${turn}`); return isolated.tab; }, |
| 96 | ForkWorktreeForTab: async (id: string, turn: number) => { forkCalls.push(`isolated:${id}:${turn}`); return { ...isolated, sourceDirty: true }; }, |
| 97 | }; |
| 98 | const adopt = async () => { forkCalls.push("adopt"); }; |
| 99 | const sync = async () => { forkCalls.push("sync"); }; |
| 100 | const notice = () => { forkCalls.push("notice"); }; |
| 101 | const sharedResult = await settleForkConversationForTab(bindings, original.id, 4, false, notice, adopt, sync); |
| 102 | ok(sharedResult.ok && forkCalls.join(",") === "shared:source:4,adopt", "lazy action entry retains exact shared-fork arguments and adoption"); |
| 103 | forkCalls.length = 0; |
| 104 | const dirtyResult = await settleForkConversationForTab(bindings, original.id, 5, true, notice, adopt, sync); |
| 105 | ok(!dirtyResult.ok && forkCalls.join(",") === "isolated:source:5,notice,sync", "lazy action entry preserves dirty-worktree refusal without adoption"); |
| 106 | |
| 107 | if (failed) process.exit(1); |
| 108 | console.log("isolated worktree tests passed"); |
| 109 |