Files
webstudio/apps/builder/app/services/api-build.server.test.ts
Oleg Isonen dde30ffb54 feat: Improve MCP authoring, compact output, and agent onboarding (#5883)
## Summary

This improves how agents discover, author, inspect, and verify Webstudio
projects while reducing unnecessary MCP context usage.

## What changed

- Adds an **Agents** section to Project Settings with ready-to-run setup
commands and client configuration for Claude Code, Codex, Cursor, and VS
Code.
- Makes large list and schema responses compact and paginated by
default, with **verbose** available when complete records or diagnostics
are needed.
- Applies consistent compact output to assets, instances, styles, design
tokens, CLI schemas, and project-session metadata.
- Returns actionable expression warnings for props, resources, and
Collection bindings, including unknown variables and risky render-time
mutation resources.
- Improves Collection expression guidance so agents can bind array items
with the correct scoped variables.
- Reports rendered audit outcomes explicitly as complete, partial,
confirmation-required, or failed.
- Expands runtime output schemas so MCP clients can validate responses
reliably.
- Strengthens the packaged CLI smoke workflow with a realistic
seven-page stdio MCP scenario.

## Architecture

- Consolidates `project-build` from 65 file-oriented entrypoints to 11
architecture-owned surfaces.
- Separates the core model, contracts, runtime authoring, transfer,
state, project sessions, MCP, visual tooling, persistence codecs, and
authenticated server services.
- Isolates Node-only JSX evaluation from browser-safe transfer utilities
and authenticated server modules.
- Keeps persistence codecs independent from authenticated database
services, preventing unrelated module initialization.
- Defines shared instance-path types once and reuses them across runtime
modules.
- Preserves Immer initialization through explicit package side-effect
metadata.
- Adds package tests that prevent a return to source-file entrypoints or
generic exposure of Node-only surfaces.

## Validation

- Full repository checks, tests, typechecks, generated-file checks, and
fixtures
- All 163 Builder test files and 2,141 tests
- All 71 project-build test files and 1,794 tests
- Lint and package-boundary validation
- Full packed CLI release smoke test, including install, preview, agent
setup, seven-page stdio MCP workflow, rendered audit, and structured
commands
2026-07-15 18:11:28 +02:00

433 lines
12 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 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 {
commitBuildPatch,
commitBuildTransactions,
createBuildSnapshot,
loadBuildByProjectVersion,
loadReadableDevBuild,
serializeProjectSummary,
} from "./api-build.server";
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,
},
}) 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,
{
buildId: "build",
projectId: "project",
clientVersion: 1,
transactions: [transaction],
},
ctx
);
expect(patchBuild).toHaveBeenNthCalledWith(
2,
{
buildId: "build",
projectId: "project",
clientVersion: 1,
transactions: [
{
id: expect.any(String),
payload: transaction.payload,
},
],
},
ctx
);
});
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");
});
});