Skip to content
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
11 changes: 9 additions & 2 deletions packages/visual-editor/src/cli/commands/internal/deploy/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ export async function yextApiRequest(
path: string,
config: DeployConfig,
verbose: boolean = false,
data?: object
data?: object,
expectedStatusMessages: Partial<Record<number, string>> = {}
): Promise<YextApiResponse> {
const url = new URL(`${API_PATH_PREFIX}${path}`, config.apiHost);
url.searchParams.set("v", "20260819");
Expand All @@ -44,7 +45,13 @@ export async function yextApiRequest(
requestInit.body = JSON.stringify(data);
}

const finishLog = logApiCall(logAction, method, url, verbose);
const finishLog = logApiCall(
logAction,
method,
url,
verbose,
expectedStatusMessages
);

try {
const response = await fetch(url, new Request(url, requestInit));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,25 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { execFileSync } from "node:child_process";
import prompts from "prompts";
import ora from "ora";
import { deploy } from "./deploy.ts";
import type { DeployConfig } from "./config.ts";
import type { SectionLibraryRevision } from "./sectionLibraryApi.ts";

vi.mock("prompts");
vi.mock("ora", () => ({
default: vi.fn((options) => {
const spinner = {
text: typeof options === "string" ? options : options.text,
start: vi.fn(),
succeed: vi.fn(),
fail: vi.fn(),
info: vi.fn(),
};
spinner.start.mockReturnValue(spinner);
return spinner;
}),
}));

const config: DeployConfig = {
accountId: "123",
Expand Down Expand Up @@ -38,6 +52,7 @@ let sourceCommitHash: string;

beforeEach(() => {
vi.mocked(prompts).mockReset();
vi.mocked(ora).mockClear();
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "deploy-templates-test-"));
Expand Down Expand Up @@ -292,6 +307,68 @@ describe("deploy", () => {
expect(error).not.toHaveBeenCalled();
});

it.each([
{
name: "built-in library rejection",
message:
"Provided argument 'section_library_revision' is invalid: cannot create revision for built-in section library",
expectedMessage:
'Cannot create a revision for a built-in section library. Check the "id" field in src/library/library.json. To deploy your own library, remove the reserved "yext_" prefix and retry.',
verbose: false,
},
{
name: "built-in library rejection in verbose mode",
message:
"Provided argument 'section_library_revision' is invalid: cannot create revision for built-in section library",
expectedMessage:
'Cannot create a revision for a built-in section library. Check the "id" field in src/library/library.json. To deploy your own library, remove the reserved "yext_" prefix and retry.',
verbose: true,
},
{
name: "unrelated rejection with the same error code",
message: "The exact API error message.",
expectedMessage: "The exact API error message.",
verbose: false,
},
])("handles $name during revision creation", async (testCase) => {
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
const apiError = {
code: 104001,
type: "BAD_REQUEST",
message: testCase.message,
name: "invalidRequest",
};
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(
new Response(successfulResponse(sectionLibrary), { status: 200 })
)
.mockResolvedValueOnce(
new Response(successfulResponse({ sectionLibraryRevisions: [] }), {
status: 200,
})
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ meta: { errors: [apiError] } }), {
status: 400,
})
)
);

await expect(deploy(config, testCase.verbose)).rejects.toThrow(
testCase.expectedMessage
);
if (testCase.verbose) {
expect(errorLog).toHaveBeenCalledWith(
`[debug] API Errors: ${JSON.stringify([apiError])}`
);
} else {
expect(errorLog).not.toHaveBeenCalled();
}
});

