Files
webstudio/packages/cli/src/project-session-api.test.ts
Oleg Isonen 2808f06808 feat: Improve MCP validation errors with actionable guidance (#5881)
## What changed

- Return structured validation issues with stable codes, complete field
paths, constraints, and minimal correction examples.
- Preserve validation details through the Builder API, CLI, MCP, local
project sessions, and tRPC errors.
- Explain invalid expressions, JSX fragments, page selectors, component
inputs, asset updates, and project settings in terms an agent can act
on.
- Remove stack frames and credentials from parser details before
exposing them.
- Keep concise human-readable errors while making the complete
structured issues available to agents.

## Why

Agents previously received vague errors without enough information to
repair a request. They could repeat the same invalid call or require
manual intervention.

## User impact

CLI and MCP agents can now identify the exact invalid field, understand
the constraint, see a valid example, and retry with a corrected request.

## Validation

- Focused runtime, MCP, CLI, API, and tRPC validation tests.
- Structured error propagation tests across local and server execution.
- Typechecks and generated contract verification.
2026-07-15 11:33:36 +02:00

343 lines
9.5 KiB
TypeScript

import { describe, expect, test, vi } from "vitest";
import { executeProjectSessionApiOperation } from "./project-session-api";
type CreateProjectSession = NonNullable<
Parameters<
typeof executeProjectSessionApiOperation
>[0]["createProjectSession"]
>;
describe("project session api adapter", () => {
test("routes local-capable commands through project session runtime", async () => {
const session = {
initialize: vi.fn(async () => undefined),
read: vi.fn(async () => ({
operationId: "pages.list",
projectId: "project-1",
source: "local",
result: ["page"],
state: { committed: false, freshness: {} },
namespaces: {
read: ["pages"],
write: [],
invalidated: [],
missing: [],
},
diagnostics: [],
})),
mutate: vi.fn(),
refresh: vi.fn(),
executeServerOperation: vi.fn(),
};
const createProjectSession = vi.fn(
() => session
) as unknown as CreateProjectSession;
const result = await executeProjectSessionApiOperation({
command: "list-pages",
input: { includeFolders: true },
connection: {
projectId: "project-1",
origin: "https://example.com",
authToken: "token",
},
createProjectSession,
});
expect(session.read).toHaveBeenCalledWith(
"pages.list",
{ projectId: "project-1", includeFolders: true },
{ permit: "view" }
);
expect(result.result).toEqual(["page"]);
});
test("keeps configured project id for local-capable commands", async () => {
const session = {
initialize: vi.fn(async () => undefined),
read: vi.fn(async () => ({
operationId: "pages.list",
projectId: "project-1",
source: "local",
result: [],
state: { committed: false, freshness: {} },
namespaces: {
read: ["pages"],
write: [],
invalidated: [],
missing: [],
},
diagnostics: [],
})),
mutate: vi.fn(),
refresh: vi.fn(),
executeServerOperation: vi.fn(),
};
await executeProjectSessionApiOperation({
command: "list-pages",
input: { projectId: "other-project" },
connection: {
projectId: "project-1",
origin: "https://example.com",
authToken: "token",
},
createProjectSession: vi.fn(
() => session
) as unknown as CreateProjectSession,
});
expect(session.read).toHaveBeenCalledWith(
"pages.list",
{ projectId: "project-1" },
{ permit: "view" }
);
});
test("does not inject project id into strict audit input", async () => {
const session = {
initialize: vi.fn(async () => undefined),
read: vi.fn(async () => ({
operationId: "project.audit",
projectId: "project-1",
source: "local",
result: { findings: [] },
state: { committed: false, freshness: {} },
namespaces: {
read: [],
write: [],
invalidated: [],
missing: [],
},
diagnostics: [],
})),
mutate: vi.fn(),
refresh: vi.fn(),
executeServerOperation: vi.fn(),
};
await executeProjectSessionApiOperation({
command: "audit",
input: { scopes: ["seo"] },
connection: {
projectId: "project-1",
origin: "https://example.com",
authToken: "token",
},
createProjectSession: vi.fn(
() => session
) as unknown as CreateProjectSession,
});
expect(session.read).toHaveBeenCalledWith(
"project.audit",
{ scopes: ["seo"] },
{ permit: "view" }
);
});
test("routes server-only commands through project session transport", async () => {
const session = {
initialize: vi.fn(async () => undefined),
read: vi.fn(),
mutate: vi.fn(),
executeServerOperation: vi.fn(async () => ({
operationId: "auth.me",
projectId: "project-1",
source: "server",
result: { actor: "token" },
state: { committed: true, freshness: {} },
namespaces: { read: [], write: [], invalidated: [], missing: [] },
diagnostics: [],
})),
refresh: vi.fn(),
};
const createProjectSession = vi.fn(
() => session
) as unknown as CreateProjectSession;
const result = await executeProjectSessionApiOperation({
command: "whoami",
input: {},
connection: {
projectId: "project-1",
origin: "https://example.com",
authToken: "token",
},
createProjectSession,
getServerApiContract: async () => ({
clientVersion: "public-api:client",
serverVersion: "public-api:server",
supportedOperationIds: new Set(["auth.me"]),
missingServerOperationIds: [],
negotiated: true,
}),
});
expect(session.executeServerOperation).toHaveBeenCalledWith(
{
id: "auth.me",
invalidatesNamespaces: [],
refetchInvalidatedNamespaces: false,
},
{}
);
expect(result.result).toEqual({ actor: "token" });
});
test("rejects unsupported server-only commands before dispatch", async () => {
const session = {
initialize: vi.fn(async () => undefined),
read: vi.fn(),
mutate: vi.fn(),
executeServerOperation: vi.fn(),
refresh: vi.fn(),
};
await expect(
executeProjectSessionApiOperation({
command: "whoami",
input: {},
connection: {
projectId: "project-1",
origin: "https://example.com",
authToken: "token",
},
createProjectSession: vi.fn(
() => session
) as unknown as CreateProjectSession,
getServerApiContract: async () => ({
clientVersion: "public-api:client",
supportedOperationIds: new Set(),
missingServerOperationIds: ["auth.me"],
negotiated: false,
}),
})
).rejects.toThrow('does not advertise server operation "auth.me"');
expect(session.executeServerOperation).not.toHaveBeenCalled();
});
test("refreshes local namespaces before local-capable commands when requested", async () => {
const session = {
initialize: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
read: vi.fn(async () => ({
operationId: "pages.list",
projectId: "project-1",
source: "local",
result: ["page"],
state: { committed: false, freshness: {} },
namespaces: {
read: ["pages"],
write: [],
invalidated: [],
missing: [],
},
diagnostics: [],
})),
mutate: vi.fn(),
executeServerOperation: vi.fn(),
};
await executeProjectSessionApiOperation({
command: "list-pages",
input: {},
connection: {
projectId: "project-1",
origin: "https://example.com",
authToken: "token",
},
createProjectSession: vi.fn(
() => session
) as unknown as CreateProjectSession,
refresh: true,
});
expect(session.refresh).toHaveBeenCalledWith(["pages"]);
});
test("explains dry-run is only for local-capable mutations", async () => {
const createProjectSession = vi.fn() as unknown as CreateProjectSession;
await expect(
executeProjectSessionApiOperation({
command: "list-pages",
input: {},
connection: {
projectId: "project-1",
origin: "https://example.com",
authToken: "token",
},
createProjectSession,
dryRun: true,
})
).rejects.toThrow(
"list-pages does not support --dry-run. Use --dry-run only with local-capable mutation tools; omit it for read or server-only tools."
);
expect(createProjectSession).not.toHaveBeenCalled();
});
test("preserves project session diagnostic code on operation errors", async () => {
const session = {
initialize: vi.fn(async () => undefined),
read: vi.fn(async () => ({
operationId: "pages.list",
projectId: "project-1",
source: "local",
result: undefined,
state: { committed: false, freshness: {} },
namespaces: {
read: ["pages"],
write: [],
invalidated: [],
missing: [],
},
diagnostics: [
{
level: "error",
code: "INVALID_INPUT",
message: "Page input is invalid.",
issues: [
{
code: "invalid_expression",
path: ["values", "title"],
message: "Invalid Webstudio expression",
constraint: "valid_webstudio_expression",
example: 'pageTitle ?? "Pricing"',
detail: "Unexpected token",
},
],
},
],
})),
mutate: vi.fn(),
refresh: vi.fn(),
executeServerOperation: vi.fn(),
};
await expect(
executeProjectSessionApiOperation({
command: "list-pages",
input: {},
connection: {
projectId: "project-1",
origin: "https://example.com",
authToken: "token",
},
createProjectSession: vi.fn(
() => session
) as unknown as CreateProjectSession,
})
).rejects.toMatchObject({
code: "INVALID_INPUT",
message: "Page input is invalid.",
issues: [
expect.objectContaining({
path: ["values", "title"],
constraint: "valid_webstudio_expression",
}),
],
});
});
});