Files
webstudio/apps/builder/app/shared/sync/command-queue.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

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");
}
});
});
});