| 1 | // Run: tsx src/__tests__/workspace-resize-interaction.test.tsx |
| 2 | |
| 3 | import { JSDOM } from "jsdom"; |
| 4 | import React from "react"; |
| 5 | import { act } from "react"; |
| 6 | import { createRoot } from "react-dom/client"; |
| 7 | import { WorkspacePanel } from "../components/WorkspacePanel"; |
| 8 | import type { AppBindings } from "../lib/bridge"; |
| 9 | import { LocaleProvider } from "../lib/i18n"; |
| 10 | import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory"; |
| 11 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 12 | |
| 13 | let passed = 0; |
| 14 | let failed = 0; |
| 15 | |
| 16 | function eq<T>(actual: T, expected: T, label: string) { |
| 17 | if (Object.is(actual, expected)) { |
| 18 | process.stdout.write(` PASS ${label}\n`); |
| 19 | passed += 1; |
| 20 | } else { |
| 21 | process.stdout.write(` FAIL ${label}: expected ${String(expected)}, got ${String(actual)}\n`); |
| 22 | failed += 1; |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | function flushTimers(): Promise<void> { |
| 27 | return new Promise((resolve) => setTimeout(resolve, 0)); |
| 28 | } |
| 29 | |
| 30 | async function waitFor(label: string, predicate: () => boolean) { |
| 31 | for (let attempt = 0; attempt < 30; attempt += 1) { |
| 32 | await act(async () => { |
| 33 | await flushTimers(); |
| 34 | }); |
| 35 | if (predicate()) return; |
| 36 | } |
| 37 | throw new Error(`timed out waiting for ${label}`); |
| 38 | } |
| 39 | |
| 40 | class TestResizeObserver { |
| 41 | observe() {} |
| 42 | unobserve() {} |
| 43 | disconnect() {} |
| 44 | } |
| 45 | |
| 46 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { |
| 47 | pretendToBeVisual: true, |
| 48 | url: "http://localhost/", |
| 49 | }); |
| 50 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 51 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 52 | globalThis.document = dom.window.document; |
| 53 | Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: "en-US" }); |
| 54 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 55 | globalThis.Node = dom.window.Node; |
| 56 | globalThis.Element = dom.window.Element; |
| 57 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 58 | globalThis.Event = dom.window.Event; |
| 59 | globalThis.CustomEvent = dom.window.CustomEvent; |
| 60 | globalThis.KeyboardEvent = dom.window.KeyboardEvent; |
| 61 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 62 | globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent; |
| 63 | globalThis.MutationObserver = dom.window.MutationObserver; |
| 64 | globalThis.ResizeObserver = TestResizeObserver; |
| 65 | dom.window.ResizeObserver = TestResizeObserver; |
| 66 | globalThis.localStorage = dom.window.localStorage; |
| 67 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); |
| 68 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); |
| 69 | Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} }); |
| 70 | Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get: () => 800 }); |
| 71 | Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", { |
| 72 | configurable: true, |
| 73 | get: function offsetHeight(this: HTMLElement) { |
| 74 | return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0; |
| 75 | }, |
| 76 | }); |
| 77 | Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", { |
| 78 | configurable: true, |
| 79 | value: function getBoundingClientRect(this: HTMLElement) { |
| 80 | const width = 800; |
| 81 | const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0; |
| 82 | return { x: 100, y: 0, top: 0, left: 100, right: 900, bottom: height, width, height, toJSON: () => ({}) } as DOMRect; |
| 83 | }, |
| 84 | }); |
| 85 | |
| 86 | console.log("\nworkspace right-side tree resize interaction"); |
| 87 | |
| 88 | resetWorkspaceTreeMemoryForTests(); |
| 89 | installDesktopHostStub(({ |
| 90 | main: { |
| 91 | App: { |
| 92 | ListDirForTab: async (_tabId, dir) => dir === "" ? [{ name: "app.ts", isDir: false }] : [], |
| 93 | SearchFileRefsForTab: async () => [], |
| 94 | WorkspaceGitHistory: async () => [], |
| 95 | WorkspaceChanges: async () => ({ files: [], gitAvailable: true }), |
| 96 | WorkspaceChangeDetail: async () => ({}), |
| 97 | ResolveWorkspacePathForTab: async (_tabId, path) => path.startsWith("/") ? path : `/repo/${path}`, |
| 98 | ReadFileForTab: async (_tabId, path) => ({ path, body: "const value = 1;", size: 16, truncated: false, binary: false }), |
| 99 | } as Partial<AppBindings> as AppBindings, |
| 100 | }, |
| 101 | }).main.App); |
| 102 | |
| 103 | const rootElement = document.getElementById("root"); |
| 104 | if (!rootElement) throw new Error("missing root"); |
| 105 | const root = createRoot(rootElement); |
| 106 | await act(async () => { |
| 107 | root.render( |
| 108 | <LocaleProvider> |
| 109 | <WorkspacePanel |
| 110 | open |
| 111 | tabId="resize-tab" |
| 112 | cwd="/repo" |
| 113 | maximized={false} |
| 114 | panelWidth={800} |
| 115 | initialViewMode="files" |
| 116 | onClose={() => {}} |
| 117 | onToggleMaximized={() => {}} |
| 118 | /> |
| 119 | </LocaleProvider>, |
| 120 | ); |
| 121 | await flushTimers(); |
| 122 | }); |
| 123 | |
| 124 | await waitFor("workspace file", () => document.querySelector('[data-workspace-path="app.ts"]') !== null); |
| 125 | await act(async () => { |
| 126 | document.querySelector<HTMLButtonElement>('[data-workspace-path="app.ts"]')?.click(); |
| 127 | await flushTimers(); |
| 128 | }); |
| 129 | await waitFor("tree separator", () => document.querySelector(".workspace-tree-resizer") !== null); |
| 130 | |
| 131 | const separator = document.querySelector<HTMLButtonElement>(".workspace-tree-resizer"); |
| 132 | if (!separator) throw new Error("missing tree separator"); |
| 133 | const initialWidth = Number(separator.getAttribute("aria-valuenow")); |
| 134 | |
| 135 | await act(async () => { |
| 136 | separator.dispatchEvent(new window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowRight" })); |
| 137 | await flushTimers(); |
| 138 | }); |
| 139 | eq(Number(separator.getAttribute("aria-valuenow")), initialWidth - 16, "ArrowRight moves the separator right and narrows the right-side tree"); |
| 140 | |
| 141 | await act(async () => { |
| 142 | separator.dispatchEvent(new window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowLeft" })); |
| 143 | await flushTimers(); |
| 144 | }); |
| 145 | eq(Number(separator.getAttribute("aria-valuenow")), initialWidth, "ArrowLeft moves the separator left and widens the right-side tree"); |
| 146 | |
| 147 | await act(async () => { |
| 148 | separator.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, cancelable: true, clientX: 600 })); |
| 149 | window.dispatchEvent(new window.MouseEvent("pointermove", { bubbles: true, clientX: 650 })); |
| 150 | window.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, clientX: 650 })); |
| 151 | await flushTimers(); |
| 152 | }); |
| 153 | eq(Number(separator.getAttribute("aria-valuenow")), 250, "pointer position measures tree width from the panel's right edge"); |
| 154 | |
| 155 | await act(async () => { |
| 156 | separator.dispatchEvent(new window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "End" })); |
| 157 | await flushTimers(); |
| 158 | }); |
| 159 | eq(Number(separator.getAttribute("aria-valuenow")), 660, "End widens the tree to keep only the preview minimum"); |
| 160 | |
| 161 | await act(async () => { |
| 162 | root.unmount(); |
| 163 | }); |
| 164 | dom.window.close(); |
| 165 | |
| 166 | console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 167 | if (failed > 0) process.exit(1); |
| 168 |