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