mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-03 02:27:57 +09:00
## 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
333 lines
8.5 KiB
TypeScript
333 lines
8.5 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
import type { Instance, WebstudioData } from "@webstudio-is/sdk";
|
|
import { createDefaultPages } from "@webstudio-is/project-build";
|
|
import type { ProjectSettings } from "@webstudio-is/project-build";
|
|
import type { BuilderPatchChange } from "@webstudio-is/project-build/contracts";
|
|
import {
|
|
createSyncChangesFromBuilderPatchPayload,
|
|
createTransactionFromBuilderPatchPayload,
|
|
} from "./builder-patch";
|
|
import { registerContainers, serverSyncStore } from "./sync-stores";
|
|
import {
|
|
$assets,
|
|
$breakpoints,
|
|
$dataSources,
|
|
$instances,
|
|
$pages,
|
|
$projectSettings,
|
|
$props,
|
|
$resources,
|
|
$styleSourceSelections,
|
|
$styleSources,
|
|
$styles,
|
|
} from "./data-stores";
|
|
|
|
registerContainers();
|
|
|
|
const createInstance = (
|
|
id: Instance["id"],
|
|
children: Instance["children"] = []
|
|
): Instance => ({
|
|
type: "instance",
|
|
id,
|
|
component: "ws:element",
|
|
tag: "div",
|
|
children,
|
|
});
|
|
|
|
type TestData = WebstudioData & { projectSettings: ProjectSettings };
|
|
|
|
const createData = (): TestData => ({
|
|
pages: createDefaultPages({ rootInstanceId: "body" }),
|
|
instances: new Map([
|
|
["body", createInstance("body")],
|
|
["existing", createInstance("existing", [{ type: "id", value: "old" }])],
|
|
["removed", createInstance("removed")],
|
|
]),
|
|
props: new Map(),
|
|
dataSources: new Map(),
|
|
resources: new Map(),
|
|
breakpoints: new Map(),
|
|
styleSourceSelections: new Map(),
|
|
styleSources: new Map(),
|
|
styles: new Map(),
|
|
assets: new Map(),
|
|
projectSettings: { meta: {}, compiler: {} },
|
|
});
|
|
|
|
const setupStores = (data: TestData) => {
|
|
serverSyncStore.transactionManager.currentStack = [];
|
|
serverSyncStore.transactionManager.undoneStack = [];
|
|
serverSyncStore.popAll();
|
|
$pages.set(data.pages);
|
|
$instances.set(data.instances);
|
|
$props.set(data.props);
|
|
$dataSources.set(data.dataSources);
|
|
$resources.set(data.resources);
|
|
$breakpoints.set(data.breakpoints);
|
|
$styleSourceSelections.set(data.styleSourceSelections);
|
|
$styleSources.set(data.styleSources);
|
|
$styles.set(data.styles);
|
|
$assets.set(data.assets);
|
|
$projectSettings.set(data.projectSettings);
|
|
};
|
|
|
|
describe("builder patch sync adapter", () => {
|
|
test("converts builder patches into sync changes with reverse patches", () => {
|
|
const data = createData();
|
|
const added = createInstance("added");
|
|
const payload: BuilderPatchChange[] = [
|
|
{ namespace: "props", patches: [] },
|
|
{
|
|
namespace: "instances",
|
|
patches: [
|
|
{ op: "add", path: ["added"], value: added },
|
|
{
|
|
op: "add",
|
|
path: ["added", "children", 0],
|
|
value: { type: "id", value: "child" },
|
|
},
|
|
{
|
|
op: "replace",
|
|
path: ["existing", "children", 0, "value"],
|
|
value: "new",
|
|
},
|
|
{ op: "remove", path: ["removed"] },
|
|
],
|
|
},
|
|
];
|
|
|
|
const changes = createSyncChangesFromBuilderPatchPayload({
|
|
data,
|
|
payload,
|
|
});
|
|
|
|
expect(changes).toHaveLength(1);
|
|
const [change] = changes;
|
|
expect(change.namespace).toEqual("instances");
|
|
expect(change.patches).toEqual([
|
|
{
|
|
op: "add",
|
|
path: ["added"],
|
|
value: createInstance("added"),
|
|
},
|
|
{
|
|
op: "add",
|
|
path: ["added", "children", 0],
|
|
value: { type: "id", value: "child" },
|
|
},
|
|
{
|
|
op: "replace",
|
|
path: ["existing", "children", 0, "value"],
|
|
value: "new",
|
|
},
|
|
{ op: "remove", path: ["removed"] },
|
|
]);
|
|
expect(change.revisePatches).toEqual(
|
|
expect.arrayContaining([
|
|
{ op: "remove", path: ["added"] },
|
|
{
|
|
op: "replace",
|
|
path: ["existing", "children", 0, "value"],
|
|
value: "old",
|
|
},
|
|
{ op: "add", path: ["removed"], value: createInstance("removed") },
|
|
])
|
|
);
|
|
|
|
added.children.push({ type: "id", value: "mutated-after-conversion" });
|
|
expect(change.patches[0]).toEqual({
|
|
op: "add",
|
|
path: ["added"],
|
|
value: createInstance("added"),
|
|
});
|
|
});
|
|
|
|
test("compacts reverse patches for undo", () => {
|
|
const data = createData();
|
|
const added = createInstance("added");
|
|
const payload: BuilderPatchChange[] = [
|
|
{
|
|
namespace: "instances",
|
|
patches: [
|
|
{ op: "add", path: ["added"], value: added },
|
|
{
|
|
op: "add",
|
|
path: ["added", "children", 0],
|
|
value: { type: "id", value: "child" },
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const changes = createSyncChangesFromBuilderPatchPayload({
|
|
data,
|
|
payload,
|
|
});
|
|
|
|
expect(changes).toHaveLength(1);
|
|
const [change] = changes;
|
|
expect(change.revisePatches).toEqual([
|
|
{
|
|
op: "remove",
|
|
path: ["added"],
|
|
},
|
|
]);
|
|
expect(change.patches).toEqual([
|
|
{
|
|
op: "add",
|
|
path: ["added"],
|
|
value: createInstance("added"),
|
|
},
|
|
{
|
|
op: "add",
|
|
path: ["added", "children", 0],
|
|
value: { type: "id", value: "child" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("preserves runtime add patches for undefined object fields", () => {
|
|
const data = createData();
|
|
const page = data.pages.pages.get(data.pages.homePageId);
|
|
if (page === undefined) {
|
|
throw Error("Expected fixture page");
|
|
}
|
|
page.marketplace = {
|
|
include: true,
|
|
category: undefined,
|
|
thumbnailAssetId: undefined,
|
|
};
|
|
|
|
const changes = createSyncChangesFromBuilderPatchPayload({
|
|
data,
|
|
payload: [
|
|
{
|
|
namespace: "pages",
|
|
patches: [
|
|
{
|
|
op: "add",
|
|
path: ["pages", data.pages.homePageId, "marketplace", "category"],
|
|
value: "Runtime Examples",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(changes).toEqual([
|
|
{
|
|
namespace: "pages",
|
|
patches: [
|
|
{
|
|
op: "add",
|
|
path: ["pages", data.pages.homePageId, "marketplace", "category"],
|
|
value: "Runtime Examples",
|
|
},
|
|
],
|
|
revisePatches: [
|
|
{
|
|
op: "replace",
|
|
path: ["pages", data.pages.homePageId, "marketplace", "category"],
|
|
value: undefined,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("commits builder patch payloads through sync undo and redo", () => {
|
|
const data = createData();
|
|
setupStores(data);
|
|
|
|
createTransactionFromBuilderPatchPayload({
|
|
data,
|
|
payload: [
|
|
{
|
|
namespace: "instances",
|
|
patches: [
|
|
{
|
|
op: "replace",
|
|
path: ["existing", "children", 0, "value"],
|
|
value: "new",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect($instances.get().get("existing")?.children).toEqual([
|
|
{ type: "id", value: "new" },
|
|
]);
|
|
|
|
serverSyncStore.undo();
|
|
expect($instances.get().get("existing")?.children).toEqual([
|
|
{ type: "id", value: "old" },
|
|
]);
|
|
|
|
serverSyncStore.redo();
|
|
expect($instances.get().get("existing")?.children).toEqual([
|
|
{ type: "id", value: "new" },
|
|
]);
|
|
});
|
|
|
|
test("commits project settings through sync undo and redo", () => {
|
|
const data = createData();
|
|
data.projectSettings.meta.siteName = "Before";
|
|
setupStores(data);
|
|
|
|
createTransactionFromBuilderPatchPayload({
|
|
data,
|
|
payload: [
|
|
{
|
|
namespace: "projectSettings",
|
|
patches: [
|
|
{
|
|
op: "replace",
|
|
path: ["meta", "siteName"],
|
|
value: "After",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect($projectSettings.get()?.meta.siteName).toBe("After");
|
|
|
|
serverSyncStore.undo();
|
|
expect($projectSettings.get()?.meta.siteName).toBe("Before");
|
|
|
|
serverSyncStore.redo();
|
|
expect($projectSettings.get()?.meta.siteName).toBe("After");
|
|
});
|
|
|
|
test("commits style source selection removals through sync undo and redo", () => {
|
|
const data = createData();
|
|
data.styleSourceSelections.set("card", {
|
|
instanceId: "card",
|
|
values: ["local"],
|
|
});
|
|
setupStores(data);
|
|
|
|
createTransactionFromBuilderPatchPayload({
|
|
data,
|
|
payload: [
|
|
{
|
|
namespace: "styleSourceSelections",
|
|
patches: [{ op: "remove", path: ["card"] }],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect($styleSourceSelections.get()).toEqual(new Map());
|
|
|
|
serverSyncStore.undo();
|
|
expect($styleSourceSelections.get()).toEqual(
|
|
new Map([["card", { instanceId: "card", values: ["local"] }]])
|
|
);
|
|
|
|
serverSyncStore.redo();
|
|
expect($styleSourceSelections.get()).toEqual(new Map());
|
|
});
|
|
});
|