addons/isl-server/src/__tests__/analytics.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 {ServerSideTracker} from '../analytics/serverSideTracker';
b69ab319import type {FullTrackData} from '../analytics/types';
b69ab3110import type {ServerPlatform} from '../serverPlatform';
b69ab3111import type {RepositoryContext} from '../serverTypes';
b69ab3112
b69ab3113import * as ejeca from 'shared/ejeca';
b69ab3114import {mockLogger} from 'shared/testUtils';
b69ab3115import {defer} from 'shared/utils';
b69ab3116import {Repository} from '../Repository';
b69ab3117import {makeServerSideTracker} from '../analytics/serverSideTracker';
b69ab3118import {setConfigOverrideForTests} from '../commands';
b69ab3119
b69ab3120/** Matches any non-empty string */
b69ab3121const anyActualString = expect.stringMatching(/.+/);
b69ab3122
b69ab3123const mockTracker = makeServerSideTracker(
b69ab3124 mockLogger,
b69ab3125 {platformName: 'test'} as ServerPlatform,
b69ab3126 '0.1',
b69ab3127 jest.fn(),
b69ab3128);
b69ab3129
b69ab3130const mockCtx: RepositoryContext = {
b69ab3131 logger: mockLogger,
b69ab3132 tracker: mockTracker,
b69ab3133 cwd: '/test',
b69ab3134 cmd: 'sl',
b69ab3135};
b69ab3136
b69ab3137jest.mock('../WatchForChanges', () => {
b69ab3138 class MockWatchForChanges {
b69ab3139 dispose = jest.fn();
b69ab3140 }
b69ab3141 return {WatchForChanges: MockWatchForChanges};
b69ab3142});
b69ab3143
b69ab3144function mockEjeca(
b69ab3145 cmds: Array<[RegExp, (() => {stdout: string} | Error) | {stdout: string} | Error]>,
b69ab3146) {
b69ab3147 return jest.spyOn(ejeca, 'ejeca').mockImplementation(((cmd: string, args: Array<string>) => {
b69ab3148 const argStr = cmd + ' ' + args?.join(' ');
b69ab3149 const ejecaOther = {
b69ab3150 kill: jest.fn(),
b69ab3151 on: jest.fn((event, cb) => {
b69ab3152 // immediately call exit cb to teardown timeout
b69ab3153 if (event === 'exit') {
b69ab3154 cb();
b69ab3155 }
b69ab3156 }),
b69ab3157 };
b69ab3158 for (const [regex, output] of cmds) {
b69ab3159 if (regex.test(argStr)) {
b69ab3160 let value = output;
b69ab3161 if (typeof output === 'function') {
b69ab3162 value = output();
b69ab3163 }
b69ab3164 if (value instanceof Error) {
b69ab3165 throw value;
b69ab3166 }
b69ab3167 return {...ejecaOther, ...value};
b69ab3168 }
b69ab3169 }
b69ab3170 return {...ejecaOther, stdout: ''};
b69ab3171 }) as unknown as typeof ejeca.ejeca);
b69ab3172}
b69ab3173
b69ab3174describe('track', () => {
b69ab3175 const mockSendData = jest.fn();
b69ab3176 let tracker: ServerSideTracker;
b69ab3177
b69ab3178 beforeEach(() => {
b69ab3179 mockSendData.mockClear();
b69ab3180 tracker = makeServerSideTracker(
b69ab3181 mockLogger,
b69ab3182 {platformName: 'test'} as ServerPlatform,
b69ab3183 '0.1',
b69ab3184 mockSendData,
b69ab3185 );
b69ab3186 });
b69ab3187 it('tracks events', () => {
b69ab3188 tracker.track('ClickedRefresh');
b69ab3189 expect(mockSendData).toHaveBeenCalledWith(
b69ab3190 expect.objectContaining({eventName: 'ClickedRefresh'}),
b69ab3191 mockLogger,
b69ab3192 );
b69ab3193 });
b69ab3194
b69ab3195 it('defines all fields', () => {
b69ab3196 tracker.track('ClickedRefresh');
b69ab3197 expect(mockSendData).toHaveBeenCalledWith(
b69ab3198 {
b69ab3199 eventName: anyActualString,
b69ab31100 timestamp: expect.anything(),
b69ab31101 id: expect.anything(),
b69ab31102
b69ab31103 platform: 'test',
b69ab31104 version: '0.1',
b69ab31105 sessionId: anyActualString,
b69ab31106 unixname: anyActualString,
b69ab31107 repo: undefined,
b69ab31108 osType: anyActualString,
b69ab31109 osArch: anyActualString,
b69ab31110 osRelease: anyActualString,
b69ab31111 hostname: anyActualString,
b69ab31112 },
b69ab31113 mockLogger,
b69ab31114 );
b69ab31115 });
b69ab31116
b69ab31117 it('allows setting repository', () => {
b69ab31118 // No need to call the actual command lines to test tracking
b69ab31119 setConfigOverrideForTests([
b69ab31120 ['path.default', 'https://github.com/facebook/sapling.git'],
b69ab31121 ['github.pull_request_domain', 'github.com'],
b69ab31122 ]);
b69ab31123 const ejecaSpy = mockEjeca([
b69ab31124 [/^sl root --dotdir/, {stdout: '/path/to/myRepo/.sl'}],
b69ab31125 [/^sl root/, {stdout: '/path/to/myRepo'}],
b69ab31126 [
b69ab31127 /^gh auth status --hostname gitlab.myCompany.com/,
b69ab31128 new Error('not authenticated on this hostname'),
b69ab31129 ],
b69ab31130 [/^gh auth status --hostname ghe.myCompany.com/, {stdout: ''}],
b69ab31131 [/^gh api graphql/, {stdout: '{}'}],
b69ab31132 ]);
b69ab31133
b69ab31134 const repo = new Repository(
b69ab31135 {
b69ab31136 type: 'success',
b69ab31137 codeReviewSystem: {
b69ab31138 type: 'github',
b69ab31139 repo: 'sapling',
b69ab31140 owner: 'facebook',
b69ab31141 hostname: 'github.com',
b69ab31142 },
b69ab31143 command: 'sl',
b69ab31144 repoRoot: '/path',
b69ab31145 dotdir: '/path/.sl',
b69ab31146 pullRequestDomain: undefined,
b69ab31147 isEdenFs: false,
b69ab31148 },
b69ab31149 mockCtx,
b69ab31150 );
b69ab31151 tracker.context.setRepo(repo);
b69ab31152 tracker.track('ClickedRefresh');
b69ab31153 expect(mockSendData).toHaveBeenCalledWith(
b69ab31154 expect.objectContaining({
b69ab31155 repo: 'github:github.com/facebook/sapling',
b69ab31156 }),
b69ab31157 mockLogger,
b69ab31158 );
b69ab31159 repo.dispose();
b69ab31160 ejecaSpy.mockClear();
b69ab31161 });
b69ab31162
b69ab31163 it('uses consistent session id, but different track ids', () => {
b69ab31164 tracker.track('ClickedRefresh');
b69ab31165 tracker.track('ClickedRefresh');
b69ab31166 const call0 = mockSendData.mock.calls[0][0] as FullTrackData;
b69ab31167 const call1 = mockSendData.mock.calls[1][0] as FullTrackData;
b69ab31168 expect(call0.id).not.toEqual(call1.id);
b69ab31169 expect(call0.sessionId).toEqual(call1.sessionId);
b69ab31170 });
b69ab31171
b69ab31172 it('supports trees of events via trackAsParent', () => {
b69ab31173 const childTracker = tracker.trackAsParent('ClickedRefresh');
b69ab31174 childTracker.track('ClickedRefresh');
b69ab31175 const call0 = mockSendData.mock.calls[0][0];
b69ab31176 const call1 = mockSendData.mock.calls[1][0];
b69ab31177 expect(call0.id).toEqual(call1.parentId);
b69ab31178 });
b69ab31179
b69ab31180 describe('trackError', () => {
b69ab31181 it('handles synchronous operations throwing', () => {
b69ab31182 tracker.error('ClickedRefresh', 'RepositoryError', new Error('uh oh'), {});
b69ab31183 expect(mockSendData).toHaveBeenCalledWith(
b69ab31184 expect.objectContaining({
b69ab31185 eventName: 'ClickedRefresh',
b69ab31186 errorName: 'RepositoryError',
b69ab31187 errorMessage: 'uh oh',
b69ab31188 }),
b69ab31189 mockLogger,
b69ab31190 );
b69ab31191 });
b69ab31192 });
b69ab31193
b69ab31194 describe('trackOperation', () => {
b69ab31195 it('handles synchronous operations', () => {
b69ab31196 const op = jest.fn();
b69ab31197 tracker.operation('ClickedRefresh', 'RepositoryError', {}, op);
b69ab31198 expect(mockSendData).toHaveBeenCalledWith(
b69ab31199 expect.objectContaining({
b69ab31200 eventName: 'ClickedRefresh',
b69ab31201 }),
b69ab31202 mockLogger,
b69ab31203 );
b69ab31204 // there should not be an error field filled out
b69ab31205 expect(mockSendData).not.toHaveBeenCalledWith(
b69ab31206 expect.objectContaining({
b69ab31207 errorName: anyActualString,
b69ab31208 errorMessage: anyActualString,
b69ab31209 }),
b69ab31210 mockLogger,
b69ab31211 );
b69ab31212 expect(op).toHaveBeenCalled();
b69ab31213 });
b69ab31214
b69ab31215 it('handles synchronous operations throwing', () => {
b69ab31216 const op = jest.fn().mockImplementation(() => {
b69ab31217 throw new Error('uh oh');
b69ab31218 });
b69ab31219 expect(() => tracker.operation('ClickedRefresh', 'RepositoryError', {}, op)).toThrow();
b69ab31220 expect(mockSendData).toHaveBeenCalledWith(
b69ab31221 expect.objectContaining({
b69ab31222 eventName: 'ClickedRefresh',
b69ab31223 errorName: 'RepositoryError',
b69ab31224 errorMessage: 'uh oh',
b69ab31225 }),
b69ab31226 mockLogger,
b69ab31227 );
b69ab31228 expect(op).toHaveBeenCalled();
b69ab31229 });
b69ab31230
b69ab31231 it('handles async operations', async () => {
b69ab31232 const d = defer();
b69ab31233 const op = jest.fn().mockImplementation(() => {
b69ab31234 return d.promise;
b69ab31235 });
b69ab31236
b69ab31237 const promise = tracker.operation('ClickedRefresh', 'RepositoryError', {}, op);
b69ab31238 expect(mockSendData).not.toHaveBeenCalled();
b69ab31239
b69ab31240 d.resolve(1);
b69ab31241
b69ab31242 await promise;
b69ab31243
b69ab31244 expect(mockSendData).toHaveBeenCalledWith(
b69ab31245 expect.objectContaining({
b69ab31246 eventName: 'ClickedRefresh',
b69ab31247 }),
b69ab31248 mockLogger,
b69ab31249 );
b69ab31250 // there should not be an error field filled out
b69ab31251 expect(mockSendData).not.toHaveBeenCalledWith(
b69ab31252 expect.objectContaining({
b69ab31253 errorName: anyActualString,
b69ab31254 errorMessage: anyActualString,
b69ab31255 }),
b69ab31256 mockLogger,
b69ab31257 );
b69ab31258 expect(op).toHaveBeenCalled();
b69ab31259 });
b69ab31260
b69ab31261 it('handles async operations throwing', async () => {
b69ab31262 const d = defer();
b69ab31263 const op = jest.fn().mockImplementation(() => {
b69ab31264 return d.promise;
b69ab31265 });
b69ab31266
b69ab31267 const promise = tracker.operation('ClickedRefresh', 'RepositoryError', {}, op);
b69ab31268 expect(mockSendData).not.toHaveBeenCalled();
b69ab31269
b69ab31270 d.reject(new Error('oh no'));
b69ab31271
b69ab31272 await expect(promise).rejects.toEqual(new Error('oh no'));
b69ab31273
b69ab31274 expect(mockSendData).toHaveBeenCalledWith(
b69ab31275 expect.objectContaining({
b69ab31276 eventName: 'ClickedRefresh',
b69ab31277 errorName: 'RepositoryError',
b69ab31278 errorMessage: 'oh no',
b69ab31279 }),
b69ab31280 mockLogger,
b69ab31281 );
b69ab31282 expect(op).toHaveBeenCalled();
b69ab31283 });
b69ab31284 });
b69ab31285});