addons/isl-server/src/__tests__/WatchForChanges.tsblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {Client} from 'fb-watchman';
b69ab319import type {RepoInfo} from 'isl/src/types';
b69ab3110import type {EdenFSNotifications} from '../edenFsNotifications';
b69ab3111import type {ServerPlatform} from '../serverPlatform';
b69ab3112import type {Watchman} from '../watchman';
b69ab3113
b69ab3114import fs from 'node:fs';
b69ab3115import {TypedEventEmitter} from 'shared/TypedEventEmitter';
b69ab3116import {mockLogger} from 'shared/testUtils';
b69ab3117import {Internal} from '../Internal';
b69ab3118import {PageFocusTracker} from '../PageFocusTracker';
b69ab3119import {WatchForChanges} from '../WatchForChanges';
b69ab3120import {makeServerSideTracker} from '../analytics/serverSideTracker';
b69ab3121import {ONE_MINUTE_MS} from '../constants';
b69ab3122
b69ab3123const mockTracker = makeServerSideTracker(
b69ab3124 mockLogger,
b69ab3125 {platformName: 'test'} as ServerPlatform,
b69ab3126 '0.1',
b69ab3127 jest.fn(),
b69ab3128);
b69ab3129
b69ab3130jest.mock('fb-watchman', () => {
b69ab3131 // make a fake watchman object which returns () => undefined for every property
b69ab3132 // so we don't need to manually mock every function watchman provides.
b69ab3133 class FakeWatchman {
b69ab3134 constructor() {
b69ab3135 return new Proxy(this, {
b69ab3136 get: () => () => undefined,
b69ab3137 });
b69ab3138 }
b69ab3139 }
b69ab3140 return {
b69ab3141 Client: FakeWatchman,
b69ab3142 };
b69ab3143});
b69ab3144
b69ab3145describe('WatchForChanges - watchman', () => {
b69ab3146 const mockInfo: RepoInfo = {
b69ab3147 type: 'success',
b69ab3148 command: 'sl',
b69ab3149 repoRoot: '/testRepo',
b69ab3150 dotdir: '/testRepo/.sl',
b69ab3151 codeReviewSystem: {type: 'unknown'},
b69ab3152 pullRequestDomain: undefined,
b69ab3153 isEdenFs: false,
b69ab3154 };
b69ab3155
b69ab3156 let focusTracker: PageFocusTracker;
b69ab3157 const onChange = jest.fn();
b69ab3158 let watch: WatchForChanges;
b69ab3159
b69ab3160 beforeEach(() => {
b69ab3161 Internal.fetchFeatureFlag = jest.fn().mockImplementation((_ctx, _flag) => {
b69ab3162 return Promise.resolve(false);
b69ab3163 });
b69ab3164
b69ab3165 const ctx = {
b69ab3166 cmd: 'sl',
b69ab3167 cwd: '/path/to/cwd',
b69ab3168 logger: mockLogger,
b69ab3169 tracker: mockTracker,
b69ab3170 };
b69ab3171
b69ab3172 jest.useFakeTimers();
b69ab3173 onChange.mockClear();
b69ab3174
b69ab3175 jest.spyOn(fs.promises, 'realpath').mockImplementation((path, _opts) => {
b69ab3176 return Promise.resolve(path as string);
b69ab3177 });
b69ab3178
b69ab3179 focusTracker = new PageFocusTracker();
b69ab3180 watch = new WatchForChanges(mockInfo, focusTracker, onChange, ctx);
b69ab3181 // pretend watchman is not running for most tests
b69ab3182 (watch.watchman.status as string) = 'errored';
b69ab3183 // change is triggered on first subscription
b69ab3184 expect(onChange).toHaveBeenCalledTimes(1);
b69ab3185 onChange.mockClear();
b69ab3186 });
b69ab3187
b69ab3188 afterEach(() => {
b69ab3189 watch.dispose();
b69ab3190 });
b69ab3191
b69ab3192 afterAll(() => {
b69ab3193 jest.useRealTimers();
b69ab3194 });
b69ab3195
b69ab3196 it('polls for changes on an interval', () => {
b69ab3197 // |-----------1-----------2-----------3-----------4-----------5-----------6-----------7-----------8-----------9----------10----------11----------12----------13----------14----------15----------16 (minutes)
b69ab3198 // ^ poll
b69ab3199 expect(onChange).not.toHaveBeenCalled();
b69ab31100 jest.advanceTimersByTime(15.5 * ONE_MINUTE_MS);
b69ab31101 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31102 expect(onChange).toHaveBeenCalledWith('everything', undefined);
b69ab31103 });
b69ab31104
b69ab31105 it('polls more often when the page is visible', () => {
b69ab31106 // |-----------1-----------2-----------3-----------4---- (minutes)
b69ab31107 // | ^ | ^ (poll)
b69ab31108 // 0 poll 2 (times fetched)
b69ab31109 focusTracker.setState('page0', 'visible');
b69ab31110 onChange.mockClear(); // ignore immediate visibility change poll
b69ab31111 expect(onChange).not.toHaveBeenCalled();
b69ab31112 jest.advanceTimersByTime(1.0 * ONE_MINUTE_MS);
b69ab31113 expect(onChange).not.toHaveBeenCalled();
b69ab31114 jest.advanceTimersByTime(1.25 * ONE_MINUTE_MS);
b69ab31115 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31116 });
b69ab31117 it('polls more often when the page is focused', () => {
b69ab31118 // |-----------1-----------2---- (minutes)
b69ab31119 // | ^| ^| ^ ^ ^ ^ ^ ^ ^ (poll)
b69ab31120 // 0 1 (times fetched)
b69ab31121 focusTracker.setState('page0', 'focused');
b69ab31122 onChange.mockClear(); // ignore immediate focus change poll
b69ab31123 expect(onChange).not.toHaveBeenCalled();
b69ab31124 jest.advanceTimersByTime(0.25 * ONE_MINUTE_MS);
b69ab31125 expect(onChange).not.toHaveBeenCalled();
b69ab31126 jest.advanceTimersByTime(0.25 * ONE_MINUTE_MS);
b69ab31127 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31128 });
b69ab31129
b69ab31130 it('polls the moment visibility is gained', () => {
b69ab31131 // |-----------1-----*-----2-----------3-----------4----------- (minutes)
b69ab31132 // | || | ^ | ^ (poll)
b69ab31133 // 0 |1 1 2 3 (times fetched)
b69ab31134 // visible
b69ab31135 // (resets interval at 2min)
b69ab31136 jest.advanceTimersByTime(1.5 * ONE_MINUTE_MS);
b69ab31137 expect(onChange).not.toHaveBeenCalled();
b69ab31138 focusTracker.setState('page0', 'visible');
b69ab31139 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31140 jest.advanceTimersByTime(0.75 * ONE_MINUTE_MS);
b69ab31141 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31142 jest.advanceTimersByTime(1.25 * ONE_MINUTE_MS);
b69ab31143 expect(onChange).toHaveBeenCalledTimes(2);
b69ab31144 });
b69ab31145
b69ab31146 it('debounces additional polling when focus is gained', () => {
b69ab31147 // |-----------1-----*--*--2-*---------3-----------4----------- (minutes)
b69ab31148 // | || | || ^ | ^ (poll)
b69ab31149 // 0 |1 | |1 2 3 (times fetched)
b69ab31150 // visible^ | ^visible (debounce)
b69ab31151 // hide
b69ab31152 jest.advanceTimersByTime(1.5 * ONE_MINUTE_MS);
b69ab31153 expect(onChange).not.toHaveBeenCalled();
b69ab31154 focusTracker.setState('page0', 'visible');
b69ab31155 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31156 jest.advanceTimersByTime(0.1 * ONE_MINUTE_MS);
b69ab31157 focusTracker.setState('page0', 'hidden');
b69ab31158 jest.advanceTimersByTime(0.15 * ONE_MINUTE_MS); // 15 seconds (0.25 min) throttle for focus
b69ab31159 focusTracker.setState('page0', 'visible'); // debounced to not fetch again
b69ab31160 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31161 jest.advanceTimersByTime(0.5 * ONE_MINUTE_MS);
b69ab31162 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31163 jest.advanceTimersByTime(1.25 * ONE_MINUTE_MS);
b69ab31164 expect(onChange).toHaveBeenCalledTimes(2);
b69ab31165 });
b69ab31166
b69ab31167 it('polls at higher frequency if any page is focused', () => {
b69ab31168 // |-----------1-----*-*---2-----------3-- (minutes)
b69ab31169 // | ||| ^ ^ ^ ^ ^ (poll)
b69ab31170 // 0 |1| 2 3 (times fetched)
b69ab31171 // hidden | |hidden
b69ab31172 // focused^ |hidden
b69ab31173 // hidden ^focused
b69ab31174 jest.advanceTimersByTime(1.5 * ONE_MINUTE_MS);
b69ab31175 expect(onChange).not.toHaveBeenCalled();
b69ab31176 focusTracker.setState('page0', 'hidden');
b69ab31177 focusTracker.setState('page1', 'hidden');
b69ab31178 focusTracker.setState('page2', 'hidden');
b69ab31179 focusTracker.setState('page1', 'focused');
b69ab31180 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31181 jest.advanceTimersByTime(0.5 * ONE_MINUTE_MS);
b69ab31182 expect(onChange).toHaveBeenCalledTimes(2);
b69ab31183 focusTracker.setState('page0', 'focused'); // since 1 is still focused, this does not immediately poll
b69ab31184 focusTracker.setState('page1', 'hidden');
b69ab31185 expect(onChange).toHaveBeenCalledTimes(2);
b69ab31186 jest.advanceTimersByTime(0.5 * ONE_MINUTE_MS);
b69ab31187 expect(onChange).toHaveBeenCalledTimes(3);
b69ab31188 });
b69ab31189
b69ab31190 it('clears out previous intervals', () => {
b69ab31191 // |-----------1-----*-----2-----*-----3--- ... --7-----------8-- (minutes)
b69ab31192 // | | ^ ^ ^ (poll)
b69ab31193 // 0 1 2 3 4 (times fetched)
b69ab31194 // focused^ ^hidden
b69ab31195 jest.advanceTimersByTime(1.5 * ONE_MINUTE_MS);
b69ab31196 expect(onChange).not.toHaveBeenCalled();
b69ab31197 focusTracker.setState('page0', 'focused');
b69ab31198 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31199 jest.advanceTimersByTime(1 * ONE_MINUTE_MS);
b69ab31200 expect(onChange).toHaveBeenCalledTimes(3);
b69ab31201 focusTracker.setState('page0', 'hidden');
b69ab31202 // fast focused interval is removed, and we revert to 15 min interval
b69ab31203 jest.advanceTimersByTime(15 * ONE_MINUTE_MS);
b69ab31204 expect(onChange).toHaveBeenCalledTimes(4);
b69ab31205 });
b69ab31206
b69ab31207 it('polls less when watchman appears healthy', () => {
b69ab31208 // |*----------1-----------2-----------3-----------4-----------5-----------6-----------7-----------8-----------9----------10----------11----------12----------13----------14----------15----------16 (minutes)
b69ab31209 // | ^ poll
b69ab31210 // focused
b69ab31211
b69ab31212 (watch.watchman.status as string) = 'healthy';
b69ab31213 focusTracker.setState('page0', 'focused');
b69ab31214 expect(onChange).toHaveBeenCalledTimes(0);
b69ab31215 jest.advanceTimersByTime(15.5 * ONE_MINUTE_MS);
b69ab31216 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31217 });
b69ab31218
b69ab31219 it('results from watchman reset polling timers', async () => {
b69ab31220 // |-----------1-----------2----- ... ----15----------16 (minutes)
b69ab31221 // | ^ (poll)
b69ab31222 // watchman result
b69ab31223
b69ab31224 const ctx = {
b69ab31225 cmd: 'sl',
b69ab31226 cwd: '/path/to/cwd',
b69ab31227 logger: mockLogger,
b69ab31228 tracker: mockTracker,
b69ab31229 };
b69ab31230 watch.dispose(); // don't use pre-existing WatchForChanges
b69ab31231 const emitter1 = new TypedEventEmitter();
b69ab31232 const emitter2 = new TypedEventEmitter();
b69ab31233 const mockWatchman: Watchman = {
b69ab31234 client: {} as unknown as Client,
b69ab31235 status: 'initializing',
b69ab31236 watchDirectoryRecursive: jest
b69ab31237 .fn()
b69ab31238 .mockImplementationOnce(() => {
b69ab31239 return Promise.resolve({emitter: emitter1});
b69ab31240 })
b69ab31241 .mockImplementationOnce(() => {
b69ab31242 return Promise.resolve({emitter: emitter2});
b69ab31243 }),
b69ab31244 unwatch: jest.fn(),
b69ab31245 } as unknown as Watchman;
b69ab31246
b69ab31247 watch = new WatchForChanges(mockInfo, focusTracker, onChange, ctx, mockWatchman);
b69ab31248 await watch.waitForDirstateSubscriptionReady();
b69ab31249 await watch.setupSubscriptions(ctx);
b69ab31250 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31251 onChange.mockClear();
b69ab31252
b69ab31253 // wait an actual async tick so mock subscriptions are set up
b69ab31254 const setImmediate = jest.requireActual('timers').setImmediate;
b69ab31255 await new Promise(res => setImmediate(() => res(undefined)));
b69ab31256
b69ab31257 jest.advanceTimersByTime(1.5 * ONE_MINUTE_MS);
b69ab31258 expect(onChange).toHaveBeenCalledTimes(0);
b69ab31259 emitter2.emit('change', undefined);
b69ab31260 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31261 expect(onChange).toHaveBeenCalledWith('uncommitted changes');
b69ab31262 jest.advanceTimersByTime(14.0 * ONE_MINUTE_MS); // original timer didn't cause a poll
b69ab31263 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31264 jest.advanceTimersByTime(2.0 * ONE_MINUTE_MS); // 15 minutes after watchman change, a new poll occurred
b69ab31265 expect(onChange).toHaveBeenCalledTimes(2);
b69ab31266 expect(onChange).toHaveBeenCalledWith('everything', undefined);
b69ab31267 });
b69ab31268});
b69ab31269
b69ab31270describe('WatchForChanges - edenfs', () => {
b69ab31271 const mockInfo: RepoInfo = {
b69ab31272 type: 'success',
b69ab31273 command: 'sl',
b69ab31274 repoRoot: '/testRepo',
b69ab31275 dotdir: '/testRepo/.sl',
b69ab31276 codeReviewSystem: {type: 'unknown'},
b69ab31277 pullRequestDomain: undefined,
b69ab31278 isEdenFs: true,
b69ab31279 };
b69ab31280
b69ab31281 let focusTracker: PageFocusTracker;
b69ab31282 const onChange = jest.fn();
b69ab31283 let watch: WatchForChanges;
b69ab31284 let emitter1: TypedEventEmitter<string, unknown>;
b69ab31285
b69ab31286 beforeEach(async () => {
b69ab31287 Internal.fetchFeatureFlag = jest.fn().mockImplementation((_ctx, _flag) => {
b69ab31288 return Promise.resolve(true);
b69ab31289 });
b69ab31290 const ctx = {
b69ab31291 cmd: 'sl',
b69ab31292 cwd: '/path/to/cwd',
b69ab31293 logger: mockLogger,
b69ab31294 tracker: mockTracker,
b69ab31295 };
b69ab31296
b69ab31297 jest.useFakeTimers();
b69ab31298 onChange.mockClear();
b69ab31299
b69ab31300 jest.spyOn(fs.promises, 'realpath').mockImplementation((path, _opts) => {
b69ab31301 return Promise.resolve(path as string);
b69ab31302 });
b69ab31303
b69ab31304 emitter1 = new TypedEventEmitter();
b69ab31305 const mockEdenFS: EdenFSNotifications = {
b69ab31306 watchDirectoryRecursive: jest
b69ab31307 .fn()
b69ab31308 .mockImplementation(
b69ab31309 (_localDirectoryPath, _rawSubscriptionName, _subscriptionOptions, callback) => {
b69ab31310 emitter1.on('change', change => {
b69ab31311 callback(null, change);
b69ab31312 });
b69ab31313 emitter1.on('error', error => {
b69ab31314 callback(error, null);
b69ab31315 });
b69ab31316 emitter1.on('close', () => {
b69ab31317 callback(null, null);
b69ab31318 });
b69ab31319 return Promise.resolve(emitter1);
b69ab31320 },
b69ab31321 ),
b69ab31322 unwatch: jest.fn(),
b69ab31323 } as unknown as EdenFSNotifications;
b69ab31324
b69ab31325 focusTracker = new PageFocusTracker();
b69ab31326 watch = new WatchForChanges(mockInfo, focusTracker, onChange, ctx, undefined, mockEdenFS);
b69ab31327 await watch.waitForDirstateSubscriptionReady();
b69ab31328
b69ab31329 // change is triggered on first subscription
b69ab31330 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31331 onChange.mockClear();
b69ab31332 });
b69ab31333
b69ab31334 afterEach(() => {
b69ab31335 watch.dispose();
b69ab31336 });
b69ab31337
b69ab31338 afterAll(() => {
b69ab31339 jest.useRealTimers();
b69ab31340 });
b69ab31341
b69ab31342 it('Handles results from edenfs', async () => {
b69ab31343 // |-----------1-----------2-----------3-----------4-----------5-----------6-----------7-----------8-----------9----------10----------11----------12----------13----------14----------15----------16 (minutes)
b69ab31344 // ^ poll
b69ab31345 const ctx = {
b69ab31346 cmd: 'sl',
b69ab31347 cwd: '/path/to/cwd',
b69ab31348 logger: mockLogger,
b69ab31349 tracker: mockTracker,
b69ab31350 };
b69ab31351
b69ab31352 focusTracker.setState('page', 'visible');
b69ab31353 await watch.setupSubscriptions(ctx);
b69ab31354
b69ab31355 // wait an actual async tick so mock subscriptions are set up
b69ab31356 const setImmediate = jest.requireActual('timers').setImmediate;
b69ab31357 await new Promise(res => setImmediate(() => res(undefined)));
b69ab31358
b69ab31359 jest.advanceTimersByTime(1.5 * ONE_MINUTE_MS);
b69ab31360 expect(onChange).toHaveBeenCalledTimes(0);
b69ab31361 emitter1.emit('change', {changes: [1]});
b69ab31362 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31363 jest.advanceTimersByTime(13.0 * ONE_MINUTE_MS); // original timer didn't cause a poll
b69ab31364 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31365 jest.advanceTimersByTime(3.0 * ONE_MINUTE_MS); // 15 minutes after watchman change, a new poll occurred
b69ab31366 expect(onChange).toHaveBeenCalledTimes(2);
b69ab31367 expect(onChange).toHaveBeenCalledWith('everything', undefined);
b69ab31368
b69ab31369 focusTracker.setState('page', 'hidden');
b69ab31370 jest.advanceTimersByTime(180 * ONE_MINUTE_MS);
b69ab31371 expect(onChange).toHaveBeenCalledTimes(3);
b69ab31372 expect(onChange).toHaveBeenCalledWith('everything', undefined);
b69ab31373 });
b69ab31374});