Files
webstudio/apps/builder/app/services/api-build.server.test.ts
Oleg Isonen a4c3b37fc2 fix: Unblock reported MCP workflows and improve issue diagnostics (#6438)
## What this fixes

MCP authors can reuse an existing design token in JSX fragments, create
valid border shorthands with CSS variables, and bypass a font that never
settles during responsive screenshots. Common fixed page titles with
spaced separators are also accepted. Issue reports now include the
project ID and safe, bounded IDs from the failed operation, plus clearer
response and browser-startup diagnostics.

## Scope and tradeoffs

- `token("name")` attaches an existing design token; an unknown name
returns a not-found error. Empty token definitions from Builder/UI
remain definitions and follow the normal conflict rules.
- Border shorthands map a single unresolved `var()` to the remaining
width, style, or color field when the other fields are explicit.
- `waitForFonts: false` skips font readiness but still waits for stable
layout frames. The default remains unchanged.
- Fixed titles such as `Pricing - Plans` stay literal. Addition such as
`first + second` remains an expression. Wrap ambiguous subtraction in
parentheses, for example `(first - second)`. Other ambiguous title forms
may still need an exact report.
- Only recognized operation-level IDs are attached to a public GitHub
issue. Authored values, raw arguments, URLs, and credentials are
excluded. IDs from later successful diagnostic reads are not captured.
- This does not fix the underlying response-transform failure; it
improves future reports for that case.

Closes #6424
Closes #6425
Closes #6434
Closes #6439
Ref #6435
Ref #6430
Ref #6417

## Verification

- [x] Reproduced the privacy, page-expression, empty-token, and
content-mode token-reference regressions with failing tests, then
confirmed the fixes pass. A regression test also covers parenthesized
subtraction.
- [x] Full protocol, template, vision, project-build, and CLI test
suites pass; focused Builder issue-report and fragment tests pass. The
protocol, CLI, and project-build suites were rerun after the CI fix.
- [x] Relevant package typechecks, lint, package-boundary check, fixture
pipeline, generated API check, and generated docs check pass. Fixture
output has no diff.
- [x] Reviewed the authoritative MCP documentation and regenerated the
API metadata and MCP docs.
- [x] CSS parser regressions cover border, each side shorthand, and
unresolved variables in width, style, and color positions; css-data
tests, typecheck, and lint pass.
- [x] Fixed the local evaluation runner to pass the task and Webstudio
MCP tools to Luna/low. Each of the six fixtures passed at least once;
focused MCP/evaluator tests, CLI and project-build typechecks, lint, and
formatting pass.
- [x] The complete Luna/low suite passed all six fixtures in two
consecutive sequential runs. Baselines remain incompatible with this
model/effort override and were not updated.

## Follow-up

- If another fixed-title form still fails, use the new project-level
report context and the exact title shape to narrow the parser case.
2026-09-25 21:01:43 +02:00

530 lines
14 KiB
TypeScript

import { afterEach, describe, expect, test, vi } from "vitest";
import { createDefaultPages } from "@webstudio-is/project-build";
import type { CompactBuild } from "@webstudio-is/project-build";
import {
createTestServer,
db,
json,
testContext,
} from "@webstudio-is/postgrest/testing";
import { buildPatchTransaction } from "@webstudio-is/protocol";
import { getApiCompatibilityPayload } from "@webstudio-is/trpc-interface/api-compatibility";
import type { AppContext } from "@webstudio-is/trpc-interface/index.server";
import { authorizeProject } from "@webstudio-is/trpc-interface/index.server";
import { db as authDb } from "@webstudio-is/authorization-token/index.server";
import * as projectApi from "@webstudio-is/project/index.server";
import * as projectBuild from "@webstudio-is/project-build/server";
import type { z } from "zod";
import {
buildPatchInput,
buildRestorePointInput,
commitBuildPatch,
commitBuildTransactions,
createBuildSnapshot,
loadBuildByProjectVersion,
loadReadableDevBuild,
serializeProjectSummary,
} from "./api-build.server";
test("separates validated restore points from raw build patches", () => {
const input = {
projectId: "project-1",
baseVersion: 1,
transactions: [
{
id: "restore-1",
payload: [
{
namespace: "instances" as const,
patches: [{ op: "replace" as const, path: [], value: [] }],
},
],
},
],
};
expect(buildPatchInput.safeParse(input).success).toBe(false);
expect(buildRestorePointInput.safeParse(input).success).toBe(true);
expect(
buildRestorePointInput.safeParse({
...input,
transactions: [...input.transactions, ...input.transactions],
}).success
).toBe(false);
expect(
buildRestorePointInput.safeParse({
...input,
transactions: [
{
id: "restore-1",
payload: [
{
namespace: "instances",
patches: [{ op: "add", path: [], value: [] }],
},
],
},
],
}).success
).toBe(false);
expect(
buildRestorePointInput.safeParse({
...input,
transactions: [
{
id: "restore-assets",
payload: [
{
namespace: "assets",
patches: [{ op: "replace", path: [], value: [] }],
},
],
},
],
}).success
).toBe(false);
});
const server = createTestServer();
type ProjectSummaryInput = Parameters<typeof serializeProjectSummary>[0];
type SnapshotBuildInput = Parameters<typeof createBuildSnapshot>[0]["build"];
const createContext = (
allowAdditionalPermissions: boolean,
authorization: AppContext["authorization"] = {
type: "token",
authToken: "secret-token",
ownerId: "user-1",
}
) =>
({
authorization,
planFeatures: {
allowAdditionalPermissions,
},
apiClient: { type: "browser", version: undefined },
}) as AppContext;
const createToken = (
overrides: Partial<Awaited<ReturnType<typeof authDb.getTokenInfo>>> = {}
) =>
({
token: "token-1",
projectId: "project-1",
name: "Token",
relation: "builders",
createdAt: "2024-01-01T00:00:00.000Z",
canClone: true,
canCopy: true,
canPublish: false,
canUseApi: true,
...overrides,
}) as Awaited<ReturnType<typeof authDb.getTokenInfo>>;
const createBuildRow = (overrides: Record<string, unknown> = {}) => ({
id: "build-1",
projectId: "project-1",
version: 3,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
pages: JSON.stringify({
meta: {},
homePage: {
id: "page-1",
name: "Home",
path: "",
title: "Home",
meta: {},
rootInstanceId: "root-1",
},
pages: [],
}),
breakpoints: JSON.stringify([]),
styles: JSON.stringify([]),
styleSources: JSON.stringify([]),
styleSourceSelections: JSON.stringify([]),
props: JSON.stringify([]),
dataSources: JSON.stringify([]),
resources: JSON.stringify([]),
instances: JSON.stringify([
{ id: "root-1", type: "instance", component: "Body", children: [] },
]),
deployment: null,
marketplaceProduct: JSON.stringify({}),
projectSettings: JSON.stringify({ meta: {}, compiler: {} }),
...overrides,
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("api build loading", () => {
test("loads a specific project build version", async () => {
server.use(db.get("Build", () => json([createBuildRow()])));
await expect(
loadBuildByProjectVersion(testContext as AppContext, "project-1", 3)
).resolves.toMatchObject({
id: "build-1",
projectId: "project-1",
version: 3,
});
});
test("throws not found when project build version is missing", async () => {
server.use(db.get("Build", () => json([])));
await expect(
loadBuildByProjectVersion(testContext as AppContext, "project-1", 99)
).rejects.toMatchObject({
code: "NOT_FOUND",
message: "Build version not found for project",
});
});
test("checks view permission before loading readable dev build", async () => {
const ctx = createContext(true);
const build = { id: "build-1" } as Awaited<
ReturnType<typeof projectBuild.loadDevBuildByProjectId>
>;
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(createToken());
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
const loadDevBuild = vi
.spyOn(projectBuild, "loadDevBuildByProjectId")
.mockResolvedValue(build);
await expect(loadReadableDevBuild(ctx, "project-1")).resolves.toBe(build);
expect(loadDevBuild).toHaveBeenCalledWith(ctx, "project-1");
});
});
describe("api build patch commits", () => {
const build = {
id: "build",
projectId: "project",
version: 1,
} as CompactBuild;
const transaction: z.infer<typeof buildPatchTransaction> = {
id: "transaction",
payload: [
{
namespace: "pages",
patches: [{ op: "replace", path: ["meta", "siteName"], value: "Site" }],
},
],
};
test("commits transactions and semantic payloads", async () => {
const ctx = createContext(true);
const patchBuild = vi
.spyOn(projectApi, "patchBuild")
.mockResolvedValue({ status: "ok", version: 2 });
await expect(
commitBuildTransactions({
ctx,
projectId: "project",
buildId: "build",
clientVersion: 1,
transactions: [transaction],
})
).resolves.toEqual({ version: 2 });
await expect(
commitBuildPatch({
build,
ctx,
projectId: "project",
payload: transaction.payload,
})
).resolves.toEqual({ version: 2 });
expect(patchBuild).toHaveBeenNthCalledWith(
1,
{
assetStore: expect.objectContaining({
readFile: expect.any(Function),
uploadFile: expect.any(Function),
}),
buildId: "build",
projectId: "project",
clientVersion: 1,
transactions: [transaction],
},
ctx
);
expect(patchBuild).toHaveBeenNthCalledWith(
2,
{
assetStore: expect.objectContaining({
readFile: expect.any(Function),
uploadFile: expect.any(Function),
}),
buildId: "build",
projectId: "project",
clientVersion: 1,
transactions: [
{
id: expect.any(String),
payload: transaction.payload,
},
],
},
ctx
);
});
test("rejects an old CLI contract before committing a patch", async () => {
const ctx = createContext(true);
ctx.apiClient = { type: "cli", version: "old", contractVersion: undefined };
const patchBuild = vi.spyOn(projectApi, "patchBuild");
let error: unknown;
try {
await commitBuildTransactions({
ctx,
projectId: "project",
buildId: "build",
clientVersion: 1,
transactions: [transaction],
});
} catch (caught) {
error = caught;
}
expect(getApiCompatibilityPayload(error)).toMatchObject({
reason: "clientVersionUnsupported",
target: "cli",
action: { type: "updateCli" },
});
expect(patchBuild).not.toHaveBeenCalled();
});
test("maps patch conflicts and errors to TRPC errors", async () => {
const ctx = createContext(true);
const patchBuild = vi.spyOn(projectApi, "patchBuild");
patchBuild.mockResolvedValueOnce({
status: "version_mismatched",
errors: "Version changed",
});
await expect(
commitBuildTransactions({
ctx,
projectId: "project",
buildId: "build",
clientVersion: 1,
transactions: [transaction],
})
).rejects.toMatchObject({
code: "CONFLICT",
message: "Version changed",
});
patchBuild.mockResolvedValueOnce({
status: "error",
errors: "Invalid patch",
});
await expect(
commitBuildTransactions({
ctx,
projectId: "project",
buildId: "build",
clientVersion: 1,
transactions: [transaction],
})
).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "Invalid patch",
});
});
});
describe("api build snapshot", () => {
test("returns only requested build namespaces", () => {
const tokenStyleSource = {
type: "token" as const,
id: "token-1",
name: "Primary",
};
const localStyleSource = { type: "local" as const, id: "local-1" };
const build = {
id: "build-1",
projectId: "project-1",
version: 7,
pages: createDefaultPages({ rootInstanceId: "root-1" }),
instances: [{ type: "instance", id: "root-1", component: "Body" }],
props: [],
styles: [],
styleSources: [tokenStyleSource, localStyleSource],
styleSourceSelections: [],
resources: [],
dataSources: [],
breakpoints: [],
} as unknown as SnapshotBuildInput;
expect(
createBuildSnapshot({
build,
include: new Set(["designTokens", "instances"]),
projectId: "project-1",
})
).toEqual({
projectId: "project-1",
buildId: "build-1",
version: 7,
designTokens: [tokenStyleSource],
instances: [{ type: "instance", id: "root-1", component: "Body" }],
});
});
test("returns page folders and data namespaces when requested", () => {
const pages = createDefaultPages({ rootInstanceId: "root-1" });
pages.redirects = [{ old: "/old", new: "/new", status: "301" }];
pages.pageTemplates = new Map([
[
"template-1",
{
id: "template-1",
name: "Landing",
title: "Landing",
rootInstanceId: "template-root-1",
meta: {},
},
],
]);
const build = {
id: "build-1",
projectId: "project-1",
version: 7,
pages,
instances: [],
props: [],
styles: [],
styleSources: [],
styleSourceSelections: [],
resources: [{ id: "resource-1" }],
dataSources: [{ id: "variable-1" }],
breakpoints: [{ id: "breakpoint-1" }],
marketplaceProduct: { categories: ["template"] },
projectSettings: {
meta: { siteName: "Acme" },
compiler: { atomicStyles: true },
},
} as unknown as SnapshotBuildInput;
expect(
createBuildSnapshot({
build,
include: new Set([
"pages",
"folders",
"resources",
"variables",
"breakpoints",
"projectSettings",
"marketplaceProduct",
]),
projectId: "project-1",
})
).toEqual(
expect.objectContaining({
projectId: "project-1",
buildId: "build-1",
version: 7,
homePageId: build.pages.homePageId,
rootFolderId: build.pages.rootFolderId,
projectSettings: {
meta: { siteName: "Acme" },
compiler: { atomicStyles: true },
},
redirects: [{ old: "/old", new: "/new", status: "301" }],
pageTemplates: [
{
id: "template-1",
name: "Landing",
title: "Landing",
rootInstanceId: "template-root-1",
meta: {},
},
],
resources: [{ id: "resource-1" }],
variables: [{ id: "variable-1" }],
breakpoints: [{ id: "breakpoint-1" }],
marketplaceProduct: { categories: ["template"] },
})
);
});
});
describe("api project serialization", () => {
test("uses latest virtual build time as project updated time", () => {
const project = {
id: "project-1",
title: "Project",
domain: "example.com",
createdAt: "2024-01-01T00:00:00.000Z",
latestBuildVirtual: {
createdAt: "2024-01-03T00:00:00.000Z",
},
latestStaticBuild: {
updatedAt: "2024-01-02T00:00:00.000Z",
},
} as unknown as ProjectSummaryInput;
expect(serializeProjectSummary(project)).toEqual({
id: "project-1",
name: "Project",
domain: "example.com",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-03T00:00:00.000Z",
});
});
test("falls back to latest static build and then project creation time", () => {
expect(
serializeProjectSummary({
id: "project-1",
title: "Project",
domain: null,
createdAt: "2024-01-01T00:00:00.000Z",
latestBuildVirtual: null,
latestStaticBuild: {
updatedAt: "2024-01-02T00:00:00.000Z",
},
} as unknown as ProjectSummaryInput)
).toEqual({
id: "project-1",
name: "Project",
domain: undefined,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-02T00:00:00.000Z",
});
expect(
serializeProjectSummary({
id: "project-1",
title: "Project",
domain: null,
createdAt: "2024-01-01T00:00:00.000Z",
latestBuildVirtual: null,
latestStaticBuild: null,
} as unknown as ProjectSummaryInput)
).toEqual({
id: "project-1",
name: "Project",
domain: undefined,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
});
});
test("throws not found when project is missing", () => {
expect(() =>
serializeProjectSummary(null as unknown as ProjectSummaryInput)
).toThrow("Project not found");
});
});