addons/isl/integrationTests/setup.tsxblame
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 {Level} from 'isl-server/src/logger';
b69ab319import type {ServerPlatform} from 'isl-server/src/serverPlatform';
b69ab3110import type {RepositoryContext} from 'isl-server/src/serverTypes';
b69ab3111import type {TypedEventEmitter} from 'shared/TypedEventEmitter';
b69ab3112import type {EjecaOptions} from 'shared/ejeca';
b69ab3113import type {MessageBusStatus} from '../src/MessageBus';
b69ab3114import type {Disposable, RepoRelativePath} from '../src/types';
b69ab3115
b69ab3116import {fireEvent, render, screen} from '@testing-library/react';
b69ab3117import {makeServerSideTracker} from 'isl-server/src/analytics/serverSideTracker';
b69ab3118import {runCommand} from 'isl-server/src/commands';
b69ab3119import {StdoutLogger} from 'isl-server/src/logger';
b69ab3120import fs from 'node:fs';
b69ab3121import os from 'node:os';
b69ab3122import path from 'node:path';
b69ab3123import {onClientConnection} from '../../isl-server/src/index';
b69ab3124import platform from '../src/platform';
b69ab3125
b69ab3126const IS_CI = !!process.env.SANDCASTLE || !!process.env.GITHUB_ACTIONS;
b69ab3127
b69ab3128const mockTracker = makeServerSideTracker(
b69ab3129 new StdoutLogger(),
b69ab3130 {platformName: 'test'} as ServerPlatform,
b69ab3131 '0.1',
b69ab3132 jest.fn(),
b69ab3133);
b69ab3134
b69ab3135// fake client message bus that connects to server in the same process
b69ab3136jest.mock('../src/LocalWebSocketEventBus', () => {
b69ab3137 const {TypedEventEmitter} =
b69ab3138 // this mock implementation is hoisted above all other imports, so we can't use imports "normally"
b69ab3139 // eslint-disable-next-line @typescript-eslint/no-var-requires,@typescript-eslint/consistent-type-imports
b69ab3140 require('shared/TypedEventEmitter') as typeof import('shared/TypedEventEmitter');
b69ab3141
b69ab3142 const log = console.log.bind(console);
b69ab3143
b69ab3144 class IntegrationTestMessageBus {
b69ab3145 disposables: Array<() => void> = [];
b69ab3146 onMessage(handler: (event: MessageEvent<string>) => void | Promise<void>): Disposable {
b69ab3147 const cb = (message: string) => {
b69ab3148 log('[c <- s]', message);
b69ab3149 handler({data: message} as MessageEvent<string>);
b69ab3150 };
b69ab3151
b69ab3152 this.serverToClient.on('data', cb);
b69ab3153 return {
b69ab3154 dispose: () => {
b69ab3155 this.serverToClient.off('data', cb);
b69ab3156 },
b69ab3157 };
b69ab3158 }
b69ab3159
b69ab3160 postMessage(message: string | ArrayBuffer) {
b69ab3161 log('[c -> s]', message);
b69ab3162 this.clientToServer.emit('data', message as string);
b69ab3163 }
b69ab3164
b69ab3165 public statusChangeHandlers = new Set<(status: MessageBusStatus) => unknown>();
b69ab3166 onChangeStatus(handler: (status: MessageBusStatus) => unknown): Disposable {
b69ab3167 // pretend connection opens immediately
b69ab3168 handler({type: 'open'});
b69ab3169 this.statusChangeHandlers.add(handler);
b69ab3170
b69ab3171 return {
b69ab3172 dispose: () => {
b69ab3173 this.statusChangeHandlers.delete(handler);
b69ab3174 },
b69ab3175 };
b69ab3176 }
b69ab3177
b69ab3178 /**** extra methods for testing ****/
b69ab3179
b69ab3180 clientToServer = new TypedEventEmitter<'data', string | ArrayBuffer>();
b69ab3181 serverToClient = new TypedEventEmitter<'data', string>();
b69ab3182
b69ab3183 dispose = () => {
b69ab3184 this.clientToServer.removeAllListeners();
b69ab3185 this.serverToClient.removeAllListeners();
b69ab3186 };
b69ab3187 }
b69ab3188
b69ab3189 return {LocalWebSocketEventBus: IntegrationTestMessageBus};
b69ab3190});
b69ab3191
b69ab3192type MockedClientMessageBus = {
b69ab3193 clientToServer: TypedEventEmitter<'data', string | ArrayBuffer>;
b69ab3194 serverToClient: TypedEventEmitter<'data', string>;
b69ab3195 dispose(): void;
b69ab3196};
b69ab3197
b69ab3198beforeAll(() => {
b69ab3199 global.ResizeObserver = class ResizeObserver {
b69ab31100 observe() {
b69ab31101 /* noop */
b69ab31102 }
b69ab31103 unobserve() {
b69ab31104 /* noop */
b69ab31105 }
b69ab31106 disconnect() {
b69ab31107 /* noop */
b69ab31108 }
b69ab31109 };
b69ab31110});
b69ab31111
b69ab31112class TaggedStdoutLogger extends StdoutLogger {
b69ab31113 constructor(private tag: string) {
b69ab31114 super();
b69ab31115 }
b69ab31116
b69ab31117 write(level: Level, timeStr: string, ...args: Parameters<typeof console.log>): void {
b69ab31118 super.write(level, timeStr, this.tag, ...args);
b69ab31119 }
b69ab31120}
b69ab31121
b69ab31122/**
b69ab31123 * Creates an sl repository in a temp dir on disk,
b69ab31124 * creates a single initial commit,
b69ab31125 * then performs an initial render, running both server and client in the same process.
b69ab31126 */
b69ab31127export async function initRepo() {
b69ab31128 const repoDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'isl-integration-test-repo-'));
b69ab31129 const testLogger = new TaggedStdoutLogger('[ test ]');
b69ab31130
b69ab31131 let cmd = 'sl';
b69ab31132 if (process.env.SANDCASTLE) {
b69ab31133 // On internal CI, it's easiest to run 'hg' instead of 'sl'.
b69ab31134 cmd = 'hg';
b69ab31135 process.env.PATH += ':/bin/hg';
b69ab31136 }
b69ab31137
b69ab31138 testLogger.info('sl cmd: ', cmd);
b69ab31139
b69ab31140 testLogger.log('temp repo: ', repoDir);
b69ab31141 process.chdir(repoDir);
b69ab31142
b69ab31143 const ctx: RepositoryContext = {
b69ab31144 cmd,
b69ab31145 cwd: repoDir,
b69ab31146 logger: testLogger,
b69ab31147 tracker: mockTracker,
b69ab31148 };
b69ab31149
b69ab31150 async function sl(args: Array<string>, options?: EjecaOptions) {
b69ab31151 testLogger.log(ctx.cmd, ...args);
b69ab31152 const result = await runCommand(ctx, args, {
b69ab31153 ...options,
b69ab31154 env: {
b69ab31155 ...process.env,
b69ab31156 ...(options?.env ?? {}),
b69ab31157 FB_SCM_DIAGS_NO_SCUBA: '1',
b69ab31158 } as Record<string, string> as NodeJS.ProcessEnv,
b69ab31159 extendEnv: true,
b69ab31160 });
b69ab31161 return result;
b69ab31162 }
b69ab31163
b69ab31164 async function writeFileInRepo(filePath: RepoRelativePath, content: string): Promise<void> {
b69ab31165 await fs.promises.writeFile(path.join(repoDir, filePath), content, 'utf8');
b69ab31166 }
b69ab31167
b69ab31168 /**
b69ab31169 * create test commit history from a diagram.
b69ab31170 * See https://sapling-scm.com/docs/internals/drawdag
b69ab31171 */
b69ab31172 async function drawdag(dag: string): Promise<void> {
b69ab31173 // by default, drawdag sets date to 0,
b69ab31174 // but this would hide all commits in the ISL,
b69ab31175 // so we set "now" to our javascript date so all new commits are fetched,
b69ab31176 // then make our commits relative to that
b69ab31177 const pythonLabel = 'python:';
b69ab31178 const input = `${dag}
b69ab31179${dag.includes(pythonLabel) ? '' : pythonLabel}
b69ab31180now('${new Date().toISOString()}') # set the "now" time
b69ab31181commit(date='now')
b69ab31182 `;
b69ab31183 await sl(['debugdrawdag'], {input});
b69ab31184 }
b69ab31185
b69ab31186 await sl(['version'])
b69ab31187 .catch(e => {
b69ab31188 testLogger.log('err in version', e);
b69ab31189 return e;
b69ab31190 })
b69ab31191 .then(s => {
b69ab31192 testLogger.log('sl version: ', s.stdout, s.stderr, s.exitCode);
b69ab31193 });
b69ab31194
b69ab31195 // set up empty repo
b69ab31196 await sl(['init', '--config=format.use-eager-repo=True', '--config=init.prefer-git=False', '.']);
b69ab31197 await writeFileInRepo('.watchmanconfig', '{}');
b69ab31198 const dotdir = fs.existsSync(path.join(repoDir, '.sl')) ? '.sl' : '.hg';
b69ab31199 // write to repo config
b69ab31200 await writeFileInRepo(
b69ab31201 `${dotdir}/config`,
b69ab31202 ([['paths', [`default=eager:${repoDir}`]]] as [string, string[]][])
b69ab31203 .map(([section, configs]) => `[${section}]\n${configs.join('\n')}`)
b69ab31204 .join('\n'),
b69ab31205 );
b69ab31206 await writeFileInRepo('file.txt', 'hello');
b69ab31207 await sl(['commit', '-A', '-m', 'Initial Commit']);
b69ab31208
b69ab31209 const {
b69ab31210 serverToClient,
b69ab31211 clientToServer,
b69ab31212 dispose: disposeClientConnection,
b69ab31213 } = platform.messageBus as unknown as MockedClientMessageBus;
b69ab31214
b69ab31215 const serverLogger = new TaggedStdoutLogger('[server]');
b69ab31216
b69ab31217 // start "server" in the same process, connected to fake client message bus via eventEmitters
b69ab31218 const disposeServer = onClientConnection({
b69ab31219 cwd: repoDir,
b69ab31220 version: 'integration-test',
b69ab31221 command: cmd,
b69ab31222 logger: serverLogger,
b69ab31223 appMode: {mode: 'isl'},
b69ab31224
b69ab31225 postMessage(message: string): Promise<boolean> {
b69ab31226 serverToClient.emit('data', message);
b69ab31227 return Promise.resolve(true);
b69ab31228 },
b69ab31229 onDidReceiveMessage(handler: (event: Buffer, isBinary: boolean) => void | Promise<void>): {
b69ab31230 dispose(): void;
b69ab31231 } {
b69ab31232 const cb = (e: string | ArrayBuffer) => {
b69ab31233 e instanceof ArrayBuffer
b69ab31234 ? handler(e as Buffer, true)
b69ab31235 : handler(Buffer.from(e, 'utf8'), false);
b69ab31236 };
b69ab31237 clientToServer.on('data', cb);
b69ab31238 return {
b69ab31239 dispose: () => {
b69ab31240 clientToServer.off('data', cb);
b69ab31241 },
b69ab31242 };
b69ab31243 },
b69ab31244 });
b69ab31245
b69ab31246 const refresh = () => {
b69ab31247 testLogger.log('refreshing');
b69ab31248 fireEvent.click(screen.getByTestId('refresh-button'));
b69ab31249 };
b69ab31250
b69ab31251 // Dynamically import App so our test setup happens before App globals like jotai state are run.
b69ab31252 const App = (await import('../src/App')).default;
b69ab31253 // Render the entire app, which automatically starts the connection to the server
b69ab31254 render(<App />);
b69ab31255
b69ab31256 return {
b69ab31257 repoDir,
b69ab31258 sl,
b69ab31259 cleanup: async () => {
b69ab31260 testLogger.log(' -------- cleaning up -------- ');
b69ab31261 disposeServer();
b69ab31262 disposeClientConnection();
b69ab31263 if (!IS_CI) {
b69ab31264 testLogger.log('removing repo dir');
b69ab31265 // rm -rf the temp dir with the repo in it
b69ab31266 // skip on CI because it can cause flakiness, and the job will get cleaned up anyway
b69ab31267 await retry(() => fs.promises.rm(repoDir, {recursive: true, force: true})).catch(() => {
b69ab31268 testLogger.log('failed to clean up temp dir: ', repoDir);
b69ab31269 });
b69ab31270 }
b69ab31271 },
b69ab31272 writeFileInRepo,
b69ab31273 drawdag,
b69ab31274 testLogger,
b69ab31275 refresh,
b69ab31276 };
b69ab31277}
b69ab31278
b69ab31279async function retry<T>(cb: () => Promise<T>): Promise<T> {
b69ab31280 try {
b69ab31281 return await cb();
b69ab31282 } catch {
b69ab31283 return cb();
b69ab31284 }
b69ab31285}