Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add validation for readOptions with zod #826

Merged
merged 21 commits into from
Sep 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@
"octokit": "^3.1.0",
"prettier": "^3.0.2",
"replace-in-file": "^7.0.1",
"title-case": "^3.0.3"
"title-case": "^3.0.3",
"zod": "^3.22.2",
"zod-validation-error": "^1.5.0"
},
"devDependencies": {
"@octokit/request-error": "^5.0.0",
Expand Down
8 changes: 6 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions src/bin/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import chalk from "chalk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import z from "zod";

import { bin } from "./index.js";

Expand Down Expand Up @@ -119,6 +120,34 @@ describe("bin", () => {
expect(result).toEqual(code);
});

it("returns the cancel result containing zod error of the corresponding runner and output plus cancel logs when promptForMode returns a mode that cancels", async () => {
const mode = "initialize";
const args = ["--email", "abc123"];
const code = 2;

const validationResult = z
.object({ email: z.string().email() })
.safeParse({ email: "abc123" });

mockPromptForMode.mockResolvedValue(mode);
mockInitialize.mockResolvedValue({
code: 2,
options: {},
zodError: (validationResult as z.SafeParseError<{ email: string }>).error,
});

const result = await bin(args);

expect(mockInitialize).toHaveBeenCalledWith(args);
expect(mockLogLine).toHaveBeenCalledWith(
chalk.red('Validation error: Invalid email at "email"'),
);
expect(mockCancel).toHaveBeenCalledWith(
`Operation cancelled. Exiting - maybe another time? 👋`,
);
expect(result).toEqual(code);
});

it("returns the cancel result of the corresponding runner and cancel logs when promptForMode returns a mode that fails", async () => {
const mode = "create";
const args = ["--owner", "abc123"];
Expand Down
11 changes: 10 additions & 1 deletion src/bin/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as prompts from "@clack/prompts";
import chalk from "chalk";
import { parseArgs } from "node:util";
import { fromZodError } from "zod-validation-error";

import { createRerunSuggestion } from "../create/createRerunSuggestion.js";
import { create } from "../create/index.js";
Expand Down Expand Up @@ -50,7 +51,8 @@ export async function bin(args: string[]) {
return 1;
}

const { code, options } = await { create, initialize, migrate }[mode](args);
const runners = { create, initialize, migrate };
const { code, options, zodError } = await runners[mode](args);

prompts.log.info(
[
Expand All @@ -61,6 +63,13 @@ export async function bin(args: string[]) {

if (code) {
logLine();

if (zodError) {
const validationError = fromZodError(zodError);
logLine(chalk.red(validationError));
logLine();
}

prompts.cancel(
code === StatusCodes.Cancelled
? operationMessage("cancelled")
Expand Down
2 changes: 2 additions & 0 deletions src/bin/mode.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as prompts from "@clack/prompts";
import chalk from "chalk";
import z from "zod";

import { StatusCode } from "../shared/codes.js";
import { filterPromptCancel } from "../shared/prompts.js";
Expand All @@ -8,6 +9,7 @@ import { Options } from "../shared/types.js";
export interface ModeResult {
code: StatusCode;
options: Partial<Options>;
zodError?: z.ZodError<object>;
}

export type ModeRunner = (args: string[]) => Promise<ModeResult>;
Expand Down
1 change: 1 addition & 0 deletions src/create/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export async function create(args: string[]): Promise<ModeResult> {
return {
code: StatusCodes.Cancelled,
options: inputs.options,
zodError: inputs.zodError,
};
}

Expand Down
1 change: 1 addition & 0 deletions src/initialize/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const initialize: ModeRunner = async (args) => {
return {
code: StatusCodes.Cancelled,
options: inputs.options,
zodError: inputs.zodError,
};
}

Expand Down
1 change: 1 addition & 0 deletions src/migrate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const migrate: ModeRunner = async (args) => {
return {
code: StatusCodes.Cancelled,
options: inputs.options,
zodError: inputs.zodError,
};
}

Expand Down
6 changes: 2 additions & 4 deletions src/shared/options/augmentOptionsWithExcludes.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import * as prompts from "@clack/prompts";

import { filterPromptCancel } from "../prompts.js";
import { Options } from "../types.js";

type Base = "everything" | "minimum" | "prompt";
import { InputBase, Options } from "../types.js";

const exclusionDescriptions = {
excludeCompliance: {
Expand Down Expand Up @@ -85,7 +83,7 @@

const base =
options.base ??
filterPromptCancel<Base | symbol>(
filterPromptCancel<InputBase | symbol>(

Check warning on line 86 in src/shared/options/augmentOptionsWithExcludes.ts

View check run for this annotation

Codecov / codecov/patch

src/shared/options/augmentOptionsWithExcludes.ts#L86

Added line #L86 was not covered by tests
await prompts.select({
message: `How much tooling would you like the template to set up for you?`,
options: [
Expand Down
92 changes: 92 additions & 0 deletions src/shared/options/readOptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from "vitest";
import z from "zod";

import { readOptions } from "./readOptions.js";

const emptyOptions = {
author: undefined,
base: undefined,
createRepository: undefined,
description: undefined,
email: undefined,
excludeCompliance: undefined,
excludeContributors: undefined,
excludeLintJson: undefined,
excludeLintKnip: undefined,
excludeLintMd: undefined,
excludeLintPackageJson: undefined,
excludeLintPackages: undefined,
excludeLintPerfectionist: undefined,
excludeLintSpelling: undefined,
excludeLintYml: undefined,
excludeReleases: undefined,
excludeRenovate: undefined,
excludeTests: undefined,
funding: undefined,
owner: undefined,
repository: undefined,
skipGitHubApi: false,
skipInstall: false,
skipRemoval: false,
skipRestore: undefined,
skipUninstall: false,
title: undefined,
};

const mockOptions = {
base: "prompt",
github: "mock.git",
repository: "mock.repository",
};

vi.mock("./getPrefillOrPromptedOption.js", () => ({
getPrefillOrPromptedOption() {
return () => "mock";
},
}));

vi.mock("./ensureRepositoryExists.js", () => ({
ensureRepositoryExists() {
return {
github: mockOptions.github,
repository: mockOptions.repository,
};
},
}));

vi.mock("../../shared/cli/spinners.ts", () => ({
withSpinner() {
return () => ({});
},
}));

vi.mock("./augmentOptionsWithExcludes.js", () => ({
augmentOptionsWithExcludes() {
return { ...emptyOptions, ...mockOptions };
},
}));

describe("readOptions", () => {
it("cancels the function when --email is invalid", async () => {
const validationResult = z
.object({ email: z.string().email() })
.safeParse({ email: "wrongEmail" });

expect(await readOptions(["--email", "wrongEmail"])).toStrictEqual({
cancelled: true,
options: { ...emptyOptions, email: "wrongEmail" },
zodError: (validationResult as z.SafeParseError<{ email: string }>).error,
});
});

it("successfully runs the function when --base is valid", async () => {
expect(await readOptions(["--base", mockOptions.base])).toStrictEqual({
cancelled: false,
github: mockOptions.github,
options: {
...emptyOptions,
...mockOptions,
},
});
});
});
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Praise] 💯 love it! No notes!

Loading