8.6 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 {Level} from 'isl-server/src/logger';
9import type {ServerPlatform} from 'isl-server/src/serverPlatform';
10import type {RepositoryContext} from 'isl-server/src/serverTypes';
11import type {TypedEventEmitter} from 'shared/TypedEventEmitter';
12import type {EjecaOptions} from 'shared/ejeca';
13import type {MessageBusStatus} from '../src/MessageBus';
14import type {Disposable, RepoRelativePath} from '../src/types';
15
16import {fireEvent, render, screen} from '@testing-library/react';
17import {makeServerSideTracker} from 'isl-server/src/analytics/serverSideTracker';
18import {runCommand} from 'isl-server/src/commands';
19import {StdoutLogger} from 'isl-server/src/logger';
20import fs from 'node:fs';
21import os from 'node:os';
22import path from 'node:path';
23import {onClientConnection} from '../../isl-server/src/index';
24import platform from '../src/platform';
25
26const IS_CI = !!process.env.SANDCASTLE || !!process.env.GITHUB_ACTIONS;
27
28const mockTracker = makeServerSideTracker(
29 new StdoutLogger(),
30 {platformName: 'test'} as ServerPlatform,
31 '0.1',
32 jest.fn(),
33);
34
35// fake client message bus that connects to server in the same process
36jest.mock('../src/LocalWebSocketEventBus', () => {
37 const {TypedEventEmitter} =
38 // this mock implementation is hoisted above all other imports, so we can't use imports "normally"
39 // eslint-disable-next-line @typescript-eslint/no-var-requires,@typescript-eslint/consistent-type-imports
40 require('shared/TypedEventEmitter') as typeof import('shared/TypedEventEmitter');
41
42 const log = console.log.bind(console);
43
44 class IntegrationTestMessageBus {
45 disposables: Array<() => void> = [];
46 onMessage(handler: (event: MessageEvent<string>) => void | Promise<void>): Disposable {
47 const cb = (message: string) => {
48 log('[c <- s]', message);
49 handler({data: message} as MessageEvent<string>);
50 };
51
52 this.serverToClient.on('data', cb);
53 return {
54 dispose: () => {
55 this.serverToClient.off('data', cb);
56 },
57 };
58 }
59
60 postMessage(message: string | ArrayBuffer) {
61 log('[c -> s]', message);
62 this.clientToServer.emit('data', message as string);
63 }
64
65 public statusChangeHandlers = new Set<(status: MessageBusStatus) => unknown>();
66 onChangeStatus(handler: (status: MessageBusStatus) => unknown): Disposable {
67 // pretend connection opens immediately
68 handler({type: 'open'});
69 this.statusChangeHandlers.add(handler);
70
71 return {
72 dispose: () => {
73 this.statusChangeHandlers.delete(handler);
74 },
75 };
76 }
77
78 /**** extra methods for testing ****/
79
80 clientToServer = new TypedEventEmitter<'data', string | ArrayBuffer>();
81 serverToClient = new TypedEventEmitter<'data', string>();
82
83 dispose = () => {
84 this.clientToServer.removeAllListeners();
85 this.serverToClient.removeAllListeners();
86 };
87 }
88
89 return {LocalWebSocketEventBus: IntegrationTestMessageBus};
90});
91
92type MockedClientMessageBus = {
93 clientToServer: TypedEventEmitter<'data', string | ArrayBuffer>;
94 serverToClient: TypedEventEmitter<'data', string>;
95 dispose(): void;
96};
97
98beforeAll(() => {
99 global.ResizeObserver = class ResizeObserver {
100 observe() {
101 /* noop */
102 }
103 unobserve() {
104 /* noop */
105 }
106 disconnect() {
107 /* noop */
108 }
109 };
110});
111
112class TaggedStdoutLogger extends StdoutLogger {
113 constructor(private tag: string) {
114 super();
115 }
116
117 write(level: Level, timeStr: string, ...args: Parameters<typeof console.log>): void {
118 super.write(level, timeStr, this.tag, ...args);
119 }
120}
121
122/**
123 * Creates an sl repository in a temp dir on disk,
124 * creates a single initial commit,
125 * then performs an initial render, running both server and client in the same process.
126 */
127export async function initRepo() {
128 const repoDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'isl-integration-test-repo-'));
129 const testLogger = new TaggedStdoutLogger('[ test ]');
130
131 let cmd = 'sl';
132 if (process.env.SANDCASTLE) {
133 // On internal CI, it's easiest to run 'hg' instead of 'sl'.
134 cmd = 'hg';
135 process.env.PATH += ':/bin/hg';
136 }
137
138 testLogger.info('sl cmd: ', cmd);
139
140 testLogger.log('temp repo: ', repoDir);
141 process.chdir(repoDir);
142
143 const ctx: RepositoryContext = {
144 cmd,
145 cwd: repoDir,
146 logger: testLogger,
147 tracker: mockTracker,
148 };
149
150 async function sl(args: Array<string>, options?: EjecaOptions) {
151 testLogger.log(ctx.cmd, ...args);
152 const result = await runCommand(ctx, args, {
153 ...options,
154 env: {
155 ...process.env,
156 ...(options?.env ?? {}),
157 FB_SCM_DIAGS_NO_SCUBA: '1',
158 } as Record<string, string> as NodeJS.ProcessEnv,
159 extendEnv: true,
160 });
161 return result;
162 }
163
164 async function writeFileInRepo(filePath: RepoRelativePath, content: string): Promise<void> {
165 await fs.promises.writeFile(path.join(repoDir, filePath), content, 'utf8');
166 }
167
168 /**
169 * create test commit history from a diagram.
170 * See https://sapling-scm.com/docs/internals/drawdag
171 */
172 async function drawdag(dag: string): Promise<void> {
173 // by default, drawdag sets date to 0,
174 // but this would hide all commits in the ISL,
175 // so we set "now" to our javascript date so all new commits are fetched,
176 // then make our commits relative to that
177 const pythonLabel = 'python:';
178 const input = `${dag}
179${dag.includes(pythonLabel) ? '' : pythonLabel}
180now('${new Date().toISOString()}') # set the "now" time
181commit(date='now')
182 `;
183 await sl(['debugdrawdag'], {input});
184 }
185
186 await sl(['version'])
187 .catch(e => {
188 testLogger.log('err in version', e);
189 return e;
190 })
191 .then(s => {
192 testLogger.log('sl version: ', s.stdout, s.stderr, s.exitCode);
193 });
194
195 // set up empty repo
196 await sl(['init', '--config=format.use-eager-repo=True', '--config=init.prefer-git=False', '.']);
197 await writeFileInRepo('.watchmanconfig', '{}');
198 const dotdir = fs.existsSync(path.join(repoDir, '.sl')) ? '.sl' : '.hg';
199 // write to repo config
200 await writeFileInRepo(
201 `${dotdir}/config`,
202 ([['paths', [`default=eager:${repoDir}`]]] as [string, string[]][])
203 .map(([section, configs]) => `[${section}]\n${configs.join('\n')}`)
204 .join('\n'),
205 );
206 await writeFileInRepo('file.txt', 'hello');
207 await sl(['commit', '-A', '-m', 'Initial Commit']);
208
209 const {
210 serverToClient,
211 clientToServer,
212 dispose: disposeClientConnection,
213 } = platform.messageBus as unknown as MockedClientMessageBus;
214
215 const serverLogger = new TaggedStdoutLogger('[server]');
216
217 // start "server" in the same process, connected to fake client message bus via eventEmitters
218 const disposeServer = onClientConnection({
219 cwd: repoDir,
220 version: 'integration-test',
221 command: cmd,
222 logger: serverLogger,
223 appMode: {mode: 'isl'},
224
225 postMessage(message: string): Promise<boolean> {
226 serverToClient.emit('data', message);
227 return Promise.resolve(true);
228 },
229 onDidReceiveMessage(handler: (event: Buffer, isBinary: boolean) => void | Promise<void>): {
230 dispose(): void;
231 } {
232 const cb = (e: string | ArrayBuffer) => {
233 e instanceof ArrayBuffer
234 ? handler(e as Buffer, true)
235 : handler(Buffer.from(e, 'utf8'), false);
236 };
237 clientToServer.on('data', cb);
238 return {
239 dispose: () => {
240 clientToServer.off('data', cb);
241 },
242 };
243 },
244 });
245
246 const refresh = () => {
247 testLogger.log('refreshing');
248 fireEvent.click(screen.getByTestId('refresh-button'));
249 };
250
251 // Dynamically import App so our test setup happens before App globals like jotai state are run.
252 const App = (await import('../src/App')).default;
253 // Render the entire app, which automatically starts the connection to the server
254 render(<App />);
255
256 return {
257 repoDir,
258 sl,
259 cleanup: async () => {
260 testLogger.log(' -------- cleaning up -------- ');
261 disposeServer();
262 disposeClientConnection();
263 if (!IS_CI) {
264 testLogger.log('removing repo dir');
265 // rm -rf the temp dir with the repo in it
266 // skip on CI because it can cause flakiness, and the job will get cleaned up anyway
267 await retry(() => fs.promises.rm(repoDir, {recursive: true, force: true})).catch(() => {
268 testLogger.log('failed to clean up temp dir: ', repoDir);
269 });
270 }
271 },
272 writeFileInRepo,
273 drawdag,
274 testLogger,
275 refresh,
276 };
277}
278
279async function retry<T>(cb: () => Promise<T>): Promise<T> {
280 try {
281 return await cb();
282 } catch {
283 return cb();
284 }
285}
286