addons/vscode/extension/__tests__/DiffContentProvider.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 {Repository} from 'isl-server/src/Repository';
b69ab319import type {ServerPlatform} from 'isl-server/src/serverPlatform';
b69ab3110import type {RepositoryContext} from 'isl-server/src/serverTypes';
b69ab3111import type {CommitInfo} from 'isl/src/types';
b69ab3112
b69ab3113import {makeServerSideTracker} from 'isl-server/src/analytics/serverSideTracker';
b69ab3114import {
b69ab3115 beforeRevsetForComparison,
b69ab3116 ComparisonType,
b69ab3117 currRevsetForComparison,
b69ab3118} from 'shared/Comparison';
b69ab3119import {mockLogger} from 'shared/testUtils';
b69ab3120import {nullthrows} from 'shared/utils';
b69ab3121import * as vscode from 'vscode';
b69ab3122import {
b69ab3123 decodeSaplingDiffUri,
b69ab3124 encodeSaplingDiffUri,
b69ab3125 SaplingDiffContentProvider,
b69ab3126} from '../DiffContentProvider';
b69ab3127
b69ab3128const mockCancelToken = {} as vscode.CancellationToken;
b69ab3129
b69ab3130let activeReposCallback: undefined | ((repos: Array<Repository>) => unknown) = undefined;
b69ab3131let activeRepo: (Repository & {mockChangeHeadCommit: (commit: CommitInfo) => void}) | undefined;
b69ab3132jest.mock('isl-server/src/RepositoryCache', () => {
b69ab3133 return {
b69ab3134 repositoryCache: {
b69ab3135 onChangeActiveRepos(cb: (repos: Array<Repository>) => unknown) {
b69ab3136 activeReposCallback = cb;
b69ab3137 return () => (activeReposCallback = undefined);
b69ab3138 },
b69ab3139 cachedRepositoryForPath(path: string): Repository | undefined {
b69ab3140 if (path.startsWith('/path/to/repo')) {
b69ab3141 return activeRepo;
b69ab3142 }
b69ab3143 return undefined;
b69ab3144 },
b69ab3145 },
b69ab3146 };
b69ab3147});
b69ab3148
b69ab3149const FILE1_CONTENT_HEAD = `
b69ab3150hello
b69ab3151world
b69ab3152`;
b69ab3153
b69ab3154function mockRepoAdded(): NonNullable<typeof activeRepo> {
b69ab3155 let savedOnChangeHeadCommit: (commit: CommitInfo) => unknown;
b69ab3156 activeRepo = {
b69ab3157 // eslint-disable-next-line require-await
b69ab3158 cat: jest.fn(async (_ctx: RepositoryContext, file: string, rev: string) => {
b69ab3159 if (rev === '.' && file === '/path/to/repo/file1.txt') {
b69ab3160 return FILE1_CONTENT_HEAD;
b69ab3161 }
b69ab3162 throw new Error('unknown file');
b69ab3163 }),
b69ab3164 info: {
b69ab3165 command: 'sl',
b69ab3166 repoRoot: '/path/to/repo',
b69ab3167 dotdir: '/path/to/repo/.sl',
b69ab3168 remoteRepo: {type: 'unknown', path: ''},
b69ab3169 pullRequestDomain: undefined,
b69ab3170 },
b69ab3171 subscribeToHeadCommit: jest.fn().mockImplementation(cb => {
b69ab3172 savedOnChangeHeadCommit = cb;
b69ab3173 return {dispose: jest.fn()};
b69ab3174 }),
b69ab3175 mockChangeHeadCommit(commit: CommitInfo) {
b69ab3176 savedOnChangeHeadCommit(commit);
b69ab3177 },
b69ab3178 initialConnectionContext: {
b69ab3179 cwd: '/path/to/repo',
b69ab3180 },
b69ab3181 } as unknown as typeof activeRepo;
b69ab3182 activeReposCallback?.([nullthrows(activeRepo)]);
b69ab3183 return nullthrows(activeRepo);
b69ab3184}
b69ab3185function mockNoActiveRepo() {
b69ab3186 activeRepo = undefined;
b69ab3187 activeReposCallback?.([]);
b69ab3188}
b69ab3189
b69ab3190const mockTracker = makeServerSideTracker(
b69ab3191 mockLogger,
b69ab3192 {platformName: 'test'} as ServerPlatform,
b69ab3193 '0.1',
b69ab3194 jest.fn(),
b69ab3195);
b69ab3196
b69ab3197describe('DiffContentProvider', () => {
b69ab3198 let ctx: RepositoryContext;
b69ab3199 beforeEach(() => {
b69ab31100 ctx = {
b69ab31101 cwd: 'cwd',
b69ab31102 cmd: 'sl',
b69ab31103 logger: mockLogger,
b69ab31104 tracker: mockTracker,
b69ab31105 };
b69ab31106 });
b69ab31107
b69ab31108 const encodedFile1 = encodeSaplingDiffUri(
b69ab31109 vscode.Uri.file('/path/to/repo/file1.txt'),
b69ab31110 beforeRevsetForComparison({type: ComparisonType.UncommittedChanges}),
b69ab31111 );
b69ab31112
b69ab31113 it('provides file contents', async () => {
b69ab31114 const provider = new SaplingDiffContentProvider(ctx);
b69ab31115
b69ab31116 const repo = mockRepoAdded();
b69ab31117
b69ab31118 const content = await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
b69ab31119
b69ab31120 expect(content).toEqual(FILE1_CONTENT_HEAD);
b69ab31121 expect(repo.cat).toHaveBeenCalledTimes(1);
b69ab31122 expect(repo.cat).toHaveBeenCalledWith({cwd: '/path/to/repo'}, '/path/to/repo/file1.txt', '.');
b69ab31123 provider.dispose();
b69ab31124 });
b69ab31125
b69ab31126 it('caches file contents', async () => {
b69ab31127 const provider = new SaplingDiffContentProvider(ctx);
b69ab31128 const repo = mockRepoAdded();
b69ab31129 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
b69ab31130 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
b69ab31131 expect(repo.cat).toHaveBeenCalledTimes(1); // second call hits file content cache
b69ab31132 provider.dispose();
b69ab31133 });
b69ab31134
b69ab31135 it('invalidates files when the repository head changes', async () => {
b69ab31136 const commit1 = {hash: '1'} as CommitInfo;
b69ab31137 const commit2 = {hash: '2'} as CommitInfo;
b69ab31138 const provider = new SaplingDiffContentProvider(ctx);
b69ab31139 const onChange = jest.fn();
b69ab31140 const onChangeDisposable = provider.onDidChange(onChange);
b69ab31141 const repo = mockRepoAdded();
b69ab31142 repo.mockChangeHeadCommit(commit1);
b69ab31143 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
b69ab31144 // changing the head commit has no effect since we hadn't yet provided content
b69ab31145 expect(onChange).toHaveBeenCalledTimes(0);
b69ab31146
b69ab31147 repo.mockChangeHeadCommit(commit2);
b69ab31148 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
b69ab31149 // now the provider knows that encodedFile1 is an active file, which triggers onChange.
b69ab31150 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31151
b69ab31152 provider.dispose();
b69ab31153 onChangeDisposable.dispose();
b69ab31154 });
b69ab31155
b69ab31156 it('invalidates file content cache when the repository head changes', async () => {
b69ab31157 const commit1 = {hash: '1'} as CommitInfo;
b69ab31158 const commit2 = {hash: '2'} as CommitInfo;
b69ab31159 const provider = new SaplingDiffContentProvider(ctx);
b69ab31160 const repo = mockRepoAdded();
b69ab31161 repo.mockChangeHeadCommit(commit1);
b69ab31162
b69ab31163 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
b69ab31164 expect(repo.cat).toHaveBeenCalledTimes(1);
b69ab31165
b69ab31166 repo.mockChangeHeadCommit(commit2);
b69ab31167 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
b69ab31168 expect(repo.cat).toHaveBeenCalledTimes(2);
b69ab31169
b69ab31170 provider.dispose();
b69ab31171 });
b69ab31172
b69ab31173 it('files opened before repo created provide content once repo is ready', async () => {
b69ab31174 mockNoActiveRepo();
b69ab31175 const provider = new SaplingDiffContentProvider(ctx);
b69ab31176 const onChange = jest.fn();
b69ab31177 const onChangeDisposable = provider.onDidChange(onChange);
b69ab31178
b69ab31179 const contentBeforeRepo = await provider.provideTextDocumentContent(
b69ab31180 encodedFile1,
b69ab31181 mockCancelToken,
b69ab31182 );
b69ab31183 expect(contentBeforeRepo).toEqual(null);
b69ab31184
b69ab31185 expect(onChange).not.toHaveBeenCalled();
b69ab31186 mockRepoAdded();
b69ab31187 // adding a repo triggers the content provider to tell vscode that the path changed...
b69ab31188 expect(onChange).toHaveBeenCalledWith(encodedFile1);
b69ab31189
b69ab31190 // ...which means we re-run provideTextDocumentContent
b69ab31191 const contentAfterRepo = await provider.provideTextDocumentContent(
b69ab31192 encodedFile1,
b69ab31193 mockCancelToken,
b69ab31194 );
b69ab31195 expect(contentAfterRepo).toEqual(FILE1_CONTENT_HEAD);
b69ab31196 provider.dispose();
b69ab31197 onChangeDisposable.dispose();
b69ab31198 });
b69ab31199
b69ab31200 it('closing a file disables telling vscode about file changes on checkout', async () => {
b69ab31201 let onCloseCallback: (e: vscode.TextDocument) => unknown = () => undefined;
b69ab31202 (vscode.workspace.onDidCloseTextDocument as jest.Mock).mockImplementation(cb => {
b69ab31203 onCloseCallback = cb;
b69ab31204 return {dispose: jest.fn()};
b69ab31205 });
b69ab31206 const commit1 = {hash: '1'} as CommitInfo;
b69ab31207 const commit2 = {hash: '2'} as CommitInfo;
b69ab31208 const provider = new SaplingDiffContentProvider(ctx);
b69ab31209 const onChange = jest.fn();
b69ab31210 const onChangeDisposable = provider.onDidChange(onChange);
b69ab31211 const repo = mockRepoAdded();
b69ab31212 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
b69ab31213 expect(onChange).toHaveBeenCalledTimes(0);
b69ab31214
b69ab31215 // normally if head changes, we detect it
b69ab31216 repo.mockChangeHeadCommit(commit1);
b69ab31217 expect(onChange).toHaveBeenCalledTimes(1);
b69ab31218
b69ab31219 // closing any old file doesn't do anything, we still detect head commit changes
b69ab31220 onCloseCallback({
b69ab31221 uri: vscode.Uri.file('/some/unrelated/file'),
b69ab31222 } as unknown as vscode.TextDocument);
b69ab31223 repo.mockChangeHeadCommit(commit2);
b69ab31224 expect(onChange).toHaveBeenCalledTimes(2);
b69ab31225
b69ab31226 // closing the encoded uri means we stop listening for changes
b69ab31227 onCloseCallback?.({uri: encodedFile1} as unknown as vscode.TextDocument);
b69ab31228 repo.mockChangeHeadCommit(commit2);
b69ab31229 expect(onChange).toHaveBeenCalledTimes(2); // no new call happened
b69ab31230
b69ab31231 provider.dispose();
b69ab31232 onChangeDisposable.dispose();
b69ab31233 });
b69ab31234
b69ab31235 it('closing a file, then updating the head commit removes the file content cache', async () => {
b69ab31236 let onCloseCallback: (e: vscode.TextDocument) => unknown = () => undefined;
b69ab31237 (vscode.workspace.onDidCloseTextDocument as jest.Mock).mockImplementation(cb => {
b69ab31238 onCloseCallback = cb;
b69ab31239 return {dispose: jest.fn()};
b69ab31240 });
b69ab31241 const commit2 = {hash: '2'} as CommitInfo;
b69ab31242 const provider = new SaplingDiffContentProvider(ctx);
b69ab31243 const repo = mockRepoAdded();
b69ab31244 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
b69ab31245 expect(repo.cat).toHaveBeenCalledTimes(1);
b69ab31246
b69ab31247 onCloseCallback?.({uri: encodedFile1} as unknown as vscode.TextDocument);
b69ab31248
b69ab31249 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
b69ab31250 expect(repo.cat).toHaveBeenCalledTimes(1); // file still cached
b69ab31251
b69ab31252 onCloseCallback?.({uri: encodedFile1} as unknown as vscode.TextDocument);
b69ab31253
b69ab31254 repo.mockChangeHeadCommit(commit2);
b69ab31255 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
b69ab31256 expect(repo.cat).toHaveBeenCalledTimes(2); // file no longer cached
b69ab31257
b69ab31258 provider.dispose();
b69ab31259 });
b69ab31260});
b69ab31261
b69ab31262describe('SaplingDiffEncodedUri', () => {
b69ab31263 it('is reversible', () => {
b69ab31264 const encoded = encodeSaplingDiffUri(
b69ab31265 vscode.Uri.file('/path/to/myRepo'),
b69ab31266 currRevsetForComparison({
b69ab31267 type: ComparisonType.UncommittedChanges,
b69ab31268 }),
b69ab31269 );
b69ab31270 const decoded = decodeSaplingDiffUri(encoded);
b69ab31271 expect(decoded).toEqual({
b69ab31272 originalUri: expect.anything(),
b69ab31273 revset: 'wdir()',
b69ab31274 });
b69ab31275 expect(decoded.originalUri.toString()).toEqual('file:///path/to/myRepo');
b69ab31276 });
b69ab31277});