8.1 KB283 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 {OperationCommandProgressReporter} from 'isl/src/types';
9import type {ServerPlatform} from '../serverPlatform';
10import type {RepositoryContext} from '../serverTypes';
11
12import {CommandRunner} from 'isl/src/types';
13import {mockLogger} from 'shared/testUtils';
14import {defer} from 'shared/utils';
15import {OperationQueue} from '../OperationQueue';
16import {makeServerSideTracker} from '../analytics/serverSideTracker';
17
18const mockTracker = makeServerSideTracker(
19 mockLogger,
20 {platformName: 'test'} as ServerPlatform,
21 '0.1',
22 jest.fn(),
23);
24
25const mockCtx: RepositoryContext = {
26 cwd: 'cwd',
27 cmd: 'sl',
28 logger: mockLogger,
29 tracker: mockTracker,
30};
31
32describe('OperationQueue', () => {
33 it('runs command directly when nothing queued', async () => {
34 const p = defer();
35 const runCallback = jest.fn().mockImplementation(() => p.promise);
36 const queue = new OperationQueue(runCallback);
37
38 const onProgress = jest.fn();
39
40 const runPromise = queue.runOrQueueOperation(
41 mockCtx,
42 {
43 args: ['pull'],
44 id: '1',
45 runner: CommandRunner.Sapling,
46 trackEventName: 'PullOperation',
47 },
48 onProgress,
49 );
50 // calls synchronously
51 expect(runCallback).toHaveBeenCalledTimes(1);
52
53 p.resolve(undefined);
54 const result = await runPromise;
55 expect(result).toEqual('ran');
56
57 expect(runCallback).toHaveBeenCalledTimes(1);
58
59 expect(onProgress).not.toHaveBeenCalledWith(expect.objectContaining({kind: 'queue'}));
60 });
61
62 it('sends spawn and info messages', async () => {
63 const runCallback = jest
64 .fn()
65 .mockImplementation((_op, _cwd, prog: OperationCommandProgressReporter) => {
66 prog('spawn');
67 prog('stdout', 'hello');
68 prog('stderr', 'err');
69 prog('exit', 0);
70
71 return Promise.resolve(undefined);
72 });
73 const queue = new OperationQueue(runCallback);
74
75 const onProgress = jest.fn();
76 const runPromise = queue.runOrQueueOperation(
77 mockCtx,
78 {
79 args: ['pull'],
80 id: '1',
81 runner: CommandRunner.Sapling,
82 trackEventName: 'PullOperation',
83 },
84 onProgress,
85 );
86
87 const result = await runPromise;
88 expect(result).toEqual('ran');
89
90 expect(onProgress).toHaveBeenCalledWith(
91 expect.objectContaining({id: '1', kind: 'spawn', queue: []}),
92 );
93 expect(onProgress).toHaveBeenCalledWith(
94 expect.objectContaining({id: '1', kind: 'stdout', message: 'hello'}),
95 );
96 expect(onProgress).toHaveBeenCalledWith(
97 expect.objectContaining({id: '1', kind: 'stderr', message: 'err'}),
98 );
99 expect(onProgress).toHaveBeenCalledWith(
100 expect.objectContaining({id: '1', kind: 'exit', exitCode: 0}),
101 );
102 });
103
104 it('sends abort signal', async () => {
105 const runCallback = jest.fn().mockImplementation((_op, _cwd, prog, signal: AbortSignal) => {
106 const p = defer();
107 signal.addEventListener('abort', () => {
108 p.resolve(null);
109 prog('exit', 130);
110 });
111 return p;
112 });
113 const onProgress = jest.fn();
114 const queue = new OperationQueue(runCallback);
115 const id = 'abc';
116 const op = queue.runOrQueueOperation(
117 mockCtx,
118 {args: [], id, runner: CommandRunner.Sapling, trackEventName: 'RunOperation'},
119 onProgress,
120 );
121 queue.abortRunningOperation('wrong-id');
122 expect(onProgress).not.toHaveBeenCalled();
123 queue.abortRunningOperation(id);
124 const result = await op;
125 expect(result).toEqual('ran');
126
127 expect(onProgress).toHaveBeenCalledWith(
128 expect.objectContaining({id, kind: 'exit', exitCode: 130}),
129 );
130 });
131
132 it('queues up commands', async () => {
133 const p1 = defer();
134 const p2 = defer();
135 const runP1 = jest.fn(() => p1.promise);
136 const runP2 = jest.fn(() => p2.promise);
137 const runCallback = jest.fn().mockImplementationOnce(runP1).mockImplementationOnce(runP2);
138 const queue = new OperationQueue(runCallback);
139
140 const onProgress = jest.fn();
141 expect(runP1).not.toHaveBeenCalled();
142 expect(runP2).not.toHaveBeenCalled();
143
144 const runPromise1 = queue.runOrQueueOperation(
145 mockCtx,
146 {
147 args: ['pull'],
148 id: '1',
149 runner: CommandRunner.Sapling,
150 trackEventName: 'PullOperation',
151 },
152 onProgress,
153 );
154 expect(runP1).toHaveBeenCalled();
155 expect(runP2).not.toHaveBeenCalled();
156
157 const runPromise2 = queue.runOrQueueOperation(
158 mockCtx,
159 {
160 args: ['rebase'],
161 id: '2',
162 runner: CommandRunner.Sapling,
163 trackEventName: 'RebaseOperation',
164 },
165 onProgress,
166 );
167 expect(runP1).toHaveBeenCalled();
168 expect(runP2).not.toHaveBeenCalled(); // it's queued up
169 // should notify that the command queued when it is attempted to be run
170 expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({kind: 'queue', queue: ['2']}));
171
172 p1.resolve(undefined);
173 const result1 = await runPromise1;
174 expect(result1).toEqual('ran');
175
176 // now it's dequeued
177 expect(runP2).toHaveBeenCalled();
178
179 p2.resolve(undefined);
180 const result2 = await runPromise2;
181 expect(result2).toEqual('ran');
182
183 expect(runCallback).toHaveBeenCalledTimes(2);
184 });
185
186 it('clears queue when an operation fails', async () => {
187 const p1 = defer();
188 const p2 = defer();
189 const runP1 = jest.fn(() => p1.promise);
190 const runP2 = jest.fn(() => p2.promise);
191 const runCallback = jest.fn().mockImplementationOnce(runP1).mockImplementationOnce(runP2);
192 const queue = new OperationQueue(runCallback);
193
194 const onProgress = jest.fn();
195 expect(runP1).not.toHaveBeenCalled();
196 expect(runP2).not.toHaveBeenCalled();
197
198 const runPromise1 = queue.runOrQueueOperation(
199 mockCtx,
200 {
201 args: ['pull'],
202 id: '1',
203 runner: CommandRunner.Sapling,
204 trackEventName: 'PullOperation',
205 },
206 onProgress,
207 );
208 expect(runP1).toHaveBeenCalled();
209 expect(runP2).not.toHaveBeenCalled();
210 const runPromise2 = queue.runOrQueueOperation(
211 mockCtx,
212 {
213 args: ['rebase'],
214 id: '2',
215 runner: CommandRunner.Sapling,
216 trackEventName: 'RebaseOperation',
217 },
218 onProgress,
219 );
220 expect(runP1).toHaveBeenCalled();
221 expect(runP2).not.toHaveBeenCalled(); // it's queued up
222
223 p1.reject(new Error('fake error'));
224 // run promise still resolves, but error message was sent
225 const result1 = await runPromise1;
226 expect(result1).toEqual('ran');
227 expect(onProgress).toHaveBeenCalledWith(
228 expect.objectContaining({id: '1', kind: 'error', error: 'Error: fake error'}),
229 );
230
231 // p2 was cancelled by p1 failing
232 expect(runP2).not.toHaveBeenCalled();
233 const result2 = await runPromise2;
234 expect(result2).toEqual('skipped');
235 expect(runCallback).toHaveBeenCalledTimes(1);
236 });
237
238 it('can run commands again after an error', async () => {
239 const p1 = defer();
240 const p2 = defer();
241 const runP1 = jest.fn(() => p1.promise);
242 const runP2 = jest.fn(() => p2.promise);
243 const runCallback = jest.fn().mockImplementationOnce(runP1).mockImplementationOnce(runP2);
244 const queue = new OperationQueue(runCallback);
245
246 const onProgress = jest.fn();
247 expect(runP1).not.toHaveBeenCalled();
248 expect(runP2).not.toHaveBeenCalled();
249
250 const runPromise1 = queue.runOrQueueOperation(
251 mockCtx,
252 {
253 args: ['pull'],
254 id: '1',
255 runner: CommandRunner.Sapling,
256 trackEventName: 'PullOperation',
257 },
258 onProgress,
259 );
260
261 p1.reject(new Error('fake error'));
262 await runPromise1;
263
264 // after p1 errors, run another operation
265 const runPromise2 = queue.runOrQueueOperation(
266 mockCtx,
267 {
268 args: ['rebase'],
269 id: '2',
270 runner: CommandRunner.Sapling,
271 trackEventName: 'RebaseOperation',
272 },
273 onProgress,
274 );
275
276 // p2 runs immediately
277 p2.resolve(undefined);
278 await runPromise2;
279 expect(runP2).toHaveBeenCalled();
280 expect(runCallback).toHaveBeenCalledTimes(2);
281 });
282});
283