8.1 KB286 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 {ServerSideTracker} from '../analytics/serverSideTracker';
9import type {FullTrackData} from '../analytics/types';
10import type {ServerPlatform} from '../serverPlatform';
11import type {RepositoryContext} from '../serverTypes';
12
13import * as ejeca from 'shared/ejeca';
14import {mockLogger} from 'shared/testUtils';
15import {defer} from 'shared/utils';
16import {Repository} from '../Repository';
17import {makeServerSideTracker} from '../analytics/serverSideTracker';
18import {setConfigOverrideForTests} from '../commands';
19
20/** Matches any non-empty string */
21const anyActualString = expect.stringMatching(/.+/);
22
23const mockTracker = makeServerSideTracker(
24 mockLogger,
25 {platformName: 'test'} as ServerPlatform,
26 '0.1',
27 jest.fn(),
28);
29
30const mockCtx: RepositoryContext = {
31 logger: mockLogger,
32 tracker: mockTracker,
33 cwd: '/test',
34 cmd: 'sl',
35};
36
37jest.mock('../WatchForChanges', () => {
38 class MockWatchForChanges {
39 dispose = jest.fn();
40 }
41 return {WatchForChanges: MockWatchForChanges};
42});
43
44function mockEjeca(
45 cmds: Array<[RegExp, (() => {stdout: string} | Error) | {stdout: string} | Error]>,
46) {
47 return jest.spyOn(ejeca, 'ejeca').mockImplementation(((cmd: string, args: Array<string>) => {
48 const argStr = cmd + ' ' + args?.join(' ');
49 const ejecaOther = {
50 kill: jest.fn(),
51 on: jest.fn((event, cb) => {
52 // immediately call exit cb to teardown timeout
53 if (event === 'exit') {
54 cb();
55 }
56 }),
57 };
58 for (const [regex, output] of cmds) {
59 if (regex.test(argStr)) {
60 let value = output;
61 if (typeof output === 'function') {
62 value = output();
63 }
64 if (value instanceof Error) {
65 throw value;
66 }
67 return {...ejecaOther, ...value};
68 }
69 }
70 return {...ejecaOther, stdout: ''};
71 }) as unknown as typeof ejeca.ejeca);
72}
73
74describe('track', () => {
75 const mockSendData = jest.fn();
76 let tracker: ServerSideTracker;
77
78 beforeEach(() => {
79 mockSendData.mockClear();
80 tracker = makeServerSideTracker(
81 mockLogger,
82 {platformName: 'test'} as ServerPlatform,
83 '0.1',
84 mockSendData,
85 );
86 });
87 it('tracks events', () => {
88 tracker.track('ClickedRefresh');
89 expect(mockSendData).toHaveBeenCalledWith(
90 expect.objectContaining({eventName: 'ClickedRefresh'}),
91 mockLogger,
92 );
93 });
94
95 it('defines all fields', () => {
96 tracker.track('ClickedRefresh');
97 expect(mockSendData).toHaveBeenCalledWith(
98 {
99 eventName: anyActualString,
100 timestamp: expect.anything(),
101 id: expect.anything(),
102
103 platform: 'test',
104 version: '0.1',
105 sessionId: anyActualString,
106 unixname: anyActualString,
107 repo: undefined,
108 osType: anyActualString,
109 osArch: anyActualString,
110 osRelease: anyActualString,
111 hostname: anyActualString,
112 },
113 mockLogger,
114 );
115 });
116
117 it('allows setting repository', () => {
118 // No need to call the actual command lines to test tracking
119 setConfigOverrideForTests([
120 ['path.default', 'https://github.com/facebook/sapling.git'],
121 ['github.pull_request_domain', 'github.com'],
122 ]);
123 const ejecaSpy = mockEjeca([
124 [/^sl root --dotdir/, {stdout: '/path/to/myRepo/.sl'}],
125 [/^sl root/, {stdout: '/path/to/myRepo'}],
126 [
127 /^gh auth status --hostname gitlab.myCompany.com/,
128 new Error('not authenticated on this hostname'),
129 ],
130 [/^gh auth status --hostname ghe.myCompany.com/, {stdout: ''}],
131 [/^gh api graphql/, {stdout: '{}'}],
132 ]);
133
134 const repo = new Repository(
135 {
136 type: 'success',
137 codeReviewSystem: {
138 type: 'github',
139 repo: 'sapling',
140 owner: 'facebook',
141 hostname: 'github.com',
142 },
143 command: 'sl',
144 repoRoot: '/path',
145 dotdir: '/path/.sl',
146 pullRequestDomain: undefined,
147 isEdenFs: false,
148 },
149 mockCtx,
150 );
151 tracker.context.setRepo(repo);
152 tracker.track('ClickedRefresh');
153 expect(mockSendData).toHaveBeenCalledWith(
154 expect.objectContaining({
155 repo: 'github:github.com/facebook/sapling',
156 }),
157 mockLogger,
158 );
159 repo.dispose();
160 ejecaSpy.mockClear();
161 });
162
163 it('uses consistent session id, but different track ids', () => {
164 tracker.track('ClickedRefresh');
165 tracker.track('ClickedRefresh');
166 const call0 = mockSendData.mock.calls[0][0] as FullTrackData;
167 const call1 = mockSendData.mock.calls[1][0] as FullTrackData;
168 expect(call0.id).not.toEqual(call1.id);
169 expect(call0.sessionId).toEqual(call1.sessionId);
170 });
171
172 it('supports trees of events via trackAsParent', () => {
173 const childTracker = tracker.trackAsParent('ClickedRefresh');
174 childTracker.track('ClickedRefresh');
175 const call0 = mockSendData.mock.calls[0][0];
176 const call1 = mockSendData.mock.calls[1][0];
177 expect(call0.id).toEqual(call1.parentId);
178 });
179
180 describe('trackError', () => {
181 it('handles synchronous operations throwing', () => {
182 tracker.error('ClickedRefresh', 'RepositoryError', new Error('uh oh'), {});
183 expect(mockSendData).toHaveBeenCalledWith(
184 expect.objectContaining({
185 eventName: 'ClickedRefresh',
186 errorName: 'RepositoryError',
187 errorMessage: 'uh oh',
188 }),
189 mockLogger,
190 );
191 });
192 });
193
194 describe('trackOperation', () => {
195 it('handles synchronous operations', () => {
196 const op = jest.fn();
197 tracker.operation('ClickedRefresh', 'RepositoryError', {}, op);
198 expect(mockSendData).toHaveBeenCalledWith(
199 expect.objectContaining({
200 eventName: 'ClickedRefresh',
201 }),
202 mockLogger,
203 );
204 // there should not be an error field filled out
205 expect(mockSendData).not.toHaveBeenCalledWith(
206 expect.objectContaining({
207 errorName: anyActualString,
208 errorMessage: anyActualString,
209 }),
210 mockLogger,
211 );
212 expect(op).toHaveBeenCalled();
213 });
214
215 it('handles synchronous operations throwing', () => {
216 const op = jest.fn().mockImplementation(() => {
217 throw new Error('uh oh');
218 });
219 expect(() => tracker.operation('ClickedRefresh', 'RepositoryError', {}, op)).toThrow();
220 expect(mockSendData).toHaveBeenCalledWith(
221 expect.objectContaining({
222 eventName: 'ClickedRefresh',
223 errorName: 'RepositoryError',
224 errorMessage: 'uh oh',
225 }),
226 mockLogger,
227 );
228 expect(op).toHaveBeenCalled();
229 });
230
231 it('handles async operations', async () => {
232 const d = defer();
233 const op = jest.fn().mockImplementation(() => {
234 return d.promise;
235 });
236
237 const promise = tracker.operation('ClickedRefresh', 'RepositoryError', {}, op);
238 expect(mockSendData).not.toHaveBeenCalled();
239
240 d.resolve(1);
241
242 await promise;
243
244 expect(mockSendData).toHaveBeenCalledWith(
245 expect.objectContaining({
246 eventName: 'ClickedRefresh',
247 }),
248 mockLogger,
249 );
250 // there should not be an error field filled out
251 expect(mockSendData).not.toHaveBeenCalledWith(
252 expect.objectContaining({
253 errorName: anyActualString,
254 errorMessage: anyActualString,
255 }),
256 mockLogger,
257 );
258 expect(op).toHaveBeenCalled();
259 });
260
261 it('handles async operations throwing', async () => {
262 const d = defer();
263 const op = jest.fn().mockImplementation(() => {
264 return d.promise;
265 });
266
267 const promise = tracker.operation('ClickedRefresh', 'RepositoryError', {}, op);
268 expect(mockSendData).not.toHaveBeenCalled();
269
270 d.reject(new Error('oh no'));
271
272 await expect(promise).rejects.toEqual(new Error('oh no'));
273
274 expect(mockSendData).toHaveBeenCalledWith(
275 expect.objectContaining({
276 eventName: 'ClickedRefresh',
277 errorName: 'RepositoryError',
278 errorMessage: 'oh no',
279 }),
280 mockLogger,
281 );
282 expect(op).toHaveBeenCalled();
283 });
284 });
285});
286