mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
fix: classify errors with structured codes and types (#6539)
Invalid or revoked share links now return an actionable 401 response instead of an unexpected server error. Missing project and workspace records receive distinct responses at their lookup sites. Token, project, and workspace lookups use `.maybeSingle()` and rely on database uniqueness. Missing projects have an explicit `ProjectNotFoundError`; the loader no longer interprets `PGRST116`. The API preserves `UNAUTHORIZED` and `INVALID_AUTH_TOKEN` when authorization fails during context creation, so CLI and MCP clients can offer relinking guidance without inspecting error text. Tokens without API access carry `API_ACCESS_DISABLED`. A repository audit replaces error-message matching with typed errors, stable codes, or structured diagnostics in CLI argument/configuration handling, session conflicts, preview cleanup, import retries, domain unpublishing, asset validation, JSX/expression diagnostics, CSS validation, and publish diagnostics. `AGENTS.md` now explicitly prohibits message-based error classification and recovery. The existing build-permission preflight now advertises structured import-error support. Clients upload assets before importing into older servers that do not advertise it; newer servers keep selective retries using structured missing-file details. The internal template-validation error class remains outside public JSX bindings and input descriptions. Shared Builder `parseError` normalizes tRPC and local validation errors, so UI components only add display context instead of decoding transport payloads. Simplification removes three redundant error classes, catch-and-ignore handlers, an unused formatter, and nested loader error handling. Unpublishing an already unpublished domain succeeds after authorization. CLI configuration failures use the existing shared error handling, and plan parsing catches only JSON parsing failures. Validation: affected suites passed (271 tests across 8 suites in the latest simplification checks), affected package typechecks passed, and lint, package-boundary checks, and `git diff --check` passed. The audit replaced synthetic parser branches and private-method assertions with actual tRPC round trips, PostgREST responses, and public VM behavior. Controlled regressions confirmed that removing serialized validation details and exposing the internal JSX error class both fail the revised tests. Astra reviewed the production changes at medium effort; its compatibility finding and the GitHub JSX-binding comment are fixed, and follow-up review found no remaining substantive issues.
This commit is contained in:
1 parent
33f7b350bc
commit
c389cd3bb2
57 files changed
+885
-364
No files matched your search
@@ -72,3 +72,7 @@ packages/prisma-client/src/__generated__
|
||||
|
||||
# copilot instructions
|
||||
.github/instructions
|
||||
|
||||
# local agent instructions
|
||||
AGENTS.md
|
||||
agents.md
|
||||
@@ -38,7 +38,19 @@ describe("getPublishValidationErrorMessage", () => {
|
||||
});
|
||||
|
||||
test("locates a linked asset and points users to Webstudio MCP", () => {
|
||||
const error = new Error("Document asset-id could not be loaded");
|
||||
const error = Object.assign(new Error("Wording can change"), {
|
||||
data: {
|
||||
issues: [
|
||||
{
|
||||
code: "DOCUMENT_LOAD_FAILED",
|
||||
path: ["assets", "asset-id"],
|
||||
assetId: "asset-id",
|
||||
constraint: "readable_document",
|
||||
message: "Wording can change",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const asset = {
|
||||
id: "asset-id",
|
||||
filename: "article",
|
||||
@@ -85,10 +97,27 @@ describe("getPublishValidationErrorMessage", () => {
|
||||
|
||||
test("includes the document ID and MCP link when asset metadata is unavailable", () => {
|
||||
const message = getPublishValidationErrorMessage(
|
||||
new Error("Document asset-id could not be loaded")
|
||||
Object.assign(new Error("Wording can change"), {
|
||||
data: {
|
||||
issues: [
|
||||
{
|
||||
code: "DOCUMENT_LOAD_FAILED",
|
||||
path: ["assets", "asset-id"],
|
||||
assetId: "asset-id",
|
||||
constraint: "readable_document",
|
||||
message: "Wording can change",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(message).toContain("https://wstd.us/mcp");
|
||||
expect(message).toContain("Document ID: asset-id");
|
||||
});
|
||||
});
|
||||
|
||||
test("does not infer a document id from error prose", () => {
|
||||
const message = "Document asset-id could not be loaded";
|
||||
expect(getPublishValidationErrorMessage(new Error(message))).toBe(message);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getValidationIssues } from "@webstudio-is/project-build/runtime";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
import {
|
||||
createAssetFolderHierarchy,
|
||||
@@ -32,11 +33,10 @@ export const getPublishValidationErrorMessage = (
|
||||
return publishValidationTimeoutMessage;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : undefined;
|
||||
const unloadedDocument = /^Document (.+) could not be loaded$/.exec(
|
||||
message ?? ""
|
||||
);
|
||||
if (unloadedDocument !== null) {
|
||||
const documentId = unloadedDocument[1];
|
||||
const documentId = getValidationIssues(error)?.find(
|
||||
(issue) => issue.code === "DOCUMENT_LOAD_FAILED"
|
||||
)?.assetId;
|
||||
if (documentId !== undefined) {
|
||||
const asset = assets?.get(documentId);
|
||||
if (asset !== undefined) {
|
||||
const folderPath = formatAssetFolderPath(
|
||||
|
||||
@@ -29,6 +29,7 @@ import { TrashIcon } from "@webstudio-is/icons";
|
||||
import { type Workspace, type Role } from "@webstudio-is/project";
|
||||
import { nativeClient, trpcClient } from "~/shared/trpc/trpc-client";
|
||||
import { RoleSelect } from "./role-select";
|
||||
import { parseError } from "~/shared/error/error-parse";
|
||||
|
||||
const inviteMembers = async (
|
||||
emails: string[],
|
||||
@@ -47,21 +48,7 @@ const inviteMembers = async (
|
||||
failed.push(`${email}: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const raw = error instanceof Error ? error.message : "Unknown error";
|
||||
// tRPC surfaces Zod input validation failures as a JSON array of issues.
|
||||
// Extract the human-readable message(s) instead of dumping raw JSON.
|
||||
let message = raw;
|
||||
try {
|
||||
const issues = JSON.parse(raw);
|
||||
if (Array.isArray(issues) && issues.length > 0) {
|
||||
message = issues
|
||||
.map((i: { message?: string }) => i.message ?? "Invalid value")
|
||||
.join(", ");
|
||||
}
|
||||
} catch {
|
||||
// not JSON — use raw message as-is
|
||||
}
|
||||
failed.push(`${email}: ${message}`);
|
||||
failed.push(`${email}: ${parseError(error).message}`);
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
AuthorizationError,
|
||||
authorizeProject,
|
||||
} from "@webstudio-is/trpc-interface/index.server";
|
||||
import { createContext } from "~/shared/context.server";
|
||||
import { createContext, InvalidAuthTokenError } from "~/shared/context.server";
|
||||
import { getPlanInfo } from "@webstudio-is/plans/index.server";
|
||||
import { defaultPlanFeatures } from "@webstudio-is/plans";
|
||||
import { dashboardPath, isBuilder, isDashboard } from "~/shared/router-utils";
|
||||
@@ -82,7 +82,18 @@ export const loader = async (loaderArgs: LoaderFunctionArgs) => {
|
||||
// await checkCsrf(request);
|
||||
}
|
||||
|
||||
const context = await createContext(request);
|
||||
let context: Awaited<ReturnType<typeof createContext>>;
|
||||
try {
|
||||
context = await createContext(request);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidAuthTokenError) {
|
||||
throw new Response(
|
||||
"This share link is invalid or expired. Ask the project owner for a new link.",
|
||||
{ status: 401, statusText: "Unauthorized" }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (context.authorization.type === "service") {
|
||||
throw new AuthorizationError("Service calls are not allowed");
|
||||
@@ -125,12 +136,6 @@ export const loader = async (loaderArgs: LoaderFunctionArgs) => {
|
||||
const start = Date.now();
|
||||
const project = await projectApi.loadById(projectId, context);
|
||||
|
||||
if (project === null) {
|
||||
throw new Response(`Project "${projectId}" not found`, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const authPermit =
|
||||
(await authorizeProject.getProjectPermit(
|
||||
{
|
||||
@@ -184,12 +189,19 @@ export const loader = async (loaderArgs: LoaderFunctionArgs) => {
|
||||
.eq("isDeleted", false)
|
||||
.eq("members.userId", currentUserId ?? noMatchId)
|
||||
.is("members.removedAt", null)
|
||||
.single();
|
||||
.maybeSingle();
|
||||
|
||||
if (workspace.error) {
|
||||
throw workspace.error;
|
||||
}
|
||||
|
||||
if (workspace.data === null) {
|
||||
throw new Response(
|
||||
"This project's workspace is unavailable. Contact the project owner for help.",
|
||||
{ status: 404, statusText: "Workspace not found" }
|
||||
);
|
||||
}
|
||||
|
||||
const planResult = await getPlanInfo([workspace.data.userId], context);
|
||||
const ownerPlan = planResult.get(workspace.data.userId) ?? {
|
||||
planFeatures: defaultPlanFeatures,
|
||||
@@ -275,6 +287,12 @@ export const loader = async (loaderArgs: LoaderFunctionArgs) => {
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof projectApi.ProjectNotFoundError) {
|
||||
throw new Response(error.message, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
if (error instanceof AuthorizationError) {
|
||||
// try to re-login user if he has no access to the project
|
||||
throw await authWsLoader(loaderArgs);
|
||||
|
||||
@@ -41,6 +41,7 @@ export const assertApiTokenPermit = async (ctx: AppContext) => {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Authorization token cannot use Builder API",
|
||||
cause: { webstudioCode: "API_ACCESS_DISABLED" },
|
||||
});
|
||||
}
|
||||
return { token, permits };
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
PostgresAssetRepository,
|
||||
} from "@webstudio-is/asset-uploader/server";
|
||||
import type { ProjectPermit } from "@webstudio-is/trpc-interface/index.server";
|
||||
import { assetFolderIssue } from "@webstudio-is/sdk";
|
||||
import { assetResourceLimits } from "@webstudio-is/sdk/asset-resource-limits";
|
||||
import { parseBuilderUrl } from "@webstudio-is/protocol";
|
||||
import { assetDescriptionEncoding } from "@webstudio-is/protocol/asset-resource-api";
|
||||
@@ -229,7 +228,9 @@ const getAssetRestErrorStatus = (error: unknown) => {
|
||||
if (
|
||||
error instanceof ZodError &&
|
||||
error.issues.some(
|
||||
({ message }) => message === assetFolderIssue.duplicateName
|
||||
(issue) =>
|
||||
issue.code === "custom" &&
|
||||
issue.params?.assetFolderIssue === "duplicateName"
|
||||
)
|
||||
) {
|
||||
return 409;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DocumentGraphResolutionError } from "@webstudio-is/content-engine";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { bundleVersion } from "@webstudio-is/protocol";
|
||||
import { createPublishedProjectBundleFixture } from "@webstudio-is/protocol/fixtures";
|
||||
@@ -425,3 +426,48 @@ describe("build router project bundle import", () => {
|
||||
expect(removeStagedUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves document load failure ids for publish validation", async () => {
|
||||
const error = new DocumentGraphResolutionError({
|
||||
code: "DOCUMENT_LOAD_FAILED",
|
||||
documentId: "asset-id",
|
||||
message: "Storage is temporarily unavailable",
|
||||
});
|
||||
await expect(
|
||||
loadContentDatabasePublishDiagnostics("project-id", {} as never, {
|
||||
loadProjectBundleByProjectId: vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error("Compilation failed", { cause: error })
|
||||
) as never,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
cause: {
|
||||
webstudioCode: "DOCUMENT_LOAD_FAILED",
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
code: "DOCUMENT_LOAD_FAILED",
|
||||
assetId: "asset-id",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.each([true, false])(
|
||||
"only advertises import capabilities to an authorized caller: %s",
|
||||
async (allowed) => {
|
||||
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(allowed);
|
||||
const result = buildRouter
|
||||
.createCaller({} as never)
|
||||
.checkProjectBuildPermission({ projectId: "project-id" });
|
||||
if (allowed) {
|
||||
await expect(result).resolves.toEqual({
|
||||
supportsStructuredImportErrors: true,
|
||||
});
|
||||
} else {
|
||||
await expect(result).rejects.toThrow();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -326,6 +326,7 @@ export const buildRouter = router({
|
||||
.input(checkProjectBuildPermissionInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertProjectBuildPermit({ ctx, projectId: input.projectId });
|
||||
return { supportsStructuredImportErrors: true };
|
||||
}),
|
||||
|
||||
importProjectBundle: procedure.input(importProjectBundleInput).mutation(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { DocumentGraphResolutionError } from "@webstudio-is/content-engine";
|
||||
import { throwApiError } from "./api-errors.server";
|
||||
import { loadProjectBundleByProjectId } from "~/shared/db";
|
||||
import {
|
||||
formatMdxTemplatePublishDiagnostics,
|
||||
@@ -51,6 +53,32 @@ export const loadContentDatabasePublishDiagnostics = async (
|
||||
loadMdxDependencySource: loadDocumentSource,
|
||||
onMdxBlockInstanceId: (blockInstanceId) =>
|
||||
mdxBlockInstanceIds.add(blockInstanceId),
|
||||
}).catch((error: unknown): never => {
|
||||
const visited = new Set<unknown>();
|
||||
let current = error;
|
||||
while (current instanceof Error && !visited.has(current)) {
|
||||
visited.add(current);
|
||||
if (
|
||||
current instanceof DocumentGraphResolutionError &&
|
||||
current.code === "DOCUMENT_LOAD_FAILED" &&
|
||||
current.documentId !== undefined
|
||||
) {
|
||||
throwApiError("BAD_REQUEST", current.message, {
|
||||
webstudioCode: current.code,
|
||||
issues: [
|
||||
{
|
||||
code: current.code,
|
||||
path: ["assets", current.documentId],
|
||||
assetId: current.documentId,
|
||||
constraint: "readable_document",
|
||||
message: current.message,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
current = current.cause;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
const mdxErrors: Array<{
|
||||
filename: string;
|
||||
|
||||
@@ -660,7 +660,10 @@ describe("build import helpers", () => {
|
||||
loadDevBuildByProjectId,
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('Imported asset files are missing: ["image.png"]');
|
||||
).rejects.toMatchObject({
|
||||
webstudioCode: "MISSING_IMPORTED_ASSET_FILES",
|
||||
missingAssetNames: ["image.png"],
|
||||
});
|
||||
});
|
||||
|
||||
test("requires uploaded destination file rows for imported assets", async () => {
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "@webstudio-is/project-build";
|
||||
import {
|
||||
isAssetFileName,
|
||||
getMissingImportedAssetFilesMessage,
|
||||
MissingImportedAssetFilesError,
|
||||
getBundleVersionMismatchMessage,
|
||||
bundleVersion,
|
||||
type PublishedProjectBundle,
|
||||
@@ -208,10 +208,8 @@ const loadImportedAssetFiles = async ({
|
||||
(asset) => filesByName.has(asset.name) === false
|
||||
);
|
||||
if (missingAssets.length > 0) {
|
||||
throw new Error(
|
||||
getMissingImportedAssetFilesMessage(
|
||||
throw new MissingImportedAssetFilesError(
|
||||
missingAssets.map((asset) => asset.name)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const env = vi.hoisted(() => ({
|
||||
TRPC_SERVER_API_TOKEN: undefined as string | undefined,
|
||||
POSTGREST_URL: "http://localhost:3000",
|
||||
POSTGREST_URL: "http://test-postgrest",
|
||||
POSTGREST_API_KEY: "",
|
||||
PUBLISHER_HOST: "wstd.work",
|
||||
}));
|
||||
@@ -52,10 +52,6 @@ vi.mock("@webstudio-is/plans/index.server", () => ({
|
||||
getPlanInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@webstudio-is/postgrest/index.server", () => ({
|
||||
createClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/services/session.server", () => ({
|
||||
readLoginSessionBloomFilter: vi.fn(),
|
||||
}));
|
||||
@@ -65,7 +61,12 @@ vi.mock("./router-utils", () => ({
|
||||
isCanvas,
|
||||
}));
|
||||
|
||||
import { extractAuthFromRequest } from "./context.server";
|
||||
import { createTestServer, db, json } from "@webstudio-is/postgrest/testing";
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { procedure, router } from "@webstudio-is/trpc-interface/index.server";
|
||||
import { createContext, extractAuthFromRequest } from "./context.server";
|
||||
|
||||
const server = createTestServer();
|
||||
|
||||
describe("extractAuthFromRequest", () => {
|
||||
beforeEach(() => {
|
||||
@@ -124,3 +125,57 @@ describe("extractAuthFromRequest", () => {
|
||||
expect(auth.sessionData).toBe(sessionData);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid token authorization", () => {
|
||||
const appRouter = router({ check: procedure.query(() => "authorized") });
|
||||
const request = () =>
|
||||
fetchRequestHandler({
|
||||
endpoint: "/trpc",
|
||||
req: new Request("http://localhost/trpc/check", {
|
||||
headers: { "x-auth-token": "private-token" },
|
||||
}),
|
||||
router: appRouter,
|
||||
createContext: ({ req }) => createContext(req),
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ scenario: "revoked token", rows: [] },
|
||||
{ scenario: "unavailable project", rows: [{ project: null }] },
|
||||
{
|
||||
scenario: "project without an owner",
|
||||
rows: [{ project: { userId: null } }],
|
||||
},
|
||||
])("returns a structured 401 for $scenario", async ({ rows }) => {
|
||||
server.use(db.get("AuthorizationToken", () => json(rows)));
|
||||
const response = await request();
|
||||
expect(response.status).toBe(401);
|
||||
const body = await response.json();
|
||||
expect(body).toMatchObject({
|
||||
error: {
|
||||
data: { code: "UNAUTHORIZED", webstudioCode: "INVALID_AUTH_TOKEN" },
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(body)).not.toContain("private-token");
|
||||
});
|
||||
|
||||
test("reports a database outage as a server failure rather than invalid credentials", async () => {
|
||||
server.use(
|
||||
db.get("AuthorizationToken", () =>
|
||||
json(
|
||||
{ code: "08006", message: "Database unavailable" },
|
||||
{ status: 503 }
|
||||
)
|
||||
)
|
||||
);
|
||||
const response = await request();
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body).toMatchObject({
|
||||
error: { data: { code: "INTERNAL_SERVER_ERROR" } },
|
||||
});
|
||||
expect(body).not.toMatchObject({
|
||||
error: { data: { webstudioCode: "INVALID_AUTH_TOKEN" } },
|
||||
});
|
||||
expect(JSON.stringify(body)).not.toContain("private-token");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { type AppContext } from "@webstudio-is/trpc-interface/index.server";
|
||||
import env from "~/env/env.server";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
@@ -23,6 +24,16 @@ import {
|
||||
apiClientVersionHeader,
|
||||
} from "@webstudio-is/trpc-interface/api-compatibility";
|
||||
|
||||
export class InvalidAuthTokenError extends TRPCError {
|
||||
constructor() {
|
||||
super({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "The authorization token is invalid or no longer available",
|
||||
cause: { webstudioCode: "INVALID_AUTH_TOKEN" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const extractAuthFromRequest = async (request: Request) => {
|
||||
if (isCanvas(request)) {
|
||||
throw new Error("Canvas requests can't have authorization context");
|
||||
@@ -72,17 +83,19 @@ const createTokenAuthorizationContext = async (
|
||||
.from("AuthorizationToken")
|
||||
.select("project:Project(id, userId)")
|
||||
.eq("token", authToken)
|
||||
.single();
|
||||
.maybeSingle();
|
||||
|
||||
if (projectOwnerIdByToken.error) {
|
||||
throw new Error(`Project owner can't be found for token ${authToken}`);
|
||||
throw new Error("Failed to verify authorization token");
|
||||
}
|
||||
|
||||
if (projectOwnerIdByToken.data === null) {
|
||||
throw new InvalidAuthTokenError();
|
||||
}
|
||||
|
||||
const ownerId = projectOwnerIdByToken.data.project?.userId ?? null;
|
||||
if (ownerId === null) {
|
||||
throw new Error(
|
||||
`Project ${projectOwnerIdByToken.data.project?.id} has null userId`
|
||||
);
|
||||
throw new InvalidAuthTokenError();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createTRPCUntypedClient, httpBatchLink } from "@trpc/client";
|
||||
import { TRPCError, type AnyRouter } from "@trpc/server";
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { procedure, router } from "@webstudio-is/trpc-interface/index.server";
|
||||
import { expect, test } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { parseError } from "./error-parse";
|
||||
|
||||
const createClient = (appRouter: AnyRouter) =>
|
||||
createTRPCUntypedClient({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: "http://localhost/trpc",
|
||||
fetch: (input, init) =>
|
||||
fetchRequestHandler({
|
||||
req: new Request(input, init),
|
||||
endpoint: "/trpc",
|
||||
router: appRouter,
|
||||
createContext: () => ({ apiClient: { type: "browser" } }) as never,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
test.each(["Invalid email", "Enter a valid email address"])(
|
||||
"normalizes local and serialized validation errors consistently when wording changes: %s",
|
||||
async (emailMessage) => {
|
||||
const input = z.object({
|
||||
email: z.email(emailMessage),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
});
|
||||
const appRouter = router({
|
||||
save: procedure.input(input).mutation(() => "saved"),
|
||||
});
|
||||
const values = { email: "invalid", name: "" };
|
||||
const remoteError = await createClient(appRouter)
|
||||
.mutation("save", values)
|
||||
.catch((error) => error);
|
||||
const local = input.safeParse(values);
|
||||
expect(local.success).toBe(false);
|
||||
const expected = {
|
||||
status: 400,
|
||||
message: `${emailMessage}, Name is required`,
|
||||
};
|
||||
expect(parseError(remoteError)).toEqual(expected);
|
||||
if (!local.success) {
|
||||
expect(parseError(local.error)).toEqual(expected);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test("preserves a serialized permission failure without validation issues", async () => {
|
||||
const appRouter = router({
|
||||
save: procedure.mutation(() => {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You cannot edit this project",
|
||||
});
|
||||
}),
|
||||
});
|
||||
const error = await createClient(appRouter)
|
||||
.mutation("save")
|
||||
.catch((error) => error);
|
||||
expect(parseError(error)).toEqual({
|
||||
status: 403,
|
||||
message: "You cannot edit this project",
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,14 @@
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
import { isRouteErrorResponse } from "@remix-run/react";
|
||||
import { z } from "zod";
|
||||
|
||||
// Currently, we have multiple error formats, and not all of them are covered here.
|
||||
// We should consolidate these formats into a single, unified format for consistency.
|
||||
// Normalize transport and validation payloads here so UI consumers only need
|
||||
// the display message and status, regardless of where an error originated.
|
||||
const validationErrorData = z.object({
|
||||
httpStatus: z.number().int().min(400).max(599).catch(500),
|
||||
issues: z.array(z.object({ message: z.string() })).catch([]),
|
||||
});
|
||||
|
||||
const pageError = z.union([
|
||||
z.string().transform((message) => ({ message, description: undefined })),
|
||||
z
|
||||
@@ -30,6 +36,21 @@ export const parseError = (
|
||||
message: string;
|
||||
description?: string;
|
||||
} => {
|
||||
if (error instanceof TRPCClientError || error instanceof z.ZodError) {
|
||||
const parsed = validationErrorData.safeParse(
|
||||
error instanceof z.ZodError
|
||||
? { httpStatus: 400, issues: error.issues }
|
||||
: error.data
|
||||
);
|
||||
const messages = parsed.success
|
||||
? parsed.data.issues.map((issue) => issue.message)
|
||||
: [];
|
||||
return {
|
||||
status: parsed.success ? parsed.data.httpStatus : 500,
|
||||
message: messages.length === 0 ? error.message : messages.join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
message: error.message,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CliError } from "./errors";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { cwd } from "node:process";
|
||||
import { join } from "node:path";
|
||||
@@ -35,7 +36,8 @@ export const resolveApiConnection = async (
|
||||
if (projectId === undefined) {
|
||||
const localConfigFile = join(projectRoot, LOCAL_CONFIG_FILE);
|
||||
if ((await dependencies.isFileExists(localConfigFile)) === false) {
|
||||
throw new Error(
|
||||
throw new CliError(
|
||||
"NOT_INITIALIZED",
|
||||
"Local config file is not found. Run webstudio init --link <api-share-link> from a Webstudio project."
|
||||
);
|
||||
}
|
||||
@@ -45,14 +47,15 @@ export const resolveApiConnection = async (
|
||||
}
|
||||
|
||||
if (projectId.trim() === "") {
|
||||
throw new Error("Project id must not be empty.");
|
||||
throw new CliError("INVALID_ARGUMENT", "Project id must not be empty.");
|
||||
}
|
||||
const globalConfig = jsonToGlobalConfig(
|
||||
JSON.parse(await dependencies.readFile(GLOBAL_CONFIG_FILE, "utf-8"))
|
||||
);
|
||||
const projectConfig = globalConfig[projectId];
|
||||
if (projectConfig === undefined) {
|
||||
throw new Error(
|
||||
throw new CliError(
|
||||
"NOT_INITIALIZED",
|
||||
isExplicitProject
|
||||
? `Project config for "${projectId}" is not found. Run webstudio init --link <api-share-link> once to save its credentials.`
|
||||
: "Project config is not found. Run webstudio init --link <api-share-link>."
|
||||
|
||||
@@ -830,10 +830,9 @@ test("explains missing Builder API access instead of leaking token ownership err
|
||||
mockConfig();
|
||||
createCliProjectSession.mockImplementationOnce(() => ({
|
||||
initialize: vi.fn(async () => {
|
||||
throw Object.assign(
|
||||
new Error("Project owner can't be found for token token-1"),
|
||||
{ data: { code: "INTERNAL_SERVER_ERROR" } }
|
||||
);
|
||||
throw Object.assign(new Error("The credential has been revoked"), {
|
||||
data: { code: "UNAUTHORIZED", webstudioCode: "INVALID_AUTH_TOKEN" },
|
||||
});
|
||||
}),
|
||||
refresh: vi.fn(),
|
||||
executeServerOperation: vi.fn(),
|
||||
@@ -1152,7 +1151,7 @@ test("rejects redirect status and clear-status together", async () => {
|
||||
|
||||
expectJsonErrorOutput({
|
||||
command: "update-redirect",
|
||||
code: "API_COMMAND_FAILED",
|
||||
code: "INVALID_ARGUMENT",
|
||||
message: "--clear-status cannot be used with --status.",
|
||||
});
|
||||
expect(apiCalls.updateRedirect).not.toHaveBeenCalled();
|
||||
@@ -1268,7 +1267,7 @@ test("rejects breakpoint clear and value options together", async () => {
|
||||
|
||||
expectJsonErrorOutput({
|
||||
command: "update-breakpoint",
|
||||
code: "API_COMMAND_FAILED",
|
||||
code: "INVALID_ARGUMENT",
|
||||
message: "--clear-min-width cannot be used with --min-width.",
|
||||
});
|
||||
expect(apiCalls.updateBreakpoint).not.toHaveBeenCalled();
|
||||
@@ -2456,3 +2455,23 @@ test("requires global config for configured project", async () => {
|
||||
projectId: false,
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
"version details are unavailable",
|
||||
"CONFLICT in a log message",
|
||||
"A token is required by a remote dependency",
|
||||
])("does not infer error codes from prose: %s", async (message) => {
|
||||
mockConfig();
|
||||
apiCalls.getPage.mockRejectedValueOnce(new Error(message));
|
||||
await expect(
|
||||
apiCommand(
|
||||
{ command: "get-page", page: "page-1", json: true },
|
||||
dependencies
|
||||
)
|
||||
).rejects.toThrow("Handled CLI error");
|
||||
expectJsonErrorOutput({
|
||||
command: "get-page",
|
||||
code: "API_COMMAND_FAILED",
|
||||
message,
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import * as httpClient from "@webstudio-is/http-client";
|
||||
import type { MarketplaceProduct } from "@webstudio-is/project-build";
|
||||
import type { AssetType, PageRedirect } from "@webstudio-is/sdk";
|
||||
import { isFileExists } from "../fs-utils";
|
||||
import { HandledCliError } from "../errors";
|
||||
import { CliError, HandledCliError } from "../errors";
|
||||
import {
|
||||
createLocalUploadAssetInput,
|
||||
createLocalUploadAssetsInput,
|
||||
@@ -1539,7 +1539,7 @@ type UpdateDomainInput = Parameters<
|
||||
|
||||
const requireOption = (value: string | undefined, name: string) => {
|
||||
if (value === undefined || value.length === 0) {
|
||||
throw new Error(`${name} is required.`);
|
||||
throw new CliError("INVALID_ARGUMENT", `${name} is required.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
@@ -1556,14 +1556,14 @@ const requireSingleOption = (
|
||||
|
||||
const requireStringOption = (value: string | undefined, name: string) => {
|
||||
if (value === undefined) {
|
||||
throw new Error(`${name} is required.`);
|
||||
throw new CliError("INVALID_ARGUMENT", `${name} is required.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const requireTrueOption = (value: boolean | undefined, name: string) => {
|
||||
if (value !== true) {
|
||||
throw new Error(`${name} is required.`);
|
||||
throw new CliError("INVALID_ARGUMENT", `${name} is required.`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1574,7 +1574,8 @@ const rejectOptionConflict = (
|
||||
valueOptionName: string
|
||||
) => {
|
||||
if (clearOption === true && valueOption !== undefined) {
|
||||
throw new Error(
|
||||
throw new CliError(
|
||||
"INVALID_ARGUMENT",
|
||||
`${clearOptionName} cannot be used with ${valueOptionName}.`
|
||||
);
|
||||
}
|
||||
@@ -1582,7 +1583,7 @@ const rejectOptionConflict = (
|
||||
|
||||
const requireNumberOption = (value: number | undefined, name: string) => {
|
||||
if (value === undefined) {
|
||||
throw new Error(`${name} is required.`);
|
||||
throw new CliError("INVALID_ARGUMENT", `${name} is required.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
@@ -1606,7 +1607,7 @@ const requireListOption = (
|
||||
) => {
|
||||
const values = listStringOption(value);
|
||||
if (values === undefined || values.length === 0) {
|
||||
throw new Error(`${name} is required.`);
|
||||
throw new CliError("INVALID_ARGUMENT", `${name} is required.`);
|
||||
}
|
||||
return values;
|
||||
};
|
||||
@@ -1622,7 +1623,7 @@ const optionalChoice = <Choice extends string>(
|
||||
if ((choices as readonly string[]).includes(value)) {
|
||||
return value as Choice;
|
||||
}
|
||||
throw new Error(`Unsupported ${name} "${value}".`);
|
||||
throw new CliError("INVALID_ARGUMENT", `Unsupported ${name} "${value}".`);
|
||||
};
|
||||
|
||||
const requiredChoice = <Choice extends string>(
|
||||
@@ -1632,7 +1633,7 @@ const requiredChoice = <Choice extends string>(
|
||||
) => {
|
||||
const choice = optionalChoice(value, choices, name);
|
||||
if (choice === undefined) {
|
||||
throw new Error(`${name} is required.`);
|
||||
throw new CliError("INVALID_ARGUMENT", `${name} is required.`);
|
||||
}
|
||||
return choice;
|
||||
};
|
||||
@@ -1686,7 +1687,10 @@ const readOptionalJsonObject = async (
|
||||
}
|
||||
const value = await readJsonFile(dependencies, path, "--input");
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error("--input must contain a JSON object.");
|
||||
throw new CliError(
|
||||
"INVALID_ARGUMENT",
|
||||
"--input must contain a JSON object."
|
||||
);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
@@ -1698,7 +1702,10 @@ const readJsonArray = async (
|
||||
) => {
|
||||
const value = await readJsonFile(dependencies, path, name);
|
||||
if (Array.isArray(value) === false) {
|
||||
throw new Error(`${name} must contain a JSON array.`);
|
||||
throw new CliError(
|
||||
"INVALID_ARGUMENT",
|
||||
`${name} must contain a JSON array.`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
@@ -1725,13 +1732,19 @@ const parseVariableValue = (
|
||||
if (valueType === "number") {
|
||||
const number = Number(rawValue);
|
||||
if (Number.isFinite(number) === false) {
|
||||
throw new Error("--value must be a finite number.");
|
||||
throw new CliError(
|
||||
"INVALID_ARGUMENT",
|
||||
"--value must be a finite number."
|
||||
);
|
||||
}
|
||||
return { type: valueType, value: number };
|
||||
}
|
||||
if (valueType === "boolean") {
|
||||
if (rawValue !== "true" && rawValue !== "false") {
|
||||
throw new Error('--value must be "true" or "false".');
|
||||
throw new CliError(
|
||||
"INVALID_ARGUMENT",
|
||||
'--value must be "true" or "false".'
|
||||
);
|
||||
}
|
||||
return { type: valueType, value: rawValue === "true" };
|
||||
}
|
||||
@@ -1746,7 +1759,10 @@ const parseOptionalVariableValue = (
|
||||
return;
|
||||
}
|
||||
if (options.value === undefined || options.valueType === undefined) {
|
||||
throw new Error("--value and --value-type must be provided together.");
|
||||
throw new CliError(
|
||||
"INVALID_ARGUMENT",
|
||||
"--value and --value-type must be provided together."
|
||||
);
|
||||
}
|
||||
return parseVariableValue(options.valueType, options.value);
|
||||
};
|
||||
@@ -1817,7 +1833,6 @@ const redirectStatusOption = (status: ApiCommandOptions["status"]) =>
|
||||
status === "301" || status === "302" ? status : undefined;
|
||||
|
||||
const getErrorCode = (error: unknown) => {
|
||||
const message = getCliErrorMessage(error);
|
||||
const stableCode = getStableErrorCode(error);
|
||||
if (isMissingApiAccessError(error)) {
|
||||
return "UNAUTHORIZED";
|
||||
@@ -1837,24 +1852,6 @@ const getErrorCode = (error: unknown) => {
|
||||
if (error instanceof SyntaxError) {
|
||||
return "INVALID_JSON";
|
||||
}
|
||||
if (
|
||||
message.includes("Local config file") ||
|
||||
message.includes("Project config")
|
||||
) {
|
||||
return "NOT_INITIALIZED";
|
||||
}
|
||||
if (message.includes(" is required")) {
|
||||
return "INVALID_ARGUMENT";
|
||||
}
|
||||
if (message.includes("does not support --dry-run")) {
|
||||
return "INVALID_ARGUMENT";
|
||||
}
|
||||
if (message.includes("Invalid patch JSON")) {
|
||||
return "INVALID_PATCH";
|
||||
}
|
||||
if (message.includes("version") || message.includes("CONFLICT")) {
|
||||
return "VERSION_CONFLICT";
|
||||
}
|
||||
return "API_COMMAND_FAILED";
|
||||
};
|
||||
|
||||
|
||||
@@ -1101,10 +1101,9 @@ test("formats unknown MCP resource failures as structured JSON", () => {
|
||||
});
|
||||
|
||||
test("formats missing Builder API access in MCP single-op-call failures", () => {
|
||||
const error = Object.assign(
|
||||
new Error("Project owner can't be found for token token-1"),
|
||||
{ code: "INTERNAL_SERVER_ERROR" }
|
||||
);
|
||||
const error = Object.assign(new Error("The credential has been revoked"), {
|
||||
code: "INVALID_AUTH_TOKEN",
|
||||
});
|
||||
|
||||
expect(
|
||||
createMcpSingleOpCallErrorPayload({
|
||||
|
||||
@@ -226,6 +226,10 @@ type PreviewProcessOwner = {
|
||||
previewPid?: unknown;
|
||||
};
|
||||
|
||||
export class PreviewCleanupError extends Error {
|
||||
readonly code = "PREVIEW_CLEANUP_FAILED";
|
||||
}
|
||||
|
||||
export const createPreviewCleanupError = async ({
|
||||
previewProjectDir,
|
||||
lockedPath,
|
||||
@@ -255,7 +259,7 @@ export const createPreviewCleanupError = async ({
|
||||
pids.length === 0
|
||||
? "Stop the CLI/MCP process that started preview. On WSL/Windows, use PowerShell Get-CimInstance Win32_Process to find a command whose path is inside the locked preview directory, verify it belongs to this project, and stop only that PID."
|
||||
: `Stop preview from its owning CLI/MCP session. If that owner is gone, verify the recorded PID belongs to this project, then terminate only its tree (WSL/Windows: taskkill.exe /pid ${pids.at(-1)} /t /f; Linux/macOS: kill -- -${pids.at(-1)}).`;
|
||||
return new Error(
|
||||
return new PreviewCleanupError(
|
||||
`PREVIEW_CLEANUP_FAILED: Could not remove generated preview path ${lockedPath}. A preview process may still have it open. ${ownerHint} ${recovery}`,
|
||||
{ cause }
|
||||
);
|
||||
@@ -340,10 +344,7 @@ export const ensurePreviewDependencies = async (
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.startsWith("PREVIEW_CLEANUP_FAILED:")
|
||||
) {
|
||||
if (error instanceof PreviewCleanupError) {
|
||||
throw error;
|
||||
}
|
||||
// Continue with the CLI dependency tree or an isolated install.
|
||||
|
||||
@@ -859,7 +859,7 @@ test("throws handled error when local project config is missing", async () => {
|
||||
).rejects.toThrow("Handled CLI error");
|
||||
|
||||
expect(indicator.stop).toHaveBeenCalledWith(
|
||||
"Local config file is not found. Please make sure current directory is a webstudio project",
|
||||
expect.stringContaining("webstudio init --link <api-share-link>"),
|
||||
2
|
||||
);
|
||||
});
|
||||
|
||||
@@ -206,14 +206,11 @@ export const sync = async (
|
||||
try {
|
||||
connection = await dependencies.resolveApiConnection(dependencies);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
syncing.stop(
|
||||
message.includes("Local config file")
|
||||
? "Local config file is not found. Please make sure current directory is a webstudio project"
|
||||
: message.includes("Project config")
|
||||
? "Project config is not found, please run webstudio link"
|
||||
: message,
|
||||
2
|
||||
stopSpinnerWithError(
|
||||
syncing,
|
||||
error,
|
||||
"Unable to load project configuration",
|
||||
"sync"
|
||||
);
|
||||
throw new HandledCliError();
|
||||
}
|
||||
|
||||
@@ -154,11 +154,10 @@ describe("getCliErrorMessage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("explains missing Builder API access for opaque project owner token errors", () => {
|
||||
const error = Object.assign(
|
||||
new Error("Project owner can't be found for token token-1"),
|
||||
{ data: { code: "INTERNAL_SERVER_ERROR" } }
|
||||
);
|
||||
test("explains missing Builder API access from structured codes", () => {
|
||||
const error = Object.assign(new Error("The credential has been revoked"), {
|
||||
data: { code: "UNAUTHORIZED", webstudioCode: "INVALID_AUTH_TOKEN" },
|
||||
});
|
||||
|
||||
expect(isMissingApiAccessError(error)).toBe(true);
|
||||
expect(getCliErrorMessage(error)).toBe(
|
||||
@@ -166,3 +165,23 @@ describe("getCliErrorMessage", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("does not classify authorization failures by message text", () => {
|
||||
expect(
|
||||
isMissingApiAccessError(
|
||||
new Error("Project owner can't be found for token token-1")
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isMissingApiAccessError({
|
||||
data: { webstudioCode: "API_ACCESS_DISABLED" },
|
||||
message: "Any wording",
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMissingApiAccessError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "The credential has been revoked",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
getValidationIssues,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
|
||||
const missingProjectOwnerForTokenPattern =
|
||||
/^Project owner can't be found for token\b/;
|
||||
const stableErrorCodePattern = /^[A-Z][A-Z0-9_]{0,159}$/;
|
||||
|
||||
const getErrorMessage = (error: unknown) =>
|
||||
@@ -73,8 +71,8 @@ export const getStableErrorCode = (error: unknown) => {
|
||||
};
|
||||
|
||||
export const isMissingApiAccessError = (error: unknown) => {
|
||||
const message = getErrorMessage(error);
|
||||
return missingProjectOwnerForTokenPattern.test(message);
|
||||
const code = getStableErrorCode(error);
|
||||
return code === "INVALID_AUTH_TOKEN" || code === "API_ACCESS_DISABLED";
|
||||
};
|
||||
|
||||
export const getCliErrorSummary = (error: unknown, command = "mcp") => {
|
||||
@@ -86,7 +84,7 @@ export const getCliErrorSummary = (error: unknown, command = "mcp") => {
|
||||
return compatibilityMessage;
|
||||
}
|
||||
const message = getErrorMessage(error);
|
||||
if (missingProjectOwnerForTokenPattern.test(message)) {
|
||||
if (isMissingApiAccessError(error)) {
|
||||
return "This project cannot be accessed through the Builder API with the current share link/token. Enable API access in the share-link settings, then relink the project with `webstudio init --link <share-link> --json`.";
|
||||
}
|
||||
return message;
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
export class CliError extends Error {
|
||||
readonly code: string;
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.name = "CliError";
|
||||
}
|
||||
}
|
||||
|
||||
export class HandledCliError extends Error {
|
||||
constructor() {
|
||||
super("Handled CLI error");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CliError } from "./errors";
|
||||
import {
|
||||
getPublicApiOperation,
|
||||
publicApiOperationRequiresServerSupport,
|
||||
@@ -99,7 +100,8 @@ export const executeProjectSessionApiOperation = async ({
|
||||
dryRun === true &&
|
||||
(runtimeOperationId === undefined || operation.method !== "mutation")
|
||||
) {
|
||||
throw new Error(
|
||||
throw new CliError(
|
||||
"INVALID_ARGUMENT",
|
||||
`${command} does not support --dry-run. Use --dry-run only with local-capable mutation tools; omit it for read or server-only tools.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ test("reports only the shape of an untransformable server response", () => {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
responseJSON: [{ result: { data: "private customer content" } }],
|
||||
responseJSON: [{ result: "private customer content" }],
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -650,7 +650,7 @@ describe("cli project session storage", () => {
|
||||
},
|
||||
{ expectedRevision: "stale" }
|
||||
)
|
||||
).rejects.toThrow("changed on disk");
|
||||
).rejects.toMatchObject({ code: "PROJECT_SESSION_BUSY" });
|
||||
|
||||
await expect(
|
||||
storage.save(
|
||||
@@ -1136,9 +1136,17 @@ describe("cli project session transport", () => {
|
||||
getBuildSnapshot: async (input) => {
|
||||
includes.push(input.include);
|
||||
if (input.include?.includes("projectSettings")) {
|
||||
throw new Error(
|
||||
'invalid_enum_value: received "projectSettings", expected one of pages|folders'
|
||||
);
|
||||
throw Object.assign(new Error("Unsupported include"), {
|
||||
data: {
|
||||
code: "BAD_REQUEST",
|
||||
issues: [
|
||||
{
|
||||
code: "invalid_value",
|
||||
path: ["include", input.include.indexOf("projectSettings")],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
projectId: "project-1",
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
getHomePage,
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
ProjectSessionBusyError,
|
||||
createProjectSession,
|
||||
createDefaultProjectSessionCompatibility,
|
||||
serializeRestorePointTransaction,
|
||||
@@ -234,12 +235,34 @@ const createCliProjectSessionCompatibility = (
|
||||
apiCompatibilityVersion: connection.headers?.["x-webstudio-client-version"],
|
||||
});
|
||||
|
||||
const isUnsupportedProjectSettingsIncludeError = (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const isUnsupportedProjectSettingsIncludeError = (
|
||||
error: unknown,
|
||||
includes: readonly string[]
|
||||
) => {
|
||||
return getIssueReportErrorRecords(error).some((record) => {
|
||||
const data = isPlainRecord(record.data) ? record.data : record;
|
||||
return (
|
||||
message.includes("invalid_enum_value") &&
|
||||
message.includes('received "projectSettings"')
|
||||
Array.isArray(data.issues) &&
|
||||
data.issues.some((issue) => {
|
||||
if (
|
||||
!isPlainRecord(issue) ||
|
||||
(issue.code !== "invalid_enum_value" &&
|
||||
issue.code !== "invalid_value") ||
|
||||
!Array.isArray(issue.path) ||
|
||||
issue.path.length !== 2 ||
|
||||
issue.path[0] !== "include"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const index = issue.path[1];
|
||||
return (
|
||||
(typeof index === "number" || typeof index === "string") &&
|
||||
Number.isInteger(Number(index)) &&
|
||||
includes[Number(index)] === "projectSettings"
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const getLegacyPublicApiInclude = (namespaces: readonly BuilderNamespace[]) =>
|
||||
@@ -389,11 +412,27 @@ export const createIssueReportFailure = (
|
||||
? "error"
|
||||
: "other";
|
||||
const httpStatus = httpClient.getErrorStatus(error) ?? response?.status;
|
||||
const isResponseTransformFailure = records.some(
|
||||
(candidate) =>
|
||||
candidate.name === "TRPCClientError" &&
|
||||
candidate.message === "Unable to transform response from server"
|
||||
const isResponseTransformFailure = records.some((candidate) => {
|
||||
if (
|
||||
candidate.name !== "TRPCClientError" ||
|
||||
!isPlainRecord(candidate.meta)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const json = candidate.meta.responseJSON;
|
||||
const envelopes = Array.isArray(json) ? json : [json];
|
||||
return (
|
||||
json !== undefined &&
|
||||
envelopes.some(
|
||||
(envelope) =>
|
||||
!isPlainRecord(envelope) ||
|
||||
("error" in envelope
|
||||
? !isPlainRecord(envelope.error) ||
|
||||
typeof envelope.error.code !== "number"
|
||||
: !isPlainRecord(envelope.result))
|
||||
)
|
||||
);
|
||||
});
|
||||
const processExit = isPlainRecord(record?.processExit)
|
||||
? record.processExit
|
||||
: undefined;
|
||||
@@ -609,7 +648,7 @@ export const createCliProjectSessionStorage = (
|
||||
options.expectedRevision !== undefined &&
|
||||
current?.revision !== options.expectedRevision
|
||||
) {
|
||||
throw new Error("Project session snapshot changed on disk.");
|
||||
throw new ProjectSessionBusyError();
|
||||
}
|
||||
const revision = randomUUID();
|
||||
await writeFileAtomic(
|
||||
@@ -736,13 +775,14 @@ export const createCliProjectSessionTransport = ({
|
||||
include,
|
||||
})
|
||||
) as Promise<PublicBuildSnapshot>;
|
||||
const includes = getPublicBuildIncludes(namespaces);
|
||||
let snapshot: PublicBuildSnapshot;
|
||||
try {
|
||||
snapshot = await fetchSnapshot(getPublicBuildIncludes(namespaces));
|
||||
snapshot = await fetchSnapshot(includes);
|
||||
} catch (error) {
|
||||
if (
|
||||
namespaces.includes("projectSettings") === false ||
|
||||
isUnsupportedProjectSettingsIncludeError(error) === false
|
||||
isUnsupportedProjectSettingsIncludeError(error, includes) === false
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ export const isValidDeclaration = (
|
||||
const matchResult = lexer.matchProperty(property, ast);
|
||||
|
||||
// allow to parse unknown properties as unparsed
|
||||
if (matchResult.error?.message.includes("Unknown property")) {
|
||||
if (lexer.getProperty(property) === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -231,15 +231,7 @@ export const remove = async (
|
||||
await unpublishBuild(
|
||||
{ projectId: props.projectId, domain: domain.domain },
|
||||
context
|
||||
).catch((error) => {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === `Domain ${domain.domain} is not published`
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
);
|
||||
|
||||
const deleteResult = await context.postgrest.client
|
||||
.from("ProjectDomain")
|
||||
|
||||
@@ -378,17 +378,7 @@ export const unpublishProjectDomains = async (
|
||||
for (const domain of domains) {
|
||||
const dbDomain = domain.replace(`.${env.PUBLISHER_HOST}`, "");
|
||||
const result = await deploymentTrpc.unpublish.mutate({ domain });
|
||||
await unpublishBuild({ projectId, domain: dbDomain }, context).catch(
|
||||
(error) => {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === `Domain ${dbDomain} is not published`
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
await unpublishBuild({ projectId, domain: dbDomain }, context);
|
||||
if (result.success === false && result.error !== "NOT_IMPLEMENTED") {
|
||||
throw new Error(`Failed to unpublish ${domain}: ${result.error}`);
|
||||
}
|
||||
|
||||
@@ -127,11 +127,18 @@ describe("lint expression", () => {
|
||||
message,
|
||||
});
|
||||
|
||||
const warn = (from: number, to: number, message: string): Diagnostic => ({
|
||||
const warn = (
|
||||
from: number,
|
||||
to: number,
|
||||
message: string,
|
||||
identifier: string
|
||||
): Diagnostic => ({
|
||||
from,
|
||||
to,
|
||||
severity: "warning",
|
||||
message,
|
||||
code: "unknown-identifier",
|
||||
identifier,
|
||||
});
|
||||
|
||||
test("forbid empty expression", () => {
|
||||
@@ -246,7 +253,7 @@ describe("lint expression", () => {
|
||||
allowAssignment: true,
|
||||
availableVariables: new Set(["c"]),
|
||||
})
|
||||
).toEqual([warn(1, 2, `"a" is not defined in the scope`)]);
|
||||
).toEqual([warn(1, 2, `"a" is not defined in the scope`, "a")]);
|
||||
});
|
||||
|
||||
test("lint assignment operators and targets", () => {
|
||||
@@ -306,8 +313,8 @@ describe("lint expression", () => {
|
||||
expect(
|
||||
lintExpression({ expression: ` a = b + 1`, allowAssignment: true })
|
||||
).toEqual([
|
||||
warn(5, 6, `"b" is not defined in the scope`),
|
||||
warn(1, 2, `"a" is not defined in the scope`),
|
||||
warn(5, 6, `"b" is not defined in the scope`, "b"),
|
||||
warn(1, 2, `"a" is not defined in the scope`, "a"),
|
||||
]);
|
||||
expect(
|
||||
lintExpression({
|
||||
|
||||
@@ -39,6 +39,8 @@ export type Diagnostic = {
|
||||
to: number;
|
||||
severity: "error" | "hint" | "info" | "warning";
|
||||
message: string;
|
||||
code?: "unknown-identifier";
|
||||
identifier?: string;
|
||||
};
|
||||
|
||||
type ExpressionVisitor = {
|
||||
@@ -298,7 +300,8 @@ export const lintExpression = ({
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
const addMessage = (
|
||||
message: string,
|
||||
severity: "error" | "warning" = "error"
|
||||
severity: "error" | "warning" = "error",
|
||||
metadata: Pick<Diagnostic, "code" | "identifier"> = {}
|
||||
) => {
|
||||
return (node: { start: number; end: number }) => {
|
||||
diagnostics.push({
|
||||
@@ -307,6 +310,7 @@ export const lintExpression = ({
|
||||
to: node.end - 1,
|
||||
severity,
|
||||
message: message,
|
||||
...metadata,
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -332,10 +336,10 @@ export const lintExpression = ({
|
||||
simple(root, {
|
||||
Identifier(node) {
|
||||
if (availableVariables.has(node.name) === false) {
|
||||
addMessage(
|
||||
`"${node.name}" is not defined in the scope`,
|
||||
"warning"
|
||||
)(node);
|
||||
addMessage(`"${node.name}" is not defined in the scope`, "warning", {
|
||||
code: "unknown-identifier",
|
||||
identifier: node.name,
|
||||
})(node);
|
||||
}
|
||||
},
|
||||
Literal() {},
|
||||
@@ -362,7 +366,8 @@ export const lintExpression = ({
|
||||
if (availableVariables.has(node.name) === false) {
|
||||
addMessage(
|
||||
`"${node.name}" is not defined in the scope`,
|
||||
"warning"
|
||||
"warning",
|
||||
{ code: "unknown-identifier", identifier: node.name }
|
||||
)(node);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2327,9 +2327,16 @@ test("imports project bundle through staged upload", async () => {
|
||||
expect(JSON.stringify(trpcBody)).not.toContain("largeContent");
|
||||
});
|
||||
|
||||
test.each([undefined, "source-folder"])(
|
||||
"imports assets from folder %s and preserves their final placement",
|
||||
async (folderId) => {
|
||||
test.each(
|
||||
[true, false].flatMap((supportsStructuredImportErrors) =>
|
||||
[undefined, "source-folder"].map((folderId) => ({
|
||||
folderId,
|
||||
supportsStructuredImportErrors,
|
||||
}))
|
||||
)
|
||||
)(
|
||||
"imports assets from folder $folderId (structured errors: $supportsStructuredImportErrors)",
|
||||
async ({ folderId, supportsStructuredImportErrors }) => {
|
||||
const asset = createImageAssetFixture({ name: "image.png", folderId });
|
||||
const assetFolders =
|
||||
folderId === undefined
|
||||
@@ -2363,7 +2370,17 @@ test.each([undefined, "source-folder"])(
|
||||
url.pathname === "/trpc/build.checkProjectBuildPermission"
|
||||
) {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify([{ result: { data: undefined } }]));
|
||||
response.end(
|
||||
JSON.stringify([
|
||||
{
|
||||
result: {
|
||||
data: supportsStructuredImportErrors
|
||||
? { supportsStructuredImportErrors: true }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2426,12 +2443,16 @@ test.each([undefined, "source-folder"])(
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify([
|
||||
importAttempts === 1
|
||||
supportsStructuredImportErrors && importAttempts === 1
|
||||
? {
|
||||
error: {
|
||||
message: "Imported asset files are missing: image.png",
|
||||
message: "Import requires uploaded files",
|
||||
code: -32603,
|
||||
data: { code: "INTERNAL_SERVER_ERROR" },
|
||||
data: {
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
webstudioCode: "MISSING_IMPORTED_ASSET_FILES",
|
||||
missingAssetNames: ["image.png"],
|
||||
},
|
||||
},
|
||||
}
|
||||
: { result: { data: { version: 2 } } },
|
||||
@@ -2486,7 +2507,13 @@ test.each([undefined, "source-folder"])(
|
||||
}
|
||||
|
||||
expect(calls).toContain("GET /trpc/build.checkProjectBuildPermission");
|
||||
expect(importAttempts).toBe(2);
|
||||
if (!supportsStructuredImportErrors) {
|
||||
expect(calls.indexOf("POST /rest/assets/uploads/image.png")).toBeLessThan(
|
||||
calls.indexOf("POST /trpc/build.importProjectBundle")
|
||||
);
|
||||
}
|
||||
const expectedImportAttempts = supportsStructuredImportErrors ? 2 : 1;
|
||||
expect(importAttempts).toBe(expectedImportAttempts);
|
||||
expect(uploadAttempts).toBe(1);
|
||||
expect(
|
||||
uploadUrls.map((url) => new URL(url).searchParams.get("folderId"))
|
||||
@@ -2497,19 +2524,20 @@ test.each([undefined, "source-folder"])(
|
||||
const importedBundles = Array.from(uploadChunks.values(), (chunks) =>
|
||||
JSON.parse(Buffer.concat(chunks).toString("utf8"))
|
||||
);
|
||||
expect(importedBundles.map((bundle) => bundle.assetFolders)).toEqual([
|
||||
assetFolders,
|
||||
assetFolders,
|
||||
]);
|
||||
expect(importedBundles.map((bundle) => bundle.assetFolders)).toEqual(
|
||||
Array.from({ length: expectedImportAttempts }, () => assetFolders)
|
||||
);
|
||||
expect(importedBundles.map((bundle) => bundle.assets)).toEqual([
|
||||
[asset],
|
||||
...(supportsStructuredImportErrors ? [[asset]] : []),
|
||||
[{ ...asset, name: "image_destination.png" }],
|
||||
]);
|
||||
expect(uploadUrls.every((url) => url.includes("assetId=") === false)).toBe(
|
||||
true
|
||||
);
|
||||
expect(uploadOffsets.size).toBe(2);
|
||||
expect(importAttemptMessages).toEqual(["attempt", "attempt"]);
|
||||
expect(uploadOffsets.size).toBe(expectedImportAttempts);
|
||||
expect(importAttemptMessages).toEqual(
|
||||
Array.from({ length: expectedImportAttempts }, () => "attempt")
|
||||
);
|
||||
expect(missingAssetMessages).toEqual([]);
|
||||
expect(uploadAssetMessages).toEqual(["image.png"]);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
buildPatchNamespaces,
|
||||
bundleVersion as currentBundleVersion,
|
||||
importProjectBundleResult,
|
||||
parseMissingImportedAssetFilesMessage,
|
||||
getMissingImportedAssetFiles,
|
||||
publishedProjectBundle,
|
||||
maxProjectBundleSize,
|
||||
stagedUploadPath,
|
||||
@@ -1327,13 +1327,20 @@ export const toLocalProjectBundle = (project: PublishedProjectBundle) => {
|
||||
|
||||
export const checkProjectBuildPermission = async (
|
||||
params: AuthProjectParams
|
||||
): Promise<void> => {
|
||||
await createAuthTrpcClient(params).query(
|
||||
): Promise<{ supportsStructuredImportErrors: boolean }> => {
|
||||
const result = await createAuthTrpcClient(params).query(
|
||||
"build.checkProjectBuildPermission",
|
||||
{
|
||||
projectId: params.projectId,
|
||||
}
|
||||
);
|
||||
return {
|
||||
supportsStructuredImportErrors:
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"supportsStructuredImportErrors" in result &&
|
||||
result.supportsStructuredImportErrors === true,
|
||||
};
|
||||
};
|
||||
|
||||
const formatSchemaIssues = (
|
||||
@@ -1356,8 +1363,11 @@ export const parseBuildPatchTransactions = (transactions: unknown) => {
|
||||
|
||||
const result = buildPatchTransaction.array().safeParse(input);
|
||||
if (result.success === false) {
|
||||
throw new Error(
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`Invalid patch JSON: ${formatSchemaIssues(result.error.issues)}`
|
||||
),
|
||||
{ code: "INVALID_PATCH", issues: result.error.issues }
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
@@ -2544,13 +2554,67 @@ export const importProjectBundleWithAssets = async (
|
||||
}
|
||||
)
|
||||
): Promise<ImportProjectBundleResult> => {
|
||||
await checkProjectBuildPermission(params);
|
||||
const capabilities = await checkProjectBuildPermission(params);
|
||||
|
||||
let dataToImport =
|
||||
params.skipAssets === true ? { ...params.data, assets: [] } : params.data;
|
||||
|
||||
const maxRetries = params.maxMissingAssetImportRetries ?? 5;
|
||||
let uploadAttempts = 0;
|
||||
const uploadImportAssets = async (
|
||||
assetsToUpload: Asset[],
|
||||
readAssetData: (asset: Asset) => Promise<BinaryAssetData>
|
||||
) => {
|
||||
if (uploadAttempts === 0) {
|
||||
params.onUploadAssets?.(assetsToUpload);
|
||||
} else {
|
||||
params.onMissingAssets?.(assetsToUpload);
|
||||
}
|
||||
const originalAssetsById = new Map(
|
||||
assetsToUpload.map((asset) => [asset.id, asset])
|
||||
);
|
||||
const uploadedAssets = await uploadAssets({
|
||||
...params,
|
||||
// Source folders do not exist in the destination yet. The final
|
||||
// bundle import creates them and restores the original assignments.
|
||||
assets: assetsToUpload.map((asset) => ({
|
||||
...asset,
|
||||
folderId: undefined,
|
||||
})),
|
||||
readAssetData: (asset) =>
|
||||
readAssetData(originalAssetsById.get(asset.id)!),
|
||||
});
|
||||
if (uploadedAssets.length !== assetsToUpload.length) {
|
||||
throw new Error(
|
||||
`Expected ${assetsToUpload.length} uploaded assets, received ${uploadedAssets.length}.`
|
||||
);
|
||||
}
|
||||
const uploadedNamesById = new Map(
|
||||
assetsToUpload.map((asset, index) => [
|
||||
asset.id,
|
||||
uploadedAssets[index].name,
|
||||
])
|
||||
);
|
||||
dataToImport = {
|
||||
...dataToImport,
|
||||
assets: dataToImport.assets.map((asset) => {
|
||||
const name = uploadedNamesById.get(asset.id);
|
||||
return name === undefined ? asset : { ...asset, name };
|
||||
}),
|
||||
};
|
||||
uploadAttempts += 1;
|
||||
};
|
||||
|
||||
// Older servers return no capabilities. Upload first rather than inferring
|
||||
// missing files from their unstructured import error messages.
|
||||
if (
|
||||
capabilities.supportsStructuredImportErrors === false &&
|
||||
params.skipAssets !== true &&
|
||||
dataToImport.assets.length > 0
|
||||
) {
|
||||
await uploadImportAssets(params.data.assets, params.readAssetData);
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
||||
params.onImportAttempt?.();
|
||||
try {
|
||||
@@ -2563,7 +2627,7 @@ export const importProjectBundleWithAssets = async (
|
||||
const missingAssetNames =
|
||||
params.skipAssets === true
|
||||
? undefined
|
||||
: parseMissingImportedAssetFilesMessage(error);
|
||||
: getMissingImportedAssetFiles(error);
|
||||
const assetNames = missingAssetNames ?? [];
|
||||
if (assetNames.length === 0 || attempt === maxRetries) {
|
||||
throw error;
|
||||
@@ -2592,44 +2656,7 @@ export const importProjectBundleWithAssets = async (
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (uploadAttempts === 0) {
|
||||
params.onUploadAssets?.(missingAssets);
|
||||
} else {
|
||||
params.onMissingAssets?.(missingAssets);
|
||||
}
|
||||
const originalAssetsById = new Map(
|
||||
missingAssets.map((asset) => [asset.id, asset])
|
||||
);
|
||||
const uploadedAssets = await uploadAssets({
|
||||
...params,
|
||||
// Source folders do not exist in the destination yet. The final
|
||||
// bundle import creates them and restores the original assignments.
|
||||
assets: missingAssets.map((asset) => ({
|
||||
...asset,
|
||||
folderId: undefined,
|
||||
})),
|
||||
readAssetData: (asset) =>
|
||||
readAssetData(originalAssetsById.get(asset.id)!),
|
||||
});
|
||||
if (uploadedAssets.length !== missingAssets.length) {
|
||||
throw new Error(
|
||||
`Expected ${missingAssets.length} uploaded assets, received ${uploadedAssets.length}.`
|
||||
);
|
||||
}
|
||||
const uploadedNamesById = new Map(
|
||||
missingAssets.map((asset, index) => [
|
||||
asset.id,
|
||||
uploadedAssets[index].name,
|
||||
])
|
||||
);
|
||||
dataToImport = {
|
||||
...dataToImport,
|
||||
assets: dataToImport.assets.map((asset) => {
|
||||
const name = uploadedNamesById.get(asset.id);
|
||||
return name === undefined ? asset : { ...asset, name };
|
||||
}),
|
||||
};
|
||||
uploadAttempts += 1;
|
||||
await uploadImportAssets(missingAssets, readAssetData);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,8 +75,12 @@ export type PlanConfig = {
|
||||
* Returns a Map of plan name → { name, features, prices }.
|
||||
*/
|
||||
export const parsePlansEnv = (raw: string): Map<string, PlanConfig> => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
return new Map();
|
||||
}
|
||||
@@ -112,9 +116,7 @@ export const parsePlansEnv = (raw: string): Map<string, PlanConfig> => {
|
||||
);
|
||||
return [];
|
||||
}
|
||||
const pricesResult = prices.safeParse(
|
||||
"prices" in item ? item.prices : {}
|
||||
);
|
||||
const pricesResult = prices.safeParse("prices" in item ? item.prices : {});
|
||||
if (!pricesResult.success) {
|
||||
console.error(
|
||||
`Invalid PLANS entry "${item.name}" prices:`,
|
||||
@@ -168,10 +170,4 @@ export const parsePlansEnv = (raw: string): Map<string, PlanConfig> => {
|
||||
});
|
||||
}
|
||||
return resolved;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("PLANS entry")) {
|
||||
throw error;
|
||||
}
|
||||
return new Map();
|
||||
}
|
||||
};
|
||||
@@ -840,7 +840,7 @@ describe("unpublishBuild (msw)", () => {
|
||||
).rejects.toThrow(AuthorizationError);
|
||||
});
|
||||
|
||||
test("throws when domain is not published", async () => {
|
||||
test("succeeds when the domain is already unpublished", async () => {
|
||||
const projectId = uid();
|
||||
server.use(
|
||||
ownershipHandler,
|
||||
@@ -850,7 +850,7 @@ describe("unpublishBuild (msw)", () => {
|
||||
|
||||
await expect(
|
||||
unpublishBuild({ projectId, domain: "example.com" }, createContext())
|
||||
).rejects.toThrow("is not published");
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("deletes build when it has only the one domain", async () => {
|
||||
|
||||
@@ -344,10 +344,6 @@ export const unpublishBuild = async (
|
||||
return deployment.domains.includes(props.domain);
|
||||
});
|
||||
|
||||
if (targetBuilds.length === 0) {
|
||||
throw new Error(`Domain ${props.domain} is not published`);
|
||||
}
|
||||
|
||||
// Process all builds that contain this domain
|
||||
for (const targetBuild of targetBuilds) {
|
||||
const deployment = parseDeployment(targetBuild.deployment);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { encodeDataVariableId, getStyleDeclKey } from "@webstudio-is/sdk";
|
||||
import type { BuilderNamespace } from "./contracts/namespaces";
|
||||
import type { BuilderPatchTransaction } from "./contracts/patch";
|
||||
import {
|
||||
ProjectSessionBusyError,
|
||||
createDefaultProjectSessionCompatibility,
|
||||
createProjectSession,
|
||||
hasProjectSessionPermit,
|
||||
@@ -136,7 +137,7 @@ const createOneTimeConflictingStorage = (
|
||||
if (saveAttempts === 1) {
|
||||
revision = "rev-concurrent";
|
||||
current = { ...current, revision };
|
||||
throw new Error("Project session snapshot changed on disk.");
|
||||
throw new ProjectSessionBusyError();
|
||||
}
|
||||
expect(options.expectedRevision).toBe(revision);
|
||||
revision = "rev-reconciled";
|
||||
@@ -628,7 +629,7 @@ describe("project session", () => {
|
||||
revision: "rev-concurrent",
|
||||
});
|
||||
revision = current.revision;
|
||||
throw new Error("Project session snapshot changed on disk.");
|
||||
throw new ProjectSessionBusyError();
|
||||
}
|
||||
expect(options.expectedRevision).toBe(revision);
|
||||
revision = "rev-retry";
|
||||
@@ -685,7 +686,7 @@ describe("project session", () => {
|
||||
return createPersistedSnapshot({ revision: "rev-concurrent" });
|
||||
},
|
||||
async save() {
|
||||
throw new Error("Project session snapshot changed on disk.");
|
||||
throw new ProjectSessionBusyError();
|
||||
},
|
||||
async clear() {},
|
||||
};
|
||||
@@ -1018,7 +1019,7 @@ describe("project session", () => {
|
||||
},
|
||||
async save() {
|
||||
saveAttempts += 1;
|
||||
throw new Error("Project session snapshot changed on disk.");
|
||||
throw new ProjectSessionBusyError();
|
||||
},
|
||||
async clear() {},
|
||||
};
|
||||
|
||||
@@ -346,7 +346,7 @@ const wait = (ms: number) =>
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
class ProjectSessionBusyError extends Error {
|
||||
export class ProjectSessionBusyError extends Error {
|
||||
code = "PROJECT_SESSION_BUSY";
|
||||
|
||||
constructor(options: { cause?: unknown } = {}) {
|
||||
@@ -523,12 +523,6 @@ const getProjectSessionErrorCode = (error: unknown) => {
|
||||
if (error instanceof BuilderRuntimeError) {
|
||||
return error.code;
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("Project session snapshot changed on disk")
|
||||
) {
|
||||
return "PROJECT_SESSION_BUSY";
|
||||
}
|
||||
if (typeof error === "object" && error !== null) {
|
||||
if ("code" in error) {
|
||||
const code = error.code;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
assetFolderIssue,
|
||||
assetFolderName,
|
||||
assetFolders,
|
||||
createAssetFolderHierarchy,
|
||||
@@ -63,19 +62,18 @@ const validateFolderMutation = (folders: AssetFolders, name: string) => {
|
||||
return;
|
||||
}
|
||||
const issue = result.error.issues[0];
|
||||
if (issue?.message === assetFolderIssue.duplicateName) {
|
||||
const issueCode =
|
||||
issue?.code === "custom" ? issue.params?.assetFolderIssue : undefined;
|
||||
if (issueCode === "duplicateName") {
|
||||
return throwBuilderRuntimeError(
|
||||
"CONFLICT",
|
||||
`A folder named "${name}" already exists in this location`
|
||||
);
|
||||
}
|
||||
if (issue?.message === assetFolderIssue.parentMissing) {
|
||||
if (issueCode === "parentMissing") {
|
||||
return throwBuilderRuntimeError("NOT_FOUND", "Parent folder not found");
|
||||
}
|
||||
if (
|
||||
issue?.message === assetFolderIssue.selfParent ||
|
||||
issue?.message === assetFolderIssue.cycle
|
||||
) {
|
||||
if (issueCode === "selfParent" || issueCode === "cycle") {
|
||||
return throwBuilderRuntimeError(
|
||||
"BAD_REQUEST",
|
||||
"A folder can't be moved into itself or one of its descendants"
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type BuilderPatchChange,
|
||||
} from "../contracts/patch";
|
||||
import {
|
||||
InvalidCursorError,
|
||||
paginateOutput,
|
||||
projectOutput,
|
||||
type PaginatedOutputInput,
|
||||
@@ -1064,7 +1065,7 @@ export const listAssets = (
|
||||
input,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "Invalid asset cursor") {
|
||||
if (error instanceof InvalidCursorError) {
|
||||
return throwBuilderRuntimeError("BAD_REQUEST", error.message);
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -314,17 +314,17 @@ export const verifyBindings = (
|
||||
allowAssignment,
|
||||
});
|
||||
for (const diagnostic of diagnostics) {
|
||||
const unknownMatch = diagnostic.message.match(
|
||||
/^"(.+)" is not defined in the scope$/
|
||||
);
|
||||
const code = unknownMatch ? "unknown-identifier" : "invalid-expression";
|
||||
const isUnknownIdentifier = diagnostic.code === "unknown-identifier";
|
||||
const code = isUnknownIdentifier
|
||||
? "unknown-identifier"
|
||||
: "invalid-expression";
|
||||
findings.push(
|
||||
createFinding(code, bindingKind, location, {
|
||||
category: unknownMatch ? "scope" : "validity",
|
||||
category: isUnknownIdentifier ? "scope" : "validity",
|
||||
severity: diagnostic.severity === "error" ? "error" : "warning",
|
||||
message: diagnostic.message,
|
||||
remediation: unknownMatch
|
||||
? `Define ${JSON.stringify(unknownMatch[1])} in this instance scope or use an available variable, then rerun verify-bindings.`
|
||||
remediation: isUnknownIdentifier
|
||||
? `Define ${JSON.stringify(diagnostic.identifier)} in this instance scope or use an available variable, then rerun verify-bindings.`
|
||||
: "Correct the persisted expression using supported Webstudio expression syntax, then rerun verify-bindings.",
|
||||
range: { from: diagnostic.from, to: diagnostic.to },
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
TemplateValidationError,
|
||||
ActionValue,
|
||||
AssetValue,
|
||||
PageValue,
|
||||
@@ -19,7 +20,7 @@ import { isMdxTemplateComponentName } from "@webstudio-is/content-engine/mdx";
|
||||
const css = (strings: TemplateStringsArray, ...values: string[]) => {
|
||||
const source = String.raw({ raw: strings }, ...values);
|
||||
if (hasUnsupportedCssTemplateRules(source)) {
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
"css templates do not support selectors or at-rules such as @keyframes. Use declarations and @media rules only. Use Webstudio Animation components for editable animations."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// server evaluator boundary.
|
||||
import { expect, test } from "vitest";
|
||||
import { evaluateWebstudioJsxFragment } from "./fragment.server";
|
||||
import { webstudioJsxFragmentInputDescription } from "./bindings";
|
||||
|
||||
test("uses intrinsic tags and direct component identifiers", async () => {
|
||||
const fragment = await evaluateWebstudioJsxFragment(
|
||||
@@ -36,3 +37,19 @@ test("rejects component member syntax", async () => {
|
||||
evaluateWebstudioJsxFragment("<Library.Card />")
|
||||
).rejects.toThrow("JSX component namespaces are not supported");
|
||||
});
|
||||
|
||||
test("keeps internal validation errors out of the public JSX environment", async () => {
|
||||
const fragment = await evaluateWebstudioJsxFragment(
|
||||
"<div title={typeof TemplateValidationError} />"
|
||||
);
|
||||
expect(fragment.props).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "title",
|
||||
type: "string",
|
||||
value: "undefined",
|
||||
})
|
||||
);
|
||||
expect(webstudioJsxFragmentInputDescription).not.toContain(
|
||||
"TemplateValidationError"
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createId, type WebstudioFragment } from "@webstudio-is/sdk";
|
||||
import { renderTemplate } from "@webstudio-is/template";
|
||||
import {
|
||||
renderTemplate,
|
||||
TemplateValidationError,
|
||||
} from "@webstudio-is/template";
|
||||
import { componentMetas } from "@webstudio-is/sdk-components-registry/metas";
|
||||
import { componentIds } from "@webstudio-is/sdk-components-registry/components";
|
||||
import {
|
||||
@@ -10,21 +13,6 @@ import { evaluateJsx } from "./evaluate.server";
|
||||
import { getErrorMessage } from "./errors";
|
||||
import { inspectWebstudioJsxFragmentSyntax } from "./syntax";
|
||||
|
||||
const templateValidationMessagePrefixes = [
|
||||
"Do not use React fragment shorthand",
|
||||
"Invalid JSX component",
|
||||
"Invalid JSX prop",
|
||||
"token()",
|
||||
"ws:style",
|
||||
"ws:tokens",
|
||||
"css templates",
|
||||
];
|
||||
|
||||
const isTemplateValidationMessage = (message: string) =>
|
||||
templateValidationMessagePrefixes.some((prefix) =>
|
||||
message.startsWith(prefix)
|
||||
);
|
||||
|
||||
const createWebstudioJsxFragmentIdFactory = () => {
|
||||
const salt = createId();
|
||||
let index = 0;
|
||||
@@ -53,7 +41,7 @@ export const evaluateWebstudioJsxFragment = async (
|
||||
)}`,
|
||||
evaluationErrorMessage: (error) => {
|
||||
const message = getErrorMessage(error);
|
||||
if (isTemplateValidationMessage(message)) {
|
||||
if (error instanceof TemplateValidationError) {
|
||||
return message;
|
||||
}
|
||||
return `Could not evaluate JSX fragment. Use ${webstudioJsxBindingGuidance}. ${message}`;
|
||||
|
||||
@@ -209,11 +209,8 @@ export const inspectWebstudioJsxFragmentSyntax = (source: string) => {
|
||||
ast = parseWebstudioJsxFragmentExpression(source);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
const recovery = message.startsWith(
|
||||
"Expected corresponding JSX closing tag"
|
||||
)
|
||||
? " Every opening element must use the same closing tag or end with />. Fragment parsing is stateless, so refresh cannot repair unmatched JSX."
|
||||
: "";
|
||||
const recovery =
|
||||
" Every opening element must use the same closing tag or end with />. Fragment parsing is stateless, so refresh cannot repair unmatched JSX.";
|
||||
return throwWebstudioJsxValidationError(
|
||||
`Could not parse JSX fragment. Pass Webstudio JSX such as <section><Heading tag="h2">Title</Heading></section>. ${message}${recovery}`,
|
||||
"valid_webstudio_jsx_syntax",
|
||||
|
||||
@@ -37,6 +37,8 @@ export const projectOutput = <Compact extends object, Expanded extends object>({
|
||||
}): Compact | (Compact & Expanded) =>
|
||||
input.verbose === true ? { ...compact, ...expanded() } : compact;
|
||||
|
||||
export class InvalidCursorError extends Error {}
|
||||
|
||||
const parseCursor = (
|
||||
cursor: string | undefined,
|
||||
invalidCursorMessage: string
|
||||
@@ -46,7 +48,7 @@ const parseCursor = (
|
||||
}
|
||||
const offset = Number(cursor);
|
||||
if (Number.isInteger(offset) === false || offset < 0) {
|
||||
throw new Error(invalidCursorMessage);
|
||||
throw new InvalidCursorError(invalidCursorMessage);
|
||||
}
|
||||
return offset;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
createTestServer,
|
||||
db,
|
||||
@@ -6,9 +6,12 @@ import {
|
||||
empty,
|
||||
testContext,
|
||||
} from "@webstudio-is/postgrest/testing";
|
||||
import type { AppContext } from "@webstudio-is/trpc-interface/index.server";
|
||||
import {
|
||||
authorizeProject,
|
||||
type AppContext,
|
||||
} from "@webstudio-is/trpc-interface/index.server";
|
||||
import { defaultPlanFeatures } from "@webstudio-is/plans";
|
||||
import { create } from "./project";
|
||||
import { create, loadById, ProjectNotFoundError } from "./project";
|
||||
|
||||
const server = createTestServer();
|
||||
|
||||
@@ -72,3 +75,45 @@ describe("create — maxProjectsAllowedPerUser guard", () => {
|
||||
expect(result).toMatchObject({ id: "proj-new", title: "New Project" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadById", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
test("treats an absent project as an explicit not-found result", async () => {
|
||||
server.use(db.get("Project", () => json([])));
|
||||
const result = loadById("missing-project", createContext());
|
||||
await expect(result).rejects.toBeInstanceOf(ProjectNotFoundError);
|
||||
await expect(result).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
cause: { webstudioCode: "NOT_FOUND" },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns the unique project with its optional static build", async () => {
|
||||
server.use(
|
||||
db.get("Project", () =>
|
||||
json([{ id: "project-1", latestStaticBuild: [] }])
|
||||
)
|
||||
);
|
||||
await expect(loadById("project-1", createContext())).resolves.toMatchObject(
|
||||
{ id: "project-1", latestStaticBuild: null }
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves database failures instead of reporting a missing project", async () => {
|
||||
server.use(
|
||||
db.get("Project", () =>
|
||||
json(
|
||||
{ code: "08006", message: "Database unavailable" },
|
||||
{ status: 503 }
|
||||
)
|
||||
)
|
||||
);
|
||||
await expect(loadById("project-1", createContext())).rejects.toMatchObject({
|
||||
code: "08006",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createId } from "@webstudio-is/sdk";
|
||||
import {
|
||||
authorizeProject,
|
||||
@@ -41,6 +42,16 @@ export const findProjectIdsByUserId = async (
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export class ProjectNotFoundError extends TRPCError {
|
||||
constructor() {
|
||||
super({
|
||||
code: "NOT_FOUND",
|
||||
message: "This project is unavailable or no longer exists.",
|
||||
cause: { webstudioCode: "NOT_FOUND" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type Project = Awaited<ReturnType<typeof loadById>>;
|
||||
|
||||
export const loadById = async (projectId: string, context: AppContext) => {
|
||||
@@ -66,11 +77,14 @@ export const loadById = async (projectId: string, context: AppContext) => {
|
||||
)
|
||||
.eq("id", projectId)
|
||||
.eq("isDeleted", false)
|
||||
.single();
|
||||
.maybeSingle();
|
||||
|
||||
if (data.error) {
|
||||
throw data.error;
|
||||
}
|
||||
if (data.data === null) {
|
||||
throw new ProjectNotFoundError();
|
||||
}
|
||||
const { latestStaticBuild, ...project } = data.data;
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { builderNamespaces } from "@webstudio-is/project-build/contracts";
|
||||
import { builderPatchTransactionSchema } from "@webstudio-is/project-build/contracts";
|
||||
import {
|
||||
isAssetFileName,
|
||||
getMissingImportedAssetFilesMessage,
|
||||
parseMissingImportedAssetFilesMessage,
|
||||
MissingImportedAssetFilesError,
|
||||
getMissingImportedAssetFiles,
|
||||
getBundleVersionMismatchMessage,
|
||||
importProjectBundleInput,
|
||||
publishedProjectBundle,
|
||||
@@ -35,30 +35,26 @@ describe("project bundle contract", () => {
|
||||
expect(isAssetFileName("folder\\image.png")).toBe(false);
|
||||
});
|
||||
|
||||
test("formats and parses missing imported asset file messages", () => {
|
||||
const message = getMissingImportedAssetFilesMessage([
|
||||
"image.png",
|
||||
"font,latin.woff2",
|
||||
]);
|
||||
|
||||
expect(message).toBe(
|
||||
test("reads missing imported asset files from structured local and remote errors", () => {
|
||||
const names = ["image.png", "font,latin.woff2"];
|
||||
const error = new MissingImportedAssetFilesError(names);
|
||||
error.message = "Wording may change";
|
||||
expect(getMissingImportedAssetFiles(error)).toEqual(names);
|
||||
expect(
|
||||
getMissingImportedAssetFiles({
|
||||
data: {
|
||||
webstudioCode: "MISSING_IMPORTED_ASSET_FILES",
|
||||
missingAssetNames: names,
|
||||
},
|
||||
})
|
||||
).toEqual(names);
|
||||
expect(
|
||||
getMissingImportedAssetFiles(
|
||||
new Error(
|
||||
'Imported asset files are missing: ["image.png","font,latin.woff2"]'
|
||||
);
|
||||
expect(parseMissingImportedAssetFilesMessage(new Error(message))).toEqual([
|
||||
"image.png",
|
||||
"font,latin.woff2",
|
||||
]);
|
||||
expect(
|
||||
parseMissingImportedAssetFilesMessage("Other error")
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parses already-deployed comma-separated missing asset messages", () => {
|
||||
expect(
|
||||
parseMissingImportedAssetFilesMessage(
|
||||
new Error("Imported asset files are missing: image.png, font.woff2")
|
||||
)
|
||||
).toEqual(["image.png", "font.woff2"]);
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("preserves published project metadata", () => {
|
||||
|
||||
@@ -29,34 +29,38 @@ const assetFileName = z
|
||||
export const isAssetFileName = (value: string) =>
|
||||
assetFileName.safeParse(value).success;
|
||||
|
||||
const missingImportedAssetFilesPrefix = "Imported asset files are missing: ";
|
||||
export class MissingImportedAssetFilesError extends Error {
|
||||
readonly webstudioCode = "MISSING_IMPORTED_ASSET_FILES";
|
||||
readonly missingAssetNames: string[];
|
||||
constructor(missingAssetNames: string[]) {
|
||||
super(
|
||||
`Imported asset files are missing: ${JSON.stringify(missingAssetNames)}`
|
||||
);
|
||||
this.missingAssetNames = missingAssetNames;
|
||||
this.name = "MissingImportedAssetFilesError";
|
||||
}
|
||||
}
|
||||
|
||||
export const getMissingImportedAssetFilesMessage = (assetNames: string[]) =>
|
||||
`${missingImportedAssetFilesPrefix}${JSON.stringify(assetNames)}`;
|
||||
|
||||
export const parseMissingImportedAssetFilesMessage = (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const start = message.indexOf(missingImportedAssetFilesPrefix);
|
||||
if (start === -1) {
|
||||
export const getMissingImportedAssetFiles = (
|
||||
error: unknown
|
||||
): string[] | undefined => {
|
||||
const visited = new Set<unknown>();
|
||||
const visit = (value: unknown): string[] | undefined => {
|
||||
if (typeof value !== "object" || value === null || visited.has(value)) {
|
||||
return;
|
||||
}
|
||||
const data = message.slice(start + missingImportedAssetFilesPrefix.length);
|
||||
try {
|
||||
const names = JSON.parse(data);
|
||||
visited.add(value);
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
Array.isArray(names) &&
|
||||
names.every((name) => typeof name === "string")
|
||||
record.webstudioCode === "MISSING_IMPORTED_ASSET_FILES" &&
|
||||
Array.isArray(record.missingAssetNames) &&
|
||||
record.missingAssetNames.every((name) => typeof name === "string")
|
||||
) {
|
||||
return names;
|
||||
return record.missingAssetNames;
|
||||
}
|
||||
} catch {
|
||||
// Support already-deployed API responses using the previous comma-separated
|
||||
// format. New responses use JSON so valid filenames can contain commas.
|
||||
}
|
||||
return data
|
||||
.split(",")
|
||||
.map((name) => name.trim())
|
||||
.filter((name) => name !== "");
|
||||
return visit(record.data) ?? visit(record.cause);
|
||||
};
|
||||
return visit(error);
|
||||
};
|
||||
|
||||
export const projectBundle = z.object({
|
||||
|
||||
@@ -48,6 +48,7 @@ export const assetFolders = z
|
||||
code: "custom",
|
||||
path: [folderId, "id"],
|
||||
message: assetFolderIssue.idMismatch,
|
||||
params: { assetFolderIssue: "idMismatch" },
|
||||
});
|
||||
}
|
||||
if (folder.parentId === folder.id) {
|
||||
@@ -55,6 +56,7 @@ export const assetFolders = z
|
||||
code: "custom",
|
||||
path: [folderId, "parentId"],
|
||||
message: assetFolderIssue.selfParent,
|
||||
params: { assetFolderIssue: "selfParent" },
|
||||
});
|
||||
} else if (
|
||||
folder.parentId !== undefined &&
|
||||
@@ -64,6 +66,7 @@ export const assetFolders = z
|
||||
code: "custom",
|
||||
path: [folderId, "parentId"],
|
||||
message: assetFolderIssue.parentMissing,
|
||||
params: { assetFolderIssue: "parentMissing" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,6 +76,7 @@ export const assetFolders = z
|
||||
code: "custom",
|
||||
path: [folderId, "name"],
|
||||
message: assetFolderIssue.duplicateName,
|
||||
params: { assetFolderIssue: "duplicateName" },
|
||||
});
|
||||
}
|
||||
siblingKeys.add(siblingKey);
|
||||
@@ -82,6 +86,7 @@ export const assetFolders = z
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: assetFolderIssue.projectMismatch,
|
||||
params: { assetFolderIssue: "projectMismatch" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,6 +97,7 @@ export const assetFolders = z
|
||||
code: "custom",
|
||||
path: [folderId, "parentId"],
|
||||
message: assetFolderIssue.cycle,
|
||||
params: { assetFolderIssue: "cycle" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ import { isMdxTemplateComponentName } from "@webstudio-is/content-engine/mdx";
|
||||
import { parseTemplateCss, type TemplateStyleDecl } from "./css";
|
||||
import { camelCaseProperty, parseMediaQuery } from "@webstudio-is/css-data";
|
||||
|
||||
export class TemplateValidationError extends Error {}
|
||||
|
||||
declare module "react" {
|
||||
interface HTMLAttributes<T> {
|
||||
"ws:id"?: string;
|
||||
@@ -71,7 +73,7 @@ export const setTemplateMeta = (
|
||||
element: JSX.Element
|
||||
): JSX.Element => {
|
||||
if (isMdxTemplateComponentName(meta.name) === false) {
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
"Template name must be a PascalCase JavaScript identifier, for example PromotionCard"
|
||||
);
|
||||
}
|
||||
@@ -90,7 +92,7 @@ export class Token {
|
||||
|
||||
export const token = (name: string, styles?: TemplateStyleDecl[]): Token => {
|
||||
if (typeof name !== "string" || name.length === 0) {
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
'token() requires a non-empty string name, for example token("brand", css`color: red;`).'
|
||||
);
|
||||
}
|
||||
@@ -98,12 +100,12 @@ export const token = (name: string, styles?: TemplateStyleDecl[]): Token => {
|
||||
return new Token(name);
|
||||
}
|
||||
if (Array.isArray(styles) === false) {
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
'token() styles must come from css`...`, for example token("brand", css`color: red;`).'
|
||||
);
|
||||
}
|
||||
if (styles.length === 0) {
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
'token() styles must include at least one valid CSS declaration from css`...`, for example token("brand", css`color: red;`).'
|
||||
);
|
||||
}
|
||||
@@ -144,7 +146,7 @@ export class ResourceValue {
|
||||
config: ResourceConfig;
|
||||
constructor(name: string, config: ResourceConfig) {
|
||||
if (config === undefined) {
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
"Invalid JSX prop: ResourceValue requires a resource definition. Existing resource ids are not supported in JSX; insert the component first, then bind its resource prop with update-props."
|
||||
);
|
||||
}
|
||||
@@ -255,11 +257,13 @@ const findUnsupportedSerializableValue = (
|
||||
| {
|
||||
path: string[];
|
||||
message: string;
|
||||
kind?: "function";
|
||||
}
|
||||
| undefined => {
|
||||
if (typeof value === "function") {
|
||||
return {
|
||||
path,
|
||||
kind: "function",
|
||||
message:
|
||||
"Do not pass JavaScript functions. Use Webstudio actions instead",
|
||||
};
|
||||
@@ -365,10 +369,11 @@ const validateSerializablePropValue = (name: string, value: unknown) => {
|
||||
unsupportedValue.path.length === 0
|
||||
? ""
|
||||
: ` at "${unsupportedValue.path.join(".")}"`;
|
||||
const actionHint = unsupportedValue.message.includes("functions")
|
||||
const actionHint =
|
||||
unsupportedValue.kind === "function"
|
||||
? ` For actions, use ${name}={new ActionValue(["event"], expression\`console.log(event)\`)}.`
|
||||
: "";
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
`Invalid JSX prop "${name}"${nestedPath}. ${unsupportedValue.message}.${actionHint}`
|
||||
);
|
||||
};
|
||||
@@ -390,7 +395,7 @@ const hasInvalidStyleValue = (styles: TemplateStyleDecl[]) =>
|
||||
|
||||
const convertReactStyleObject = (value: unknown): TemplateStyleDecl[] => {
|
||||
if (isPlainObject(value) === false) {
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
"style prop must be a plain object, for example style={{ padding: 24 }}."
|
||||
);
|
||||
}
|
||||
@@ -402,7 +407,7 @@ const convertReactStyleObject = (value: unknown): TemplateStyleDecl[] => {
|
||||
const property = getReactStyleProperty(name);
|
||||
if (typeof styleValue === "number") {
|
||||
if (Number.isFinite(styleValue) === false) {
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
`Invalid style prop "${name}". Use a finite number or string value.`
|
||||
);
|
||||
}
|
||||
@@ -420,7 +425,7 @@ const convertReactStyleObject = (value: unknown): TemplateStyleDecl[] => {
|
||||
styles.push(...parseReactStyleDeclaration(property, styleValue));
|
||||
continue;
|
||||
}
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
`Invalid style prop "${name}". Use a string, finite number, null, or undefined value.`
|
||||
);
|
||||
}
|
||||
@@ -635,7 +640,7 @@ export const renderTemplate = (
|
||||
element: JSX.Element
|
||||
): Instance["children"][number] => {
|
||||
if (element.type === Fragment) {
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
"Do not use React fragment shorthand <>...</> inside Webstudio JSX. Pass sibling Webstudio components directly at the top level, or wrap text in an element such as <section><p>Text</p></section>."
|
||||
);
|
||||
}
|
||||
@@ -645,7 +650,7 @@ export const renderTemplate = (
|
||||
intrinsicTag !== undefined &&
|
||||
intrinsicTags.has(intrinsicTag) === false
|
||||
) {
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
`Invalid intrinsic JSX element "${intrinsicTag}". Use a standard lowercase HTML or SVG tag.`
|
||||
);
|
||||
}
|
||||
@@ -653,7 +658,7 @@ export const renderTemplate = (
|
||||
options.allowManualIds === false &&
|
||||
element.props?.["ws:id"] !== undefined
|
||||
) {
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
"Do not set ws:id in JSX fragments. Webstudio runtime generates system ids automatically."
|
||||
);
|
||||
}
|
||||
@@ -669,7 +674,7 @@ export const renderTemplate = (
|
||||
: typeof element.type === "object" && element.type !== null
|
||||
? element.type.displayName
|
||||
: undefined;
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
`Invalid JSX component${componentName ? ` "${componentName}"` : ""} in Webstudio JSX. Use a lowercase HTML element or an available PascalCase component identifier.`
|
||||
);
|
||||
}
|
||||
@@ -698,7 +703,7 @@ export const renderTemplate = (
|
||||
const { value } = mappedProp;
|
||||
if (name === "ws:tag") {
|
||||
if (intrinsicTag !== undefined) {
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
`Do not set ws:tag on intrinsic JSX <${intrinsicTag}>. Use the intended HTML tag directly.`
|
||||
);
|
||||
}
|
||||
@@ -708,12 +713,12 @@ export const renderTemplate = (
|
||||
if (name === "ws:style") {
|
||||
const localStyles = value as TemplateStyleDecl[];
|
||||
if (Array.isArray(localStyles) === false) {
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
"ws:style must come from css`...`, for example ws:style={css`padding: 24px;`}."
|
||||
);
|
||||
}
|
||||
if (localStyles.length === 0) {
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
"ws:style must include at least one valid CSS declaration from css`...`, for example ws:style={css`padding: 24px;`}."
|
||||
);
|
||||
}
|
||||
@@ -730,7 +735,7 @@ export const renderTemplate = (
|
||||
Array.isArray(value) === false ||
|
||||
value.every((token) => token instanceof Token) === false
|
||||
) {
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
'ws:tokens must be an array of token(...) values, for example ws:tokens={[token("brand", css`color: red;`)]}.'
|
||||
);
|
||||
}
|
||||
@@ -801,7 +806,7 @@ export const renderTemplate = (
|
||||
return `${issue.message}${path}`;
|
||||
})
|
||||
.join("; ");
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
`Invalid JSX prop "${name}". Expected animationAction for ${component}.${name}. ${details}`
|
||||
);
|
||||
}
|
||||
@@ -877,7 +882,7 @@ export const renderTemplate = (
|
||||
options.allowManualIds === false &&
|
||||
(children.length === 0 || instances.length === 0)
|
||||
) {
|
||||
throw new Error(
|
||||
throw new TemplateValidationError(
|
||||
"JSX fragment must contain at least one element or component, for example <section><p>Text</p></section>."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,3 +111,32 @@ test("does not classify a domain not-found error as API incompatibility", async
|
||||
});
|
||||
expect(JSON.stringify(body)).not.toContain("apiCompatibility");
|
||||
});
|
||||
|
||||
test("preserves missing import filenames independently of error wording", async () => {
|
||||
const appRouter = router({
|
||||
fail: procedure.mutation(() => {
|
||||
throw Object.assign(new Error("Import files are unavailable"), {
|
||||
webstudioCode: "MISSING_IMPORTED_ASSET_FILES",
|
||||
missingAssetNames: ["font,latin.woff2"],
|
||||
});
|
||||
}),
|
||||
});
|
||||
const response = await fetchRequestHandler({
|
||||
endpoint: "/trpc",
|
||||
req: new Request("http://localhost/trpc/fail", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "null",
|
||||
}),
|
||||
router: appRouter,
|
||||
createContext,
|
||||
});
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
error: {
|
||||
data: {
|
||||
webstudioCode: "MISSING_IMPORTED_ASSET_FILES",
|
||||
missingAssetNames: ["font,latin.woff2"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,21 @@ const getValidationIssues = (cause: unknown) => {
|
||||
return Array.isArray(issues) ? issues : undefined;
|
||||
};
|
||||
|
||||
const getMissingAssetNames = (cause: unknown) => {
|
||||
if (typeof cause !== "object" || cause === null) {
|
||||
return;
|
||||
}
|
||||
const error = cause as {
|
||||
webstudioCode?: unknown;
|
||||
missingAssetNames?: unknown;
|
||||
};
|
||||
return error.webstudioCode === "MISSING_IMPORTED_ASSET_FILES" &&
|
||||
Array.isArray(error.missingAssetNames) &&
|
||||
error.missingAssetNames.every((name) => typeof name === "string")
|
||||
? error.missingAssetNames
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export const {
|
||||
router,
|
||||
procedure,
|
||||
@@ -42,6 +57,7 @@ export const {
|
||||
})
|
||||
: undefined);
|
||||
const issues = getValidationIssues(error.cause);
|
||||
const missingAssetNames = getMissingAssetNames(error.cause);
|
||||
|
||||
if (
|
||||
payload === undefined &&
|
||||
@@ -58,6 +74,7 @@ export const {
|
||||
...(payload === undefined ? {} : { apiCompatibility: payload }),
|
||||
...(webstudioCode === undefined ? {} : { webstudioCode }),
|
||||
...(issues === undefined ? {} : { issues }),
|
||||
...(missingAssetNames === undefined ? {} : { missingAssetNames }),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user