addons/isl-server/src/__tests__/RepositoryCache.test.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 {RepoInfo, RepositoryError, ValidatedRepoInfo} from 'isl/src/types';
b69ab319import type {Repository} from '../Repository';
b69ab3110import type {Logger} from '../logger';
b69ab3111import type {ServerPlatform} from '../serverPlatform';
b69ab3112import type {RepositoryContext} from '../serverTypes';
b69ab3113
b69ab3114import {ensureTrailingPathSep} from 'shared/pathUtils';
b69ab3115import {mockLogger} from 'shared/testUtils';
b69ab3116import {defer} from 'shared/utils';
b69ab3117import {__TEST__} from '../RepositoryCache';
b69ab3118import {makeServerSideTracker} from '../analytics/serverSideTracker';
b69ab3119
b69ab3120const {RepositoryCache, RepoMap, RefCounted} = __TEST__;
b69ab3121
b69ab3122const mockTracker = makeServerSideTracker(
b69ab3123 mockLogger,
b69ab3124 {platformName: 'test'} as ServerPlatform,
b69ab3125 '0.1',
b69ab3126 jest.fn(),
b69ab3127);
b69ab3128
b69ab3129class SimpleMockRepositoryImpl {
b69ab3130 static getRepoInfo(ctx: RepositoryContext): Promise<RepoInfo> {
b69ab3131 const {cwd, cmd} = ctx;
b69ab3132 let data;
b69ab3133 if (cwd.includes('/path/to/repo')) {
b69ab3134 data = {
b69ab3135 repoRoot: '/path/to/repo',
b69ab3136 dotdir: '/path/to/repo/.sl',
b69ab3137 };
b69ab3138 } else if (cwd.includes('/path/to/anotherrepo')) {
b69ab3139 data = {
b69ab3140 repoRoot: '/path/to/anotherrepo',
b69ab3141 dotdir: '/path/to/anotherrepo/.sl',
b69ab3142 };
b69ab3143 } else if (cwd.includes('/path/to/submodule')) {
b69ab3144 data = {
b69ab3145 repoRoot: cwd.endsWith('/cwd') ? cwd.slice(0, -4) : cwd,
b69ab3146 dotdir: ensureTrailingPathSep(cwd) + '.sl',
b69ab3147 };
b69ab3148 } else {
b69ab3149 return Promise.resolve({type: 'cwdNotARepository', cwd} as RepositoryError);
b69ab3150 }
b69ab3151 return Promise.resolve({
b69ab3152 type: 'success',
b69ab3153 command: cmd,
b69ab3154 pullRequestDomain: undefined,
b69ab3155 preferredSubmitCommand: 'pr',
b69ab3156 codeReviewSystem: {type: 'unknown'},
b69ab3157 isEdenFs: false,
b69ab3158 ...data,
b69ab3159 });
b69ab3160 }
b69ab3161 constructor(
b69ab3162 public info: RepoInfo,
b69ab3163 public logger: Logger,
b69ab3164 ) {}
b69ab3165
b69ab3166 dispose = jest.fn();
b69ab3167}
b69ab3168const SimpleMockRepository = SimpleMockRepositoryImpl as unknown as typeof Repository;
b69ab3169
b69ab3170const ctx: RepositoryContext = {
b69ab3171 cmd: 'sl',
b69ab3172 logger: mockLogger,
b69ab3173 tracker: mockTracker,
b69ab3174 cwd: '/path/to/repo/cwd',
b69ab3175};
b69ab3176
b69ab3177describe('RepositoryCache', () => {
b69ab3178 it('Provides repository references that resolve', async () => {
b69ab3179 const cache = new RepositoryCache(SimpleMockRepository);
b69ab3180 const ref = cache.getOrCreate(ctx);
b69ab3181
b69ab3182 const repo = await ref.promise;
b69ab3183 expect(repo).toEqual(
b69ab3184 expect.objectContaining({
b69ab3185 info: expect.objectContaining({
b69ab3186 repoRoot: '/path/to/repo',
b69ab3187 dotdir: '/path/to/repo/.sl',
b69ab3188 }),
b69ab3189 }),
b69ab3190 );
b69ab3191
b69ab3192 ref.unref();
b69ab3193 });
b69ab3194
b69ab3195 it('Gives error for paths without repos', async () => {
b69ab3196 const cache = new RepositoryCache(SimpleMockRepository);
b69ab3197 const ref = cache.getOrCreate({...ctx, cwd: '/some/invalid/repo'});
b69ab3198
b69ab3199 const repo = await ref.promise;
b69ab31100 expect(repo).toEqual({type: 'cwdNotARepository', cwd: '/some/invalid/repo'});
b69ab31101
b69ab31102 ref.unref();
b69ab31103 });
b69ab31104
b69ab31105 it('Disposes repositories', async () => {
b69ab31106 const cache = new RepositoryCache(SimpleMockRepository);
b69ab31107 const ref = cache.getOrCreate(ctx);
b69ab31108
b69ab31109 const repo = await ref.promise;
b69ab31110 const disposeFunc = (repo as Repository).dispose;
b69ab31111
b69ab31112 expect(cache.numberOfActiveServers()).toBe(1);
b69ab31113
b69ab31114 ref.unref();
b69ab31115 expect(disposeFunc).toHaveBeenCalledTimes(1);
b69ab31116 expect(cache.numberOfActiveServers()).toBe(0);
b69ab31117 });
b69ab31118
b69ab31119 it('Can dispose references before the repo promise has resolved', async () => {
b69ab31120 const cache = new RepositoryCache(SimpleMockRepository);
b69ab31121 const ref = cache.getOrCreate(ctx);
b69ab31122
b69ab31123 ref.unref();
b69ab31124
b69ab31125 const repo = await ref.promise;
b69ab31126 // even though this would be a valid repo, by disposing the ref before it is created,
b69ab31127 // we prevent creating a repo.
b69ab31128 expect(repo).toEqual({type: 'unknownError', error: expect.anything()});
b69ab31129 });
b69ab31130
b69ab31131 it('shares repositories under the same cwd', async () => {
b69ab31132 const cache = new RepositoryCache(SimpleMockRepository);
b69ab31133 const ref1 = cache.getOrCreate({...ctx, cwd: '/path/to/repo'});
b69ab31134 const repo1 = await ref1.promise;
b69ab31135
b69ab31136 const ref2 = cache.getOrCreate({...ctx, cwd: '/path/to/repo'});
b69ab31137 const repo2 = await ref2.promise;
b69ab31138
b69ab31139 expect(cache.numberOfActiveServers()).toBe(2);
b69ab31140
b69ab31141 expect(repo1).toBe(repo2);
b69ab31142
b69ab31143 ref1.unref();
b69ab31144 ref2.unref();
b69ab31145 });
b69ab31146
b69ab31147 it('shares repositories under the same repo', async () => {
b69ab31148 const cache = new RepositoryCache(SimpleMockRepository);
b69ab31149 const ref1 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd1'});
b69ab31150 const repo1 = await ref1.promise;
b69ab31151
b69ab31152 const ref2 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd2'});
b69ab31153 const repo2 = await ref2.promise;
b69ab31154
b69ab31155 expect(cache.numberOfActiveServers()).toBe(2);
b69ab31156
b69ab31157 expect(repo1).toBe(repo2);
b69ab31158
b69ab31159 ref1.unref();
b69ab31160 ref2.unref();
b69ab31161 });
b69ab31162
b69ab31163 it('does not share repositories under different cwds', async () => {
b69ab31164 const cache = new RepositoryCache(SimpleMockRepository);
b69ab31165 const ref1 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd'});
b69ab31166 const ref2 = cache.getOrCreate({...ctx, cwd: '/path/to/anotherrepo/cwd'});
b69ab31167
b69ab31168 const repo1 = await ref1.promise;
b69ab31169 const repo2 = await ref2.promise;
b69ab31170
b69ab31171 expect(repo1).not.toBe(repo2);
b69ab31172
b69ab31173 ref1.unref();
b69ab31174 ref2.unref();
b69ab31175 });
b69ab31176
b69ab31177 it('reference counts and disposes after 0 refs', async () => {
b69ab31178 const cache = new RepositoryCache(SimpleMockRepository);
b69ab31179 const ref1 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd1'});
b69ab31180 const ref2 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd2'});
b69ab31181
b69ab31182 const repo = await ref1.promise;
b69ab31183 await ref2.promise;
b69ab31184 expect(cache.numberOfActiveServers()).toBe(2);
b69ab31185
b69ab31186 const disposeFunc = (repo as Repository).dispose;
b69ab31187
b69ab31188 ref1.unref();
b69ab31189 expect(disposeFunc).not.toHaveBeenCalled();
b69ab31190 ref2.unref();
b69ab31191 expect(disposeFunc).toHaveBeenCalledTimes(1);
b69ab31192
b69ab31193 expect(cache.numberOfActiveServers()).toBe(0);
b69ab31194 });
b69ab31195
b69ab31196 it('does not reuse disposed repos', async () => {
b69ab31197 const cache = new RepositoryCache(SimpleMockRepository);
b69ab31198 const ref1 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd1'});
b69ab31199
b69ab31200 const repo1 = await ref1.promise;
b69ab31201 ref1.unref();
b69ab31202
b69ab31203 const ref2 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd2'});
b69ab31204 const repo2 = await ref2.promise;
b69ab31205
b69ab31206 expect(cache.numberOfActiveServers()).toBe(1);
b69ab31207
b69ab31208 expect(repo1).not.toBe(repo2);
b69ab31209 expect((repo1 as Repository).dispose).toHaveBeenCalledTimes(1);
b69ab31210 expect((repo2 as Repository).dispose).not.toHaveBeenCalled();
b69ab31211
b69ab31212 ref2.unref();
b69ab31213 });
b69ab31214
b69ab31215 it('prefix matching repos are treated separately', async () => {
b69ab31216 const cache = new RepositoryCache(SimpleMockRepository);
b69ab31217 const ref1 = cache.getOrCreate({...ctx, cwd: '/path/to/repo'});
b69ab31218 await ref1.promise;
b69ab31219
b69ab31220 expect(cache.cachedRepositoryForPath('/path/to/repo')).not.toEqual(undefined);
b69ab31221 expect(cache.cachedRepositoryForPath('/path/to/repo/')).not.toEqual(undefined);
b69ab31222 expect(cache.cachedRepositoryForPath('/path/to/repo/foo')).not.toEqual(undefined);
b69ab31223 // this is actually different repo
b69ab31224 expect(cache.cachedRepositoryForPath('/path/to/repo-1')).toEqual(undefined);
b69ab31225
b69ab31226 ref1.unref();
b69ab31227 expect(cache.cachedRepositoryForPath('/path/to/repo')).toEqual(undefined);
b69ab31228
b69ab31229 // Test longest prefix match for nested repos/submodules
b69ab31230 const refSubmodule = cache.getOrCreate({...ctx, cwd: '/path/to/submodule'});
b69ab31231 await refSubmodule.promise;
b69ab31232 const refSubmoduleNested = cache.getOrCreate({...ctx, cwd: '/path/to/submodule/nested'});
b69ab31233 await refSubmoduleNested.promise;
b69ab31234
b69ab31235 const repoSubmodule = cache.cachedRepositoryForPath('/path/to/submodule');
b69ab31236 const repoSubmoduleFoo = cache.cachedRepositoryForPath('/path/to/submodule/foo');
b69ab31237 const repoNested = cache.cachedRepositoryForPath('/path/to/submodule/nested');
b69ab31238 const repoNestedBar = cache.cachedRepositoryForPath('/path/to/submodule/nested/bar');
b69ab31239
b69ab31240 expect(repoSubmodule?.info.repoRoot).toEqual('/path/to/submodule');
b69ab31241 expect(repoSubmoduleFoo).toEqual(repoSubmodule);
b69ab31242 expect(repoNested?.info.repoRoot).toEqual('/path/to/submodule/nested');
b69ab31243 expect(repoNestedBar).toEqual(repoNested);
b69ab31244
b69ab31245 refSubmoduleNested.unref();
b69ab31246 expect(cache.cachedRepositoryForPath('/path/to/submodule/nested')).toEqual(undefined);
b69ab31247 expect(cache.cachedRepositoryForPath('/path/to/submodule')).not.toEqual(undefined);
b69ab31248
b69ab31249 refSubmodule.unref();
b69ab31250 expect(cache.cachedRepositoryForPath('/path/to/submodule')).toEqual(undefined);
b69ab31251 });
b69ab31252
b69ab31253 it('only creates one Repository even when racing lookups', async () => {
b69ab31254 const repoInfo = {
b69ab31255 type: 'success',
b69ab31256 command: 'sl',
b69ab31257 pullRequestDomain: undefined,
b69ab31258 codeReviewSystem: {type: 'unknown'},
b69ab31259 repoRoot: '/path/to/repo',
b69ab31260 dotdir: '/path/to/repo/.sl',
b69ab31261 isEdenFs: false,
b69ab31262 } as RepoInfo;
b69ab31263
b69ab31264 // two different fake async fetches for RepoInfo
b69ab31265 const p1 = defer<RepoInfo>();
b69ab31266 const p2 = defer<RepoInfo>();
b69ab31267
b69ab31268 class BlockingMockRepository {
b69ab31269 static getRepoInfo(ctx: RepositoryContext): Promise<RepoInfo> {
b69ab31270 const {cwd} = ctx;
b69ab31271 if (cwd === '/path/to/repo/cwd1') {
b69ab31272 return p1.promise;
b69ab31273 } else if (cwd === '/path/to/repo/cwd2') {
b69ab31274 return p2.promise;
b69ab31275 }
b69ab31276 throw new Error('unknown repo');
b69ab31277 }
b69ab31278 constructor(
b69ab31279 public info: RepoInfo,
b69ab31280 public logger: Logger,
b69ab31281 ) {}
b69ab31282
b69ab31283 dispose = jest.fn();
b69ab31284 }
b69ab31285
b69ab31286 const cache = new RepositoryCache(BlockingMockRepository as unknown as typeof Repository);
b69ab31287 // start looking up repoInfos at the same time
b69ab31288 const ref1 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd1'});
b69ab31289 const ref2 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd2'});
b69ab31290
b69ab31291 p2.resolve({...repoInfo});
b69ab31292 const repo2 = await ref2.promise;
b69ab31293
b69ab31294 p1.resolve({...repoInfo});
b69ab31295 const repo1 = await ref1.promise;
b69ab31296
b69ab31297 // we end up with the same repo
b69ab31298 expect(repo1).toBe(repo2);
b69ab31299
b69ab31300 ref1.unref();
b69ab31301 ref2.unref();
b69ab31302 });
b69ab31303});
b69ab31304
b69ab31305describe('RepoMap', () => {
b69ab31306 it('iteration with values() and forEach()', async () => {
b69ab31307 const repoNum = 10;
b69ab31308 const repoRoots = [];
b69ab31309 const promises = [];
b69ab31310 const createRefCountedRepo = async (ctx: RepositoryContext) => {
b69ab31311 const repoInfo = await SimpleMockRepository.getRepoInfo(ctx);
b69ab31312 const repo = new SimpleMockRepository(repoInfo as ValidatedRepoInfo, ctx);
b69ab31313 return new RefCounted(repo);
b69ab31314 };
b69ab31315 for (let i = 0; i < repoNum; i++) {
b69ab31316 repoRoots.push(`/path/to/submodule${i}`);
b69ab31317 promises.push(createRefCountedRepo({...ctx, cwd: `/path/to/submodule${i}`}));
b69ab31318 }
b69ab31319 const repos = await Promise.all(promises);
b69ab31320
b69ab31321 const repoMap = new RepoMap();
b69ab31322 for (let i = 0; i < repoNum; i++) {
b69ab31323 repos[i].ref();
b69ab31324 repoMap.set(repoRoots[i], repos[i]);
b69ab31325 }
b69ab31326
b69ab31327 const values = [...repoMap.values()];
b69ab31328 expect(values.length).toBe(repoNum);
b69ab31329 for (let i = 0; i < repoNum; i++) {
b69ab31330 expect(values[i].value.info.repoRoot).toBe(repoRoots[i]);
b69ab31331 }
b69ab31332
b69ab31333 repoMap.forEach(repo => expect(repo.getNumberOfReferences()).toBe(1));
b69ab31334 repoMap.forEach(repo => repo.dispose());
b69ab31335 repoMap.forEach(repo => expect(repo.getNumberOfReferences()).toBe(0));
b69ab31336 });
b69ab31337
b69ab31338 it('longest prefix match', async () => {
b69ab31339 const createRefCountedRepo = async (ctx: RepositoryContext) => {
b69ab31340 const repoInfo = await SimpleMockRepository.getRepoInfo(ctx);
b69ab31341 const repo = new SimpleMockRepository(repoInfo as ValidatedRepoInfo, ctx);
b69ab31342 return new RefCounted(repo);
b69ab31343 };
b69ab31344 const a = await createRefCountedRepo({...ctx, cwd: '/path/to/submoduleA/cwd'});
b69ab31345 const b = await createRefCountedRepo({...ctx, cwd: '/path/to/submoduleB/cwd'});
b69ab31346 const nested = await createRefCountedRepo({
b69ab31347 ...ctx,
b69ab31348 cwd: '/path/to/submoduleA/submoduleNested/cwd',
b69ab31349 });
b69ab31350
b69ab31351 const repoMap = new RepoMap();
b69ab31352 // A raw map would iterate in insertion order, so we
b69ab31353 // call set in reverse order to test longest prefix match
b69ab31354 repoMap.set('/path/to/submoduleA/submoduleNested', nested);
b69ab31355 repoMap.set('/path/to/submoduleB', b);
b69ab31356 repoMap.set('/path/to/submoduleA', a);
b69ab31357
b69ab31358 expect(repoMap.get('/path/to/submoduleA')).toBe(a);
b69ab31359 expect(repoMap.get('/path/to/submoduleA/some/dir')).toBeUndefined();
b69ab31360 expect(repoMap.getLongestPrefixMatch('/path/to/submoduleA/some/dir')).toBe(a);
b69ab31361 expect(repoMap.getLongestPrefixMatch('/path/to/submoduleB/some/dir')).toBe(b);
b69ab31362 expect(repoMap.getLongestPrefixMatch('/path/to/submoduleA/submoduleNested/dir')).toBe(nested);
b69ab31363 });
b69ab31364});