9.5 KB278 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 {Repository} from 'isl-server/src/Repository';
9import type {ServerPlatform} from 'isl-server/src/serverPlatform';
10import type {RepositoryContext} from 'isl-server/src/serverTypes';
11import type {CommitInfo} from 'isl/src/types';
12
13import {makeServerSideTracker} from 'isl-server/src/analytics/serverSideTracker';
14import {
15 beforeRevsetForComparison,
16 ComparisonType,
17 currRevsetForComparison,
18} from 'shared/Comparison';
19import {mockLogger} from 'shared/testUtils';
20import {nullthrows} from 'shared/utils';
21import * as vscode from 'vscode';
22import {
23 decodeSaplingDiffUri,
24 encodeSaplingDiffUri,
25 SaplingDiffContentProvider,
26} from '../DiffContentProvider';
27
28const mockCancelToken = {} as vscode.CancellationToken;
29
30let activeReposCallback: undefined | ((repos: Array<Repository>) => unknown) = undefined;
31let activeRepo: (Repository & {mockChangeHeadCommit: (commit: CommitInfo) => void}) | undefined;
32jest.mock('isl-server/src/RepositoryCache', () => {
33 return {
34 repositoryCache: {
35 onChangeActiveRepos(cb: (repos: Array<Repository>) => unknown) {
36 activeReposCallback = cb;
37 return () => (activeReposCallback = undefined);
38 },
39 cachedRepositoryForPath(path: string): Repository | undefined {
40 if (path.startsWith('/path/to/repo')) {
41 return activeRepo;
42 }
43 return undefined;
44 },
45 },
46 };
47});
48
49const FILE1_CONTENT_HEAD = `
50hello
51world
52`;
53
54function mockRepoAdded(): NonNullable<typeof activeRepo> {
55 let savedOnChangeHeadCommit: (commit: CommitInfo) => unknown;
56 activeRepo = {
57 // eslint-disable-next-line require-await
58 cat: jest.fn(async (_ctx: RepositoryContext, file: string, rev: string) => {
59 if (rev === '.' && file === '/path/to/repo/file1.txt') {
60 return FILE1_CONTENT_HEAD;
61 }
62 throw new Error('unknown file');
63 }),
64 info: {
65 command: 'sl',
66 repoRoot: '/path/to/repo',
67 dotdir: '/path/to/repo/.sl',
68 remoteRepo: {type: 'unknown', path: ''},
69 pullRequestDomain: undefined,
70 },
71 subscribeToHeadCommit: jest.fn().mockImplementation(cb => {
72 savedOnChangeHeadCommit = cb;
73 return {dispose: jest.fn()};
74 }),
75 mockChangeHeadCommit(commit: CommitInfo) {
76 savedOnChangeHeadCommit(commit);
77 },
78 initialConnectionContext: {
79 cwd: '/path/to/repo',
80 },
81 } as unknown as typeof activeRepo;
82 activeReposCallback?.([nullthrows(activeRepo)]);
83 return nullthrows(activeRepo);
84}
85function mockNoActiveRepo() {
86 activeRepo = undefined;
87 activeReposCallback?.([]);
88}
89
90const mockTracker = makeServerSideTracker(
91 mockLogger,
92 {platformName: 'test'} as ServerPlatform,
93 '0.1',
94 jest.fn(),
95);
96
97describe('DiffContentProvider', () => {
98 let ctx: RepositoryContext;
99 beforeEach(() => {
100 ctx = {
101 cwd: 'cwd',
102 cmd: 'sl',
103 logger: mockLogger,
104 tracker: mockTracker,
105 };
106 });
107
108 const encodedFile1 = encodeSaplingDiffUri(
109 vscode.Uri.file('/path/to/repo/file1.txt'),
110 beforeRevsetForComparison({type: ComparisonType.UncommittedChanges}),
111 );
112
113 it('provides file contents', async () => {
114 const provider = new SaplingDiffContentProvider(ctx);
115
116 const repo = mockRepoAdded();
117
118 const content = await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
119
120 expect(content).toEqual(FILE1_CONTENT_HEAD);
121 expect(repo.cat).toHaveBeenCalledTimes(1);
122 expect(repo.cat).toHaveBeenCalledWith({cwd: '/path/to/repo'}, '/path/to/repo/file1.txt', '.');
123 provider.dispose();
124 });
125
126 it('caches file contents', async () => {
127 const provider = new SaplingDiffContentProvider(ctx);
128 const repo = mockRepoAdded();
129 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
130 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
131 expect(repo.cat).toHaveBeenCalledTimes(1); // second call hits file content cache
132 provider.dispose();
133 });
134
135 it('invalidates files when the repository head changes', async () => {
136 const commit1 = {hash: '1'} as CommitInfo;
137 const commit2 = {hash: '2'} as CommitInfo;
138 const provider = new SaplingDiffContentProvider(ctx);
139 const onChange = jest.fn();
140 const onChangeDisposable = provider.onDidChange(onChange);
141 const repo = mockRepoAdded();
142 repo.mockChangeHeadCommit(commit1);
143 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
144 // changing the head commit has no effect since we hadn't yet provided content
145 expect(onChange).toHaveBeenCalledTimes(0);
146
147 repo.mockChangeHeadCommit(commit2);
148 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
149 // now the provider knows that encodedFile1 is an active file, which triggers onChange.
150 expect(onChange).toHaveBeenCalledTimes(1);
151
152 provider.dispose();
153 onChangeDisposable.dispose();
154 });
155
156 it('invalidates file content cache when the repository head changes', async () => {
157 const commit1 = {hash: '1'} as CommitInfo;
158 const commit2 = {hash: '2'} as CommitInfo;
159 const provider = new SaplingDiffContentProvider(ctx);
160 const repo = mockRepoAdded();
161 repo.mockChangeHeadCommit(commit1);
162
163 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
164 expect(repo.cat).toHaveBeenCalledTimes(1);
165
166 repo.mockChangeHeadCommit(commit2);
167 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
168 expect(repo.cat).toHaveBeenCalledTimes(2);
169
170 provider.dispose();
171 });
172
173 it('files opened before repo created provide content once repo is ready', async () => {
174 mockNoActiveRepo();
175 const provider = new SaplingDiffContentProvider(ctx);
176 const onChange = jest.fn();
177 const onChangeDisposable = provider.onDidChange(onChange);
178
179 const contentBeforeRepo = await provider.provideTextDocumentContent(
180 encodedFile1,
181 mockCancelToken,
182 );
183 expect(contentBeforeRepo).toEqual(null);
184
185 expect(onChange).not.toHaveBeenCalled();
186 mockRepoAdded();
187 // adding a repo triggers the content provider to tell vscode that the path changed...
188 expect(onChange).toHaveBeenCalledWith(encodedFile1);
189
190 // ...which means we re-run provideTextDocumentContent
191 const contentAfterRepo = await provider.provideTextDocumentContent(
192 encodedFile1,
193 mockCancelToken,
194 );
195 expect(contentAfterRepo).toEqual(FILE1_CONTENT_HEAD);
196 provider.dispose();
197 onChangeDisposable.dispose();
198 });
199
200 it('closing a file disables telling vscode about file changes on checkout', async () => {
201 let onCloseCallback: (e: vscode.TextDocument) => unknown = () => undefined;
202 (vscode.workspace.onDidCloseTextDocument as jest.Mock).mockImplementation(cb => {
203 onCloseCallback = cb;
204 return {dispose: jest.fn()};
205 });
206 const commit1 = {hash: '1'} as CommitInfo;
207 const commit2 = {hash: '2'} as CommitInfo;
208 const provider = new SaplingDiffContentProvider(ctx);
209 const onChange = jest.fn();
210 const onChangeDisposable = provider.onDidChange(onChange);
211 const repo = mockRepoAdded();
212 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
213 expect(onChange).toHaveBeenCalledTimes(0);
214
215 // normally if head changes, we detect it
216 repo.mockChangeHeadCommit(commit1);
217 expect(onChange).toHaveBeenCalledTimes(1);
218
219 // closing any old file doesn't do anything, we still detect head commit changes
220 onCloseCallback({
221 uri: vscode.Uri.file('/some/unrelated/file'),
222 } as unknown as vscode.TextDocument);
223 repo.mockChangeHeadCommit(commit2);
224 expect(onChange).toHaveBeenCalledTimes(2);
225
226 // closing the encoded uri means we stop listening for changes
227 onCloseCallback?.({uri: encodedFile1} as unknown as vscode.TextDocument);
228 repo.mockChangeHeadCommit(commit2);
229 expect(onChange).toHaveBeenCalledTimes(2); // no new call happened
230
231 provider.dispose();
232 onChangeDisposable.dispose();
233 });
234
235 it('closing a file, then updating the head commit removes the file content cache', async () => {
236 let onCloseCallback: (e: vscode.TextDocument) => unknown = () => undefined;
237 (vscode.workspace.onDidCloseTextDocument as jest.Mock).mockImplementation(cb => {
238 onCloseCallback = cb;
239 return {dispose: jest.fn()};
240 });
241 const commit2 = {hash: '2'} as CommitInfo;
242 const provider = new SaplingDiffContentProvider(ctx);
243 const repo = mockRepoAdded();
244 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
245 expect(repo.cat).toHaveBeenCalledTimes(1);
246
247 onCloseCallback?.({uri: encodedFile1} as unknown as vscode.TextDocument);
248
249 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
250 expect(repo.cat).toHaveBeenCalledTimes(1); // file still cached
251
252 onCloseCallback?.({uri: encodedFile1} as unknown as vscode.TextDocument);
253
254 repo.mockChangeHeadCommit(commit2);
255 await provider.provideTextDocumentContent(encodedFile1, mockCancelToken);
256 expect(repo.cat).toHaveBeenCalledTimes(2); // file no longer cached
257
258 provider.dispose();
259 });
260});
261
262describe('SaplingDiffEncodedUri', () => {
263 it('is reversible', () => {
264 const encoded = encodeSaplingDiffUri(
265 vscode.Uri.file('/path/to/myRepo'),
266 currRevsetForComparison({
267 type: ComparisonType.UncommittedChanges,
268 }),
269 );
270 const decoded = decodeSaplingDiffUri(encoded);
271 expect(decoded).toEqual({
272 originalUri: expect.anything(),
273 revset: 'wdir()',
274 });
275 expect(decoded.originalUri.toString()).toEqual('file:///path/to/myRepo');
276 });
277});
278