12.2 KB424 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {Writable} from 'shared/typeUtils';
9import type {TestingEventBus} from './TestingMessageBus';
10import type {
11 ClientToServerMessage,
12 CommitInfo,
13 Hash,
14 Result,
15 ServerToClientMessage,
16 SmartlogCommits,
17 UncommittedChanges,
18} from './types';
19
20import {act, screen, waitFor, within} from '@testing-library/react';
21import {nextTick} from 'shared/testUtils';
22import platform from './platform';
23import {deserializeFromString, serializeToString} from './serialize';
24import {mostRecentSubscriptionIds} from './serverAPIState';
25
26const testMessageBus = platform.messageBus as TestingEventBus;
27
28export function simulateMessageFromServer(message: ServerToClientMessage): void {
29 testMessageBus.simulateMessage(serializeToString(message));
30}
31
32/** Filter out binary messages, and filter by wanted type. */
33function filterMessages<K extends string>(wantedType?: K) {
34 let messages = testMessageBus.sent
35 .filter((msg: unknown): msg is string => !(msg instanceof ArrayBuffer))
36 .map(deserializeFromString) as Array<ClientToServerMessage>;
37 if (wantedType != null) {
38 messages = messages.filter(msg => msg.type == null || msg.type === wantedType);
39 }
40 return messages;
41}
42
43export function expectMessageSentToServer(message: Partial<ClientToServerMessage>): void {
44 expect(filterMessages(message.type)).toContainEqual(message);
45}
46
47export function getLastMessageOfTypeSentToServer<K extends string>(
48 wantedType: K,
49): (ClientToServerMessage & {type: K}) | undefined {
50 const values = filterMessages(wantedType) as Array<ClientToServerMessage & {type: K}>;
51 return values[values.length - 1];
52}
53
54export function expectMessageNOTSentToServer(message: Partial<ClientToServerMessage>): void {
55 expect(filterMessages(message.type)).not.toContainEqual(message);
56}
57
58/**
59 * Return last `num` stringified JSON messages sent to the server.
60 */
61export function getLastMessagesSentToServer(num: number): Array<string> {
62 return testMessageBus.sent.slice(-num);
63}
64
65export function simulateServerDisconnected(): void {
66 testMessageBus.simulateServerStatusChange({type: 'error', error: 'server disconnected'});
67}
68
69export function simulateCommits(commits: Result<SmartlogCommits>) {
70 simulateMessageFromServer({
71 type: 'subscriptionResult',
72 kind: 'smartlogCommits',
73 subscriptionID: mostRecentSubscriptionIds.smartlogCommits,
74 data: {
75 fetchStartTimestamp: 1,
76 fetchCompletedTimestamp: 2,
77 commits,
78 },
79 });
80}
81export function simulateUncommittedChangedFiles(files: Result<UncommittedChanges>) {
82 simulateMessageFromServer({
83 type: 'subscriptionResult',
84 kind: 'uncommittedChanges',
85 subscriptionID: mostRecentSubscriptionIds.uncommittedChanges,
86 data: {
87 fetchStartTimestamp: 1,
88 fetchCompletedTimestamp: 2,
89 files,
90 },
91 });
92}
93export function simulateRepoConnected(repoRoot?: string, cwd?: string) {
94 const root = repoRoot ?? '/path/to/repo';
95 simulateMessageFromServer({
96 type: 'repoInfo',
97 info: {
98 type: 'success',
99 repoRoot: root,
100 dotdir: `${root}/.sl`,
101 command: 'sl',
102 pullRequestDomain: undefined,
103 codeReviewSystem: {type: 'github', owner: 'owner', repo: 'repo', hostname: 'github.com'},
104 isEdenFs: false,
105 },
106 cwd,
107 });
108 testMessageBus.simulateServerStatusChange({type: 'open'});
109}
110
111export function resetTestMessages() {
112 act(() => {
113 testMessageBus.resetTestMessages();
114 });
115}
116
117export function commitInfoIsOpen(): boolean {
118 return (
119 screen.queryByTestId('commit-info-view') != null ||
120 screen.queryByTestId('commit-info-view-loading') != null
121 );
122}
123
124export function closeCommitInfoSidebar() {
125 if (!commitInfoIsOpen()) {
126 return;
127 }
128 screen.queryAllByTestId('drawer-label').forEach(el => {
129 const commitInfoTab = within(el).queryByText('Commit Info');
130 commitInfoTab?.click();
131 });
132}
133
134export function openCommitInfoSidebar() {
135 if (commitInfoIsOpen()) {
136 return;
137 }
138 screen.queryAllByTestId('drawer-label').forEach(el => {
139 const commitInfoTab = within(el).queryByText('Commit Info');
140 commitInfoTab?.click();
141 });
142}
143
144export const emptyCommit: CommitInfo = {
145 title: 'title',
146 hash: '0',
147 parents: [],
148 grandparents: [],
149 phase: 'draft',
150 isDot: false,
151 author: 'author',
152 date: new Date(),
153 description: '',
154 bookmarks: [],
155 remoteBookmarks: [],
156 filePathsSample: [],
157 totalFileCount: 0,
158 maxCommonPathPrefix: '',
159};
160
161export function COMMIT(
162 hash: string,
163 title: string,
164 parent: Hash,
165 info?: Partial<CommitInfo>,
166): CommitInfo {
167 return {
168 ...emptyCommit,
169 ...info,
170 hash,
171 title,
172 parents: [parent],
173 };
174}
175
176/**
177 ```
178 | z - Commit Z
179 | |
180 | y - Commit Y
181 | |
182 | x - Commit X
183 |/
184 2 - another public branch (remote/master)
185 |
186 | e - Commit E (You are here)
187 | |
188 | d - Commit D
189 | |
190 | c - Commit C
191 | |
192 | b - Commit B
193 | |
194 | a - Commit A
195 |/
196 1 - some public base
197 ```
198*/
199export const TEST_COMMIT_HISTORY = [
200 COMMIT('z', 'Commit Z', 'y'),
201 COMMIT('y', 'Commit Y', 'x'),
202 COMMIT('x', 'Commit X', '2'),
203 COMMIT('2', 'another public branch', '0', {phase: 'public', remoteBookmarks: ['remote/master']}),
204 COMMIT('e', 'Commit E', 'd', {isDot: true}),
205 COMMIT('d', 'Commit D', 'c'),
206 COMMIT('c', 'Commit C', 'b'),
207 COMMIT('b', 'Commit B', 'a'),
208 COMMIT('a', 'Commit A', '1'),
209 COMMIT('1', 'some public base', '0', {phase: 'public'}),
210];
211
212export const fireMouseEvent = function (
213 type: string,
214 elem: EventTarget,
215 centerX: number,
216 centerY: number,
217 additionalProperties?: Partial<MouseEvent | InputEvent>,
218) {
219 const evt = document.createEvent('MouseEvents') as Writable<MouseEvent & InputEvent>;
220 evt.initMouseEvent(
221 type,
222 true,
223 true,
224 window,
225 1,
226 1,
227 1,
228 centerX,
229 centerY,
230 false,
231 false,
232 false,
233 false,
234 0,
235 elem,
236 );
237 evt.dataTransfer = {} as DataTransfer;
238 if (additionalProperties != null) {
239 for (const [key, value] of Object.entries(additionalProperties)) {
240 (evt as Record<string, unknown>)[key] = value;
241 }
242 }
243 return elem.dispatchEvent(evt);
244};
245
246export const drag = (elemDrag: HTMLElement, elemDrop: HTMLElement) => {
247 act(() => {
248 // calculate positions
249 let pos = elemDrag.getBoundingClientRect();
250 const center1X = Math.floor((pos.left + pos.right) / 2);
251 const center1Y = Math.floor((pos.top + pos.bottom) / 2);
252
253 pos = elemDrop.getBoundingClientRect();
254 const center2X = Math.floor((pos.left + pos.right) / 2);
255 const center2Y = Math.floor((pos.top + pos.bottom) / 2);
256
257 // mouse over dragged element and mousedown
258 fireMouseEvent('mousemove', elemDrag, center1X, center1Y);
259 fireMouseEvent('mouseenter', elemDrag, center1X, center1Y);
260 fireMouseEvent('mouseover', elemDrag, center1X, center1Y);
261 fireMouseEvent('mousedown', elemDrag, center1X, center1Y);
262
263 if (!elemDrag.draggable) {
264 return;
265 }
266
267 // start dragging process over to drop target
268 const dragStarted = fireMouseEvent('dragstart', elemDrag, center1X, center1Y);
269 if (!dragStarted) {
270 return;
271 }
272
273 fireMouseEvent('drag', elemDrag, center1X, center1Y);
274 fireMouseEvent('mousemove', elemDrag, center1X, center1Y);
275 fireMouseEvent('drag', elemDrag, center2X, center2Y);
276 fireMouseEvent('mousemove', elemDrop, center2X, center2Y);
277
278 // trigger dragging process on top of drop target
279 fireMouseEvent('mouseenter', elemDrop, center2X, center2Y);
280 fireMouseEvent('dragenter', elemDrop, center2X, center2Y);
281 fireMouseEvent('mouseover', elemDrop, center2X, center2Y);
282 fireMouseEvent('dragover', elemDrop, center2X, center2Y);
283 });
284};
285export const drop = (elemDrag: HTMLElement, elemDrop: HTMLElement) => {
286 act(() => {
287 // calculate positions
288 let pos = elemDrag.getBoundingClientRect();
289 pos = elemDrop.getBoundingClientRect();
290 const center2X = Math.floor((pos.left + pos.right) / 2);
291 const center2Y = Math.floor((pos.top + pos.bottom) / 2);
292
293 // release dragged element on top of drop target
294 fireMouseEvent('drop', elemDrop, center2X, center2Y);
295 fireMouseEvent('dragend', elemDrag, center2X, center2Y);
296 fireMouseEvent('mouseup', elemDrag, center2X, center2Y);
297 });
298};
299
300// See https://github.com/testing-library/user-event/issues/440
301export const dragAndDrop = (elemDrag: HTMLElement, elemDrop: HTMLElement) => {
302 drag(elemDrag, elemDrop);
303 drop(elemDrag, elemDrop);
304};
305
306export function dragAndDropCommits(
307 draggedCommit: Hash | HTMLElement,
308 onto: Hash,
309 op: typeof dragAndDrop | typeof drag | typeof drop = dragAndDrop,
310) {
311 const draggableCommit =
312 typeof draggedCommit !== 'string'
313 ? draggedCommit
314 : within(screen.getByTestId(`commit-${draggedCommit}`)).queryByTestId('draggable-commit');
315 expect(draggableCommit).toBeDefined();
316 const dragTargetCommit = screen.queryByTestId(`commit-${onto}`)?.querySelector('.commit-details');
317 expect(dragTargetCommit).toBeDefined();
318
319 act(() => {
320 op(draggableCommit as HTMLElement, dragTargetCommit as HTMLElement);
321 jest.advanceTimersByTime(2);
322 });
323}
324
325export function dragCommits(draggedCommit: Hash | HTMLElement, onto: Hash) {
326 dragAndDropCommits(draggedCommit, onto, drag);
327}
328
329export function dropCommits(draggedCommit: Hash | HTMLElement, onto: Hash) {
330 dragAndDropCommits(draggedCommit, onto, drop);
331}
332
333/** Check that YouAreHere points to the given commit. */
334export function expectYouAreHerePointAt(hash: string) {
335 // The previous row of "hash" should be "YouAreHere".
336 // YouAreHere
337 // /
338 // hash
339 const row = screen.getByTestId(`dag-row-group-${hash}`);
340 const previousRow = row.previousElementSibling;
341 expect(previousRow).toHaveTextContent('You are here');
342}
343
344/**
345 * Despite catching the error in our error boundary, react + jsdom still
346 * print big scary messages to console.warn when we throw an error.
347 * We can ignore these during the select tests that test error boundaries.
348 * This should be done only when needed, to prevent filtering out useful
349 * console.error statements.
350 * See also: https://github.com/facebook/react/issues/11098#issuecomment-412682721
351 */
352export function suppressReactErrorBoundaryErrorMessages() {
353 beforeAll(() => {
354 jest.spyOn(console, 'error').mockImplementation(() => undefined);
355 });
356 afterAll(() => {
357 jest.restoreAllMocks();
358 });
359}
360
361/**
362 * Print test name beforeEach. This can be useful to figure out which test prints
363 * React or testing-library warnings, if the stack trace does not include the test
364 * code.
365 */
366export function beforeEachPrintTestName() {
367 beforeEach(() => {
368 // eslint-disable-next-line no-console
369 console.log(`Starting test: ${expect.getState().currentTestName}`);
370 });
371}
372
373/**
374 * Drop-in replacement of `waitFor` that includes a `nextTick` to workaround
375 * some `act()` warnings.
376 */
377export function waitForWithTick<T>(callback: () => Promise<T> | T): Promise<T> {
378 return waitFor(async () => {
379 await nextTick();
380 return callback();
381 });
382}
383
384/**
385 * Check that the "commit" is in the forked branch from "base".
386 *
387 * o <- does not return commit hashes here (not right-side)
388 * :
389 * | ┌─────┐
390 * | │ o │
391 * | │ : │
392 * | │ o │ <- return commit hashes here (right-side branch)
393 * | │ | │
394 * | │ o │
395 * | │ / │
396 * | └─────┘
397 * | /
398 * base
399 *
400 * This is a naive implementation that does not consider merges.
401 */
402export function scanForkedBranchHashes(base: string): string[] {
403 const baseRow = screen.getByTestId(`dag-row-group-${base}`);
404 const getAttr = (e: Element, attr: string) => e.querySelector(`[${attr}]`)?.getAttribute(attr);
405 const getNodeColumn = (row: Element) => parseInt(getAttr(row, 'data-nodecolumn') ?? '-1');
406 const baseIndent = getNodeColumn(baseRow);
407 // Scan rows above baseRow.
408 let prevRow = baseRow.previousElementSibling;
409 const branchHashes = [];
410 while (prevRow) {
411 const prevIndent = getNodeColumn(prevRow);
412 if (prevIndent <= baseIndent) {
413 // No longer right-side branch from 'base'.
414 break;
415 }
416 const hash = getAttr(prevRow, 'data-commit-hash');
417 if (hash) {
418 branchHashes.push(hash);
419 }
420 prevRow = prevRow.previousElementSibling;
421 }
422 return branchHashes;
423}
424