Skip to content
Source
Sign inSign up

RightPanelTabs.browser.test.tsx

238 lines8.9 KB

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