Files
webstudio/apps/builder/app/services/api-runtime.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

164 lines
4.8 KiB
TypeScript

import { describe, expect, test } from "vitest";
import { TRPCError } from "@trpc/server";
import { createDefaultPages } from "@webstudio-is/project-build";
import type { CompactBuild } from "@webstudio-is/project-build";
import { createEmptyWebstudioFragment } from "@webstudio-is/project-build/runtime";
import type { Asset } from "@webstudio-is/sdk";
import {
createBuilderRuntimeState,
executeApiRuntimeOperation,
} from "./api-runtime.server";
const createBuild = (): CompactBuild =>
({
id: "build-1",
projectId: "project-1",
version: 1,
pages: createDefaultPages({ rootInstanceId: "root-1" }),
instances: [
{
type: "instance",
id: "root-1",
component: "Body",
children: [],
},
],
props: [],
styles: [],
styleSources: [],
styleSourceSelections: [],
dataSources: [],
resources: [],
assets: [],
breakpoints: [],
}) as unknown as CompactBuild;
describe("api runtime adapter", () => {
test("normalizes compact build arrays into builder state maps", () => {
const asset = { id: "asset-1", name: "image.png" } as Asset;
const state = createBuilderRuntimeState(createBuild(), [asset]);
expect(state.pages?.homePageId).toBeDefined();
expect(state.instances?.get("root-1")).toMatchObject({
id: "root-1",
component: "Body",
});
expect(state.assets?.get("asset-1")).toEqual(asset);
expect(state.assets?.get("asset-1")).not.toBe(asset);
});
test("executes runtime reads and maps runtime errors to public api errors", async () => {
const build = createBuild();
await expect(
executeApiRuntimeOperation({
id: "pages.list",
build,
input: { projectId: "project-1" },
})
).resolves.toMatchObject({
pages: [expect.objectContaining({ isHome: true })],
});
await expect(
executeApiRuntimeOperation({
id: "pages.get",
build,
input: { pageId: "missing" },
})
).rejects.toThrow("Page not found");
});
test("strips API transport fields before strict runtime validation", async () => {
await expect(
executeApiRuntimeOperation({
id: "project.search",
build: createBuild(),
input: { projectId: "project-1", query: "Home" },
})
).resolves.toMatchObject({ query: "Home" });
});
test("preserves actionable validation issues in public api errors", async () => {
await expect(
executeApiRuntimeOperation({
id: "pages.create",
build: createBuild(),
input: { projectId: "project-1", name: false, path: "/pricing" },
})
).rejects.toMatchObject({
code: "BAD_REQUEST",
cause: expect.objectContaining({
webstudioCode: "INVALID_INPUT",
issues: [
expect.objectContaining({
path: ["name"],
constraint: "type:string",
}),
],
}),
} satisfies Partial<TRPCError>);
});
test("awaits async runtime mutations before reading mutation payload", async () => {
const mutation = await executeApiRuntimeOperation({
id: "instances.insertComponent",
build: createBuild(),
input: {
projectId: "project-1",
parentInstanceId: "root-1",
component: "Form",
},
});
expect(mutation).toMatchObject({
payload: expect.arrayContaining([
expect.objectContaining({ namespace: "instances" }),
]),
result: {
rootInstanceIds: expect.arrayContaining([expect.any(String)]),
instanceIds: expect.arrayContaining([expect.any(String)]),
},
});
});
test("executes semantic Collection insertion through the server runtime", async () => {
const mutation = await executeApiRuntimeOperation({
id: "instances.insertCollection",
build: createBuild(),
input: {
projectId: "project-1",
parentInstanceId: "root-1",
data: { type: "json", value: [{ title: "First" }] },
itemFragment: {
...createEmptyWebstudioFragment(),
children: [{ type: "id", value: "item-root" }],
instances: [
{
type: "instance",
id: "item-root",
component: "ws:element",
tag: "article",
children: [],
},
],
},
},
});
expect(mutation).toMatchObject({
payload: expect.arrayContaining([
expect.objectContaining({ namespace: "instances" }),
expect.objectContaining({ namespace: "props" }),
expect.objectContaining({ namespace: "dataSources" }),
]),
result: {
collectionInstanceId: expect.any(String),
itemRootInstanceId: expect.any(String),
itemParameterId: expect.any(String),
itemKeyParameterId: expect.any(String),
},
});
});
});