blob: e355a9b0a4839cc722f0664484ad135c49f7b425 [file]
// 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);
}
});
});
}