| 1 | import assert from "node:assert/strict"; |
| 2 | import React, { act } from "react"; |
| 3 | import { createRoot } from "react-dom/client"; |
| 4 | import { JSDOM } from "jsdom"; |
| 5 | import { useWorktreeMergeCommands } from "../app-runtime/useWorktreeMergeCommands"; |
| 6 | import type { TabMeta, WorktreeMergeResult } from "../lib/types"; |
| 7 | import type { Translator } from "../lib/i18n"; |
| 8 | |
| 9 | const dom = new JSDOM("<div id='root'></div>"); |
| 10 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); |
| 11 | const root = createRoot(document.getElementById("root")!); |
| 12 | |
| 13 | const t = ((key: string) => key) as Translator; |
| 14 | const sourceTab = { id: "source-tab", workspaceRoot: "/source" } as TabMeta; |
| 15 | const worktreeTab = { id: "worktree-tab", workspaceRoot: "/worktree" } as TabMeta; |
| 16 | const receipt: WorktreeMergeResult = { |
| 17 | merged: true, |
| 18 | alreadyMerged: false, |
| 19 | recoveryRequired: false, |
| 20 | sourceRoot: "/source", |
| 21 | targetBranch: "main", |
| 22 | mergedCommit: "merge-head", |
| 23 | worktreeRoot: "/worktree", |
| 24 | worktreeBranch: "reasonix/delivery-test", |
| 25 | worktreeHead: "worktree-head", |
| 26 | }; |
| 27 | |
| 28 | const toasts: string[] = []; |
| 29 | const cleanups: unknown[] = []; |
| 30 | const lifecycleCalls: string[] = []; |
| 31 | let navigationToken: string | null = "nav-token"; |
| 32 | let navigationCurrent = true; |
| 33 | let staleAfterEnsure = false; |
| 34 | |
| 35 | let states!: ReturnType<typeof useWorktreeMergeCommands>; |
| 36 | function Probe() { |
| 37 | states = useWorktreeMergeCommands({ |
| 38 | noteNavigationIntent: () => 42, |
| 39 | registeredNavigationIntent: async () => navigationToken, |
| 40 | isNavigationIntentCurrent: () => navigationCurrent, |
| 41 | ensureBlankSurface: async () => { |
| 42 | lifecycleCalls.push("ensure"); |
| 43 | if (staleAfterEnsure) navigationCurrent = false; |
| 44 | return sourceTab; |
| 45 | }, |
| 46 | seedSource: () => { lifecycleCalls.push("seed"); }, |
| 47 | listTabs: async () => { lifecycleCalls.push("list"); return [sourceTab, worktreeTab]; }, |
| 48 | closeWorktree: async () => { lifecycleCalls.push("close"); return { closed: true, idempotent: false }; }, |
| 49 | finalize: async () => { |
| 50 | lifecycleCalls.push("finalize"); |
| 51 | return { completed: true, worktreeRemoved: true, branchDeleted: true, blockers: [] }; |
| 52 | }, |
| 53 | showToast: (message) => { toasts.push(message); }, |
| 54 | t, |
| 55 | showCleanup: (cleanup) => { cleanups.push(cleanup); }, |
| 56 | }); |
| 57 | return null; |
| 58 | } |
| 59 | |
| 60 | try { |
| 61 | await act(async () => root.render(<Probe />)); |
| 62 | assert.equal(states.worktreeMergeTabId, null, "the merge overlay starts closed"); |
| 63 | |
| 64 | await act(async () => { states.openWorktreeMerge("worktree-tab"); }); |
| 65 | assert.equal(states.worktreeMergeTabId, "worktree-tab", "the topicbar merge action opens the overlay for its tab"); |
| 66 | await act(async () => { states.closeWorktreeMerge(); }); |
| 67 | assert.equal(states.worktreeMergeTabId, null, "the overlay close command clears the tab"); |
| 68 | |
| 69 | await assert.rejects( |
| 70 | () => states.handleWorktreeMerged({ ...receipt, mergedCommit: "" }), |
| 71 | /worktree\.mergeReceiptInvalid/, |
| 72 | "an invalid receipt rejects without touching navigation", |
| 73 | ); |
| 74 | assert.equal(toasts.length, 0, "an invalid receipt surfaces through the throw, not a toast"); |
| 75 | |
| 76 | navigationToken = null; |
| 77 | await act(async () => { states.openWorktreeMerge("worktree-tab"); }); |
| 78 | await act(async () => { await states.handleWorktreeMerged(receipt); }); |
| 79 | assert.deepEqual(toasts, ["worktree.navigationChangedPreserved"], "a superseded navigation intent preserves the worktree with a toast"); |
| 80 | assert.deepEqual(lifecycleCalls, [], "a superseded intent runs no close or finalize"); |
| 81 | navigationToken = "nav-token"; |
| 82 | |
| 83 | toasts.length = 0; |
| 84 | await act(async () => { await states.handleWorktreeMerged(receipt); }); |
| 85 | assert.deepEqual(lifecycleCalls, ["ensure", "seed", "list", "close", "finalize"], "a stable intent runs the full close/finalize chain in order"); |
| 86 | assert.equal(cleanups.length, 1, "a finalized merge hands the cleanup receipt to the notice"); |
| 87 | |
| 88 | staleAfterEnsure = true; |
| 89 | toasts.length = 0; |
| 90 | lifecycleCalls.length = 0; |
| 91 | await act(async () => { await states.handleWorktreeMerged(receipt); }); |
| 92 | assert.deepEqual(lifecycleCalls, ["ensure"], "a mid-flight navigation change stops the lifecycle before closing anything"); |
| 93 | assert.deepEqual(toasts, ["worktree.navigationChangedPreserved"], "the preserved path reports through the lifecycle toast"); |
| 94 | staleAfterEnsure = false; |
| 95 | navigationCurrent = true; |
| 96 | |
| 97 | await act(async () => root.unmount()); |
| 98 | console.log("worktree merge commands: overlay state, receipt gate, intent fences and close/finalize chain passed"); |
| 99 | } finally { dom.window.close(); } |
| 100 |