it("explains how to resolve missing section library write permissions", async () => {
vi.stubGlobal(
"fetch",
Expand Down Expand Up @@ -354,6 +431,11 @@ describe("deploy", () => {
await expect(deploy(config)).rejects.toThrow(
/Yext API key does not have required permissions\./
);
const spinner = vi.mocked(ora).mock.results[0].value;
expect(spinner.fail).toHaveBeenCalledWith(
"Fetching Section Library... error"
);
expect(spinner.info).not.toHaveBeenCalled();
});

it("uses the API message for error code 104001", async () => {
Expand Down Expand Up @@ -477,6 +559,9 @@ describe("deploy", () => {

await deploy(config, false, { isInteractive: true });

const spinner = vi.mocked(ora).mock.results[0].value;
expect(spinner.info).toHaveBeenCalledWith("Section library not found");
expect(spinner.fail).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenNthCalledWith(
2,
new URL(
Expand Down Expand Up @@ -507,6 +592,9 @@ describe("deploy", () => {
deploy(config, false, { isInteractive: false })
).rejects.toThrow(/Section library "library\/123" does not exist/);

const spinner = vi.mocked(ora).mock.results[0].value;
expect(spinner.info).toHaveBeenCalledWith("Section library not found");
expect(spinner.fail).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(prompts).not.toHaveBeenCalled();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ export function logApiCall(
text: string,
method: string,
url: URL,
verbose: boolean = false
verbose: boolean = false,
expectedStatusMessages: Partial<Record<number, string>> = {}
): (res?: YextApiResponse) => void {
const spinner = text.length > 0 ? ora(text).start() : undefined;
const finishVerboseLog = verbose ? verboseLogApiCall(method, url) : undefined;

return (res?: YextApiResponse) => {
if (spinner) {
if (res?.ok) {
const expectedMessage = res && expectedStatusMessages[res.status];
if (expectedMessage) {
spinner.info(expectedMessage);
} else if (res?.ok) {
spinner.succeed(`${text} ${color("done", true)}`);
} else {
spinner.fail(`${text} ${color("error", false)}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ export async function createSectionLibraryRevision(
);

if (!result.ok) {
if (
result.errors[0]?.message.includes(
"cannot create revision for built-in section library"
)
) {
throw new Error(
'Cannot create a revision for a built-in section library. Check the "id" field in src/library/library.json. To deploy your own library, remove the reserved "yext_" prefix and retry.'
);
}
throw sectionLibraryApiError(
result.errors,
"Failed to upload current commit as a Section Library Revision"
Expand Down Expand Up @@ -170,7 +179,9 @@ export async function getSectionLibrary(
"GET",
`accounts/me/sectionLibraries/${encodeURIComponent(libraryId)}`,
config,
verbose
verbose,
undefined,
{ 404: "Section library not found" }
);

if (!result.ok && result.status !== 404) {
Expand Down
21 changes: 21 additions & 0 deletions packages/visual-editor/src/cli/yextve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,27 @@ describe("yextve", () => {
});
});

it("rejects a library id containing an underscore during validation", async () => {
const rootDir = createTempRoot();
fs.outputJsonSync(path.join(rootDir, "src", "library", "library.json"), {
schemaVersion: 1,
id: "foo_test",
displayName: "Library",
});

const result = await invoke(
["validate", "--skip-repo-structure-check", "--skip-code-check"],
rootDir
);

expect(result.exitCode).toBe(1);
expect(result.stdout).toContain("src/library/library.json");
expect(result.stdout).toContain(
"id must be 2–63 characters, contain only lowercase letters, numbers, and hyphens, start with a lowercase letter, and end with a letter or number."
);
expect(result.stdout).toContain("Validation failed. 1 error.");
});

it("renders all skipped stages and succeeds", async () => {
const result = await invoke([
"validate",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,43 @@ describe("validateLibraryMetadata", () => {
);
});

it("reports an unsafe id", () => {
it.each([
"unsafe id",
"foo_test",
"safe-id\n",
"Safe-id",
"safe-Id",
"1safe-id",
"-safe-id",
"safe-id-",
"a",
"a".repeat(64),
])("reports an unsafe id %s", (id) => {
const rootDir = createTempRoot();
writeLibraryJson(rootDir, validMetadata({ id: "unsafe id" }));
writeLibraryJson(rootDir, validMetadata({ id }));

expect(validateLibraryMetadata(rootDir).issues).toContainEqual(
expect.objectContaining({ rule: "field/id/safe" })
expect.objectContaining({
rule: "field/id/safe",
message:
"id must be 2–63 characters, contain only lowercase letters, numbers, and hyphens, start with a lowercase letter, and end with a letter or number.",
})
);
});

it.each(["ab", "a0", "a-b", "a".repeat(63)])(
"accepts a valid id %s",
(id) => {
const rootDir = createTempRoot();
writeLibraryJson(rootDir, validMetadata({ id }));

expect(validateLibraryMetadata(rootDir)).toEqual({
issues: [],
metadata: validMetadata({ id }),
});
}
);

it("aggregates independent field errors", () => {
const rootDir = createTempRoot();
writeLibraryJson(rootDir, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import fs from "fs-extra";
import type { LibraryMetadata } from "../../../../types/sectionLibrary.ts";
import type { ValidationIssue } from "../../types.ts";

const safeIdPattern = /^[A-Za-z0-9_-]{1,64}$/;
// Require the actual end of input; JavaScript $ also matches before a trailing newline.
const safeIdPattern = /^[a-z][a-z0-9-]{0,61}[a-z0-9](?![\s\S])/;
const descriptionMaxLength = 1024;

/** validateLibraryMetadata validates that the library.json has the required fields. */
Expand Down Expand Up @@ -82,7 +83,7 @@ export const validateLibraryMetadata = (
if (id && !safeIdPattern.test(id)) {
addIssue(
"field/id/safe",
"id must be at most 64 characters and may contain only letters, numbers, underscores, and hyphens."
"id must be 2–63 characters, contain only lowercase letters, numbers, and hyphens, start with a lowercase letter, and end with a letter or number."
);
}

Expand Down
Loading