| 1 | import assert from "node:assert/strict"; |
| 2 | import React, { act, StrictMode, useRef } from "react"; |
| 3 | import { createRoot } from "react-dom/client"; |
| 4 | import { JSDOM } from "jsdom"; |
| 5 | import { useBranchSwitcher } from "../lib/useBranchSwitcher"; |
| 6 | import { useWorkspaceDiffStats } from "../lib/useWorkspaceDiffStats"; |
| 7 | import { createSerialWorkspacePoll } from "../lib/serialWorkspacePoll"; |
| 8 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 9 | import type { WorkspaceChangesView } from "../lib/types"; |
| 10 | import { DockLauncher } from "../components/DockLauncher"; |
| 11 | import { LocaleProvider } from "../lib/i18n"; |
| 12 | |
| 13 | function deferred<T>() { |
| 14 | let resolve!: (value: T) => void; |
| 15 | let reject!: (error: Error) => void; |
| 16 | const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; }); |
| 17 | return { promise, resolve, reject }; |
| 18 | } |
| 19 | const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); }; |
| 20 | |
| 21 | // Completion-driven polling with a clock controlled by the test. |
| 22 | const timers = new Map<number, () => void>(); |
| 23 | let nextTimer = 0; |
| 24 | const poll = createSerialWorkspacePoll({ |
| 25 | schedule(callback, delay) { assert.equal(delay, 5000); timers.set(++nextTimer, callback); return nextTimer; }, |
| 26 | cancel(handle) { timers.delete(handle as number); }, |
| 27 | }); |
| 28 | const pending: ReturnType<typeof deferred<void>>[] = []; |
| 29 | const job = () => { const request = deferred<void>(); pending.push(request); return request.promise; }; |
| 30 | poll.setJob(job); |
| 31 | await flush(); |
| 32 | assert.equal(pending.length, 1); |
| 33 | assert.equal(timers.size, 0, "slow request must not schedule overlapping polls"); |
| 34 | poll.refresh(); poll.refresh(); poll.refresh(); |
| 35 | pending[0].resolve(); await flush(); |
| 36 | assert.equal(pending.length, 2, "manual refreshes coalesce"); |
| 37 | pending[1].resolve(); await flush(); |
| 38 | assert.equal(timers.size, 1, "wait five seconds after completion"); |
| 39 | const tick = [...timers.values()][0]; timers.clear(); tick(); await flush(); |
| 40 | assert.equal(pending.length, 3); |
| 41 | poll.setJob(null); pending[2].resolve(); await flush(); |
| 42 | assert.equal(timers.size, 0); |
| 43 | poll.setJob(job); poll.setJob(null); await flush(); |
| 44 | assert.equal(pending.length, 3, "hide before dispatch cancels queued work"); |
| 45 | |
| 46 | const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost/", pretendToBeVisual: true }); |
| 47 | Object.assign(globalThis, { |
| 48 | window: dom.window, document: dom.window.document, Node: dom.window.Node, |
| 49 | localStorage: dom.window.localStorage, IS_REACT_ACT_ENVIRONMENT: true, |
| 50 | }); |
| 51 | let foreground = true; |
| 52 | Object.defineProperty(document, "visibilityState", { get: () => foreground ? "visible" : "hidden" }); |
| 53 | const lists: { tab: string; root: string; request: ReturnType<typeof deferred<string[]>> }[] = []; |
| 54 | const switches: { tab: string; root: string; branch: string; request: ReturnType<typeof deferred<void>> }[] = []; |
| 55 | const stats: { tab: string; root: string; request: ReturnType<typeof deferred<WorkspaceChangesView>> }[] = []; |
| 56 | const stub = installDesktopHostStub({ |
| 57 | GitBranchesForTab(tab: string, root: string) { |
| 58 | const request = deferred<string[]>(); lists.push({ tab, root, request }); return request.promise; |
| 59 | }, |
| 60 | GitCheckoutForTab(tab: string, root: string, branch: string) { |
| 61 | const request = deferred<void>(); switches.push({ tab, root, branch, request }); return request.promise; |
| 62 | }, |
| 63 | GitCreateBranchForTab(tab: string, root: string, branch: string) { |
| 64 | const request = deferred<void>(); switches.push({ tab, root, branch, request }); return request.promise; |
| 65 | }, |
| 66 | WorkspaceGitStatsForTab(tab: string, root: string) { |
| 67 | const request = deferred<WorkspaceChangesView>(); stats.push({ tab, root, request }); return request.promise; |
| 68 | }, |
| 69 | }); |
| 70 | let branch!: ReturnType<typeof useBranchSwitcher>; |
| 71 | let diff!: ReturnType<typeof useWorkspaceDiffStats>; |
| 72 | let changes = 0; |
| 73 | function Harness({ scope, visible }: { scope: string; visible: boolean }) { |
| 74 | const rootRef = useRef<HTMLDivElement>(null); |
| 75 | diff = useWorkspaceDiffStats("reused-tab", scope, "/" + scope, visible); |
| 76 | branch = useBranchSwitcher({ |
| 77 | tabId: "reused-tab", scopeKey: scope, workspaceRoot: "/" + scope, gitBranch: "main", rootRef, |
| 78 | onBranchChanged: () => { changes++; diff.reloadDiffStats(); }, |
| 79 | }); |
| 80 | return <div ref={rootRef} />; |
| 81 | } |
| 82 | const root = createRoot(document.getElementById("root")!); |
| 83 | const render = async (scope: string, visible = true) => act(async () => { |
| 84 | root.render(<StrictMode><Harness scope={scope} visible={visible} /></StrictMode>); |
| 85 | await flush(); |
| 86 | }); |
| 87 | await render("A"); |
| 88 | await act(async () => { branch.toggleBranchMenu(); branch.setBranchQuery("old"); await flush(); }); |
| 89 | assert.equal(lists[0].root, "/A"); |
| 90 | await act(async () => { void branch.checkoutBranch("shared"); await flush(); }); |
| 91 | assert.equal(switches[0].root, "/A"); |
| 92 | await render("B"); |
| 93 | assert.equal(branch.branchQuery, ""); |
| 94 | assert.deepEqual(branch.branches, []); |
| 95 | assert.equal(branch.branchSwitchErr, ""); |
| 96 | await act(async () => { branch.toggleBranchMenu(); await flush(); }); |
| 97 | assert.equal(lists[1].root, "/B", "same tab and same branch still carry project identity"); |
| 98 | await act(async () => { |
| 99 | lists[1].request.resolve(["main", "shared", "B-only"]); |
| 100 | lists[0].request.resolve(["A-only"]); |
| 101 | switches[0].request.resolve(); |
| 102 | await flush(); |
| 103 | }); |
| 104 | assert.deepEqual(branch.branches, ["main", "shared", "B-only"]); |
| 105 | assert.equal(branch.activeBranch, "main", "late A checkout cannot change B's branch"); |
| 106 | assert.equal(changes, 0, "late A checkout cannot refresh B"); |
| 107 | assert.equal(stats.length, 1, "B waits for the in-flight A statistics request"); |
| 108 | await act(async () => { stats[0].request.resolve({ files: [], gitAvailable: true, added: 99 }); await flush(); }); |
| 109 | assert.equal(diff.diffStats, null, "A statistics must not render in B"); |
| 110 | assert.equal(stats.length, 2); |
| 111 | assert.equal(stats[1].root, "/B"); |
| 112 | await act(async () => { stats[1].request.resolve({ files: [], gitAvailable: true, added: 2, incomplete: true }); await flush(); }); |
| 113 | assert.equal(diff.diffStats?.added, 2); |
| 114 | assert.equal(diff.diffStats?.incomplete, true); |
| 115 | await act(async () => { void branch.checkoutBranch("shared"); void branch.checkoutBranch("shared"); await flush(); }); |
| 116 | assert.equal(switches.length, 2, "duplicate checkout clicks coalesce synchronously"); |
| 117 | await act(async () => { switches[1].request.resolve(); await flush(); }); |
| 118 | assert.equal(branch.activeBranch, "shared"); |
| 119 | assert.equal(changes, 1); |
| 120 | assert.equal(lists.length, 3, "successful checkout refreshes its own branch list"); |
| 121 | assert.equal(stats.length, 3, "successful checkout refreshes its own totals"); |
| 122 | await act(async () => { |
| 123 | lists[2].request.resolve(["main", "shared"]); |
| 124 | stats[2].request.reject(new Error("timeout")); |
| 125 | await flush(); |
| 126 | }); |
| 127 | assert.equal(diff.diffStats?.added, 2, "failed count retains last estimate"); |
| 128 | assert.equal(diff.diffStats?.incomplete, true); |
| 129 | await act(async () => { void branch.createBranch("new"); await flush(); }); |
| 130 | await render("C"); |
| 131 | await act(async () => { switches[2].request.reject(new Error("A stale failure")); await flush(); }); |
| 132 | assert.equal(branch.branchSwitchErr, ""); |
| 133 | await act(async () => { |
| 134 | foreground = false; document.dispatchEvent(new dom.window.Event("visibilitychange")); await flush(); |
| 135 | }); |
| 136 | const countBeforeHide = stats.length; |
| 137 | await act(async () => { |
| 138 | stats.at(-1)!.request.resolve({ files: [], gitAvailable: true, added: 100 }); |
| 139 | diff.reloadDiffStats(); await flush(); |
| 140 | }); |
| 141 | assert.equal(stats.length, countBeforeHide, "hidden page stops manual and periodic dispatch"); |
| 142 | assert.equal(diff.diffStats, null, "hidden request result is invalidated"); |
| 143 | await act(async () => { foreground = true; document.dispatchEvent(new dom.window.Event("visibilitychange")); await flush(); }); |
| 144 | assert.equal(stats.length, countBeforeHide + 1); |
| 145 | const beforeRemount = stats.length; |
| 146 | await act(async () => { root.render(null); await flush(); }); |
| 147 | await render("C"); |
| 148 | assert.equal(stats.length, beforeRemount, "remount waits for the old scope's running RPC"); |
| 149 | await act(async () => { stats[beforeRemount - 1].request.resolve({ files: [], gitAvailable: true, added: 999 }); await flush(); }); |
| 150 | assert.equal(stats.length, beforeRemount + 1, "remount scans again only after the old request completes"); |
| 151 | assert.equal(diff.diffStats, null, "remount must discard the pre-hide result"); |
| 152 | await render("C", false); |
| 153 | await act(async () => { stats.at(-1)!.request.resolve({ files: [], gitAvailable: true }); await flush(); }); |
| 154 | await act(async () => { |
| 155 | root.render(<LocaleProvider><DockLauncher tabId="badge" scopeKey="badge" workspaceRoot="/badge" visible overlay gitBranch="main" onSelect={() => {}} /></LocaleProvider>); |
| 156 | await flush(); |
| 157 | }); |
| 158 | await act(async () => { stats.at(-1)!.request.resolve({ files: [], gitAvailable: true, added: 0, removed: 0, incomplete: true }); await flush(); }); |
| 159 | const badge = document.querySelector(".dock-launcher__entry-stats"); |
| 160 | assert(badge, "an incomplete zero must not disappear as an exact clean result"); |
| 161 | assert(badge.textContent.includes("~"), "incomplete totals visibly show an estimate"); |
| 162 | assert(badge.getAttribute("title")?.includes("incomplete"), "estimate has an accessible explanation"); |
| 163 | await act(async () => root.unmount()); |
| 164 | stub.uninstall(); dom.window.close(); |
| 165 | console.log("PASS scoped branch races, stale checkout/create, serial polling, visibility and incomplete totals"); |
| 166 |