Skip to content
Source
Sign inSign up

RightPanelTabs.browser.test.tsx

290 lines10.5 KB

1import "~/index.css";
2​
3import { EnvironmentId } from "@t3tools/contracts";
4import { describe, expect, it, vi } from "vite-plus/test";
5import { userEvent } from "vite-plus/test/browser";
6​
7import { InputGroup, InputGroupAddon, InputGroupInput } from "~/components/ui/input-group";
8import type { RightPanelSurface } from "~/rightPanelStore";
9import { render, stubGlobal } from "../../test/render";
10import { HistorySwipeNavigation } from "./HistorySwipeNavigation";
11import { RightPanelTabs, type RightPanelTabsProps } from "./RightPanelTabs";
12import { subscribePreviewAction } from "./preview/previewActionBus";
13​
14const navigateHistory = vi.hoisted(() => vi.fn());
15vi.mock("../navigationHistory", () => ({ canNavigateHistory: () => true, navigateHistory }));
16​
17// Chromium starts a gesture scroll, and Electron reports its begin, only for a
18// wheel event the page did not preventDefault; it then scrolls the latched scroller.
19function trackpad() {
20 const begin = new Set<() => void>();
21 const end = new Set<() => void>();
22 const subscribe = (listeners: Set<() => void>) => (listener: () => void) => {
23 listeners.add(listener);
24 return () => listeners.delete(listener);
25 };
26 let scrolling = false;
27 return {
28 bridge: { onTrackpadScrollBegin: subscribe(begin), onTrackpadScrollEnd: subscribe(end) },
29 scroll(target: Element, deltas: readonly number[]) {
30 let scroller: Element | null = target;
31 while (
32 scroller &&
33 (scroller.scrollWidth <= scroller.clientWidth ||
34 !/auto|scroll/.test(getComputedStyle(scroller).overflowX))
35 ) {
36 scroller = scroller.parentElement;
37 }
38 for (const deltaX of deltas) {
39 const event = new WheelEvent("wheel", {
40 deltaX,
41 bubbles: true,
42 cancelable: true,
43 composed: true,
44 });
45 target.dispatchEvent(event);
46 if (event.defaultPrevented) continue;
47 if (scroller) scroller.scrollLeft += deltaX;
48 if (scrolling) continue;
49 scrolling = true;
50 for (const listener of begin) listener();
51 }
52 if (scrolling) for (const listener of end) listener();
53 scrolling = false;
54 },
55 };
57​
58const surfaces: RightPanelSurface[] = Array.from({ length: 16 }, (_, index) => ({
59 id: `terminal:${index}`,
60 kind: "terminal",
61 resourceId: `${index}`,
62 terminalIds: [`${index}`],
63 activeTerminalId: `${index}`,
64}));
65​
66describe("RightPanelTabs", () => {
67 it("a flick that scrolls the tab strip to its left edge keeps the thread, a swipe from the edge goes back", async () => {
68 navigateHistory.mockReset();
69 const { bridge, scroll } = trackpad();
70 using _bridge = stubGlobal("desktopBridge", bridge);
71 using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
72 const panel = document.createElement("div");
73 panel.style.cssText = "width: 320px; height: 200px; display: flex";
74 const noop = () => {};
75 await using screen = await render(
76 <>
77 <HistorySwipeNavigation />
78 <RightPanelTabs
79 mode="embedded"
80 keybindings={[]}
81 getShortcutContext={() => ({
82 terminalFocus: false,
83 terminalOpen: false,
84 previewFocus: false,
85 previewOpen: false,
86 isWeb: true,
87 isDesktop: false,
88 })}
89 surfaces={surfaces}
90 environmentId={null}
91 activeSurfaceId={null}
92 pendingSurfaceIds={new Set()}
93 previewSessions={{}}
94 desktopByTabId={{}}
95 terminalLabelsById={new Map(surfaces.map((surface) => [surface.id, "Terminal"]))}
96 onActivate={noop}
97 onCloseSurface={noop}
98 onCloseOtherSurfaces={noop}
99 onCloseSurfacesToRight={noop}
100 onCloseAllSurfaces={noop}
101 onCopyFilePath={noop}
102 onAddBrowser={noop}
103 onAddBrowserInProfile={noop}
104 onAddTerminal={noop}
105 onAddDiff={noop}
106 onAddFiles={noop}
107 onAddPullRequest={noop}
108 onAddPullRequests={noop}
109 onAddDevice={noop}
110 browserAvailable={false}
111 terminalAvailable
112 diffAvailable={false}
113 filesAvailable={false}
114 pullRequestAvailable={false}
115 pullRequestsAvailable={false}
116 deviceAvailable={false}
117 >
118 {null}
119 </RightPanelTabs>
120 </>,
121 { container: panel },
122 );
123 const viewport = screen.container.querySelector<HTMLElement>(
124 '[data-right-panel-tab-list] [data-slot="scroll-area-viewport"]',
125 )!;
126 expect(viewport.scrollWidth - viewport.clientWidth).toBeGreaterThan(120);
127 viewport.scrollLeft = 120;
128​
129 const momentum = Array.from({ length: 24 }, (_, index) => -60 * 0.85 ** index);
130 scroll(viewport.querySelector("[data-active-tab]")!, [-10, -20, -30, ...momentum]);
131​
132 expect(viewport.scrollLeft).toBe(0);
133 expect(navigateHistory).not.toHaveBeenCalled();
134​
135 viewport.scrollLeft = 0;
136 scroll(viewport.querySelector("[data-active-tab]")!, [-40, -40, -40, -40, -40]);
137​
138 expect(navigateHistory).toHaveBeenCalledExactlyOnceWith("back");
139 });
140});
141​
142const BROWSER_SURFACE: RightPanelSurface = {
143 id: "browser:tab-1",
144 kind: "preview",
145 resourceId: "tab-1",
146};
147const DIFF_SURFACE: RightPanelSurface = { id: "diff", kind: "diff" };
148​
149function panel(activeSurface: RightPanelSurface) {
150 const noop = () => {};
151 const props: RightPanelTabsProps = {
152 mode: "inline",
153 keybindings: [],
154 getShortcutContext: () => ({
155 terminalFocus: false,
156 terminalOpen: false,
157 previewFocus: false,
158 previewOpen: true,
159 isWeb: true,
160 isDesktop: false,
161 }),
162 surfaces: [BROWSER_SURFACE, DIFF_SURFACE],
163 environmentId: EnvironmentId.make("environment-local"),
164 activeSurfaceId: activeSurface.id,
165 pendingSurfaceIds: new Set(),
166 previewSessions: {},
167 desktopByTabId: {},
168 terminalLabelsById: new Map(),
169 onActivate: noop,
170 onCloseSurface: noop,
171 onCloseOtherSurfaces: noop,
172 onCloseSurfacesToRight: noop,
173 onCloseAllSurfaces: noop,
174 onCopyFilePath: noop,
175 onAddBrowser: noop,
176 onAddBrowserInProfile: noop,
177 onAddTerminal: noop,
178 onAddDiff: noop,
179 onAddFiles: noop,
180 onAddPullRequest: noop,
181 onAddPullRequests: noop,
182 onAddDevice: noop,
183 browserAvailable: true,
184 terminalAvailable: true,
185 diffAvailable: true,
186 filesAvailable: true,
187 pullRequestAvailable: true,
188 pullRequestsAvailable: true,
189 deviceAvailable: true,
190 children: (
191 <div data-testid="surface-content">
192 {/* Stands in for ServerBrowserSurface, whose page canvas handles thumb buttons itself. */}
193 <canvas data-testid="page-canvas" onMouseUp={(event) => event.preventDefault()} />
194 <InputGroup>
195 <InputGroupInput aria-label="Address" />
196 <InputGroupAddon data-testid="address-addon" align="inline-end" />
197 </InputGroup>
198 </div>
199 ),
200 };
201 return <RightPanelTabs {...props} />;
203​
204// Chromium focuses on an uncancelled mousedown and runs history back/forward on an
205// uncancelled button 3/4 mouseup.
206function releaseThumbButton(
207 target: Element,
208 button: 3 | 4,
209): { focusMoved: boolean; routerMoved: boolean } {
210 const focused = document.activeElement;
211 const down = new MouseEvent("mousedown", { bubbles: true, cancelable: true, button });
212 const up = new MouseEvent("mouseup", { bubbles: true, cancelable: true, button });
213 target.dispatchEvent(down);
214 target.dispatchEvent(up);
215 return {
216 focusMoved: !down.defaultPrevented || document.activeElement !== focused,
217 routerMoved: !up.defaultPrevented,
218 };
220​
221const STAYS_PUT = { focusMoved: false, routerMoved: false };
222​
223describe("right panel thumb buttons", () => {
224 it("keep the app router still over a surface that is not a browser", async () => {
225 await using screen = await render(panel(DIFF_SURFACE));
226 const tabStrip = screen.container.querySelector("[data-right-panel-tabbar]")!;
227 const content = screen.getByTestId("surface-content").element();
228​
229 expect(releaseThumbButton(tabStrip, 3)).toEqual(STAYS_PUT);
230 expect(releaseThumbButton(tabStrip, 4)).toEqual(STAYS_PUT);
231 expect(releaseThumbButton(content, 3)).toEqual(STAYS_PUT);
232 expect(releaseThumbButton(content, 4)).toEqual(STAYS_PUT);
233 });
234​
235 it("go back and forward in the active browser tab instead of the app router", async () => {
236 const actions: string[] = [];
237 using _subscription = { [Symbol.dispose]: subscribePreviewAction((a) => actions.push(a)) };
238 await using screen = await render(panel(BROWSER_SURFACE));
239 const tabStrip = screen.container.querySelector("[data-right-panel-tabbar]")!;
240 const content = screen.getByTestId("surface-content").element();
241​
242 expect(releaseThumbButton(tabStrip, 3)).toEqual(STAYS_PUT);
243 expect(releaseThumbButton(content, 4)).toEqual(STAYS_PUT);
244 // The address addon focuses its input on press; a thumb press must leave focus alone.
245 expect(releaseThumbButton(screen.getByTestId("address-addon").element(), 3)).toEqual(STAYS_PUT);
246 expect(actions).toEqual(["back", "forward", "back"]);
247​
248 // The page canvas already went back in the tab; the panel must not go back a second time.
249 releaseThumbButton(screen.getByTestId("page-canvas").element(), 3);
250 expect(actions).toEqual(["back", "forward", "back"]);
251 });
252});
253​
254const DEVICE_SURFACE: RightPanelSurface = {
255 id: "device:host:phone",
256 kind: "device",
257 target: { hostId: "host", deviceId: "phone", platform: "ios", name: "Phone" },
258};
259​
260describe("device tab rename", () => {
261 it("keeps the editor open on an Enter that confirms an IME candidate", async () => {
262 const onRenameDevice = vi.fn();
263 const props = panel(DEVICE_SURFACE).props as RightPanelTabsProps;
264 await using screen = await render(
265 <RightPanelTabs {...props} surfaces={[DEVICE_SURFACE]} onRenameDevice={onRenameDevice}>
266 {null}
267 </RightPanelTabs>,
268 );
269 await userEvent.dblClick(screen.getByRole("button", { name: "Phone", exact: true }));
270 const input = screen.getByRole("textbox", { name: "Device tab name" });
271 await userEvent.fill(input, "にほん");
272​
273 for (const shape of [
274 { isComposing: true, keyCode: 13 },
275 { isComposing: false, keyCode: 229 },
276 ]) {
277 input
278 .element()
279 .dispatchEvent(
280 new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, ...shape }),
281 );
282 }
283​
284 await expect.element(input).toHaveFocus();
285 expect(onRenameDevice).not.toHaveBeenCalled();
286 await userEvent.fill(input, "日本語");
287 await userEvent.keyboard("{Enter}");
288 expect(onRenameDevice).toHaveBeenCalledExactlyOnceWith(DEVICE_SURFACE.id, "日本語");
289 });
290});