Skip to content

Commit

Permalink
Add possibility to read output from stderr
Browse files Browse the repository at this point in the history
Fix #86
  • Loading branch information
MarcelRobitaille committed Sep 20, 2024
1 parent f1cb44a commit 26272f9
Show file tree
Hide file tree
Showing 8 changed files with 173 additions and 36 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]

- Add support for getting results from stderr (fix issue #86)
- Improve error handling
- Improve parsing of boolean arguments

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ Arguments for the extension:
* `description`: shown as a placeholder in 'Quick Pick', provides context for the input
* `maxBuffer`: largest amount of data in bytes allowed on stdout. Default is 1024 * 1024. If exceeded ENOBUFS error will be displayed
* `defaultOptions`: if the command doesn't return anything, the list provided will be set for the user to choose from
* `stdio`: specifies whether to get the results from `stdout`, `stderr`, or `both`. Default is `stdout`.

As of today, the extension supports variable substitution for:

Expand Down
2 changes: 1 addition & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ShellCommandException } from './util/exceptions';
export function activate(this: any, context: vscode.ExtensionContext) {
const command = 'shellCommand.execute';
const userInputContext = new UserInputContext();
const callback = (args: object) => {
const callback = (args: { [key: string]: unknown }) => {
try {
const handler = new CommandHandler(args, userInputContext, context, subprocess);
return handler.handle();
Expand Down
86 changes: 66 additions & 20 deletions src/lib/CommandHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ const mockExtensionContext = {
vi.mock("child_process", async (importOriginal) => ({
...await importOriginal<typeof import("child_process")>(),
}));
const execSyncSpy = vi.spyOn(child_process, 'execSync');
const execFileSyncSpy = vi.spyOn(child_process, 'execFileSync');
const execSpy = vi.spyOn(child_process, 'exec');
const execFileSpy = vi.spyOn(child_process, 'execFile');

beforeEach(() => {
execSyncSpy.mockClear();
execFileSyncSpy.mockClear();
execSpy.mockClear();
execFileSpy.mockClear();
});

describe("Simple cases", async () => {
Expand All @@ -46,9 +46,9 @@ describe("Simple cases", async () => {

await handler.handle();

expect(execFileSyncSpy).toHaveBeenCalledTimes(0);
expect(execSyncSpy).toHaveBeenCalledTimes(1);
expect(execSyncSpy).toHaveBeenCalledWith(
expect(execFileSpy).toHaveBeenCalledTimes(0);
expect(execSpy).toHaveBeenCalledTimes(1);
expect(execSpy).toHaveBeenCalledWith(
`cat ${filePath}`,
{
cwd: testDataPath,
Expand All @@ -60,6 +60,7 @@ describe("Simple cases", async () => {
},
maxBuffer: undefined,
},
expect.anything(),
);
});

Expand All @@ -75,16 +76,17 @@ describe("Simple cases", async () => {

await handler.handle();

expect(execFileSyncSpy).toHaveBeenCalledTimes(0);
expect(execSyncSpy).toHaveBeenCalledTimes(1);
expect(execSyncSpy).toHaveBeenCalledWith(
expect(execFileSpy).toHaveBeenCalledTimes(0);
expect(execSpy).toHaveBeenCalledTimes(1);
expect(execSpy).toHaveBeenCalledWith(
`cat ${filePath}`,
{
cwd: testDataPath,
encoding: "utf8",
env: undefined,
maxBuffer: undefined,
},
expect.anything(),
);
});
});
Expand Down Expand Up @@ -125,16 +127,17 @@ describe("Multiple workspaces", async () => {

await handler.handle();

expect(execFileSyncSpy).toHaveBeenCalledTimes(0);
expect(execSyncSpy).toHaveBeenCalledTimes(1);
expect(execSyncSpy).toHaveBeenCalledWith(
expect(execFileSpy).toHaveBeenCalledTimes(0);
expect(execSpy).toHaveBeenCalledTimes(1);
expect(execSpy).toHaveBeenCalledWith(
`echo ${expectedResult}`,
{
cwd: taskId.startsWith("a") ? `${testDataPath}/a` : `${testDataPath}/b`,
encoding: "utf8",
env: undefined,
maxBuffer: undefined,
},
expect.anything(),
);
});
}
Expand All @@ -158,16 +161,17 @@ test("Command variable interop", async () => {

await handler.handle();

expect(execFileSyncSpy).toHaveBeenCalledTimes(0);
expect(execSyncSpy).toHaveBeenCalledTimes(1);
expect(execSyncSpy).toHaveBeenCalledWith(
expect(execFileSpy).toHaveBeenCalledTimes(0);
expect(execSpy).toHaveBeenCalledTimes(1);
expect(execSpy).toHaveBeenCalledWith(
"echo 'ItWorked'",
{
cwd: testDataPath,
encoding: "utf8",
env: undefined,
maxBuffer: undefined,
},
expect.anything(),
);
});

Expand All @@ -189,9 +193,9 @@ test("commandArgs", async () => {

await handler.handle();

expect(execSyncSpy).toHaveBeenCalledTimes(0);
expect(execFileSyncSpy).toHaveBeenCalledTimes(1);
expect(execFileSyncSpy).toHaveBeenCalledWith(
expect(execSpy).toHaveBeenCalledTimes(0);
expect(execFileSpy).toHaveBeenCalledTimes(1);
expect(execFileSpy).toHaveBeenCalledWith(
`${testDataPath}/command with spaces.sh`,
[filePath],
{
Expand All @@ -200,9 +204,50 @@ test("commandArgs", async () => {
env: undefined,
maxBuffer: undefined,
},
expect.anything(),
);
});

test("stdio", async () => {
const testDataPath = path.join(__dirname, "../test/testData/stdio");

const tasksJson = await import(path.join(testDataPath, ".vscode/tasks.json"));
const mockData = (await import(path.join(testDataPath, "mockData.ts"))).default;

mockVscode.setMockData(mockData);
const input = tasksJson.inputs[0].args;
const expectationStdout = expect.objectContaining({ value: "this is on stdout" })

Check failure on line 219 in src/lib/CommandHandler.test.ts

View workflow job for this annotation

GitHub Actions / build

Missing semicolon
const expectationStderr = expect.objectContaining({ value: "this is on stderr" })

Check failure on line 220 in src/lib/CommandHandler.test.ts

View workflow job for this annotation

GitHub Actions / build

Missing semicolon

for (const { setting, expectation } of [
{ setting: "stdout", expectation: [ expectationStdout ] },
{ setting: "stderr", expectation: [ expectationStderr ] },
{ setting: "both", expectation: [ expectationStdout, expectationStderr ] },
]) {
execSpy.mockClear()

Check failure on line 227 in src/lib/CommandHandler.test.ts

View workflow job for this annotation

GitHub Actions / build

Missing semicolon
execFileSpy.mockClear()

Check failure on line 228 in src/lib/CommandHandler.test.ts

View workflow job for this annotation

GitHub Actions / build

Missing semicolon

const handler = new CommandHandler(
{ ...input, stdio: setting },
new UserInputContext(),
mockExtensionContext as unknown as vscode.ExtensionContext,
child_process,
);

// @ts-ignore

Check failure on line 237 in src/lib/CommandHandler.test.ts

View workflow job for this annotation

GitHub Actions / build

Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free
handler.quickPick = vi.fn()

Check failure on line 238 in src/lib/CommandHandler.test.ts

View workflow job for this annotation

GitHub Actions / build

Missing semicolon

await handler.handle();

expect(execSpy).toHaveBeenCalledTimes(1);
expect(execFileSpy).toHaveBeenCalledTimes(0);
// @ts-ignore

Check failure on line 244 in src/lib/CommandHandler.test.ts

View workflow job for this annotation

GitHub Actions / build

Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free
expect(handler.quickPick).toHaveBeenCalledTimes(1)

Check failure on line 245 in src/lib/CommandHandler.test.ts

View workflow job for this annotation

GitHub Actions / build

Missing semicolon
// @ts-ignore

Check failure on line 246 in src/lib/CommandHandler.test.ts

View workflow job for this annotation

GitHub Actions / build

Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free
expect(handler.quickPick).toHaveBeenCalledWith(expectation)

Check failure on line 247 in src/lib/CommandHandler.test.ts

View workflow job for this annotation

GitHub Actions / build

Missing semicolon
}
})

describe("Errors", async () => {
test("It should trigger an error for an empty result", async () => {
const testDataPath = path.join(__dirname, "../test/testData/errors");
Expand All @@ -226,11 +271,12 @@ describe("Errors", async () => {

describe("Argument parsing", () => {
test("Test defaults and that all boolean properties use parseBoolean", () => {
expect(CommandHandler.resolveBooleanArgs({ extraTestThing: 42 }))
expect(CommandHandler.resolveArgs({ extraTestThing: 42 }))
.toStrictEqual({
rememberPrevious: false,
useFirstResult: false,
useSingleResult: false,
stdio: "stdout",
extraTestThing: 42,
});
});
Expand Down
55 changes: 40 additions & 15 deletions src/lib/CommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ import { ShellCommandException } from "../util/exceptions";
import { UserInputContext } from "./UserInputContext";
import { QuickPickItem } from "./QuickPickItem";

function promisify<T extends unknown[], R>(fn: (...args: [...T, (err: Error | null, stdout: string, stderr: string) => void]) => R) {
return (...args: [...T]) =>
new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
fn(...args, (err: Error | null, stdout: string, stderr: string) => {
if (err) {
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
}

export class CommandHandler {
protected args: ShellCommandOptions;
protected EOL = /\r\n|\r|\n/;
Expand All @@ -16,12 +29,12 @@ export class CommandHandler {
protected commandArgs: string[] | undefined;
protected subprocess: typeof child_process;

constructor(args: object,
constructor(args: { [key: string]: unknown },
userInputContext: UserInputContext,
context: vscode.ExtensionContext,
subprocess: typeof child_process,
) {
this.args = CommandHandler.resolveBooleanArgs(args);
this.args = CommandHandler.resolveArgs(args);
if (!Object.prototype.hasOwnProperty.call(this.args, "command")) {
throw new ShellCommandException('Please specify the "command" property.');
}
Expand Down Expand Up @@ -52,14 +65,14 @@ export class CommandHandler {
this.subprocess = subprocess;
}

static resolveBooleanArgs(args: object): ShellCommandOptions {
const opt = args as ShellCommandOptions;
const resolvedBooleans = {
useFirstResult: CommandHandler.parseBoolean(opt.useFirstResult, false),
useSingleResult: CommandHandler.parseBoolean(opt.useSingleResult, false),
rememberPrevious: CommandHandler.parseBoolean(opt.rememberPrevious, false),
};
return {...args, ...resolvedBooleans} as ShellCommandOptions;
static resolveArgs(args: { [key: string]: unknown }): ShellCommandOptions {
return {
useFirstResult: CommandHandler.parseBoolean(args.useFirstResult, false),
useSingleResult: CommandHandler.parseBoolean(args.useSingleResult, false),
rememberPrevious: CommandHandler.parseBoolean(args.rememberPrevious, false),
stdio: ["stdout", "stderr", "both"].includes(args.stdio as string) ? args.stdio : "stdout",
...args,
} as ShellCommandOptions;
}

static parseBoolean(value: unknown, defaultValue: boolean): boolean {
Expand Down Expand Up @@ -128,7 +141,7 @@ export class CommandHandler {
async handle() {
await this.resolveArgs();

const result = this.runCommand();
const result = await this.runCommand();
const nonEmptyInput = this.parseResult(result);
const useFirstResult =
this.args.useFirstResult || (this.args.useSingleResult && nonEmptyInput.length === 1);
Expand All @@ -142,7 +155,7 @@ export class CommandHandler {
}
}

protected runCommand() {
protected async runCommand() {
const options: child_process.ExecSyncOptionsWithStringEncoding = {
encoding: "utf8",
cwd: this.args.cwd,
Expand All @@ -152,13 +165,25 @@ export class CommandHandler {
};

if (this.commandArgs !== undefined) {
return this.subprocess.execFileSync(this.command, this.commandArgs, options);
const execFile = promisify(this.subprocess.execFile);
return await execFile(this.command, this.commandArgs, options);
} else {
return this.subprocess.execSync(this.command, options);
const exec = promisify<[string, child_process.ExecOptionsWithStringEncoding], child_process.ChildProcess>(this.subprocess.exec);
return exec(this.command, options);
}
}

protected parseResult(result: string): QuickPickItem[] {
protected parseResult({ stdout, stderr }: { stdout: string, stderr: string }): QuickPickItem[] {
let result = "";

if (("stdout" == this.args.stdio) || ("both" == this.args.stdio)) {
result += stdout;
}

if (("stderr" == this.args.stdio) || ("both" == this.args.stdio)) {
result += stderr;
}

if (result.trim().length == 0) {
throw new ShellCommandException(`The command for input '${this.input.id}' returned empty result.`);
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/ShellCommandOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export interface ShellCommandOptions
maxBuffer?: number;
taskId?: string;
defaultOptions?: [string];
stdio?: "stdout" | "stderr" | "both";
}
22 changes: 22 additions & 0 deletions src/test/testData/stdio/.vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Echo Project File",
"type": "shell",
"command": "echo ${input:inputTest}",
"problemMatcher": []
}
],
"inputs": [
{
"id": "inputTest",
"type": "command",
"command": "shellCommand.execute",
"args": {
"command": "echo this is on stdout && echo this is on stderr >&2",
"stdio": "stderr"
}
}
]
}
41 changes: 41 additions & 0 deletions src/test/testData/stdio/mockData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export default {
"calls": {
"workspace.getConfiguration().inspect()": {
"[\"tasks\",null,\"inputs\"]": {
"key": "tasks.inputs",
"workspaceValue": [
{
"id": "inputTest",
"type": "command",
"command": "shellCommand.execute",
"args": {
"command": "echo this is on stdout && echo this is on stderr >&2",
"cwd": "${workspaceFolder}",
"env": {
"WORKSPACE": "${workspaceFolder[0]}",
"FILE": "${file}",
"PROJECT": "${workspaceFolderBasename}"
}
}
}
]
}
}
},
"staticData": {
"window.activeTextEditor.document.fileName": `${__dirname}/.vscode/tasks.json`,
"workspace.workspaceFolders": [
{
"uri": {
"$mid": 1,
"fsPath": `${__dirname}`,
"external": `file://${__dirname}}`,
"path": `${__dirname}`,
"scheme": "file"
},
"name": "vscode-shell-command",
"index": 0
}
]
}
};

0 comments on commit 26272f9

Please sign in to comment.