| // Copyright 2022 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import { type ChildProcess, type SpawnOptions } from 'child_process'; |
| |
| import { EventEmitter } from 'events'; |
| import { type SinonSandbox, type SinonStub } from 'sinon'; |
| import { Readable } from 'stream'; |
| import * as vscode from 'vscode'; |
| |
| // eslint-disable-next-line @typescript-eslint/no-require-imports |
| const childProcess = require('child_process') as typeof import('child_process'); |
| |
| /** |
| * Waits for a single macrotask to complete on the macrotask event loop. |
| * |
| * Since the event queue is deterministic, waiting for macrotasks to complete |
| * (when possible) is less flaky than waiting for a time duration to finish. |
| * |
| * Use like this: |
| * ``` |
| * async (): Promise<void> => { |
| * let completed = false; |
| * setTimeout(() => completed = true); |
| * await macroTask(); |
| * assert.ok(completed) |
| * } |
| * ``` |
| * |
| * @returns a promise that will resolve after the macrotask completes. |
| */ |
| export async function macrotask(): Promise<void> { |
| await new Promise(resolve => setTimeout(resolve)); |
| } |
| |
| /** |
| * Runs a given `task` with a separate timeout budget from the overall test's |
| * timeout budget. |
| * |
| * @param test The test's `this` object. |
| * @param task A promise to run with a separate timeout budget. |
| * @param timeout The separate timeout budget for `task`. If `null` it is |
| * unlimited. |
| * @returns A promise wrapping `task` which has a separate timeout from the |
| * overall test's timeout. |
| */ |
| export async function separateTimeoutBudget<T>( |
| test: Mocha.Context, |
| task: Thenable<T>, |
| timeout: number | null |
| ): Promise<T> { |
| const testTimeout = test.timeout(); |
| test.timeout(0); |
| const initial = Date.now(); |
| const taskDeadline = timeout === null ? [] : [ |
| new Promise<T>((_, reject) => |
| setTimeout(() => reject(new Error(`Task timeout budget ${timeout} exceeded.`)), timeout) |
| ) |
| ]; |
| const result = await Promise.race([ |
| task, |
| ...taskDeadline, |
| ]); |
| test.timeout(testTimeout + Date.now() - initial); |
| return result; |
| } |
| |
| /** |
| * Runs a given `task` without contributing to the overall test's timeout |
| * budget. |
| * |
| * Equivalent to `separateTimeoutBudget(test, task, null)`. |
| * |
| * @param test The test's `this` object. |
| * @param task A promise to run without contributing to test timeout. |
| * @returns A promise wrapping `task` which does not contribute to the test's |
| * overall timeout. |
| */ |
| export function noTimeout<T>(test: Mocha.Context, task: Thenable<T>): Promise<T> { |
| return separateTimeoutBudget(test, task, null); |
| } |
| |
| /** |
| * Creates a fake child process. |
| * @returns the child process object. |
| */ |
| export function createFakeChildProcess(): ChildProcess { |
| const spawnEvent: ChildProcess = new EventEmitter() as ChildProcess; |
| spawnEvent.stdout = new Readable({ read() {} }); |
| spawnEvent.stderr = new Readable({ read() {} }); |
| |
| spawnEvent.kill = (signal?: number) => { |
| spawnEvent.emit('exit', signal); |
| spawnEvent.emit('close', signal); |
| return true; |
| }; |
| return spawnEvent; |
| } |
| |
| /// Class which creates a stubbed out child_process.spawn. |
| export class StubbedSpawn { |
| public spawnEvent: ChildProcess; |
| public spawnStubInfo: SinonStub<[command: string, args: readonly string[], |
| options: SpawnOptions], ChildProcess>; |
| |
| constructor(sandbox: SinonSandbox) { |
| this.spawnEvent = createFakeChildProcess(); |
| |
| // esm imports are *supposed* to be read-only, so use old-school |
| // imports. this is a teeensy bit janky, but we're on thin |
| // ice with runtime stubs anyway. we should prob use something |
| // like jest or esbuild's module-replacing functionality |
| this.spawnStubInfo = sandbox. |
| stub(childProcess, 'spawn'). |
| returns(this.spawnEvent) as |
| SinonStub<[ |
| command: string, |
| args: readonly string[], |
| options: SpawnOptions], |
| ChildProcess>; |
| } |
| |
| get stdout(): EventEmitter { |
| return this.spawnEvent.stdout!; |
| } |
| } |
| |
| export const MOCKED_COMMAND_DURATION = 10; |
| |
| export type MockedCommandInvocation = { |
| args: string[]; |
| output: (string | Error | object); |
| }; |
| |
| /** |
| * An ffx-specific version of `setupMockedCommand`. |
| * |
| * Prepends the specified `ffxPath` and ffx analytics flags onto the `args` in `ffxInvocations`. |
| * |
| * See `setupMockedCommand` for more details. |
| */ |
| export function setupMockFfx( |
| stubbedSpawn: StubbedSpawn, |
| cwd: string, |
| ffxPath: string, |
| ffxInvocations: MockedCommandInvocation[] |
| ): { |
| actualInvocations: string[][], |
| expectedInvocations: string[][], |
| singleCommandDuration: () => Promise<void> |
| } { |
| const ANALYTICS_FLAG = ['--config', `fuchsia.analytics.ffx_invoker=Extension: ${vscode.env.appName}`]; |
| return setupMockedCommand( |
| stubbedSpawn, |
| cwd, |
| ffxInvocations.map(inv => ({ |
| ...inv, |
| args: [ffxPath, ...ANALYTICS_FLAG, ...inv.args], |
| })), |
| ); |
| } |
| |
| /** |
| * Mocks a sequence of expected commands and their outputs. |
| * |
| * All command executions are handled by this mock. |
| * Non-matching command binaries will immediately exit with code 0 and are excluded in the list of |
| * `actualInvocations`. |
| * Mismatching command arguments will emit a warning, exit with code 1, and are included in the list |
| * of `actualInvocations`. |
| * |
| * Example usage: |
| * ``` |
| * const mockCommands = (...commandInvocations: MockedCommandInvocation[]) => setupMockedCommand( |
| * stubbedSpawn, |
| * TEST_CWD, |
| * commandInvocations, |
| * ); |
| * |
| * const {actualInvocations, expectedInvocations} = mockCommands( |
| * { |
| * args: ['fx', 'list-devices'], |
| * output: '127.0.0.1 fuchsia-emulator', |
| * }, |
| * { |
| * args: ['/path/to/ffx', '--target', 'sample-device', 'target', 'reboot'], |
| * output: {message: 'restarted sample-device', success: true}, |
| * }, |
| * { |
| * args: ['/path/to/ffx', '--target', 'sample-device', 'target', 'wait', '-t', '300'], |
| * output: new Error('Failed to establish RCS connection to device.'), |
| * }, |
| * ); |
| * |
| * // This block is fake sample code. |
| * const devices = await enumerateDevices(); |
| * assert.strictEqual(devices.length, 1); |
| * await assert.rejects( |
| * () => device.rebootAndReconnect(), |
| * new Error('fuchsia-emulator failed to come back online!'). |
| * ); |
| * |
| * assert.deepStrictEqual(actualInvocations, expectedInvocations); |
| * ``` |
| * |
| * @param stubbedSpawn: A StubbedSpawn instance. |
| * @param cwd: The expected working directory that commands are run in. |
| * @param commandInvocations an array of objects with the following properties: |
| * args: An array of command and arguments to match against. |
| * output: A string or object to be serialized (for stdout), or an Error |
| * which message will be emitted for stderr. The former will mock exit |
| * code 0 whereas the latter will mock exit code 1. |
| * @returns an object with the following properties: |
| * actualInvocations: An array of string arrays representing the commands |
| * that were run. |
| * expectedInvocations: An array of string arrays mapped from the input |
| * `commandInvocations` `args`. |
| * singleCommandDuration: A promise that can be `awaited` to wait for a |
| * single ffx command execution macrotask to complete. |
| * This is useful for getter/setter tests in which test code cannot await |
| * getter/setter side-effects involving ffx command execution. |
| */ |
| export function setupMockedCommand( |
| stubbedSpawn: StubbedSpawn, |
| cwd: string, |
| commandInvocations: MockedCommandInvocation[] |
| ): { |
| actualInvocations: string[][], |
| expectedInvocations: string[][], |
| singleCommandDuration: () => Promise<void> |
| } { |
| const expectedInvocations = commandInvocations.map(inv => inv.args); |
| const actualInvocations: string[][] = []; |
| let idx = 0; |
| stubbedSpawn.spawnStubInfo.callsFake(( |
| command: string, |
| args: readonly string[], |
| options: SpawnOptions |
| ) => { |
| const fakeSpawnedProcess = createFakeChildProcess(); |
| |
| // Push onto macrotask queue since we may need time for the command to add listeners for |
| // `.on('data')` and `.on('close')`, which may be deferred onto the microtask queue when invoked |
| // within an `async` function context. |
| // |
| // Add a 10ms delay to better simulate real-world execution. |
| // |
| // For getter/setter tests in which test code cannot await getter/setter side-effects involving |
| // command execution you can use `await mockFfxResult.singleCommandDuration()` to wait for a |
| // single command execution macrotask to complete. |
| // Note: This alone is insufficient if the getter/setter chains a command execution macrotask |
| // with other macrotasks in series. |
| // In this case, test code will need to await multiple `singleCommandDuration()` and/or |
| // `macrotask()` in series. |
| setTimeout(() => { |
| if (command !== expectedInvocations[idx]?.at(0)) { |
| console.warn( |
| `[setupMockedCommand] Ignoring non-matching command ${command}, ` + |
| `${JSON.stringify(args)}, ${JSON.stringify(options)}` |
| ); |
| fakeSpawnedProcess.emit('exit', 0); |
| fakeSpawnedProcess.emit('close', 0); |
| return; |
| } |
| |
| actualInvocations.push([command, ...args]); |
| const [expectedArgs, nextOutput] = [ |
| expectedInvocations[idx]?.slice(1), |
| commandInvocations[idx]?.output, |
| ]; |
| idx++; |
| if (JSON.stringify(expectedArgs) !== JSON.stringify(args) || options.cwd !== cwd) { |
| const errmsg = `[setupMockedCommand] Unexpected call. |
| Expected invocations:\n${expectedInvocations.join('\n')}, CWD=${cwd} |
| Actual invocations:\n${actualInvocations.join('\n')}, CWD=${String(options.cwd)}`; |
| console.error(errmsg); |
| fakeSpawnedProcess.stderr?.emit('data', JSON.stringify(errmsg)); |
| fakeSpawnedProcess.emit('exit', 1); |
| fakeSpawnedProcess.emit('close', 1); |
| return; |
| } |
| |
| if (nextOutput instanceof Error) { |
| fakeSpawnedProcess.stderr?.emit('data', nextOutput.message); |
| fakeSpawnedProcess.emit('exit', 1); |
| fakeSpawnedProcess.emit('close', 1); |
| } else if (typeof nextOutput === 'string') { |
| fakeSpawnedProcess.stdout?.emit('data', nextOutput); |
| fakeSpawnedProcess.emit('exit', 0); |
| fakeSpawnedProcess.emit('close', 0); |
| } else { |
| fakeSpawnedProcess.stdout?.emit('data', JSON.stringify(nextOutput)); |
| fakeSpawnedProcess.emit('exit', 0); |
| fakeSpawnedProcess.emit('close', 0); |
| } |
| }, MOCKED_COMMAND_DURATION); |
| return fakeSpawnedProcess; |
| }); |
| |
| return { |
| actualInvocations, |
| expectedInvocations, |
| singleCommandDuration: () => new Promise(resolve => setTimeout(resolve, 10)), |
| }; |
| }; |
| |
| /** |
| * Wait for an event to trigger. |
| * |
| * Optionally, wait for an event with a specific condition |
| * (won't fail, will just keep waiting). |
| * |
| * ``` |
| * let change = await anEventOf(vscode.workspace.onDidChangeConfiguration); |
| * if (change.affectsConfiguration("fuchsia.ffxPath") { } |
| * ``` |
| */ |
| export function anEventOf<T>(onEvent: vscode.Event<T>, onlyWhen?: (evt: T) => boolean): Promise<T> { |
| return new Promise((resolve) => { |
| const handler = onEvent((evt) => { |
| if (!onlyWhen || onlyWhen(evt)) { |
| handler.dispose(); // don't fire again |
| resolve(evt); |
| } |
| }); |
| }); |
| } |