14.3 KB375 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 {Client} from 'fb-watchman';
9import type {RepoInfo} from 'isl/src/types';
10import type {EdenFSNotifications} from '../edenFsNotifications';
11import type {ServerPlatform} from '../serverPlatform';
12import type {Watchman} from '../watchman';
13
14import fs from 'node:fs';
15import {TypedEventEmitter} from 'shared/TypedEventEmitter';
16import {mockLogger} from 'shared/testUtils';
17import {Internal} from '../Internal';
18import {PageFocusTracker} from '../PageFocusTracker';
19import {WatchForChanges} from '../WatchForChanges';
20import {makeServerSideTracker} from '../analytics/serverSideTracker';
21import {ONE_MINUTE_MS} from '../constants';
22
23const mockTracker = makeServerSideTracker(
24 mockLogger,
25 {platformName: 'test'} as ServerPlatform,
26 '0.1',
27 jest.fn(),
28);
29
30jest.mock('fb-watchman', () => {
31 // make a fake watchman object which returns () => undefined for every property
32 // so we don't need to manually mock every function watchman provides.
33 class FakeWatchman {
34 constructor() {
35 return new Proxy(this, {
36 get: () => () => undefined,
37 });
38 }
39 }
40 return {
41 Client: FakeWatchman,
42 };
43});
44
45describe('WatchForChanges - watchman', () => {
46 const mockInfo: RepoInfo = {
47 type: 'success',
48 command: 'sl',
49 repoRoot: '/testRepo',
50 dotdir: '/testRepo/.sl',
51 codeReviewSystem: {type: 'unknown'},
52 pullRequestDomain: undefined,
53 isEdenFs: false,
54 };
55
56 let focusTracker: PageFocusTracker;
57 const onChange = jest.fn();
58 let watch: WatchForChanges;
59
60 beforeEach(() => {
61 Internal.fetchFeatureFlag = jest.fn().mockImplementation((_ctx, _flag) => {
62 return Promise.resolve(false);
63 });
64
65 const ctx = {
66 cmd: 'sl',
67 cwd: '/path/to/cwd',
68 logger: mockLogger,
69 tracker: mockTracker,
70 };
71
72 jest.useFakeTimers();
73 onChange.mockClear();
74
75 jest.spyOn(fs.promises, 'realpath').mockImplementation((path, _opts) => {
76 return Promise.resolve(path as string);
77 });
78
79 focusTracker = new PageFocusTracker();
80 watch = new WatchForChanges(mockInfo, focusTracker, onChange, ctx);
81 // pretend watchman is not running for most tests
82 (watch.watchman.status as string) = 'errored';
83 // change is triggered on first subscription
84 expect(onChange).toHaveBeenCalledTimes(1);
85 onChange.mockClear();
86 });
87
88 afterEach(() => {
89 watch.dispose();
90 });
91
92 afterAll(() => {
93 jest.useRealTimers();
94 });
95
96 it('polls for changes on an interval', () => {
97 // |-----------1-----------2-----------3-----------4-----------5-----------6-----------7-----------8-----------9----------10----------11----------12----------13----------14----------15----------16 (minutes)
98 // ^ poll
99 expect(onChange).not.toHaveBeenCalled();
100 jest.advanceTimersByTime(15.5 * ONE_MINUTE_MS);
101 expect(onChange).toHaveBeenCalledTimes(1);
102 expect(onChange).toHaveBeenCalledWith('everything', undefined);
103 });
104
105 it('polls more often when the page is visible', () => {
106 // |-----------1-----------2-----------3-----------4---- (minutes)
107 // | ^ | ^ (poll)
108 // 0 poll 2 (times fetched)
109 focusTracker.setState('page0', 'visible');
110 onChange.mockClear(); // ignore immediate visibility change poll
111 expect(onChange).not.toHaveBeenCalled();
112 jest.advanceTimersByTime(1.0 * ONE_MINUTE_MS);
113 expect(onChange).not.toHaveBeenCalled();
114 jest.advanceTimersByTime(1.25 * ONE_MINUTE_MS);
115 expect(onChange).toHaveBeenCalledTimes(1);
116 });
117 it('polls more often when the page is focused', () => {
118 // |-----------1-----------2---- (minutes)
119 // | ^| ^| ^ ^ ^ ^ ^ ^ ^ (poll)
120 // 0 1 (times fetched)
121 focusTracker.setState('page0', 'focused');
122 onChange.mockClear(); // ignore immediate focus change poll
123 expect(onChange).not.toHaveBeenCalled();
124 jest.advanceTimersByTime(0.25 * ONE_MINUTE_MS);
125 expect(onChange).not.toHaveBeenCalled();
126 jest.advanceTimersByTime(0.25 * ONE_MINUTE_MS);
127 expect(onChange).toHaveBeenCalledTimes(1);
128 });
129
130 it('polls the moment visibility is gained', () => {
131 // |-----------1-----*-----2-----------3-----------4----------- (minutes)
132 // | || | ^ | ^ (poll)
133 // 0 |1 1 2 3 (times fetched)
134 // visible
135 // (resets interval at 2min)
136 jest.advanceTimersByTime(1.5 * ONE_MINUTE_MS);
137 expect(onChange).not.toHaveBeenCalled();
138 focusTracker.setState('page0', 'visible');
139 expect(onChange).toHaveBeenCalledTimes(1);
140 jest.advanceTimersByTime(0.75 * ONE_MINUTE_MS);
141 expect(onChange).toHaveBeenCalledTimes(1);
142 jest.advanceTimersByTime(1.25 * ONE_MINUTE_MS);
143 expect(onChange).toHaveBeenCalledTimes(2);
144 });
145
146 it('debounces additional polling when focus is gained', () => {
147 // |-----------1-----*--*--2-*---------3-----------4----------- (minutes)
148 // | || | || ^ | ^ (poll)
149 // 0 |1 | |1 2 3 (times fetched)
150 // visible^ | ^visible (debounce)
151 // hide
152 jest.advanceTimersByTime(1.5 * ONE_MINUTE_MS);
153 expect(onChange).not.toHaveBeenCalled();
154 focusTracker.setState('page0', 'visible');
155 expect(onChange).toHaveBeenCalledTimes(1);
156 jest.advanceTimersByTime(0.1 * ONE_MINUTE_MS);
157 focusTracker.setState('page0', 'hidden');
158 jest.advanceTimersByTime(0.15 * ONE_MINUTE_MS); // 15 seconds (0.25 min) throttle for focus
159 focusTracker.setState('page0', 'visible'); // debounced to not fetch again
160 expect(onChange).toHaveBeenCalledTimes(1);
161 jest.advanceTimersByTime(0.5 * ONE_MINUTE_MS);
162 expect(onChange).toHaveBeenCalledTimes(1);
163 jest.advanceTimersByTime(1.25 * ONE_MINUTE_MS);
164 expect(onChange).toHaveBeenCalledTimes(2);
165 });
166
167 it('polls at higher frequency if any page is focused', () => {
168 // |-----------1-----*-*---2-----------3-- (minutes)
169 // | ||| ^ ^ ^ ^ ^ (poll)
170 // 0 |1| 2 3 (times fetched)
171 // hidden | |hidden
172 // focused^ |hidden
173 // hidden ^focused
174 jest.advanceTimersByTime(1.5 * ONE_MINUTE_MS);
175 expect(onChange).not.toHaveBeenCalled();
176 focusTracker.setState('page0', 'hidden');
177 focusTracker.setState('page1', 'hidden');
178 focusTracker.setState('page2', 'hidden');
179 focusTracker.setState('page1', 'focused');
180 expect(onChange).toHaveBeenCalledTimes(1);
181 jest.advanceTimersByTime(0.5 * ONE_MINUTE_MS);
182 expect(onChange).toHaveBeenCalledTimes(2);
183 focusTracker.setState('page0', 'focused'); // since 1 is still focused, this does not immediately poll
184 focusTracker.setState('page1', 'hidden');
185 expect(onChange).toHaveBeenCalledTimes(2);
186 jest.advanceTimersByTime(0.5 * ONE_MINUTE_MS);
187 expect(onChange).toHaveBeenCalledTimes(3);
188 });
189
190 it('clears out previous intervals', () => {
191 // |-----------1-----*-----2-----*-----3--- ... --7-----------8-- (minutes)
192 // | | ^ ^ ^ (poll)
193 // 0 1 2 3 4 (times fetched)
194 // focused^ ^hidden
195 jest.advanceTimersByTime(1.5 * ONE_MINUTE_MS);
196 expect(onChange).not.toHaveBeenCalled();
197 focusTracker.setState('page0', 'focused');
198 expect(onChange).toHaveBeenCalledTimes(1);
199 jest.advanceTimersByTime(1 * ONE_MINUTE_MS);
200 expect(onChange).toHaveBeenCalledTimes(3);
201 focusTracker.setState('page0', 'hidden');
202 // fast focused interval is removed, and we revert to 15 min interval
203 jest.advanceTimersByTime(15 * ONE_MINUTE_MS);
204 expect(onChange).toHaveBeenCalledTimes(4);
205 });
206
207 it('polls less when watchman appears healthy', () => {
208 // |*----------1-----------2-----------3-----------4-----------5-----------6-----------7-----------8-----------9----------10----------11----------12----------13----------14----------15----------16 (minutes)
209 // | ^ poll
210 // focused
211
212 (watch.watchman.status as string) = 'healthy';
213 focusTracker.setState('page0', 'focused');
214 expect(onChange).toHaveBeenCalledTimes(0);
215 jest.advanceTimersByTime(15.5 * ONE_MINUTE_MS);
216 expect(onChange).toHaveBeenCalledTimes(1);
217 });
218
219 it('results from watchman reset polling timers', async () => {
220 // |-----------1-----------2----- ... ----15----------16 (minutes)
221 // | ^ (poll)
222 // watchman result
223
224 const ctx = {
225 cmd: 'sl',
226 cwd: '/path/to/cwd',
227 logger: mockLogger,
228 tracker: mockTracker,
229 };
230 watch.dispose(); // don't use pre-existing WatchForChanges
231 const emitter1 = new TypedEventEmitter();
232 const emitter2 = new TypedEventEmitter();
233 const mockWatchman: Watchman = {
234 client: {} as unknown as Client,
235 status: 'initializing',
236 watchDirectoryRecursive: jest
237 .fn()
238 .mockImplementationOnce(() => {
239 return Promise.resolve({emitter: emitter1});
240 })
241 .mockImplementationOnce(() => {
242 return Promise.resolve({emitter: emitter2});
243 }),
244 unwatch: jest.fn(),
245 } as unknown as Watchman;
246
247 watch = new WatchForChanges(mockInfo, focusTracker, onChange, ctx, mockWatchman);
248 await watch.waitForDirstateSubscriptionReady();
249 await watch.setupSubscriptions(ctx);
250 expect(onChange).toHaveBeenCalledTimes(1);
251 onChange.mockClear();
252
253 // wait an actual async tick so mock subscriptions are set up
254 const setImmediate = jest.requireActual('timers').setImmediate;
255 await new Promise(res => setImmediate(() => res(undefined)));
256
257 jest.advanceTimersByTime(1.5 * ONE_MINUTE_MS);
258 expect(onChange).toHaveBeenCalledTimes(0);
259 emitter2.emit('change', undefined);
260 expect(onChange).toHaveBeenCalledTimes(1);
261 expect(onChange).toHaveBeenCalledWith('uncommitted changes');
262 jest.advanceTimersByTime(14.0 * ONE_MINUTE_MS); // original timer didn't cause a poll
263 expect(onChange).toHaveBeenCalledTimes(1);
264 jest.advanceTimersByTime(2.0 * ONE_MINUTE_MS); // 15 minutes after watchman change, a new poll occurred
265 expect(onChange).toHaveBeenCalledTimes(2);
266 expect(onChange).toHaveBeenCalledWith('everything', undefined);
267 });
268});
269
270describe('WatchForChanges - edenfs', () => {
271 const mockInfo: RepoInfo = {
272 type: 'success',
273 command: 'sl',
274 repoRoot: '/testRepo',
275 dotdir: '/testRepo/.sl',
276 codeReviewSystem: {type: 'unknown'},
277 pullRequestDomain: undefined,
278 isEdenFs: true,
279 };
280
281 let focusTracker: PageFocusTracker;
282 const onChange = jest.fn();
283 let watch: WatchForChanges;
284 let emitter1: TypedEventEmitter<string, unknown>;
285
286 beforeEach(async () => {
287 Internal.fetchFeatureFlag = jest.fn().mockImplementation((_ctx, _flag) => {
288 return Promise.resolve(true);
289 });
290 const ctx = {
291 cmd: 'sl',
292 cwd: '/path/to/cwd',
293 logger: mockLogger,
294 tracker: mockTracker,
295 };
296
297 jest.useFakeTimers();
298 onChange.mockClear();
299
300 jest.spyOn(fs.promises, 'realpath').mockImplementation((path, _opts) => {
301 return Promise.resolve(path as string);
302 });
303
304 emitter1 = new TypedEventEmitter();
305 const mockEdenFS: EdenFSNotifications = {
306 watchDirectoryRecursive: jest
307 .fn()
308 .mockImplementation(
309 (_localDirectoryPath, _rawSubscriptionName, _subscriptionOptions, callback) => {
310 emitter1.on('change', change => {
311 callback(null, change);
312 });
313 emitter1.on('error', error => {
314 callback(error, null);
315 });
316 emitter1.on('close', () => {
317 callback(null, null);
318 });
319 return Promise.resolve(emitter1);
320 },
321 ),
322 unwatch: jest.fn(),
323 } as unknown as EdenFSNotifications;
324
325 focusTracker = new PageFocusTracker();
326 watch = new WatchForChanges(mockInfo, focusTracker, onChange, ctx, undefined, mockEdenFS);
327 await watch.waitForDirstateSubscriptionReady();
328
329 // change is triggered on first subscription
330 expect(onChange).toHaveBeenCalledTimes(1);
331 onChange.mockClear();
332 });
333
334 afterEach(() => {
335 watch.dispose();
336 });
337
338 afterAll(() => {
339 jest.useRealTimers();
340 });
341
342 it('Handles results from edenfs', async () => {
343 // |-----------1-----------2-----------3-----------4-----------5-----------6-----------7-----------8-----------9----------10----------11----------12----------13----------14----------15----------16 (minutes)
344 // ^ poll
345 const ctx = {
346 cmd: 'sl',
347 cwd: '/path/to/cwd',
348 logger: mockLogger,
349 tracker: mockTracker,
350 };
351
352 focusTracker.setState('page', 'visible');
353 await watch.setupSubscriptions(ctx);
354
355 // wait an actual async tick so mock subscriptions are set up
356 const setImmediate = jest.requireActual('timers').setImmediate;
357 await new Promise(res => setImmediate(() => res(undefined)));
358
359 jest.advanceTimersByTime(1.5 * ONE_MINUTE_MS);
360 expect(onChange).toHaveBeenCalledTimes(0);
361 emitter1.emit('change', {changes: [1]});
362 expect(onChange).toHaveBeenCalledTimes(1);
363 jest.advanceTimersByTime(13.0 * ONE_MINUTE_MS); // original timer didn't cause a poll
364 expect(onChange).toHaveBeenCalledTimes(1);
365 jest.advanceTimersByTime(3.0 * ONE_MINUTE_MS); // 15 minutes after watchman change, a new poll occurred
366 expect(onChange).toHaveBeenCalledTimes(2);
367 expect(onChange).toHaveBeenCalledWith('everything', undefined);
368
369 focusTracker.setState('page', 'hidden');
370 jest.advanceTimersByTime(180 * ONE_MINUTE_MS);
371 expect(onChange).toHaveBeenCalledTimes(3);
372 expect(onChange).toHaveBeenCalledWith('everything', undefined);
373 });
374});
375