Files
webstudio/packages/cli/src/cli.test.ts
T
Oleg Isonen 05706b5425 feat: Make Content Block templates the MDX authoring interface (#6307)
Closes #6306

## Outcome

Content Block templates are now the public component model for MDX
authoring.

- Standard Markdown resolves through matching Content Block templates.
- PascalCase JSX resolves to a named template first, then to a
registered component.
- Adding or changing a template immediately affects matching MDX,
including content authored before that template existed.
- Every supported standard MDX element is present in new Content Blocks
by default.
- Template `name` is a stable PascalCase JSX identifier. `label` remains
optional display text only.
- Lowercase JSX represents native HTML and SVG without exposing
`ws.element` in authored syntax.
- Unknown, ambiguous, and unsafe JSX stays in source, produces a
source-ranged Builder diagnostic, and is omitted from published output.
- Frontmatter images preserve exact `$ref` values and use the Asset
description for `alt` text.
- Keyboard and mouse insertion from the slash menu remove the trigger
slash and preserve the selected template.

## Public MDX contract

- Markdown is canonical for standard prose.
- Lowercase JSX creates native elements:
`<section><p>Text</p></section>`.
- PascalCase JSX uses templates and registered components:
`<PromotionCard />`, `<Heading tag="h1">Title</Heading>`, and `<YouTube
src="https://youtu.be/KI5JHpzBK1s" />`.
- Component-name collisions use stable qualified identifiers such as
`Checkbox` and `RadixCheckbox`.
- A self-closing template keeps its designed default subtree. An
explicitly paired empty template clears it.
- Static quoted values and bare booleans are supported.
- Imports, exports, expressions, spreads, functions, executable
JavaScript, unsafe attributes, `<$.*>`, and authored `<ws.element>` are
rejected.
- `ws` remains only for compiler primitives with no React component.

## Implementation

- [x] Add stable, unique template names and keep them separate from
labels.
- [x] Parse and validate Markdown and JSX through their ASTs with
precise source ranges.
- [x] Share component identity between template JSX, Content MDX,
Builder materialization, reload, copy, and publication.
- [x] Resolve templates live after add, edit, rename, reorder, removal,
undo/redo, collaboration updates, and reload.
- [x] Preserve authored props, children, Asset references, template
defaults, and the exact slash-menu template choice.
- [x] Make one typed MDX semantic inventory drive default templates and
slash-menu eligibility.
- [x] Move component-dependent template assembly into the component
registry.
- [x] Replace production `$`, `animation`, and `radix` JSX namespaces
with direct component imports.
- [x] Keep only explicit compiler primitives in `ws` template JSX.
- [x] Migrate repository templates, fixtures, stories, examples, MCP
guidance, CLI guidance, and generated outputs.
- [x] Add Builder unit and Playwright coverage for authoring,
diagnostics, insertion, synchronization, reload, and publication.
- [x] Review and update the authoritative Content Block and generated
MCP documentation.

## Compatibility

- Persisted project data continues to use namespaced component IDs and
`ws:element` for generic HTML elements. No project-data migration is
required.
- Legacy `<ws.element ws:tag="…">`, `<ws.element ws:name="…">`, `<$.*>`,
`<animation.*>`, and `<radix.*>` input remains readable during the
compatibility window.
- Current serializers and repository-owned sources emit only canonical
Markdown, lowercase intrinsic JSX, or PascalCase identifiers.
- Untouched user MDX is not rewritten automatically. Legacy syntax is
canonicalized when its owning source is edited.
- Deferred follow-up: remove legacy namespace readers and proxy support
only after the canonical writer is deployed and existing projects are
backfilled. This is intentionally outside this PR.

## Verification

- [x] CI package tests, typechecks, lint, package boundaries, generated
assets, migrations, and size checks pass.
- [x] All six Builder E2E shards pass.
- [x] Fixture checks pass on Ubuntu and Windows.
- [x] Targeted local Content Engine, Template, SDK, registry, Project
Build, migration, Builder, component, CLI, and Playwright tests pass.
- [x] Runtime contracts, CLI docs, and MCP docs were regenerated from
their owning sources.
- [x] Local fixture generation completed without an unexpected fixture
diff.
- [x] Documentation impact reviewed and included in this PR.
- [x] Review and approve the intentional List visual snapshot change.
- [x] Run the complete agent evaluation suite after explicit approval.
- [x] Run the network fixture link/sync pipeline against the matching PR
deployment after explicit permission.

## Known visual change

The generated List story now targets its actual default `ul` element
instead of `ol`, so the default list styling appears in the visual
snapshot. The visual change was reviewed and the approved visual check
passes.
2026-09-06 15:19:54 +02:00

322 lines
9.3 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { describe, expect, test, vi } from "vitest";
import makeCLI from "yargs";
import {
apiCommandMetadata,
cliCommandMetadata,
} from "./commands/api-command-metadata";
import {
getTopLevelMcpToolForwardArgs,
getTopLevelMcpToolHint,
handleOutputStreamError,
registerCommands,
rootCliEpilogue,
} from "./cli";
import type { CommonYargsArgv } from "./commands/yargs-types";
type CommandBuilder = (yargs: CommonYargsArgv) => CommonYargsArgv;
const createYargs = () => {
const commands: { command: string; builder?: CommandBuilder }[] = [];
const yargs = {
command: vi.fn(
(
command: string | string[],
_description?: string,
builder?: unknown
) => {
commands.push({
command: Array.isArray(command) ? (command[0] ?? "") : command,
builder:
typeof builder === "function"
? (builder as CommandBuilder)
: undefined,
});
return yargs;
}
),
demandCommand: vi.fn(() => yargs),
option: vi.fn(() => yargs),
example: vi.fn(() => yargs),
version: vi.fn(() => yargs),
};
return { yargs: yargs as unknown as CommonYargsArgv, commands };
};
const runGroupBuilder = (
commands: { command: string; builder?: CommandBuilder }[],
group: string
) => {
const groupCommand = commands.find((command) => command.command === group);
expect(groupCommand?.builder).toBeDefined();
const childCommands: string[] = [];
const childYargs = {
command: vi.fn((command: string | string[]) => {
childCommands.push(Array.isArray(command) ? (command[0] ?? "") : command);
return childYargs;
}),
demandCommand: vi.fn(() => childYargs),
option: vi.fn(() => childYargs),
example: vi.fn(() => childYargs),
version: vi.fn(() => childYargs),
};
groupCommand?.builder?.(childYargs as unknown as CommonYargsArgv);
return {
childCommands,
demandCommand: childYargs.demandCommand,
};
};
const commandNames = (commands: { command: string }[]) =>
commands.map((command) => command.command);
const getExpectedGroupActions = (group: string) =>
cliCommandMetadata
.map(({ cliCommand }) => cliCommand.split(" "))
.filter(([commandGroup, action, extra]) => {
return (
commandGroup === group && action !== undefined && extra === undefined
);
})
.map(([, action]) => action);
const getHelpOutput = async (args: string[]) => {
let output = "";
const yargs = makeCLI(args)
.scriptName("webstudio")
.exitProcess(false)
.wrap(null)
.epilogue(rootCliEpilogue)
.fail((message, error) => {
throw error ?? new Error(message);
});
registerCommands(yargs as unknown as CommonYargsArgv);
await yargs.parseAsync(args, {}, (_error, _argv, helpOutput) => {
output = helpOutput;
});
return output;
};
describe("registerCommands", () => {
test("exits successfully on broken pipe output errors", () => {
const exitProcess = vi.fn((code?: number) => {
throw new Error(`exit ${code}`);
}) as never;
const error = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
expect(() => handleOutputStreamError(error, exitProcess)).toThrow("exit 0");
expect(exitProcess).toHaveBeenCalledWith(0);
});
test("rethrows non broken pipe output errors", () => {
const exitProcess = vi.fn() as never;
const error = Object.assign(new Error("write failed"), { code: "EIO" });
expect(() => handleOutputStreamError(error, exitProcess)).toThrow(error);
expect(exitProcess).not.toHaveBeenCalled();
});
test("forwards top-level mcp tool shortcuts to single-op-call", () => {
expect(getTopLevelMcpToolForwardArgs(["meta.index"])).toEqual([
"mcp",
"single-op-call",
"meta.index",
]);
expect(getTopLevelMcpToolForwardArgs(["meta.get_more_tools"])).toEqual([
"mcp",
"single-op-call",
"meta.get_more_tools",
]);
expect(
getTopLevelMcpToolForwardArgs([
"download-asset",
'{"assetId":"asset-id"}',
])
).toEqual([
"mcp",
"single-op-call",
"download-asset",
'{"assetId":"asset-id"}',
]);
expect(
getTopLevelMcpToolForwardArgs([
"insert-fragment",
'{"parentInstanceId":"parent-id","fragment":"<Box />"}',
"--dry-run",
])
).toEqual([
"mcp",
"single-op-call",
"insert-fragment",
'{"parentInstanceId":"parent-id","fragment":"<Box />"}',
"--dry-run",
]);
expect(
getTopLevelMcpToolForwardArgs([
"workflow.next",
"goal",
"design-system-page",
])
).toEqual([
"mcp",
"single-op-call",
"workflow.next",
'{"goal":"design-system-page"}',
]);
expect(
getTopLevelMcpToolForwardArgs([
"workflow.next",
"goal-design-system-page",
])
).toEqual([
"mcp",
"single-op-call",
"workflow.next",
'{"goal":"design-system-page"}',
]);
expect(getTopLevelMcpToolForwardArgs(["permissions"])).toBeUndefined();
expect(getTopLevelMcpToolForwardArgs(["audit"])).toBeUndefined();
for (const command of [
"get-marketplace-product",
"update-marketplace-product",
"submit-marketplace-product",
"set-redirects",
]) {
expect(
getTopLevelMcpToolForwardArgs([command, "--help"])
).toBeUndefined();
}
expect(getTopLevelMcpToolForwardArgs(["unknown-command"])).toBeUndefined();
});
test.each([
"get-marketplace-product",
"update-marketplace-product",
"submit-marketplace-product",
"set-redirects",
])("shows registered top-level help for %s", async (command) => {
const output = await getHelpOutput([command, "--help"]);
expect(output).toContain(`webstudio ${command}`);
expect(output).not.toContain("single-op-call <tool>");
});
test("forwards screenshot JSON input without treating it as a URL", () => {
expect(
getTopLevelMcpToolForwardArgs([
"screenshot",
'{"path":"/pricing","viewport":{"width":1440,"height":900}}',
])
).toEqual([
"mcp",
"single-op-call",
"screenshot",
'{"path":"/pricing","viewport":{"width":1440,"height":900}}',
]);
expect(
getTopLevelMcpToolForwardArgs(["screenshot", "https://example.com"])
).toBeUndefined();
});
test("suggests mcp single-op-call for top-level dotted tool names", () => {
expect(getTopLevelMcpToolHint(["meta.index"])).toContain(
"webstudio meta.index"
);
expect(getTopLevelMcpToolHint(["meta.index"])).toContain(
"webstudio mcp single-op-call meta.index"
);
expect(getTopLevelMcpToolHint(["meta.index"])).toContain(
"node packages/cli/local.js meta.index"
);
expect(getTopLevelMcpToolHint(["meta.index"])).toContain(
"node packages/cli/local.js mcp single-op-call meta.index"
);
expect(getTopLevelMcpToolHint(["insert-fragment"])).toContain(
"webstudio mcp single-op-call insert-fragment"
);
expect(getTopLevelMcpToolHint(["permissions"])).toBeUndefined();
expect(getTopLevelMcpToolHint(["unknown-command"])).toBeUndefined();
});
test("registers high-level API commands and mcp command", () => {
const { yargs, commands } = createYargs();
registerCommands(yargs);
expect(commandNames(commands)).toEqual(
expect.arrayContaining([
"preview",
"connect [client]",
"screenshot [url]",
"audit",
"import",
"permissions",
"publish",
"domains",
"registry",
"mcp",
"$0",
])
);
expect(commandNames(commands)).not.toEqual(
expect.arrayContaining(["publish deploy", "domains list"])
);
const publish = runGroupBuilder(commands, "publish");
expect(publish.childCommands).toEqual(getExpectedGroupActions("publish"));
expect(publish.demandCommand).toHaveBeenCalledWith(
1,
"Specify a publish command."
);
const domains = runGroupBuilder(commands, "domains");
expect(domains.childCommands).toEqual(getExpectedGroupActions("domains"));
expect(domains.demandCommand).toHaveBeenCalledWith(
1,
"Specify a domains command."
);
const registry = runGroupBuilder(commands, "registry");
expect(registry.childCommands).toEqual(["inspect"]);
expect(registry.demandCommand).toHaveBeenCalledWith(
1,
"Specify a registry command."
);
for (const { command } of apiCommandMetadata) {
if (
cliCommandMetadata.some(
(cliCommand) => cliCommand.operation === command
)
) {
continue;
}
expect(commandNames(commands)).not.toContain(command);
}
});
test("keeps every documented audit CLI example compatible with audit help", async () => {
const docs = await readFile(
new URL("./docs/api-use-cases.md", import.meta.url),
"utf8"
);
const commands = docs
.split("\n")
.filter((line) => line.startsWith("- webstudio audit "))
.map((line) => line.slice("- webstudio ".length).trim());
expect(commands.length).toBeGreaterThan(0);
for (const command of commands) {
const args = command.split(/\s+/).filter((arg) => arg !== "--json");
const output = await getHelpOutput([...args, "--help"]);
expect(output).toContain("webstudio audit");
expect(output).not.toContain("single-op-call <tool>");
}
});
});