mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-27 15:16:53 +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
268 lines
7.6 KiB
TypeScript
268 lines
7.6 KiB
TypeScript
import { describe, test, expect, beforeEach } from "vitest";
|
|
import type { BuilderPatchChange } from "@webstudio-is/project-build/contracts";
|
|
import type { Transaction } from "@webstudio-is/sync-client";
|
|
import { enqueue, dequeueAll } from "./command-queue";
|
|
|
|
const makeTx = (id: string): Transaction<BuilderPatchChange[]> => ({
|
|
id,
|
|
object: "server",
|
|
payload: [{ namespace: "pages", patches: [] }],
|
|
});
|
|
|
|
describe("command-queue", () => {
|
|
beforeEach(() => {
|
|
// Drain any leftover commands from previous tests
|
|
dequeueAll();
|
|
});
|
|
|
|
// ── dequeueAll ────────────────────────────────────────────────
|
|
|
|
describe("dequeueAll", () => {
|
|
test("returns empty array when queue is empty", () => {
|
|
expect(dequeueAll()).toEqual([]);
|
|
});
|
|
|
|
test("returns all enqueued commands and clears the queue", () => {
|
|
enqueue({
|
|
type: "setDetails",
|
|
projectId: "p1",
|
|
version: 1,
|
|
buildId: "b1",
|
|
authToken: "tok",
|
|
});
|
|
|
|
const first = dequeueAll();
|
|
expect(first).toHaveLength(1);
|
|
|
|
const second = dequeueAll();
|
|
expect(second).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ── enqueue: setDetails ───────────────────────────────────────
|
|
|
|
describe("enqueue — setDetails", () => {
|
|
test("pushes setDetails without merging", () => {
|
|
enqueue({
|
|
type: "setDetails",
|
|
projectId: "p1",
|
|
version: 1,
|
|
buildId: "b1",
|
|
authToken: "tok",
|
|
});
|
|
enqueue({
|
|
type: "setDetails",
|
|
projectId: "p1",
|
|
version: 2,
|
|
buildId: "b1",
|
|
authToken: "tok",
|
|
});
|
|
|
|
const commands = dequeueAll();
|
|
expect(commands).toHaveLength(2);
|
|
expect(commands[0]).toEqual(
|
|
expect.objectContaining({ type: "setDetails", version: 1 })
|
|
);
|
|
expect(commands[1]).toEqual(
|
|
expect.objectContaining({ type: "setDetails", version: 2 })
|
|
);
|
|
});
|
|
|
|
test("setDetails for different projects are kept separate", () => {
|
|
enqueue({
|
|
type: "setDetails",
|
|
projectId: "p1",
|
|
version: 1,
|
|
buildId: "b1",
|
|
authToken: undefined,
|
|
});
|
|
enqueue({
|
|
type: "setDetails",
|
|
projectId: "p2",
|
|
version: 1,
|
|
buildId: "b2",
|
|
authToken: undefined,
|
|
});
|
|
|
|
const commands = dequeueAll();
|
|
expect(commands).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
// ── enqueue: transactions merging ─────────────────────────────
|
|
|
|
describe("enqueue — transactions merging", () => {
|
|
test("merges consecutive transactions for the same project", () => {
|
|
const tx1 = makeTx("t1");
|
|
const tx2 = makeTx("t2");
|
|
|
|
enqueue({ type: "transactions", projectId: "p1", transactions: [tx1] });
|
|
enqueue({ type: "transactions", projectId: "p1", transactions: [tx2] });
|
|
|
|
const commands = dequeueAll();
|
|
expect(commands).toHaveLength(1);
|
|
expect(commands[0].type).toBe("transactions");
|
|
if (commands[0].type === "transactions") {
|
|
expect(commands[0].transactions).toEqual([tx1, tx2]);
|
|
}
|
|
});
|
|
|
|
test("does not merge transactions for different projects", () => {
|
|
enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("t1")],
|
|
});
|
|
enqueue({
|
|
type: "transactions",
|
|
projectId: "p2",
|
|
transactions: [makeTx("t2")],
|
|
});
|
|
|
|
const commands = dequeueAll();
|
|
expect(commands).toHaveLength(2);
|
|
});
|
|
|
|
test("setDetails between transactions prevents merging", () => {
|
|
enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("t1")],
|
|
});
|
|
enqueue({
|
|
type: "setDetails",
|
|
projectId: "p1",
|
|
version: 1,
|
|
buildId: "b1",
|
|
authToken: undefined,
|
|
});
|
|
enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("t2")],
|
|
});
|
|
|
|
const commands = dequeueAll();
|
|
// The setDetails breaks the merge chain, so we get 3 commands
|
|
expect(commands).toEqual([
|
|
expect.objectContaining({ type: "transactions", projectId: "p1" }),
|
|
expect.objectContaining({ type: "setDetails", projectId: "p1" }),
|
|
expect.objectContaining({ type: "transactions", projectId: "p1" }),
|
|
]);
|
|
});
|
|
|
|
test("merges multiple transactions in a single batch", () => {
|
|
const tx1 = makeTx("t1");
|
|
const tx2 = makeTx("t2");
|
|
const tx3 = makeTx("t3");
|
|
|
|
enqueue({ type: "transactions", projectId: "p1", transactions: [tx1] });
|
|
enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [tx2, tx3],
|
|
});
|
|
|
|
const commands = dequeueAll();
|
|
expect(commands).toHaveLength(1);
|
|
if (commands[0].type === "transactions") {
|
|
expect(commands[0].transactions).toEqual([tx1, tx2, tx3]);
|
|
}
|
|
});
|
|
|
|
test("does not merge across another project's pending transactions", () => {
|
|
enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("t1")],
|
|
});
|
|
enqueue({
|
|
type: "transactions",
|
|
projectId: "p2",
|
|
transactions: [makeTx("t2")],
|
|
});
|
|
enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("t3")],
|
|
});
|
|
|
|
const commands = dequeueAll();
|
|
expect(commands).toHaveLength(3);
|
|
expect(commands).toEqual([
|
|
expect.objectContaining({ type: "transactions", projectId: "p1" }),
|
|
expect.objectContaining({ type: "transactions", projectId: "p2" }),
|
|
expect.objectContaining({ type: "transactions", projectId: "p1" }),
|
|
]);
|
|
});
|
|
|
|
test("empty queue followed by a transactions command is not merged", () => {
|
|
enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("t1")],
|
|
});
|
|
|
|
const commands = dequeueAll();
|
|
expect(commands).toHaveLength(1);
|
|
if (commands[0].type === "transactions") {
|
|
expect(commands[0].transactions).toHaveLength(1);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Mixed command ordering ────────────────────────────────────
|
|
|
|
describe("mixed commands", () => {
|
|
test("setDetails then transactions preserves both", () => {
|
|
enqueue({
|
|
type: "setDetails",
|
|
projectId: "p1",
|
|
version: 1,
|
|
buildId: "b1",
|
|
authToken: undefined,
|
|
});
|
|
enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("t1")],
|
|
});
|
|
|
|
const commands = dequeueAll();
|
|
expect(commands).toHaveLength(2);
|
|
expect(commands[0]).toEqual(
|
|
expect.objectContaining({ type: "setDetails" })
|
|
);
|
|
expect(commands[1]).toEqual(
|
|
expect.objectContaining({ type: "transactions" })
|
|
);
|
|
});
|
|
|
|
test("multiple dequeueAll calls return disjoint results", () => {
|
|
enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("t1")],
|
|
});
|
|
const first = dequeueAll();
|
|
|
|
enqueue({
|
|
type: "transactions",
|
|
projectId: "p1",
|
|
transactions: [makeTx("t2")],
|
|
});
|
|
const second = dequeueAll();
|
|
|
|
expect(first).toHaveLength(1);
|
|
expect(second).toHaveLength(1);
|
|
if (
|
|
first[0].type === "transactions" &&
|
|
second[0].type === "transactions"
|
|
) {
|
|
expect(first[0].transactions[0].id).toBe("t1");
|
|
expect(second[0].transactions[0].id).toBe("t2");
|
|
}
|
|
});
|
|
});
|
|
});
|