Skip to content

Commit

Permalink
feat: handle errors in runPreset (#120)
Browse files Browse the repository at this point in the history
  • Loading branch information
JoshuaKGoldberg authored Jan 6, 2025
1 parent 224268c commit 9934655
Show file tree
Hide file tree
Showing 8 changed files with 154 additions and 23 deletions.
65 changes: 65 additions & 0 deletions packages/create/src/cli/display/runSpinnerTask.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import chalk from "chalk";
import { describe, expect, it, vi } from "vitest";

import { createClackDisplay } from "./createClackDisplay.js";
import { runSpinnerTask } from "./runSpinnerTask.js";

const mockLog = {
error: vi.fn(),
};
const mockSpinner = {
start: vi.fn(),
stop: vi.fn(),
};

vi.mock("@clack/prompts", () => ({
get log() {
return mockLog;
},
spinner: () => mockSpinner,
}));

describe("runSpinnerTask", () => {
it("displays the error when the task throws an error", async () => {
const error = new Error("Oh no!");

const actual = await runSpinnerTask(
createClackDisplay(),
"Running task",
"Ran task",
vi.fn().mockRejectedValueOnce(error),
);

expect(actual).toBe(error);
expect(mockLog.error).toHaveBeenCalledWith(chalk.red(error.stack));
expect(mockSpinner.stop.mock.calls).toMatchInlineSnapshot(`
[
[
"Error running task:",
1,
],
]
`);
});

it("displays a general log when the task resolves with a value", async () => {
const expected = { ok: true };

const actual = await runSpinnerTask(
createClackDisplay(),
"Running task",
"Ran task",
vi.fn().mockResolvedValueOnce(expected),
);

expect(actual).toBe(expected);
expect(mockLog.error).not.toHaveBeenCalled();
expect(mockSpinner.stop.mock.calls).toMatchInlineSnapshot(`
[
[
"Ran task",
],
]
`);
});
});
16 changes: 14 additions & 2 deletions packages/create/src/cli/display/runSpinnerTask.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import * as prompts from "@clack/prompts";
import chalk from "chalk";

import { tryCatchError } from "../../utils/tryCatch.js";
import { ClackDisplay } from "./createClackDisplay.js";

export async function runSpinnerTask<T>(
Expand All @@ -8,9 +12,17 @@ export async function runSpinnerTask<T>(
) {
display.spinner.start(`${start}...`);

const result = await task();
const result = await tryCatchError(task());

display.spinner.stop(`${stop}.`);
if (result instanceof Error) {
display.spinner.stop(
`Error ${start[0].toLowerCase()}${start.slice(1)}:`,
1,
);
prompts.log.error(chalk.red(result.stack));
} else {
display.spinner.stop(stop);
}

return result;
}
38 changes: 31 additions & 7 deletions packages/create/src/cli/initialize/runModeInitialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ import { runModeInitialize } from "./runModeInitialize.js";

const mockCancel = Symbol("");
const mockIsCancel = (value: unknown) => value === mockCancel;
const mockMessage = vi.fn();
const mockLog = {
error: vi.fn(),
message: vi.fn(),
};

vi.mock("@clack/prompts", () => ({
get isCancel() {
return mockIsCancel;
},
log: {
get message() {
return mockMessage;
},
get log() {
return mockLog;
},
spinner: vi.fn(),
}));
Expand Down Expand Up @@ -247,7 +248,7 @@ describe("runModeInitialize", () => {
});

expect(mockCreateRepositoryOnGitHub).toHaveBeenCalled();
expect(mockMessage.mock.calls).toMatchInlineSnapshot(`
expect(mockLog.message.mock.calls).toMatchInlineSnapshot(`
[
[
"Running with mode --initialize using the template:
Expand Down Expand Up @@ -286,7 +287,7 @@ describe("runModeInitialize", () => {
});

expect(mockCreateRepositoryOnGitHub).not.toHaveBeenCalled();
expect(mockMessage.mock.calls).toMatchInlineSnapshot(`
expect(mockLog.message.mock.calls).toMatchInlineSnapshot(`
[
[
"Running with mode --initialize using the template:
Expand All @@ -303,6 +304,29 @@ describe("runModeInitialize", () => {
`);
});

it("returns a CLI error when running the Preset rejects", async () => {
const directory = "local-directory";

mockTryImportTemplatePreset.mockResolvedValueOnce({ preset, template });
mockPromptForDirectory.mockResolvedValueOnce(directory);
mockPromptForBaseOptions.mockResolvedValueOnce({
owner: "TestOwner",
repository: "test-repository",
});
mockRunPreset.mockRejectedValueOnce(new Error("Oh no!"));

const actual = await runModeInitialize({
args: ["node", "create", "my-app"],
display,
from: "create-my-app",
});

expect(actual).toEqual({
outro: `Leaving changes to the local directory on disk. 👋`,
status: CLIStatus.Error,
});
});

it("returns a CLI success and makes an absolute directory relative when importing and running the preset succeeds", async () => {
const directory = "local-directory";
const suggestions = ["abc"];
Expand Down
6 changes: 6 additions & 0 deletions packages/create/src/cli/initialize/runModeInitialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ export async function runModeInitialize({
options,
}),
);
if (creation instanceof Error) {
return {
outro: `Leaving changes to the local directory on disk. 👋`,
status: CLIStatus.Error,
};
}

await runSpinnerTask(
display,
Expand Down
31 changes: 26 additions & 5 deletions packages/create/src/cli/migrate/runModeMigrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@ import { runModeMigrate } from "./runModeMigrate.js";

const mockCancel = Symbol("");
const mockIsCancel = (value: unknown) => value === mockCancel;
const mockMessage = vi.fn();
const mockLog = {
error: vi.fn(),
message: vi.fn(),
};

vi.mock("@clack/prompts", () => ({
get isCancel() {
return mockIsCancel;
},
log: {
get message() {
return mockMessage;
},
get log() {
return mockLog;
},
spinner: vi.fn(),
}));
Expand Down Expand Up @@ -247,6 +248,26 @@ describe("runModeMigrate", () => {
expect(actual).toEqual({ outro: message, status: CLIStatus.Error });
});

it("returns the error when runPreset resolves with an error", async () => {
mockParseMigrationSource.mockReturnValueOnce({
load: () => Promise.resolve({ preset }),
});
mockPromptForBaseOptions.mockResolvedValueOnce({});
mockGetForkedTemplateLocator.mockResolvedValueOnce(undefined);
mockRunPreset.mockRejectedValueOnce(new Error("Oh no!"));

const actual = await runModeMigrate({
args: [],
configFile: undefined,
display,
});

expect(actual).toEqual({
outro: `Leaving changes to the local directory on disk. 👋`,
status: CLIStatus.Error,
});
});

it("doesn't clear the existing repository when no forked template locator is available", async () => {
mockParseMigrationSource.mockReturnValueOnce({
load: () => Promise.resolve({ preset }),
Expand Down
13 changes: 9 additions & 4 deletions packages/create/src/cli/migrate/runModeMigrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,21 +109,26 @@ export async function runModeMigrate({
return { outro: mergedSettings.message, status: CLIStatus.Error };
}

await runSpinnerTask(
const creation = await runSpinnerTask(
display,
`Running the ${preset.about.name} preset`,
`Ran the ${preset.about.name} preset`,
async () => {
async () =>
await runPreset(preset, {
...mergedSettings,
...system,
directory,
mode: "migrate",
offline,
options,
});
},
}),
);
if (creation instanceof Error) {
return {
outro: `Leaving changes to the local directory on disk. 👋`,
status: CLIStatus.Error,
};
}

if (templateLocator) {
await runSpinnerTask(
Expand Down
4 changes: 1 addition & 3 deletions packages/create/src/cli/tryImportWithPredicate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ export async function tryImportWithPredicate<T>(
predicate: (value: unknown) => value is T,
typeName: string,
): Promise<Error | T> {
const templateModule = (await tryCatchError(importer(moduleName))) as
| Error
| object;
const templateModule = await tryCatchError(importer(moduleName));
if (templateModule instanceof Error) {
return templateModule;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/create/src/utils/tryCatch.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export async function tryCatchError(promise: Promise<unknown>) {
export async function tryCatchError<T>(promise: Promise<T>) {
try {
return await promise;
} catch (error) {
return error;
return error as Error;
}
}

Expand Down

0 comments on commit 9934655

Please sign in to comment.